diff --git a/README.rst b/README.rst index 312f712..b59128c 100644 --- a/README.rst +++ b/README.rst @@ -84,9 +84,9 @@ Add top level openapi objects like `Info `_ -* `petstore `_ +* `petstore `_ source `OpenAPI Petstore `_ +* `link-example `_ - source `OpenAPI link example `_ +* `api-with-example `_ - source `OpenAPI api_with_examples `_ To run diff --git a/docs/source/examples.rst b/docs/source/examples.rst index 5a1b037..161da14 100644 --- a/docs/source/examples.rst +++ b/docs/source/examples.rst @@ -1,3 +1,5 @@ +.. _flaskdoc-examples: + Examples ======== @@ -22,7 +24,13 @@ flaskdoc examples can be invoked as follows: $ flaskdoc start -n -Where example can either be petstore or inventory, use ``all`` to register all examples at once +Where example can either be one of + +* petstore +* inventory +* link-example +* api-with-example + Petstore Example ---------------- @@ -42,5 +50,4 @@ Implements the simple inventory api provided by swaggerhub. To run inventory exa $ flaskdoc start -n inventory -Visit http://localhost:{port}/swagger-ui to see the SwaggerUI or http://localhost:{port}/swagger-ui/redoc to see -the redoc version +Visit http://localhost:15172/docs to see the UI diff --git a/docs/source/index.rst b/docs/source/index.rst index c0df130..7130d15 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -15,6 +15,7 @@ This section provides documentation on how to get started using flaskdoc in your tutorial examples + snippets flaskdoc changelog @@ -69,9 +70,9 @@ Add top level openapi objects like `Info ", methods=["GET"]) +def get_user_by_name(username): + pass + + +@specs.get_repository_by_owner +@api.route("/2.0/repositories/", methods=["GET"]) +def get_repository_by_owner(username): + pass + + +@specs.get_repository +@api.route("/2.0/repositories//", methods=["GET"]) +def get_repository(username, slug): + pass + + +@specs.get_pull_requests_by_repository +@api.route("/2.0/repositories///pullrequests", methods=["GET"]) +def get_pull_requests_by_repository(username, slug): + pass + + +@specs.get_pull_requests_by_id +@api.route( + "/2.0/repositories///pullrequests/", methods=["GET"] +) +def get_pull_requests_by_id(username, slug, pid): + pass + + +@specs.merge_pull_request +@api.route( + "/2.0/repositories///pullrequests//merge", + methods=["POST"], +) +def merge_pull_request(username, slug, pid): + pass diff --git a/src/flaskdoc/examples/petstore_specs.py b/src/flaskdoc/examples/petstore_specs.py index b45cc85..cf986a9 100644 --- a/src/flaskdoc/examples/petstore_specs.py +++ b/src/flaskdoc/examples/petstore_specs.py @@ -74,7 +74,7 @@ class User(object): ) ], request_body=swagger.RequestBody( - content=swagger.Content( + content=swagger.MediaType( content_type="multipart/form-data", schema=swagger.Schema( properties=dict( @@ -193,7 +193,18 @@ class User(object): responses={ "200": swagger.ResponseObject( description="Successful operation", - content=[swagger.JsonType(schema=Pet), swagger.XmlType(schema=Pet)], + content=[ + swagger.JsonType( + schema=Pet, + example=Pet( + id=10456, + name="spidey", + status="pending", + category=Category(id=123, name="smoked"), + ), + ), + swagger.XmlType(schema=Pet), + ], ), "400": swagger.ResponseObject(description="Invalid ID supplied"), "404": swagger.ResponseObject(description="Pet not found"), @@ -214,13 +225,14 @@ class User(object): ) ], request_body=swagger.RequestBody( - content=swagger.Content( + content=swagger.MediaType( content_type="application/x-www-form-urlencoded", schema=swagger.Schema( properties=dict( name=swagger.String(description="Updated name of the pet"), status=swagger.String(description="Updated status of the pet"), - ) + ), + example={"name": "Sylvester Stallone", "status": "available"}, ), ) ), @@ -239,6 +251,11 @@ class User(object): name="petId", description="ID of pet that needs to be updated", schema=swagger.Int64(), + examples={ + "negative": swagger.Example( + summary="negative value", value=-100, description="deeper negative" + ) + }, ), ], responses={ @@ -255,7 +272,7 @@ class User(object): operation_id="placeOrder", request_body=swagger.RequestBody( description="order placed for purchasing the pet", - content=swagger.JsonType(schema=Order), + content=swagger.JsonType(schema=Order, examples={"order": Order()}), required=True, ), responses={ diff --git a/src/flaskdoc/pallets/app.py b/src/flaskdoc/pallets/app.py index f795636..77f60ec 100644 --- a/src/flaskdoc/pallets/app.py +++ b/src/flaskdoc/pallets/app.py @@ -113,18 +113,28 @@ def docs_ui(path="default.html"): def register_openapi( - app, info, servers=None, tags=None, security=None, docs_path="/docs", use_redoc=False, + app, + info, + examples=None, + servers=None, + tags=None, + security=None, + docs_path="/docs", + use_redoc=False, + links=None, ): """ Registers flaskdoc api specs to an existing flask app Args: app (flask.Flask): an existing flask app instance info (swagger.Info): OpenAPI info block + examples (dict[str, swagger.Example]): reusable mappings of examples servers (list[swagger.Server]): list of servers used for testing tags (list[swagger.Tag]): list of tags with name and description security (dict[str, swagger.SecurityScheme]): security schemes to apply docs_path (str): custom path name for the swagger ui docs, defaults to docs use_redoc (bool): disable normal swagger ui and use redoc ui instead + links (dict[str, swagger.Link]): reusable links mapping """ docs_path = docs_path or "docs" CONFIG["use_redoc"] = use_redoc @@ -135,6 +145,10 @@ def register_openapi( ui.add_url_rule("/openapi.json", view_func=json_path, methods=["GET"]) ui.add_url_rule("/openapi.yaml", view_func=yaml_path, methods=["GET"]) + components = swagger.Components() + components.add_component(swagger.ComponentType.EXAMPLE, examples) + components.add_component(swagger.ComponentType.LINK, links) + components.add_component(swagger.ComponentType.SECURITY_SCHEME, security) app.register_blueprint(ui, url_prefix=docs_path) app.openapi = swagger.OpenApi( info=info, @@ -142,7 +156,7 @@ def register_openapi( version="3.0.3", servers=servers, tags=tags, - security=security, + components=components, ) @@ -165,7 +179,7 @@ def get_api_docs(app): pi = parse_specs(rule, spec, api) pi.description = docs api.paths.add(plugins.parse_flask_rule(rule.rule), pi) - api.components["schemas"] = swagger.schema_factory.schemas + api.components.add_component(swagger.ComponentType.SCHEMA, swagger.schema_factory.schemas) return 1 diff --git a/src/flaskdoc/swagger/__init__.py b/src/flaskdoc/swagger/__init__.py index 71b94c1..19e419b 100644 --- a/src/flaskdoc/swagger/__init__.py +++ b/src/flaskdoc/swagger/__init__.py @@ -10,9 +10,11 @@ ApiKeySecurityScheme, AuthorizationCodeOAuthFlow, ClientCredentialsOAuthFlow, - Component, + Components, + ComponentType, Contact, CookieParameter, + ExampleReference, ExternalDocumentation, Header, HeaderParameter, @@ -21,6 +23,8 @@ ImplicitOAuthFlow, Info, License, + Link, + LinkReference, OAuth2SecurityScheme, OAuthFlow, OpenApi, @@ -51,21 +55,24 @@ Base64String, BinaryString, Boolean, - Content, Discriminator, Email, + Encoding, + Example, Image, Int64, Integer, JsonType, MediaType, MultipartFormData, + MultipartType, Number, Object, PlainText, Schema, SchemaFactory, String, + UrlEncodedFormType, XmlType, schema_factory, ) diff --git a/src/flaskdoc/swagger/models.py b/src/flaskdoc/swagger/models.py index 33de229..fde4f5c 100644 --- a/src/flaskdoc/swagger/models.py +++ b/src/flaskdoc/swagger/models.py @@ -1,12 +1,13 @@ # Standard Library import enum import logging +import re from collections import OrderedDict from typing import Union import attr -from flaskdoc.core import ApiDecoratorMixin, DictMixin, ModelMixin +from flaskdoc.core import ApiDecoratorMixin, DictMixin, ExtensionMixin, ModelMixin from flaskdoc.swagger import validators from flaskdoc.swagger.schema import ContentMixin, schema_factory @@ -22,49 +23,6 @@ def __setitem__(self, key, value): super(SwaggerDict, self).__setitem__(key, value) -class ExtensionMixin(ModelMixin): - - extensions = attr.ib(default={}) - - def add_extension(self, name, value): - """ Allows extensions to the Swagger Schema. - - The field name MUST begin with x-, for example, x-internal-id. The value can be null, a primitive, - an array or an object. - Args: - name (str): custom extension name, must begin with x- - value (Any): value, can be None, any object or list - Returns: - ModelMixin: for chaining - Raises: - ValueError: if key name is invalid - """ - self.validate_extension_name(name) - if not self.extensions: - self.extensions = SwaggerDict() - self.extensions[name] = value - return self - - @staticmethod - def validate_extension_name(value): - """ - Validates a custom extension name - Args: - value (str): custom extension name - Raises: - ValueError: if key name is invalid - """ - if value and not value.startswith("x-"): - raise ValueError("Custom extension must start with x-") - - @extensions.validator - def validate(self, _, ext): - """ Validates the name of all provided extensions """ - if ext: - for k in ext: - self.validate_extension_name(k) - - @attr.s class ContainerModel(ModelMixin): @@ -86,7 +44,7 @@ def __iter__(self): yield item def to_dict(self): - return self._parse_dict(self.items) + return self.parse(self.items) @attr.s @@ -236,40 +194,32 @@ class ReferenceObject(ModelMixin): ref = attr.ib(type=str) + _ref_object = attr.ib(default="schemas", init=False) -@attr.s -class ExternalDocumentation(ExtensionMixin): - """ Allows referencing an external resource for extended documentation. """ + def __attrs_post_init__(self): + if "#/components" not in self.ref: + self.ref = "#/components/{}/{}".format(self._ref_object, self.ref) - url = attr.ib(type=str) - description = attr.ib(default=None, type=str) - extensions = attr.ib(default={}) + def to_dict(self): + return {"$ref": self.ref} @attr.s -class Encoding(ExtensionMixin): - """ A single encoding definition applied to a single schema property. """ +class ExampleReference(ReferenceObject): + _ref_object = attr.ib(default="examples", init=False) - content_type = attr.ib(type=str) - headers = attr.ib(default=None, type=SwaggerDict) - style = attr.ib(default=None, type=Style) - explode = attr.ib(default=True) - allow_reserved = attr.ib(default=False) - extensions = attr.ib(default={}) - def add_header(self, name, header): - if not self.headers: - self.headers = SwaggerDict() - self.headers[name] = header +@attr.s +class LinkReference(ReferenceObject): + _ref_object = attr.ib(default="links", init=False) @attr.s -class Example(ExtensionMixin): +class ExternalDocumentation(ExtensionMixin): + """ Allows referencing an external resource for extended documentation. """ - summary = attr.ib(default=None, type=str) + url = attr.ib(type=str) description = attr.ib(default=None, type=str) - value = attr.ib(default=None) - external_value = attr.ib(default=None, type=str) extensions = attr.ib(default={}) @@ -277,38 +227,9 @@ class Example(ExtensionMixin): class RequestBody(ContentMixin, ExtensionMixin): description = attr.ib(default=None, type=str) - required = attr.ib(default=False) - extensions = attr.ib(default={}) - - -@attr.s -class Component(ExtensionMixin): - """ Holds a set of reusable objects for different aspects of the OAS. - - All objects defined within the components object will have no effect on the API unless they are explicitly - referenced from properties outside the components object. - - This object MAY be extended with Specification Extensions. All the fixed fields declared above are objects that - MUST use keys that match the regular expression: ^[a-zA-Z0-9\\.\\-_]+$. - - Properties: - schemas: An object to hold reusable Schema Objects. - """ - - schemas = attr.ib(default={}) - responses = attr.ib(default=None, type=dict) - parameters = attr.ib(default=None, type=dict) - examples = attr.ib(default=None, type=dict) - request_bodies = attr.ib(default={}) - headers = attr.ib(default={}) - security_schemes = attr.ib(default={}) - links = attr.ib(default={}) - callbacks = attr.ib(default={}) + required = attr.ib(default=None) extensions = attr.ib(default={}) - def add_response(self, response_name: str, response): - self.responses[response_name] = response - @attr.s class RelativePath(object): @@ -875,6 +796,62 @@ class OAuthFlow(ExtensionMixin): extensions = attr.ib(default={}) +class ComponentType(enum.Enum): + + EXAMPLE = "examples" + CALLBACKS = "callbacks" + HEADER = "headers" + LINK = "links" + PARAMETER = "parameters" + REQUEST_BODY = "request+bodies" + RESPONSE = "responses" + SCHEMA = "schemas" + SECURITY_SCHEME = "security_schemes" + + +@attr.s +class Components(ExtensionMixin): + """ Holds a set of reusable objects for different aspects of the OAS. + + All objects defined within the components object will have no effect on the API unless they are explicitly + referenced from properties outside the components object. + """ + + schemas = attr.ib(default=None, type=dict) + responses = attr.ib(default=None, type=dict) + parameters = attr.ib(default=None, type=dict) + examples = attr.ib(default=None, type=dict) + request_bodies = attr.ib(default=None, type=dict) + headers = attr.ib(default=None, type=dict) + security_schemes = attr.ib(default=None, type=dict) + links = attr.ib(default=None, type=dict) + callbacks = attr.ib(default=None, type=dict) + extensions = attr.ib(default={}) + + PATTERN = re.compile("^[a-zA-Z0-9.-_]+$") + + def add_component(self, component_type, components): + """ Adds components + + Args: + component_type (ComponentType): type of component + components (dict[str, Any]): key value mapping of components + Raises: + ValueError: If key is not a valid value for regex ``^[a-zA-Z0-9.-_]+$`` + """ + if not components: + return + + attrib_name = component_type.value + values = getattr(self, attrib_name) + if values is None: + values = {} + setattr(self, attrib_name, values) + for key, component in components.items(): + Components.PATTERN.match(key) + values[key] = component + + class OpenApi(ModelMixin): """ This is the root document object of the OpenAPI document. @@ -894,19 +871,15 @@ def __init__( servers=None, external_docs=None, components=None, - security=None, ): self.info = info self.paths = paths self.openapi = version self.tags = tags or [] self.servers = servers or [] - self.components = components or {} + self.components = components or Components() self.external_docs = external_docs - if security: - self.components["securitySchemes"] = security - def add_tag(self, tag): """ Adds a tag to the top level spec diff --git a/src/flaskdoc/swagger/schema.py b/src/flaskdoc/swagger/schema.py index bd07eb3..1a846ba 100644 --- a/src/flaskdoc/swagger/schema.py +++ b/src/flaskdoc/swagger/schema.py @@ -21,7 +21,7 @@ import attr -from flaskdoc.core import ModelMixin +from flaskdoc.core import ExtensionMixin, ModelMixin @attr.s @@ -67,6 +67,7 @@ class Schema(ModelMixin): xml = attr.ib(default=None, type="XML") external_docs = None deprecated = attr.ib(default=None, type=bool) + example = attr.ib(default=None, type=dict) def q_not(self): return self._not @@ -196,6 +197,7 @@ class SchemaFactory(object): ref_base = attr.ib(default="#/components/schemas") schemas = attr.ib(init=False, default={}) + examples = attr.ib(init=False, default={}) def parse_data_fields(self, cls, fields): """ Parses classes implemented using either py37 dataclasses or attrs @@ -273,16 +275,13 @@ def get_schema(self, cls, description=None): if isinstance(cls, enum.EnumMeta): enums = [] - sch = None + sch_type = Schema for c in cls.__members__.values(): v = c._value_ enums.append(v) if v: - sch_typ = SCHEMA_TYPES_MAP.get(type(v)) - sch = sch_typ(enum=enums, description=description) - break - if not sch: - sch = Schema(enum=enums, description=description) + sch_type = SCHEMA_TYPES_MAP.get(type(v)) + sch = sch_type(enum=enums, description=description) # handle custom jo objects elif hasattr(cls, "jo_schema"): sch = cls.jo_schema() @@ -297,48 +296,74 @@ def clear(self): @attr.s -class Content(object): - """ A content container for response and request objects """ +class MediaType(ModelMixin): + """ Each Media Type Object provides schema and examples for the media type identified by its key. """ content_type = attr.ib(type=str) - schema = attr.ib(type=type) - description = attr.ib(default=None, type=str) + schema = attr.ib(default=None, type="Schema") + example = attr.ib(default=None) + examples = attr.ib(default=None, type=dict) def to_schema(self): + if not self.schema: + return None + # handle primitives if self.schema in [str, int, bool, dict]: schema_class = SCHEMA_TYPES_MAP[self.schema] schema = schema_class() - schema.description = self.description or schema.description return schema # handle schema derivatives if isinstance(self.schema, Schema): - self.schema.description = self.description or self.schema.description return self.schema # handle custom class types return schema_factory.get_schema(self.schema) + def to_dict(self): + return dict(schema=self.to_schema(), example=self.example, examples=self.examples,) + @attr.s -class JsonType(Content): +class JsonType(MediaType): """ mime type application/json content type """ content_type = attr.ib(default="application/json", init=False) @attr.s -class PlainText(Content): +class PlainText(MediaType): content_type = attr.ib(default="text/plain", init=False) -class MultipartForm(Content): +@attr.s +class UrlEncodedFormType(MediaType): + content_type = attr.ib(default="application/x-www-form-urlencoded", init=False) + encoding = attr.ib(default=None, type=Dict[str, "Encoding"]) - content_type = "multipart/form-data" + def to_dict(self): + d = super(UrlEncodedFormType, self).to_dict() + d["encoding"] = self.encoding + return d @attr.s -class XmlType(Content): +class MultipartType(UrlEncodedFormType): + + content_type = attr.ib(default="multipart/form-data") + + @content_type.validator + def validate(self, attribute, ctype): + if not ctype.startswith("multipart"): + raise ValueError( + "Attribue {} value must start with multipart and not {}".format( + attribute, self.content_type + ) + ) + + +@attr.s +class XmlType(MediaType): content_type = attr.ib(default="application/xml", init=False) @@ -362,20 +387,10 @@ class MultipartFormData: schema_factory = SchemaFactory() -@attr.s -class MediaType(ModelMixin): - """ Each Media Type Object provides schema and examples for the media type identified by its key. """ - - schema = attr.ib(default=None, type="Schema") - example = attr.ib(default=None) - examples = attr.ib(default=None, type=dict) - encoding = attr.ib(default=None, type=dict) - - @attr.s class ContentMixin(object): - content = attr.ib() # type: Union[Content, List[Content]] + content = attr.ib() # type: Union[MediaType, List[MediaType]] def __attrs_post_init__(self): @@ -386,5 +401,32 @@ def __attrs_post_init__(self): self.content = [self.content] cnt = defaultdict(dict) for content in self.content: - cnt[content.content_type]["schema"] = content.to_schema() + cnt[content.content_type] = content.to_dict() self.content = cnt + + +@attr.s +class Encoding(ExtensionMixin): + """ A single encoding definition applied to a single schema property. """ + + content_type = attr.ib(type=str) + headers = attr.ib(default=None, type=dict) + style = attr.ib(default=None, type="Style") + explode = attr.ib(default=True) + allow_reserved = attr.ib(default=False) + extensions = attr.ib(default={}) + + def add_header(self, name, header): + if not self.headers: + self.headers = {} + self.headers[name] = header + + +@attr.s +class Example(ExtensionMixin): + + summary = attr.ib(default=None, type=str) + description = attr.ib(default=None, type=str) + value = attr.ib(default=None) + external_value = attr.ib(default=None, type=str) + extensions = attr.ib(default={})