class ApiSpecPlugin(BasePlugin, DocBlueprintMixin): """Плагин для связки json_api и swagger""" def __init__(self, app=None, spec_kwargs=None, decorators=None, tags: Dict[str, str] = None): """ :param spec_kwargs: :param decorators: :param tags: {'<name tag>': '<description tag>'} """ self.decorators_for_autodoc = decorators or tuple() self.spec_kwargs = spec_kwargs if spec_kwargs is not None else {} self.spec = None self.spec_tag = {} self.spec_schemas = {} self.app = None self._fields = [] # Use lists to enforce order self._fields = [] self._converters = [] # Инициализация ApiSpec self.app = app self._app = app # Initialize spec openapi_version = app.config.get("OPENAPI_VERSION", "2.0") openapi_major_version = int(openapi_version.split(".")[0]) if openapi_major_version < 3: base_path = app.config.get("APPLICATION_ROOT") # Don't pass basePath if '/' to avoid a bug in apispec # https://github.com/marshmallow-code/apispec/issues/78#issuecomment-431854606 # TODO: Remove this condition when the bug is fixed if base_path != "/": self.spec_kwargs.setdefault("basePath", base_path) self.spec_kwargs.update(app.config.get("API_SPEC_OPTIONS", {})) self.spec = APISpec( app.name, app.config.get("API_VERSION", "1"), openapi_version=openapi_version, plugins=[MarshmallowPlugin(), RestfulPlugin()], **self.spec_kwargs, ) tags = tags if tags else {} for tag_name, tag_description in tags.items(): self.spec_tag[tag_name] = { "name": tag_name, "description": tag_description, "add_in_spec": False } self._add_tags_in_spec(self.spec_tag[tag_name]) def after_init_plugin(self, *args, app=None, **kwargs): # Register custom fields in spec for args in self._fields: self.spec.register_field(*args) # Register custom converters in spec for args in self._converters: self.spec.register_converter(*args) # Initialize blueprint serving spec self._register_doc_blueprint() def after_route( self, resource: Union[ResourceList, ResourceDetail] = None, view=None, urls: Tuple[str] = None, self_json_api: Api = None, tag: str = None, default_parameters=None, default_schema: Schema = None, **kwargs, ) -> None: """ :param resource: :param view: :param urls: :param self_json_api: :param str tag: тег под которым стоит связать этот ресурс :param default_parameters: дефолтные поля для ресурса в сваггер (иначе просто инициализируется []) :param Schema default_schema: схема, которая подставиться вместо схемы в стили json api :param kwargs: :return: """ # Register views in API documentation for this resource # resource.register_views_in_doc(self._app, self.spec) # Add tag relative to this resource to the global tag list # We add definitions (models) to the apiscpec if resource.schema: self._add_definitions_in_spec(resource.schema) # We add tags to the apiscpec tag_name = view.title() if tag is None and view.title() not in self.spec_tag: dict_tag = { "name": view.title(), "description": "", "add_in_spec": False } self.spec_tag[dict_tag["name"]] = dict_tag self._add_tags_in_spec(dict_tag) elif tag: tag_name = self.spec_tag[tag]["name"] urls = urls if urls else tuple() for i_url in urls: self._add_paths_in_spec( path=i_url, resource=resource, default_parameters=default_parameters, default_schema=default_schema, tag_name=tag_name, **kwargs, ) @property def param_id(self) -> dict: return { "in": "path", "name": "id", "required": True, "type": "integer", "format": "int32", } @classmethod def _get_operations_for_all(cls, tag_name: str, default_parameters: list) -> Dict[str, Any]: """ Creating base dict :param tag_name: :param default_parameters: :return: """ return { "tags": [tag_name], "produces": ["application/json"], "parameters": default_parameters if default_parameters else [], } @classmethod def __get_parameters_for_include_models(cls, resource: Resource) -> dict: fields_names = [ i_field_name for i_field_name, i_field in resource.schema._declared_fields.items() if isinstance(i_field, Relationship) ] models_for_include = ",".join(fields_names) example_models_for_include = "\n".join( [f"`{f}`" for f in fields_names]) return { "default": models_for_include, "name": "include", "in": "query", "format": "string", "required": False, "description": f"Related relationships to include.\nAvailable:\n{example_models_for_include}", } @classmethod def __get_parameters_for_sparse_fieldsets(cls, resource: Resource, description: str) -> dict: # Sparse Fieldsets return { "name": f"fields[{resource.schema.Meta.type_}]", "in": "query", "type": "array", "required": False, "description": description.format(resource.schema.Meta.type_), "items": { "type": "string", "enum": list(resource.schema._declared_fields.keys()) }, } def __get_parameters_for_declared_fields( self, resource, description) -> Generator[dict, None, None]: type_schemas = {resource.schema.Meta.type_} for i_field_name, i_field in resource.schema._declared_fields.items(): if not (isinstance(i_field, Relationship) and i_field.schema.Meta.type_ not in type_schemas): continue schema_name = create_schema_name(schema=i_field.schema) new_parameter = { "name": f"fields[{i_field.schema.Meta.type_}]", "in": "query", "type": "array", "required": False, "description": description.format(i_field.schema.Meta.type_), "items": { "type": "string", "enum": list(self.spec.components.schemas[schema_name] ["properties"].keys()), }, } type_schemas.add(i_field.schema.Meta.type_) yield new_parameter @property def __list_filters_data(self) -> tuple: return ( { "default": 1, "name": "page[number]", "in": "query", "format": "int64", "required": False, "description": "Page offset", }, { "default": 10, "name": "page[size]", "in": "query", "format": "int64", "required": False, "description": "Max number of items", }, { "name": "sort", "in": "query", "format": "string", "required": False, "description": "Sort", }, { "name": "filter", "in": "query", "format": "string", "required": False, "description": "Filter (https://flask-combo-jsonapi.readthedocs.io/en/latest/filtering.html)", }, ) @classmethod def _update_parameter_for_field_spec(cls, new_param: dict, fld_sped: dict) -> None: """ :param new_param: :param fld_sped: :return: """ if "items" in fld_sped: new_items = { "type": fld_sped["items"].get("type"), } if "enum" in fld_sped["items"]: new_items["enum"] = fld_sped["items"]["enum"] new_param.update({"items": new_items}) def __get_parameter_for_not_nested(self, field_name, field_spec) -> dict: new_parameter = { "name": f"filter[{field_name}]", "in": "query", "type": field_spec.get("type"), "required": False, "description": f"{field_name} attribute filter", } self._update_parameter_for_field_spec(new_parameter, field_spec) return new_parameter def __get_parameter_for_nested_with_filtering(self, field_name, field_jsonb_name, field_jsonb_spec): new_parameter = { "name": f"filter[{field_name}{SPLIT_REL}{field_jsonb_name}]", "in": "query", "type": field_jsonb_spec.get("type"), "required": False, "description": f"{field_name}{SPLIT_REL}{field_jsonb_name} attribute filter", } self._update_parameter_for_field_spec(new_parameter, field_jsonb_spec) return new_parameter def __get_parameters_for_nested_with_filtering( self, field, field_name) -> Generator[dict, None, None]: # Allow JSONB filtering field_schema_name = create_schema_name(schema=field.schema) component_schema = self.spec.components.schemas[field_schema_name] for i_field_jsonb_name, i_field_jsonb in field.schema._declared_fields.items( ): i_field_jsonb_spec = component_schema["properties"][ i_field_jsonb_name] if i_field_jsonb_spec.get("type") == "object": # Пропускаем создание фильтров для dict. Просто не понятно как фильтровать по таким # полям continue new_parameter = self.__get_parameter_for_nested_with_filtering( field_name, i_field_jsonb_name, i_field_jsonb_spec, ) yield new_parameter def __get_list_resource_fields_filters( self, resource) -> Generator[dict, None, None]: schema_name = create_schema_name(schema=resource.schema) for i_field_name, i_field in resource.schema._declared_fields.items(): i_field_spec = self.spec.components.schemas[schema_name][ "properties"][i_field_name] if not isinstance(i_field, fields.Nested): if i_field_spec.get("type") == "object": # Skip filtering by dicts continue yield self.__get_parameter_for_not_nested( i_field_name, i_field_spec) elif getattr(i_field.schema.Meta, "filtering", False): yield from self.__get_parameters_for_nested_with_filtering( i_field, i_field_name) def _get_operations_for_get(self, resource, tag_name, default_parameters): operations_get = self._get_operations_for_all(tag_name, default_parameters) operations_get["responses"] = { **status[HTTPStatus.OK], **status[HTTPStatus.NOT_FOUND], } if issubclass(resource, ResourceDetail): operations_get["parameters"].append(self.param_id) if resource.schema is None: return operations_get description = "List that refers to the name(s) of the fields to be returned `{}`" operations_get["parameters"].extend(( self.__get_parameters_for_include_models(resource), self.__get_parameters_for_sparse_fieldsets(resource, description), )) operations_get["parameters"].extend( self.__get_parameters_for_declared_fields(resource, description)) if issubclass(resource, ResourceList): operations_get["parameters"].extend(self.__list_filters_data) operations_get["parameters"].extend( self.__get_list_resource_fields_filters(resource)) return operations_get def _get_operations_for_post(self, schema: dict, tag_name: str, default_parameters: list) -> dict: operations = self._get_operations_for_all(tag_name, default_parameters) operations["responses"] = { "201": { "description": "Created" }, "202": { "description": "Accepted" }, "403": { "description": "This implementation does not accept client-generated IDs" }, "404": { "description": "Not Found" }, "409": { "description": "Conflict" }, } operations["parameters"].append({ "name": "POST body", "in": "body", "schema": schema, "required": True, "description": f"{tag_name} attributes", }) return operations def _get_operations_for_patch(self, schema: dict, tag_name: str, default_parameters: list) -> dict: operations = self._get_operations_for_all(tag_name, default_parameters) operations["responses"] = { "200": { "description": "Success" }, "201": { "description": "Created" }, "204": { "description": "No Content" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" }, "409": { "description": "Conflict" }, } operations["parameters"].append(self.param_id) operations["parameters"].append({ "name": "POST body", "in": "body", "schema": schema, "required": True, "description": f"{tag_name} attributes", }) return operations def _get_operations_for_delete(self, tag_name: str, default_parameters: list) -> dict: operations = self._get_operations_for_all(tag_name, default_parameters) operations["parameters"].append(self.param_id) operations["responses"] = { "200": { "description": "Success" }, "202": { "description": "Accepted" }, "204": { "description": "No Content" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" }, } return operations def _add_paths_in_spec( self, path: str = "", resource: Any = None, tag_name: str = "", default_parameters: List = None, default_schema: Schema = None, **kwargs, ) -> None: operations = {} methods: Set[str] = {i_method.lower() for i_method in resource.methods} attributes = {} if resource.schema: attributes = self.spec.get_ref("schema", create_schema_name(resource.schema)) schema = (default_schema if default_schema else { "type": "object", "properties": { "data": { "type": "object", "properties": { "type": { "type": "string", }, "id": { "type": "string", }, "attributes": attributes, "relationships": { "type": "object", }, }, "required": [ "type", ], }, }, }) if "get" in methods: operations["get"] = self._get_operations_for_get( resource, tag_name, default_parameters) if "post" in methods: operations["post"] = self._get_operations_for_post( schema, tag_name, default_parameters) if "patch" in methods: operations["patch"] = self._get_operations_for_patch( schema, tag_name, default_parameters) if "delete" in methods: operations["delete"] = self._get_operations_for_delete( tag_name, default_parameters) rule = None for i_rule in self.app.url_map._rules: if i_rule.rule == path: rule = i_rule break if APISPEC_VERSION_MAJOR < 1: self.spec.add_path(path=path, operations=operations, rule=rule, resource=resource, **kwargs) else: self.spec.path(path=path, operations=operations, rule=rule, resource=resource, **kwargs) def _add_definitions_in_spec(self, schema) -> None: """ Add schema in spec :param schema: schema marshmallow :return: """ name_schema = create_schema_name(schema) if name_schema not in self.spec_schemas and name_schema not in self.spec.components.schemas: self.spec_schemas[name_schema] = schema if APISPEC_VERSION_MAJOR < 1: self.spec.definition(name_schema, schema=schema) else: self.spec.components.schema(name_schema, schema=schema) def _add_tags_in_spec(self, tag: Dict[str, str]) -> None: """ Add tags in spec :param tag: {'name': '<name tag>', 'description': '<tag description>', 'add_in_spec': <added tag in spec?>} :return: """ if tag.get("add_in_spec", True) is False: self.spec_tag[tag["name"]]["add_in_spec"] = True tag_in_spec = { "name": tag["name"], "description": tag["description"] } if APISPEC_VERSION_MAJOR < 1: self.spec.add_tag(tag_in_spec) else: self.spec.tag(tag_in_spec)
class Spec(object): title = 'Swagman' version = '1.0.0' description = 'A sample description' openapi_version = '3.0.0' def __init__(self, ignoreschema=None, **options): self.spec = APISpec(title=self.title, version=self.version, openapi_version=self.openapi_version, plugins=[], **options) self.ignoreschema = ignoreschema self._counter = {'example': {}, 'schema': {}} self._examples = {} def set_title(self, title=None): self.spec.title = title or self.title def set_version(self, version=None): self.spec.version = version or self.version def set_description(self, description=''): self.spec.options['info'] = dict( description=(description or self.description)) def add_component_response(self, name, schema): self.spec.components.response(name, schema) return self def add_component_example(self, name, schema): if self._counter['example'].get(name, None) is None: self._counter['example'] = {name: 0} _name = name else: self._counter['example'][name] += 1 _name = name + '_' + str(self._counter['example'][name]) try: self.spec.components.example(_name, schema) except DuplicateComponentNameError as e: # new schema has same repr? self.add_component_example(name, schema) return self def add_component_schema(self, name, schema): if self._counter['schema'].get(name, None) is None: self._counter['schema'] = {name: 0} _name = name else: self._counter['schema'][name] += 1 _name = name + '_' + str(self._counter['schema'][name]) try: self.spec.components.schema(_name, schema) except DuplicateComponentNameError as e: # new schema has same repr? self.add_component_schema(name, schema) return self def get_params(self, request): requestparams = [] params = request.getParams() for location, param in params.items(): for eachparam in param: schema = dict(type='string') if eachparam.get('type', None) is not None: schema['type'] = postman_to_openapi_typemap.get( eachparam.get('type'), 'string') if eachparam.get('value', None) is not None: schema['default'] = eachparam.get('value') schema['example'] = eachparam.get('value') requestparams.append({ "in": location, "name": eachparam.get('name', ''), "schema": schema }) return requestparams def json_get_path(self, match): '''return an iterator based upon MATCH.PATH. Each item is a path component, start from outer most item.''' if match.context is not None: for path_element in self.json_get_path(match.context): yield path_element yield str(match.path) def json_update_path(self, json, path, value): '''Update JSON dictionnary PATH with VALUE. Return updated JSON''' try: first = next(path) # check if item is an array if first.startswith('[') and first.endswith(']'): try: first = int(first[1:-1]) except ValueError: pass json[first] = self.json_update_path(json[first], path, value) return json except StopIteration: return value def getFilters(self, path, method, code): if not len(self.ignoreschema.keys()): return [] for _path, schemas in self.ignoreschema['schema'].items(): if PostmanParser.camelize(_path) == path: for _method, responsecode in schemas.items(): if _method == method: return responsecode.get(code, []) return [] def parse_skip(self, expr): type_explode = expr.split(':') if len(type_explode) > 1 and type_explode[-1] == 'a': return ''.join(type_explode[:-1]), True return expr, False def filterResponse(self, path, method, code, response): responsejson = response.getBody() filters = self.getFilters(path, method, code) if len(filters) > 0: for jsonfilter in filters: expr, skip = self.parse_skip(jsonfilter) expr = expr if expr else jsonfilter jsonpath_expr = jsonpath_rw.parse(expr) matches = jsonpath_expr.find(responsejson) ignoreprop = PostmanParser.IGNOREPROPKEYVAL if skip else PostmanParser.IGNOREPROP for match in matches: responsejson = self.json_update_path( responsejson, self.json_get_path(match), ignoreprop) return responsejson def get_operations(self, item): operations = dict( get=dict(responses=dict()), post=dict(responses=dict()), put=dict(responses=dict()), delete=dict(responses=dict()), head=dict(responses=dict()), ) camelizeKey = PostmanParser.camelize( item['request'].getPathNormalised()) requestbody = item['request'].getBody() requestbodyschema = PostmanParser.schemawalker(requestbody) requestbodytype = item['request'].getBodyContent() for response in item['responses']: if not item['request'].getBody(): requestbody = response.getRequestBody() requestbodyschema = PostmanParser.schemawalker(requestbody) requestbodytype = response.getRequestHeader('Content-Type') code = response.getCode() reqtype = response.getMethod().lower() responseBody = self.filterResponse(camelizeKey, reqtype, code, response) responseSchema = PostmanParser.schemawalker(responseBody) camelizeKeyExample = PostmanParser.camelize(response.getName()) ref = self.add_component_schema((camelizeKey + str(code)), responseSchema) if requestbody: self.set_example(('request' + camelizeKey), camelizeKeyExample, dict(value=requestbody)) self.set_example( ('response' + camelizeKey + str(code)), camelizeKeyExample, dict(value=response.getBody())) operations[reqtype]['operationId'] = camelizeKey + reqtype operations[reqtype]['parameters'] = self.get_params( item['request']) if requestbody: operations[reqtype]['requestBody'] = dict( content={ requestbodytype: dict(schema=requestbodyschema, examples=self.get_example(( 'request' + camelizeKey), camelizeKeyExample)) }) operations[reqtype]['responses'][code] = { 'description': response.getName(), 'content': { response.getHeader('Content-Type'): { "schema": self.get_ref('schema', (camelizeKey + str(code))), "examples": self.get_example( ('response' + camelizeKey + str(code)), camelizeKeyExample) } } } # Reset schema counter self._counter = {'example': {}, 'schema': {}} # Return new dict copy from original, containing only filled responses # since python3 doesn't allow mutating dict during iteration, that's # the best I can do currently. #@TODO fix this please newdict = dict() for k, v in operations.items(): if v['responses']: newdict[k] = v return newdict def set_example(self, responseKey, exampleKey, exampleBody): if responseKey in self._examples: self._examples[responseKey][exampleKey] = exampleBody else: self._examples[responseKey] = {exampleKey: exampleBody} self.add_component_example((responseKey + exampleKey), exampleBody) def get_example(self, responseKey, exampleKey): examples = self._examples[responseKey] for exampleKey, example in examples.items(): ref = self.get_ref('example', (responseKey + exampleKey)) examples[exampleKey] = ref return examples def get_ref(self, holder, name): refs = self.spec.get_ref(holder, name) counter = self._counter[holder].get(name, 0) if counter > 0: refs = dict(oneOf=[refs]) for i in range(1, (counter + 1)): ref = self.spec.get_ref(holder, name + '_' + str(i)) refs['oneOf'].append(ref) return refs def add_item(self, item): operations = self.get_operations(item) self.spec.path(path=item['request'].getPathNormalised(), operations=operations) return self def to_dict(self): return self.spec.to_dict() def to_yaml(self): return self.spec.to_yaml()