From 4e2b66528be76f3473baf719701ce9b6a348019b Mon Sep 17 00:00:00 2001 From: Evan Rees Date: Sat, 23 Jul 2022 13:27:19 -0400 Subject: [PATCH 1/2] schema for method view --- src/quart_schema/extension.py | 191 +++++++++++++++++++--------------- 1 file changed, 106 insertions(+), 85 deletions(-) diff --git a/src/quart_schema/extension.py b/src/quart_schema/extension.py index e319b65..46dc58d 100644 --- a/src/quart_schema/extension.py +++ b/src/quart_schema/extension.py @@ -294,6 +294,99 @@ def decorator(func: Callable) -> Callable: return decorator +def _update_paths_and_components( + func: Callable, + path_object: Dict[str, Any], + components: Dict[str, Any], + extension: QuartSchema, +) -> None: + if func.__doc__ is not None: + summary, *description = inspect.getdoc(func).splitlines() + path_object["description"] = "\n".join(description) + path_object["summary"] = summary + + if getattr(func, QUART_SCHEMA_TAG_ATTRIBUTE, None) is not None: + path_object["tags"] = list(getattr(func, QUART_SCHEMA_TAG_ATTRIBUTE)) + + response_models = getattr(func, QUART_SCHEMA_RESPONSE_ATTRIBUTE, {}) + for status_code in response_models.keys(): + model_class, headers_model_class = response_models[status_code] + schema = model_schema(model_class, ref_prefix=REF_PREFIX) + if extension.convert_casing: + schema = camelize(schema) + definitions, schema = _split_definitions(schema) + components["schemas"].update(definitions) + response_object = { + "content": { + "application/json": { + "schema": schema, + }, + }, + "description": "", + } + if model_class.__doc__ is not None: + response_object["description"] = inspect.getdoc(model_class) + + if headers_model_class is not None: + schema = model_schema(headers_model_class, ref_prefix=REF_PREFIX) + definitions, schema = _split_definitions(schema) + components["schemas"].update(definitions) + response_object["content"]["headers"] = { # type: ignore + name.replace("_", "-"): { + "schema": type_, + } + for name, type_ in schema["properties"].items() + } + path_object["responses"][status_code] = response_object + + request_data = getattr(func, QUART_SCHEMA_REQUEST_ATTRIBUTE, None) + if request_data is not None: + schema = model_schema(request_data[0], ref_prefix=REF_PREFIX) + if extension.convert_casing: + schema = camelize(schema) + definitions, schema = _split_definitions(schema) + components["schemas"].update(definitions) + + if request_data[1] == DataSource.JSON: + encoding = "application/json" + else: + encoding = "application/x-www-form-urlencoded" + + path_object["requestBody"] = { + "content": { + encoding: { + "schema": schema, + }, + }, + } + + querystring_model = getattr(func, QUART_SCHEMA_QUERYSTRING_ATTRIBUTE, None) + if querystring_model is not None: + schema = model_schema(querystring_model, ref_prefix=REF_PREFIX) + if extension.convert_casing: + schema = camelize(schema) + definitions, schema = _split_definitions(schema) + components["schemas"].update(definitions) + for name, type_ in schema["properties"].items(): + param = {"name": name, "in": "query", "schema": type_} + if "description" in type_: + param["description"] = type_.pop("description") + + path_object["parameters"].append(param) + + headers_model = getattr(func, QUART_SCHEMA_HEADERS_ATTRIBUTE, None) + if headers_model is not None: + schema = model_schema(headers_model, ref_prefix=REF_PREFIX) + definitions, schema = _split_definitions(schema) + components["schemas"].update(definitions) + for name, type_ in schema["properties"].items(): + param = {"name": name.replace("_", "-"), "in": "header", "schema": type_} + if "description" in type_: + param["description"] = type_.pop("description") + + path_object["parameters"].append(param) + + def _build_openapi_schema(app: Quart, extension: QuartSchema) -> dict: paths: Dict[str, dict] = {} components = {"schemas": {}} # type: ignore @@ -306,91 +399,8 @@ def _build_openapi_schema(app: Quart, extension: QuartSchema) -> dict: "parameters": [], "responses": {}, } - if func.__doc__ is not None: - summary, *description = inspect.getdoc(func).splitlines() - path_object["description"] = "\n".join(description) - path_object["summary"] = summary - - if getattr(func, QUART_SCHEMA_TAG_ATTRIBUTE, None) is not None: - path_object["tags"] = list(getattr(func, QUART_SCHEMA_TAG_ATTRIBUTE)) - - response_models = getattr(func, QUART_SCHEMA_RESPONSE_ATTRIBUTE, {}) - for status_code in response_models.keys(): - model_class, headers_model_class = response_models[status_code] - schema = model_schema(model_class, ref_prefix=REF_PREFIX) - if extension.convert_casing: - schema = camelize(schema) - definitions, schema = _split_definitions(schema) - components["schemas"].update(definitions) - response_object = { - "content": { - "application/json": { - "schema": schema, - }, - }, - "description": "", - } - if model_class.__doc__ is not None: - response_object["description"] = inspect.getdoc(model_class) - - if headers_model_class is not None: - schema = model_schema(headers_model_class, ref_prefix=REF_PREFIX) - definitions, schema = _split_definitions(schema) - components["schemas"].update(definitions) - response_object["content"]["headers"] = { # type: ignore - name.replace("_", "-"): { - "schema": type_, - } - for name, type_ in schema["properties"].items() - } - path_object["responses"][status_code] = response_object # type: ignore - - request_data = getattr(func, QUART_SCHEMA_REQUEST_ATTRIBUTE, None) - if request_data is not None: - schema = model_schema(request_data[0], ref_prefix=REF_PREFIX) - if extension.convert_casing: - schema = camelize(schema) - definitions, schema = _split_definitions(schema) - components["schemas"].update(definitions) - - if request_data[1] == DataSource.JSON: - encoding = "application/json" - else: - encoding = "application/x-www-form-urlencoded" - - path_object["requestBody"] = { - "content": { - encoding: { - "schema": schema, - }, - }, - } - - querystring_model = getattr(func, QUART_SCHEMA_QUERYSTRING_ATTRIBUTE, None) - if querystring_model is not None: - schema = model_schema(querystring_model, ref_prefix=REF_PREFIX) - if extension.convert_casing: - schema = camelize(schema) - definitions, schema = _split_definitions(schema) - components["schemas"].update(definitions) - for name, type_ in schema["properties"].items(): - param = {"name": name, "in": "query", "schema": type_} - if "description" in type_: - param["description"] = type_.pop("description") - - path_object["parameters"].append(param) # type: ignore - - headers_model = getattr(func, QUART_SCHEMA_HEADERS_ATTRIBUTE, None) - if headers_model is not None: - schema = model_schema(headers_model, ref_prefix=REF_PREFIX) - definitions, schema = _split_definitions(schema) - components["schemas"].update(definitions) - for name, type_ in schema["properties"].items(): - param = {"name": name.replace("_", "-"), "in": "header", "schema": type_} - if "description" in type_: - param["description"] = type_.pop("description") - path_object["parameters"].append(param) # type: ignore + _update_paths_and_components(func, path_object, components, extension) for name, converter in rule._converters.items(): type_ = "string" @@ -412,7 +422,18 @@ def _build_openapi_schema(app: Quart, extension: QuartSchema) -> dict: for method in rule.methods: if method == "HEAD" or (method == "OPTIONS" and rule.provide_automatic_options): # type: ignore # noqa: E501 continue - paths[path][method.lower()] = path_object + method_path_object = path_object.copy() + view_class = getattr(func, "view_class", None) + if view_class is not None: + sub_func = getattr(view_class, method.lower(), None) + if sub_func is not None: + _update_paths_and_components( + sub_func, + method_path_object, + components, + extension, + ) + paths[path][method.lower()] = method_path_object return { "openapi": "3.0.3", From deefa2f8d4f9eee755c6daaad9b6cddf066c7bea Mon Sep 17 00:00:00 2001 From: Evan Rees Date: Sat, 23 Jul 2022 16:27:32 -0400 Subject: [PATCH 2/2] add test --- tests/test_openapi_view.py | 206 +++++++++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 tests/test_openapi_view.py diff --git a/tests/test_openapi_view.py b/tests/test_openapi_view.py new file mode 100644 index 0000000..9825f41 --- /dev/null +++ b/tests/test_openapi_view.py @@ -0,0 +1,206 @@ +from dataclasses import dataclass +from typing import Optional, Tuple + +from pydantic import Field +from quart import Quart +from quart.views import MethodView + +from quart_schema import ( + QuartSchema, + validate_headers, + validate_querystring, + validate_request, + validate_response, +) + + +@dataclass +class QueryItem: + count_le: Optional[int] = Field(description="count_le description") + + +@dataclass +class Details: + name: str + age: Optional[int] = None + + +@dataclass +class Result: + name: str + + +@dataclass +class Headers: + x_name: str = Field(..., description="x-name description") + + +def attach_test_view(app: Quart) -> None: + test_api = TestView.as_view("test_api") + app.add_url_rule( + "/view", + view_func=test_api, + methods=["PUT"], + ) + + +class TestView(MethodView): + @validate_querystring(QueryItem) + @validate_request(Details) + @validate_headers(Headers) + @validate_response(Result, 200, Headers) + def put(self, data: Details) -> Tuple[Result, int, Headers]: + return Result(name="bob"), 200, Headers(x_name="jeff") + + +async def test_openapi_view() -> None: + app = Quart(__name__) + attach_test_view(app) + QuartSchema(app) + + @app.route("/") + @validate_querystring(QueryItem) + @validate_request(Details) + @validate_headers(Headers) + @validate_response(Result, 200, Headers) + async def index() -> Tuple[Result, int, Headers]: + """Summary + Multi-line + description. + + This is a new paragraph + + And this is an indented codeblock. + + And another paragraph.""" + return Result(name="bob"), 200, Headers(x_name="jeff") + + test_client = app.test_client() + response = await test_client.get("/openapi.json") + response_json = await response.get_json() + assert response_json == { + "components": {"schemas": {}}, + "info": {"title": "test_openapi_view", "version": "0.1.0"}, + "openapi": "3.0.3", + "paths": { + "/": { + "get": { + "description": "Multi-line\ndescription.\n\nThis is a new paragraph\n\n " + "And this is an indented codeblock.\n\nAnd another paragraph.", + "parameters": [ + { + "description": "count_le description", + "in": "query", + "name": "count_le", + "schema": {"title": "Count Le", "type": "integer"}, + }, + { + "description": "x-name description", + "in": "header", + "name": "x-name", + "schema": {"title": "X Name", "type": "string"}, + }, + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "age": {"title": "Age", "type": "integer"}, + "name": {"title": "Name", "type": "string"}, + }, + "required": ["name"], + "title": "Details", + "type": "object", + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": {"name": {"title": "Name", "type": "string"}}, + "required": ["name"], + "title": "Result", + "type": "object", + } + }, + "headers": { + "x-name": { + "schema": { + "description": "x-name description", + "title": "X Name", + "type": "string", + } + } + }, + }, + "description": "Result(name: str)", + } + }, + "summary": "Summary", + } + }, + "/view": { + "put": { + "parameters": [ + { + "description": "count_le description", + "in": "query", + "name": "count_le", + "schema": {"title": "Count Le", "type": "integer"}, + }, + { + "description": "x-name description", + "in": "header", + "name": "x-name", + "schema": {"title": "X Name", "type": "string"}, + }, + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "age": {"title": "Age", "type": "integer"}, + "name": {"title": "Name", "type": "string"}, + }, + "required": ["name"], + "title": "Details", + "type": "object", + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": {"name": {"title": "Name", "type": "string"}}, + "required": ["name"], + "title": "Result", + "type": "object", + } + }, + "headers": { + "x-name": { + "schema": { + "description": "x-name description", + "title": "X Name", + "type": "string", + } + } + }, + }, + "description": "Result(name: str)", + } + }, + } + }, + }, + "servers": [], + "tags": [], + }