From 9e905a5f645d389027ebfdea6b4a6a481607c345 Mon Sep 17 00:00:00 2001 From: Dmytro Tyzhnenko Date: Sun, 28 Jan 2024 00:21:27 +0200 Subject: [PATCH 1/3] Support mapping types in OpenAPI schema It is useful to return some grouped data ```python @dataclass class User: id: str name: str ``` Example for `Dict[str, list[User]]` ```json { "type": "object", "patternProperties": { "^[a-z0-9!\"#$%&'()*+,.\/:;<=>?@\[\] ^_`{|}~-]+$": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": "string" } }, "required": ["id", "name"] } } } } ``` --- blacksheep/server/openapi/v3.py | 61 +++++++++++++++++++++++++++++++++ tests/test_openapi_v3.py | 61 ++++++++++++++++++++++++++++++++- 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/blacksheep/server/openapi/v3.py b/blacksheep/server/openapi/v3.py index 3ee247ce..0e191feb 100644 --- a/blacksheep/server/openapi/v3.py +++ b/blacksheep/server/openapi/v3.py @@ -2,8 +2,10 @@ import inspect import warnings from abc import ABC, abstractmethod +from collections import OrderedDict, defaultdict from dataclasses import dataclass, fields, is_dataclass from datetime import date, datetime +from decimal import Decimal from enum import Enum, IntEnum from typing import Any, Dict, Iterable, List, Optional, Tuple, Type, Union from typing import _GenericAlias as GenericAlias @@ -656,6 +658,11 @@ def _get_schema_by_type( if schema: return schema + # Dict, OrderedDict, defaultdict are handled first than GenericAlias + schema = self._try_get_schema_for_mapping(object_type, type_args) + if schema: + return schema + # List, Set, Tuple are handled first than GenericAlias schema = self._try_get_schema_for_iterable(object_type, type_args) if schema: @@ -723,6 +730,60 @@ def _try_get_schema_for_iterable( items=self.get_schema_by_type(item_type, context_type_args), ) + def _try_get_schema_for_mapping( + self, object_type: Type, context_type_args: Optional[Dict[Any, Type]] = None + ) -> Optional[Schema]: + properties_regexp = { + str: r"^[a-z0-9!\"#$%&'()*+,.\/:;<=>?@\[\] ^_`{|}~-]+$", + int: r"^[0-9]+$", + float: r"^[0-9]+(?:\.[0-9]+)?$", + Decimal: r"^[0-9]+(?:\.[0-9]+)?$", + UUID: r"^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$", + bool: r"^(?:true|false)$", + } + + if object_type in {dict, defaultdict, OrderedDict}: + # the user didn't specify the key and value types + return Schema( + type=ValueType.OBJECT, + properties={ + properties_regexp[str]: Schema( + type=ValueType.STRING, + ), + }, + ) + + origin = get_origin(object_type) + + if not origin or origin not in { + dict, + Dict, + collections_abc.Mapping, + }: + return None + + # can be Dict, Dict[str, str] or dict[str, str] (Python 3.9), + # note: it could also be union if it wasn't handled above for dataclasses + try: + key_type, value_type = object_type.__args__ # type: ignore + except AttributeError: # pragma: no cover + key_type, value_type = str, str + + if context_type_args and key_type in context_type_args: + key_type = context_type_args.get(key_type, key_type) + + if context_type_args and value_type in context_type_args: + value_type = context_type_args.get(value_type, value_type) + + return Schema( + type=ValueType.OBJECT, + properties={ + properties_regexp.get( + key_type, "^[a-zA-Z0-9_]+$" + ): self.get_schema_by_type(value_type, context_type_args) + }, + ) + def get_fields(self, object_type: Any) -> List[FieldInfo]: for handler in self._object_types_handlers: if handler.handles_type(object_type): diff --git a/tests/test_openapi_v3.py b/tests/test_openapi_v3.py index 53389e62..bf9623f5 100644 --- a/tests/test_openapi_v3.py +++ b/tests/test_openapi_v3.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from datetime import date, datetime from enum import IntEnum -from typing import Generic, List, Optional, Sequence, TypeVar, Union +from typing import Generic, List, Mapping, Optional, Sequence, TypeVar, Union from uuid import UUID import pytest @@ -1312,6 +1312,65 @@ def home() -> Sequence[Cat]: ) +@pytest.mark.asyncio +async def test_handling_of_mapping(docs: OpenAPIHandler, serializer: Serializer): + app = get_app() + + @app.router.route("/") + def home() -> Mapping[str, Mapping[int, Cat]]: + ... + + docs.bind_app(app) + await app.start() + + yaml = serializer.to_yaml(docs.generate_documentation(app)) + + assert ( + yaml.strip() + == r""" +openapi: 3.0.3 +info: + title: Example + version: 0.0.1 +paths: + /: + get: + responses: + '200': + description: Success response + content: + application/json: + schema: + type: object + properties: + ^[a-z0-9!\"#$%&'()*+,.\/:;<=>?@\[\] ^_`{|}~-]+$: + type: object + properties: + ^[0-9]+$: + $ref: '#/components/schemas/Cat' + nullable: false + nullable: false + operationId: home +components: + schemas: + Cat: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + nullable: false + name: + type: string + nullable: false +tags: [] +""".strip() + ) + + def test_handling_of_generic_with_forward_references(docs: OpenAPIHandler): with pytest.warns(UserWarning): docs.register_schema_for_type(GenericWithForwardRefExample[Cat]) From 7d6659b71ecc2d37bdb45993834f12b2f22d1382 Mon Sep 17 00:00:00 2001 From: Dmytro Tyzhnenko Date: Sun, 28 Jan 2024 00:43:46 +0200 Subject: [PATCH 2/3] Pin black to 23.12.1 --- .github/workflows/main.yml | 2 +- blacksheep/server/openapi/v3.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a7bba413..a9a09130 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -93,7 +93,7 @@ jobs: - name: Install dependencies run: | pip install -r requirements.txt - pip install black isort flake8 + pip install black==23.12.1 isort flake8 - name: Compile Cython extensions run: | diff --git a/blacksheep/server/openapi/v3.py b/blacksheep/server/openapi/v3.py index 0e191feb..416984b0 100644 --- a/blacksheep/server/openapi/v3.py +++ b/blacksheep/server/openapi/v3.py @@ -779,7 +779,7 @@ def _try_get_schema_for_mapping( type=ValueType.OBJECT, properties={ properties_regexp.get( - key_type, "^[a-zA-Z0-9_]+$" + key_type, properties_regexp[str] ): self.get_schema_by_type(value_type, context_type_args) }, ) From 5b276a02ca8a1b59c3457db1a886e8914bd0f55c Mon Sep 17 00:00:00 2001 From: Dmytro Tyzhnenko Date: Fri, 2 Feb 2024 00:53:57 +0200 Subject: [PATCH 3/3] Use additionalProperties instead of patternProperties for dicts --- blacksheep/server/openapi/v3.py | 32 ++++++++------------------------ tests/test_openapi_v3.py | 15 ++++++++------- 2 files changed, 16 insertions(+), 31 deletions(-) diff --git a/blacksheep/server/openapi/v3.py b/blacksheep/server/openapi/v3.py index 416984b0..c713a940 100644 --- a/blacksheep/server/openapi/v3.py +++ b/blacksheep/server/openapi/v3.py @@ -733,24 +733,13 @@ def _try_get_schema_for_iterable( def _try_get_schema_for_mapping( self, object_type: Type, context_type_args: Optional[Dict[Any, Type]] = None ) -> Optional[Schema]: - properties_regexp = { - str: r"^[a-z0-9!\"#$%&'()*+,.\/:;<=>?@\[\] ^_`{|}~-]+$", - int: r"^[0-9]+$", - float: r"^[0-9]+(?:\.[0-9]+)?$", - Decimal: r"^[0-9]+(?:\.[0-9]+)?$", - UUID: r"^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$", - bool: r"^(?:true|false)$", - } - if object_type in {dict, defaultdict, OrderedDict}: # the user didn't specify the key and value types return Schema( type=ValueType.OBJECT, - properties={ - properties_regexp[str]: Schema( - type=ValueType.STRING, - ), - }, + additional_properties=Schema( + type=ValueType.STRING, + ), ) origin = get_origin(object_type) @@ -765,23 +754,18 @@ def _try_get_schema_for_mapping( # can be Dict, Dict[str, str] or dict[str, str] (Python 3.9), # note: it could also be union if it wasn't handled above for dataclasses try: - key_type, value_type = object_type.__args__ # type: ignore + _, value_type = object_type.__args__ # type: ignore except AttributeError: # pragma: no cover - key_type, value_type = str, str - - if context_type_args and key_type in context_type_args: - key_type = context_type_args.get(key_type, key_type) + value_type = str if context_type_args and value_type in context_type_args: value_type = context_type_args.get(value_type, value_type) return Schema( type=ValueType.OBJECT, - properties={ - properties_regexp.get( - key_type, properties_regexp[str] - ): self.get_schema_by_type(value_type, context_type_args) - }, + additional_properties=self.get_schema_by_type( + value_type, context_type_args + ), ) def get_fields(self, object_type: Any) -> List[FieldInfo]: diff --git a/tests/test_openapi_v3.py b/tests/test_openapi_v3.py index bf9623f5..a51f07b7 100644 --- a/tests/test_openapi_v3.py +++ b/tests/test_openapi_v3.py @@ -1317,7 +1317,7 @@ async def test_handling_of_mapping(docs: OpenAPIHandler, serializer: Serializer) app = get_app() @app.router.route("/") - def home() -> Mapping[str, Mapping[int, Cat]]: + def home() -> Mapping[str, Mapping[int, List[Cat]]]: ... docs.bind_app(app) @@ -1342,13 +1342,14 @@ def home() -> Mapping[str, Mapping[int, Cat]]: application/json: schema: type: object - properties: - ^[a-z0-9!\"#$%&'()*+,.\/:;<=>?@\[\] ^_`{|}~-]+$: - type: object - properties: - ^[0-9]+$: - $ref: '#/components/schemas/Cat' + additionalProperties: + type: object + additionalProperties: + type: array nullable: false + items: + $ref: '#/components/schemas/Cat' + nullable: false nullable: false operationId: home components: