123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710 |
- # -*- coding: utf-8 -*-
- from __future__ import unicode_literals, absolute_import
- import itertools
- import re
- from inspect import isclass, getdoc
- from collections import OrderedDict
- try:
- from collections.abc import Hashable
- except ImportError:
- # TODO Remove this to drop Python2 support
- from collections import Hashable
- from six import string_types, itervalues, iteritems, iterkeys
- from flask import current_app
- from werkzeug.routing import parse_rule
- from . import fields
- from .model import Model, ModelBase, OrderedModel
- from .reqparse import RequestParser
- from .utils import merge, not_none, not_none_sorted
- from ._http import HTTPStatus
- try:
- from urllib.parse import quote
- except ImportError:
- from urllib import quote
- #: Maps Flask/Werkzeug rooting types to Swagger ones
- PATH_TYPES = {
- "int": "integer",
- "float": "number",
- "string": "string",
- "default": "string",
- }
- #: Maps Python primitives types to Swagger ones
- PY_TYPES = {
- int: "integer",
- float: "number",
- str: "string",
- bool: "boolean",
- None: "void",
- }
- RE_URL = re.compile(r"<(?:[^:<>]+:)?([^<>]+)>")
- DEFAULT_RESPONSE_DESCRIPTION = "Success"
- DEFAULT_RESPONSE = {"description": DEFAULT_RESPONSE_DESCRIPTION}
- RE_RAISES = re.compile(
- r"^:raises\s+(?P<name>[\w\d_]+)\s*:\s*(?P<description>.*)$", re.MULTILINE
- )
- def ref(model):
- """Return a reference to model in definitions"""
- name = model.name if isinstance(model, ModelBase) else model
- return {"$ref": "#/definitions/{0}".format(quote(name, safe=""))}
- def _v(value):
- """Dereference values (callable)"""
- return value() if callable(value) else value
- def extract_path(path):
- """
- Transform a Flask/Werkzeug URL pattern in a Swagger one.
- """
- return RE_URL.sub(r"{\1}", path)
- def extract_path_params(path):
- """
- Extract Flask-style parameters from an URL pattern as Swagger ones.
- """
- params = OrderedDict()
- for converter, arguments, variable in parse_rule(path):
- if not converter:
- continue
- param = {"name": variable, "in": "path", "required": True}
- if converter in PATH_TYPES:
- param["type"] = PATH_TYPES[converter]
- elif converter in current_app.url_map.converters:
- param["type"] = "string"
- else:
- raise ValueError("Unsupported type converter: %s" % converter)
- params[variable] = param
- return params
- def _param_to_header(param):
- param.pop("in", None)
- param.pop("name", None)
- return _clean_header(param)
- def _clean_header(header):
- if isinstance(header, string_types):
- header = {"description": header}
- typedef = header.get("type", "string")
- if isinstance(typedef, Hashable) and typedef in PY_TYPES:
- header["type"] = PY_TYPES[typedef]
- elif (
- isinstance(typedef, (list, tuple))
- and len(typedef) == 1
- and typedef[0] in PY_TYPES
- ):
- header["type"] = "array"
- header["items"] = {"type": PY_TYPES[typedef[0]]}
- elif hasattr(typedef, "__schema__"):
- header.update(typedef.__schema__)
- else:
- header["type"] = typedef
- return not_none(header)
- def parse_docstring(obj):
- raw = getdoc(obj)
- summary = raw.strip(" \n").split("\n")[0].split(".")[0] if raw else None
- raises = {}
- details = raw.replace(summary, "").lstrip(". \n").strip(" \n") if raw else None
- for match in RE_RAISES.finditer(raw or ""):
- raises[match.group("name")] = match.group("description")
- if details:
- details = details.replace(match.group(0), "")
- parsed = {
- "raw": raw,
- "summary": summary or None,
- "details": details or None,
- "returns": None,
- "params": [],
- "raises": raises,
- }
- return parsed
- def is_hidden(resource, route_doc=None):
- """
- Determine whether a Resource has been hidden from Swagger documentation
- i.e. by using Api.doc(False) decorator
- """
- if route_doc is False:
- return True
- else:
- return hasattr(resource, "__apidoc__") and resource.__apidoc__ is False
- def build_request_body_parameters_schema(body_params):
- """
- :param body_params: List of JSON schema of body parameters.
- :type body_params: list of dict, generated from the json body parameters of a request parser
- :return dict: The Swagger schema representation of the request body
- :Example:
- {
- 'name': 'payload',
- 'required': True,
- 'in': 'body',
- 'schema': {
- 'type': 'object',
- 'properties': [
- 'parameter1': {
- 'type': 'integer'
- },
- 'parameter2': {
- 'type': 'string'
- }
- ]
- }
- }
- """
- properties = {}
- for param in body_params:
- properties[param["name"]] = {"type": param.get("type", "string")}
- return {
- "name": "payload",
- "required": True,
- "in": "body",
- "schema": {"type": "object", "properties": properties},
- }
- class Swagger(object):
- """
- A Swagger documentation wrapper for an API instance.
- """
- def __init__(self, api):
- self.api = api
- self._registered_models = {}
- def as_dict(self):
- """
- Output the specification as a serializable ``dict``.
- :returns: the full Swagger specification in a serializable format
- :rtype: dict
- """
- basepath = self.api.base_path
- if len(basepath) > 1 and basepath.endswith("/"):
- basepath = basepath[:-1]
- infos = {
- "title": _v(self.api.title),
- "version": _v(self.api.version),
- }
- if self.api.description:
- infos["description"] = _v(self.api.description)
- if self.api.terms_url:
- infos["termsOfService"] = _v(self.api.terms_url)
- if self.api.contact and (self.api.contact_email or self.api.contact_url):
- infos["contact"] = {
- "name": _v(self.api.contact),
- "email": _v(self.api.contact_email),
- "url": _v(self.api.contact_url),
- }
- if self.api.license:
- infos["license"] = {"name": _v(self.api.license)}
- if self.api.license_url:
- infos["license"]["url"] = _v(self.api.license_url)
- paths = {}
- tags = self.extract_tags(self.api)
- # register errors
- responses = self.register_errors()
- for ns in self.api.namespaces:
- for resource, urls, route_doc, kwargs in ns.resources:
- for url in self.api.ns_urls(ns, urls):
- path = extract_path(url)
- serialized = self.serialize_resource(
- ns, resource, url, route_doc=route_doc, **kwargs
- )
- paths[path] = serialized
- # register all models if required
- if current_app.config["RESTX_INCLUDE_ALL_MODELS"]:
- for m in self.api.models:
- self.register_model(m)
- # merge in the top-level authorizations
- for ns in self.api.namespaces:
- if ns.authorizations:
- if self.api.authorizations is None:
- self.api.authorizations = {}
- self.api.authorizations = merge(
- self.api.authorizations, ns.authorizations
- )
- specs = {
- "swagger": "2.0",
- "basePath": basepath,
- "paths": not_none_sorted(paths),
- "info": infos,
- "produces": list(iterkeys(self.api.representations)),
- "consumes": ["application/json"],
- "securityDefinitions": self.api.authorizations or None,
- "security": self.security_requirements(self.api.security) or None,
- "tags": tags,
- "definitions": self.serialize_definitions() or None,
- "responses": responses or None,
- "host": self.get_host(),
- }
- return not_none(specs)
- def get_host(self):
- hostname = current_app.config.get("SERVER_NAME", None) or None
- if hostname and self.api.blueprint and self.api.blueprint.subdomain:
- hostname = ".".join((self.api.blueprint.subdomain, hostname))
- return hostname
- def extract_tags(self, api):
- tags = []
- by_name = {}
- for tag in api.tags:
- if isinstance(tag, string_types):
- tag = {"name": tag}
- elif isinstance(tag, (list, tuple)):
- tag = {"name": tag[0], "description": tag[1]}
- elif isinstance(tag, dict) and "name" in tag:
- pass
- else:
- raise ValueError("Unsupported tag format for {0}".format(tag))
- tags.append(tag)
- by_name[tag["name"]] = tag
- for ns in api.namespaces:
- # hide namespaces without any Resources
- if not ns.resources:
- continue
- # hide namespaces with all Resources hidden from Swagger documentation
- if all(is_hidden(r.resource, route_doc=r.route_doc) for r in ns.resources):
- continue
- if ns.name not in by_name:
- tags.append(
- {"name": ns.name, "description": ns.description}
- if ns.description
- else {"name": ns.name}
- )
- elif ns.description:
- by_name[ns.name]["description"] = ns.description
- return tags
- def extract_resource_doc(self, resource, url, route_doc=None):
- route_doc = {} if route_doc is None else route_doc
- if route_doc is False:
- return False
- doc = merge(getattr(resource, "__apidoc__", {}), route_doc)
- if doc is False:
- return False
- # ensure unique names for multiple routes to the same resource
- # provides different Swagger operationId's
- doc["name"] = (
- "{}_{}".format(resource.__name__, url) if route_doc else resource.__name__
- )
- params = merge(self.expected_params(doc), doc.get("params", OrderedDict()))
- params = merge(params, extract_path_params(url))
- # Track parameters for late deduplication
- up_params = {(n, p.get("in", "query")): p for n, p in params.items()}
- need_to_go_down = set()
- methods = [m.lower() for m in resource.methods or []]
- for method in methods:
- method_doc = doc.get(method, OrderedDict())
- method_impl = getattr(resource, method)
- if hasattr(method_impl, "im_func"):
- method_impl = method_impl.im_func
- elif hasattr(method_impl, "__func__"):
- method_impl = method_impl.__func__
- method_doc = merge(
- method_doc, getattr(method_impl, "__apidoc__", OrderedDict())
- )
- if method_doc is not False:
- method_doc["docstring"] = parse_docstring(method_impl)
- method_params = self.expected_params(method_doc)
- method_params = merge(method_params, method_doc.get("params", {}))
- inherited_params = OrderedDict(
- (k, v) for k, v in iteritems(params) if k in method_params
- )
- method_doc["params"] = merge(inherited_params, method_params)
- for name, param in method_doc["params"].items():
- key = (name, param.get("in", "query"))
- if key in up_params:
- need_to_go_down.add(key)
- doc[method] = method_doc
- # Deduplicate parameters
- # For each couple (name, in), if a method overrides it,
- # we need to move the paramter down to each method
- if need_to_go_down:
- for method in methods:
- method_doc = doc.get(method)
- if not method_doc:
- continue
- params = {
- (n, p.get("in", "query")): p
- for n, p in (method_doc["params"] or {}).items()
- }
- for key in need_to_go_down:
- if key not in params:
- method_doc["params"][key[0]] = up_params[key]
- doc["params"] = OrderedDict(
- (k[0], p) for k, p in up_params.items() if k not in need_to_go_down
- )
- return doc
- def expected_params(self, doc):
- params = OrderedDict()
- if "expect" not in doc:
- return params
- for expect in doc.get("expect", []):
- if isinstance(expect, RequestParser):
- parser_params = OrderedDict(
- (p["name"], p) for p in expect.__schema__ if p["in"] != "body"
- )
- params.update(parser_params)
- body_params = [p for p in expect.__schema__ if p["in"] == "body"]
- if body_params:
- params["payload"] = build_request_body_parameters_schema(
- body_params
- )
- elif isinstance(expect, ModelBase):
- params["payload"] = not_none(
- {
- "name": "payload",
- "required": True,
- "in": "body",
- "schema": self.serialize_schema(expect),
- }
- )
- elif isinstance(expect, (list, tuple)):
- if len(expect) == 2:
- # this is (payload, description) shortcut
- model, description = expect
- params["payload"] = not_none(
- {
- "name": "payload",
- "required": True,
- "in": "body",
- "schema": self.serialize_schema(model),
- "description": description,
- }
- )
- else:
- params["payload"] = not_none(
- {
- "name": "payload",
- "required": True,
- "in": "body",
- "schema": self.serialize_schema(expect),
- }
- )
- return params
- def register_errors(self):
- responses = {}
- for exception, handler in iteritems(self.api.error_handlers):
- doc = parse_docstring(handler)
- response = {"description": doc["summary"]}
- apidoc = getattr(handler, "__apidoc__", {})
- self.process_headers(response, apidoc)
- if "responses" in apidoc:
- _, model, _ = list(apidoc["responses"].values())[0]
- response["schema"] = self.serialize_schema(model)
- responses[exception.__name__] = not_none(response)
- return responses
- def serialize_resource(self, ns, resource, url, route_doc=None, **kwargs):
- doc = self.extract_resource_doc(resource, url, route_doc=route_doc)
- if doc is False:
- return
- path = {"parameters": self.parameters_for(doc) or None}
- for method in [m.lower() for m in resource.methods or []]:
- methods = [m.lower() for m in kwargs.get("methods", [])]
- if doc[method] is False or methods and method not in methods:
- continue
- path[method] = self.serialize_operation(doc, method)
- path[method]["tags"] = [ns.name]
- return not_none(path)
- def serialize_operation(self, doc, method):
- operation = {
- "responses": self.responses_for(doc, method) or None,
- "summary": doc[method]["docstring"]["summary"],
- "description": self.description_for(doc, method) or None,
- "operationId": self.operation_id_for(doc, method),
- "parameters": self.parameters_for(doc[method]) or None,
- "security": self.security_for(doc, method),
- }
- # Handle 'produces' mimetypes documentation
- if "produces" in doc[method]:
- operation["produces"] = doc[method]["produces"]
- # Handle deprecated annotation
- if doc.get("deprecated") or doc[method].get("deprecated"):
- operation["deprecated"] = True
- # Handle form exceptions:
- doc_params = list(doc.get("params", {}).values())
- all_params = doc_params + (operation["parameters"] or [])
- if all_params and any(p["in"] == "formData" for p in all_params):
- if any(p["type"] == "file" for p in all_params):
- operation["consumes"] = ["multipart/form-data"]
- else:
- operation["consumes"] = [
- "application/x-www-form-urlencoded",
- "multipart/form-data",
- ]
- operation.update(self.vendor_fields(doc, method))
- return not_none(operation)
- def vendor_fields(self, doc, method):
- """
- Extract custom 3rd party Vendor fields prefixed with ``x-``
- See: https://swagger.io/specification/#specification-extensions
- """
- return dict(
- (k if k.startswith("x-") else "x-{0}".format(k), v)
- for k, v in iteritems(doc[method].get("vendor", {}))
- )
- def description_for(self, doc, method):
- """Extract the description metadata and fallback on the whole docstring"""
- parts = []
- if "description" in doc:
- parts.append(doc["description"] or "")
- if method in doc and "description" in doc[method]:
- parts.append(doc[method]["description"])
- if doc[method]["docstring"]["details"]:
- parts.append(doc[method]["docstring"]["details"])
- return "\n".join(parts).strip()
- def operation_id_for(self, doc, method):
- """Extract the operation id"""
- return (
- doc[method]["id"]
- if "id" in doc[method]
- else self.api.default_id(doc["name"], method)
- )
- def parameters_for(self, doc):
- params = []
- for name, param in iteritems(doc["params"]):
- param["name"] = name
- if "type" not in param and "schema" not in param:
- param["type"] = "string"
- if "in" not in param:
- param["in"] = "query"
- if "type" in param and "schema" not in param:
- ptype = param.get("type", None)
- if isinstance(ptype, (list, tuple)):
- typ = ptype[0]
- param["type"] = "array"
- param["items"] = {"type": PY_TYPES.get(typ, typ)}
- elif isinstance(ptype, (type, type(None))) and ptype in PY_TYPES:
- param["type"] = PY_TYPES[ptype]
- params.append(param)
- # Handle fields mask
- mask = doc.get("__mask__")
- if mask and current_app.config["RESTX_MASK_SWAGGER"]:
- param = {
- "name": current_app.config["RESTX_MASK_HEADER"],
- "in": "header",
- "type": "string",
- "format": "mask",
- "description": "An optional fields mask",
- }
- if isinstance(mask, string_types):
- param["default"] = mask
- params.append(param)
- return params
- def responses_for(self, doc, method):
- # TODO: simplify/refactor responses/model handling
- responses = {}
- for d in doc, doc[method]:
- if "responses" in d:
- for code, response in iteritems(d["responses"]):
- code = str(code)
- if isinstance(response, string_types):
- description = response
- model = None
- kwargs = {}
- elif len(response) == 3:
- description, model, kwargs = response
- elif len(response) == 2:
- description, model = response
- kwargs = {}
- else:
- raise ValueError("Unsupported response specification")
- description = description or DEFAULT_RESPONSE_DESCRIPTION
- if code in responses:
- responses[code].update(description=description)
- else:
- responses[code] = {"description": description}
- if model:
- schema = self.serialize_schema(model)
- envelope = kwargs.get("envelope")
- if envelope:
- schema = {"properties": {envelope: schema}}
- responses[code]["schema"] = schema
- self.process_headers(
- responses[code], doc, method, kwargs.get("headers")
- )
- if "model" in d:
- code = str(d.get("default_code", HTTPStatus.OK))
- if code not in responses:
- responses[code] = self.process_headers(
- DEFAULT_RESPONSE.copy(), doc, method
- )
- responses[code]["schema"] = self.serialize_schema(d["model"])
- if "docstring" in d:
- for name, description in iteritems(d["docstring"]["raises"]):
- for exception, handler in iteritems(self.api.error_handlers):
- error_responses = getattr(handler, "__apidoc__", {}).get(
- "responses", {}
- )
- code = (
- str(list(error_responses.keys())[0])
- if error_responses
- else None
- )
- if code and exception.__name__ == name:
- responses[code] = {"$ref": "#/responses/{0}".format(name)}
- break
- if not responses:
- responses[str(HTTPStatus.OK.value)] = self.process_headers(
- DEFAULT_RESPONSE.copy(), doc, method
- )
- return responses
- def process_headers(self, response, doc, method=None, headers=None):
- method_doc = doc.get(method, {})
- if "headers" in doc or "headers" in method_doc or headers:
- response["headers"] = dict(
- (k, _clean_header(v))
- for k, v in itertools.chain(
- iteritems(doc.get("headers", {})),
- iteritems(method_doc.get("headers", {})),
- iteritems(headers or {}),
- )
- )
- return response
- def serialize_definitions(self):
- return dict(
- (name, model.__schema__)
- for name, model in iteritems(self._registered_models)
- )
- def serialize_schema(self, model):
- if isinstance(model, (list, tuple)):
- model = model[0]
- return {
- "type": "array",
- "items": self.serialize_schema(model),
- }
- elif isinstance(model, ModelBase):
- self.register_model(model)
- return ref(model)
- elif isinstance(model, string_types):
- self.register_model(model)
- return ref(model)
- elif isclass(model) and issubclass(model, fields.Raw):
- return self.serialize_schema(model())
- elif isinstance(model, fields.Raw):
- return model.__schema__
- elif isinstance(model, (type, type(None))) and model in PY_TYPES:
- return {"type": PY_TYPES[model]}
- raise ValueError("Model {0} not registered".format(model))
- def register_model(self, model):
- name = model.name if isinstance(model, ModelBase) else model
- if name not in self.api.models:
- raise ValueError("Model {0} not registered".format(name))
- specs = self.api.models[name]
- if name in self._registered_models:
- return ref(model)
- self._registered_models[name] = specs
- if isinstance(specs, ModelBase):
- for parent in specs.__parents__:
- self.register_model(parent)
- if isinstance(specs, (Model, OrderedModel)):
- for field in itervalues(specs):
- self.register_field(field)
- return ref(model)
- def register_field(self, field):
- if isinstance(field, fields.Polymorph):
- for model in itervalues(field.mapping):
- self.register_model(model)
- elif isinstance(field, fields.Nested):
- self.register_model(field.nested)
- elif isinstance(field, (fields.List, fields.Wildcard)):
- self.register_field(field.container)
- def security_for(self, doc, method):
- security = None
- if "security" in doc:
- auth = doc["security"]
- security = self.security_requirements(auth)
- if "security" in doc[method]:
- auth = doc[method]["security"]
- security = self.security_requirements(auth)
- return security
- def security_requirements(self, value):
- if isinstance(value, (list, tuple)):
- return [self.security_requirement(v) for v in value]
- elif value:
- requirement = self.security_requirement(value)
- return [requirement] if requirement else None
- else:
- return []
- def security_requirement(self, value):
- if isinstance(value, (string_types)):
- return {value: []}
- elif isinstance(value, dict):
- return dict(
- (k, v if isinstance(v, (list, tuple)) else [v])
- for k, v in iteritems(value)
- )
- else:
- return None
|