From 4b1ba64e72f3050f483c84b1eaf91661030072c5 Mon Sep 17 00:00:00 2001 From: Evgeniy Alekseev Date: Mon, 24 Apr 2023 20:38:49 +0300 Subject: [PATCH] Set body parameters to requestBody field in case if openapi 3 is used According to documentation https://apispec.readthedocs.io/en/latest/api_ext.html#apispec.ext.marshmallow.openapi.OpenAPIConverter.schema2parameters in=body parameter is no longer allowed for parameters list and must be set to requestBody --- aiohttp_apispec/aiohttp_apispec.py | 56 ++++++- aiohttp_apispec/middlewares.py | 2 +- tests/conftest.py | 13 +- tests/test_documentation.py | 230 +++++++++++++++++++++-------- 4 files changed, 228 insertions(+), 73 deletions(-) diff --git a/aiohttp_apispec/aiohttp_apispec.py b/aiohttp_apispec/aiohttp_apispec.py index a47e01a..c50ac7d 100644 --- a/aiohttp_apispec/aiohttp_apispec.py +++ b/aiohttp_apispec/aiohttp_apispec.py @@ -183,7 +183,6 @@ def _register(self, app: web.Application): def _register_route( self, route: web.AbstractRoute, method: str, view: _AiohttpView ): - if not hasattr(view, "__apispec__"): return None @@ -197,11 +196,29 @@ def _update_paths(self, data: dict, method: str, url_path: str): if method not in VALID_METHODS_OPENAPI_V2: return None for schema in data.pop("schemas", []): - parameters = self.plugin.converter.schema2parameters( - schema["schema"], location=schema["location"], **schema["options"] - ) - self._add_examples(schema["schema"], parameters, schema["example"]) - data["parameters"].extend(parameters) + if ( + self.spec.components.openapi_version.major > 2 + and self._is_body_location(schema["location"]) + ): + # in OpenAPI 3.0 in=body parameters must be put into requestBody + # https://apispec.readthedocs.io/en/latest/api_ext.html#apispec.ext.marshmallow.openapi.OpenAPIConverter.schema2parameters + # lets reinvent something that works, because apispec doesn't provide anything with which we could work + body = dict(**schema["options"]) + body["schema"] = self.plugin.converter.resolve_nested_schema( + schema["schema"] + ) + self._add_examples(schema["schema"], [body], schema["example"]) + data["requestBody"] = { + "content": { + self._content_type(schema["location"]): body, + }, + } + else: + parameters = self.plugin.converter.schema2parameters( + schema["schema"], location=schema["location"], **schema["options"] + ) + self._add_examples(schema["schema"], parameters, schema["example"]) + data["parameters"].extend(parameters) existing = [p["name"] for p in data["parameters"] if p["in"] == "path"] data["parameters"].extend( @@ -264,6 +281,33 @@ def add_to_endpoint_or_ref(): else: add_to_endpoint_or_ref() + def _content_type(self, location): + """ + extract body content type from parameters location + + :param location: body location name, e.g. json, form etc + :return: return valid content-type header + """ + if not self._is_body_location(location): + raise ValueError( + f"Illegal location {location}, cannnot be converted to body" + ) + if location == "json": + return "application/json" + if location == "form": + return "application/x-www-form-urlencoded" + # fallback to something generic + return "application/octet-stream" + + def _is_body_location(self, location): + """ + check if location is valid body location + + :param location: body location name, e.g. json, form etc + :return: True in case if location looks like body and False otherwises + """ + return location in ("files", "form", "json") + def setup_aiohttp_apispec( app: web.Application, diff --git a/aiohttp_apispec/middlewares.py b/aiohttp_apispec/middlewares.py index f6913c5..a56ec64 100644 --- a/aiohttp_apispec/middlewares.py +++ b/aiohttp_apispec/middlewares.py @@ -43,7 +43,7 @@ async def validation_middleware(request: web.Request, handler) -> web.Response: if isinstance(data, list): result.extend(data) else: - result=data + result = data except (ValueError, TypeError): result = data break diff --git a/tests/conftest.py b/tests/conftest.py index d40de32..f73fae4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,7 @@ setup_aiohttp_apispec, validation_middleware, ) +from aiohttp_apispec.aiohttp_apispec import OpenApiVersion class HeaderSchema(Schema): @@ -79,14 +80,14 @@ def example_for_request_schema(): # since multiple locations are no longer supported # in a single call, location should always expect string params=[ - ({"location": "querystring"}, True), - ({"location": "querystring"}, True), - ({"location": "querystring"}, False), - ({"location": "querystring"}, False), + ({"location": "querystring"}, True, OpenApiVersion.V20), + ({"location": "querystring"}, False, OpenApiVersion.V20), + ({"location": "querystring"}, True, OpenApiVersion.V300), + ({"location": "querystring"}, False, OpenApiVersion.V300), ] ) def aiohttp_app(loop, aiohttp_client, request, example_for_request_schema): - location, nested = request.param + location, nested, openapi_version = request.param @docs( tags=["mytag"], @@ -191,6 +192,7 @@ async def validated_view(request: web.Request): url="/api/docs/api-docs", swagger_path="/api/docs", error_callback=my_error_handler, + openapi_version=openapi_version, ) v1.router.add_routes( [ @@ -216,6 +218,7 @@ async def validated_view(request: web.Request): url="/v1/api/docs/api-docs", swagger_path="/v1/api/docs", error_callback=my_error_handler, + openapi_version=openapi_version, ) app.router.add_routes( [ diff --git a/tests/test_documentation.py b/tests/test_documentation.py index 3845782..f228312 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -1,10 +1,59 @@ -import json - from aiohttp import web from aiohttp.web_urldispatcher import StaticResource from yarl import URL from aiohttp_apispec import setup_aiohttp_apispec +from aiohttp_apispec.aiohttp_apispec import OpenApiVersion + + +def ref_resolver(openapi_version, ref): + if openapi_version == OpenApiVersion.V20: + return f"#/definitions/{ref}" + return f"#/components/schemas/{ref}" + + +def parameter_description(openapi_version, source, **kwargs): + if openapi_version == OpenApiVersion.V20: + source.update(**kwargs) + else: + source["schema"] = kwargs + return source + + +def parameter_list_description(openapi_version, source, **kwargs): + if openapi_version == OpenApiVersion.V20: + source.update(**kwargs) + source["collectionFormat"] = "multi" + else: + source["schema"] = kwargs + source["explode"] = True + source["style"] = "form" + return source + + +def request_schema_description(openapi_version, source, ref): + if openapi_version == OpenApiVersion.V20: + schema = {"$ref": ref_resolver(openapi_version, ref)} + else: + schema = {"schema": {"$ref": ref_resolver(openapi_version, ref)}} + source.update(schema) + return source + + +def response_schema_description(openapi_version, source, ref): + print(source) + if openapi_version == OpenApiVersion.V20: + schema = {"schema": {"$ref": ref_resolver(openapi_version, ref)}} + else: + schema = { + "content": { + "application/json": { + "schema": {"$ref": ref_resolver(openapi_version, ref)} + } + } + } + source.update(schema) + return source def test_app_swagger_url(aiohttp_app): @@ -26,123 +75,170 @@ async def test_app_swagger_json(aiohttp_app, example_for_request_schema): docs = await resp.json() assert docs["info"]["title"] == "API documentation" assert docs["info"]["version"] == "0.0.1" + openapi_version = OpenApiVersion(docs.get("openapi", "2.0")) + docs["paths"]["/v1/test"]["get"]["parameters"] = sorted( docs["paths"]["/v1/test"]["get"]["parameters"], key=lambda x: x["name"] ) - assert json.dumps(docs["paths"]["/v1/test"]["get"], sort_keys=True) == json.dumps( - { - "parameters": [ + assert docs["paths"]["/v1/test"]["get"] == { + "parameters": [ + parameter_description( + openapi_version, { "in": "query", "name": "bool_field", "required": False, - "type": "boolean", }, + type="boolean", + ), + parameter_description( + openapi_version, { "in": "query", "name": "id", "required": False, - "type": "integer", }, + type="integer", + ), + parameter_list_description( + openapi_version, { - "collectionFormat": "multi", "in": "query", - "items": {"type": "integer"}, "name": "list_field", "required": False, - "type": "array", }, + type="array", + items={"type": "integer"}, + ), + parameter_description( + openapi_version, { "description": "name", "in": "query", "name": "name", "required": False, - "type": "string", }, + type="string", + ), + request_schema_description( + openapi_version, { # default schema_name_resolver, resolved based on schema __name__ # drops trailing "Schema so, MyNestedSchema resolves to MyNested - "$ref": "#/definitions/MyNested", "in": "query", "name": "nested_field", "required": False, }, - ], - "responses": { - "200": { + "MyNested", + ), + ], + "responses": { + "200": response_schema_description( + openapi_version, + { "description": "Success response", - "schema": {"$ref": "#/definitions/Response"}, }, - "404": {"description": "Not Found"}, - }, - "tags": ["mytag"], - "summary": "Test method summary", - "description": "Test method description", - "produces": ["application/json"], + "Response", + ), + "404": {"description": "Not Found"}, }, - sort_keys=True, - ) + "tags": ["mytag"], + "summary": "Test method summary", + "description": "Test method description", + "produces": ["application/json"], + } docs["paths"]["/v1/class_echo"]["get"]["parameters"] = sorted( docs["paths"]["/v1/class_echo"]["get"]["parameters"], key=lambda x: x["name"] ) - assert json.dumps( - docs["paths"]["/v1/class_echo"]["get"], sort_keys=True - ) == json.dumps( - { - "parameters": [ + assert docs["paths"]["/v1/class_echo"]["get"] == { + "parameters": [ + parameter_description( + openapi_version, { "in": "query", "name": "bool_field", "required": False, - "type": "boolean", }, + type="boolean", + ), + parameter_description( + openapi_version, { "in": "query", "name": "id", "required": False, - "type": "integer", }, + type="integer", + ), + parameter_list_description( + openapi_version, { - "collectionFormat": "multi", "in": "query", - "items": {"type": "integer"}, "name": "list_field", "required": False, - "type": "array", }, + type="array", + items={"type": "integer"}, + ), + parameter_description( + openapi_version, { "description": "name", "in": "query", "name": "name", "required": False, - "type": "string", }, + type="string", + ), + request_schema_description( + openapi_version, { - "$ref": "#/definitions/MyNested", "in": "query", "name": "nested_field", "required": False, }, - ], - "responses": {}, - "tags": ["mytag"], - "summary": "View method summary", - "description": "View method description", - "produces": ["application/json"], - }, - sort_keys=True, - ) - assert docs["paths"]["/v1/example_endpoint"]["post"]["parameters"] == [ - { - 'in': 'body', - 'required': False, - 'name': 'body', - 'schema': { - 'allOf': [{'$ref': '#/definitions/#/definitions/Request'}], - 'example': example_for_request_schema, - }, + "MyNested", + ), + ], + "responses": {}, + "tags": ["mytag"], + "summary": "View method summary", + "description": "View method description", + "produces": ["application/json"], + } + + if openapi_version == OpenApiVersion.V20: + assert docs["paths"]["/v1/example_endpoint"]["post"]["parameters"] == [ + { + 'in': 'body', + 'required': False, + 'name': 'body', + 'schema': { + 'allOf': [ + { + '$ref': f'#/definitions/{ref_resolver(openapi_version, "Request")}' + } + ], + 'example': example_for_request_schema, + }, + } + ] + else: + assert docs["paths"]["/v1/example_endpoint"]["post"]["requestBody"] == { + "content": { + "application/json": { + 'required': False, + 'schema': { + 'allOf': [ + { + '$ref': f'#/components/schemas/{ref_resolver(openapi_version, "Request")}' + } + ], + 'example': example_for_request_schema, + }, + } + } } - ] _request_properties = { "properties": { @@ -153,12 +249,13 @@ async def test_app_swagger_json(aiohttp_app, example_for_request_schema): "type": "array", }, "name": {"description": "name", "type": "string"}, - "nested_field": {"$ref": "#/definitions/MyNested"}, + "nested_field": {"$ref": ref_resolver(openapi_version, "MyNested")}, }, "type": "object", } - assert json.dumps(docs["definitions"], sort_keys=True) == json.dumps( - { + + if openapi_version == OpenApiVersion.V20: + assert docs["definitions"] == { "MyNested": { "properties": {"i": {"type": "integer"}}, "type": "object", @@ -169,9 +266,20 @@ async def test_app_swagger_json(aiohttp_app, example_for_request_schema): "properties": {"data": {"type": "object"}, "msg": {"type": "string"}}, "type": "object", }, - }, - sort_keys=True, - ) + } + else: + assert docs["components"]["schemas"] == { + "MyNested": { + "properties": {"i": {"type": "integer"}}, + "type": "object", + }, + "Request": {**_request_properties, 'example': example_for_request_schema}, + "Partial-Request": _request_properties, + "Response": { + "properties": {"data": {"type": "object"}, "msg": {"type": "string"}}, + "type": "object", + }, + } async def test_not_register_route_for_none_url():