Repository: Neoteroi/BlackSheep
Pull request #391 (2023-07-02 07:05:20)

Support for Pydantic v2
Add support for Pydantic v2.
The framework will support both v1 and v2 for some time, therefore this PR does not remove the current support for Pydantic v1.

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/Neoteroi/BlackSheep/pull/391?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) Report
Patch coverage: **`56.14`**% and project coverage change: **`-0.39`** :warning:
> Comparison is base [(`ff09cec`)](https://app.codecov.io/gh/Neoteroi/BlackSheep/commit/ff09cecf81a2d1a7706f4fa281e2978e42c654ae?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) 97.71% compared to head [(`fed357d`)](https://app.codecov.io/gh/Neoteroi/BlackSheep/pull/391?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) 97.33%.

:exclamation: Your organization is not using the GitHub App Integration. As a result you may experience degraded service beginning May 15th. Please [install the Github App Integration](https://github.com/apps/codecov) for your organization. [Read more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #391      +/-   ##
==========================================
- Coverage   97.71%   97.33%   -0.39%     
==========================================
  Files          66       66              
  Lines        6211     6256      +45     
==========================================
+ Hits         6069     6089      +20     
- Misses        142      167      +25     
```


| [Impacted Files](https://app.codecov.io/gh/Neoteroi/BlackSheep/pull/391?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) | Coverage Δ | |
|---|---|---|
| [blacksheep/server/openapi/v3.py](https://app.codecov.io/gh/Neoteroi/BlackSheep/pull/391?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9zZXJ2ZXIvb3BlbmFwaS92My5weQ==) | `83.36% <56.14%> (-3.58%)` | :arrow_down: |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/Neoteroi/BlackSheep/pull/391?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi).


Patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 40eccc0..cd00a97 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -98,6 +98,12 @@ jobs:
         run: |
           pytest --doctest-modules --junitxml=junit/pytest-results-${{ matrix.os }}-${{ matrix.python-version }}.xml --cov=$PROJECT_NAME --cov-report=xml
 
+      - name: Run tests with Pydantic v2
+        run: |
+          echo "[*] The previous tests used Pydantic v1, now running with v2"
+          pip install -U pydantic==2.0
+          pytest
+
       - name: Run linters
         run: |
           echo "Running linters - if build fails here, please be patient! Feel free to ask for assistance."
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 774f11e..8cf2150 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
-## [2.0a8] - 2023-06-??
+## [2.0a8] - 2023-07-02
 
 - Add support for `StreamedContent` with specific content length; fixing
   [#374](https://github.com/Neoteroi/BlackSheep/issues/374) both on the client
@@ -21,6 +21,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   403 Forbidden when the user is authenticated but not authorized to perform an
   action.
 - Fixes `TypeError` when writing a request without host header.
+- Add support for `Pydantic` `v2`: meaning feature parity with support for
+  Pydantic v1 (generating OpenAPI Documentation).
+- Add support for `Union` types in sub-properties of request handlers input and
+  output types, for generating OpenAPI Documentation, both using simple classes
+  and Pydantic [#389](https://github.com/Neoteroi/BlackSheep/issues/389)
 
 ## [2.0a7] - 2023-05-31 :corn:
 
diff --git a/blacksheep/server/openapi/exceptions.py b/blacksheep/server/openapi/exceptions.py
index ba269d7..d91f5d1 100644
--- a/blacksheep/server/openapi/exceptions.py
+++ b/blacksheep/server/openapi/exceptions.py
@@ -10,13 +10,3 @@ class DuplicatedContentTypeDocsException(DocumentationException):
             "have unique type."
         )
         self.content_type = content_type
-
-
-class UnsupportedUnionTypeException(DocumentationException):
-    def __init__(self, unsupported_type) -> None:
-        super().__init__(
-            f"Union types are not supported for automatic generation of "
-            "OpenAPI Documentation. The annotation that caused exception is: "
-            f"{unsupported_type}."
-        )
-        self.unsupported_type = unsupported_type
diff --git a/blacksheep/server/openapi/v3.py b/blacksheep/server/openapi/v3.py
index 93b82ba..3ee247c 100644
--- a/blacksheep/server/openapi/v3.py
+++ b/blacksheep/server/openapi/v3.py
@@ -52,10 +52,7 @@ from blacksheep.server.openapi.docstrings import (
     DocstringInfo,
     get_handler_docstring_info,
 )
-from blacksheep.server.openapi.exceptions import (
-    DuplicatedContentTypeDocsException,
-    UnsupportedUnionTypeException,
-)
+from blacksheep.server.openapi.exceptions import DuplicatedContentTypeDocsException
 from blacksheep.server.routing import Router
 
 from ..application import Application
@@ -107,18 +104,21 @@ def check_union(object_type: Any) -> Tuple[bool, Any]:
         and object_type.__origin__ is Union
         or _is_union_type(object_type)
         or (hasattr(object_type, "nullable"))
+        or (hasattr(object_type, "is_required"))
     ):
-        # For Pydantic
         if hasattr(object_type, "nullable"):
+            # Pydantic v1
             return object_type.nullable, object_type
-        # support only Union[None, Type] - that is equivalent of Optional[Type]
-        if type(None) not in object_type.__args__ or len(object_type.__args__) > 2:
-            raise UnsupportedUnionTypeException(object_type)
+        if hasattr(object_type, "is_required"):
+            # Pydantic v2
+            return object_type.is_required(), object_type
 
-        for possible_type in object_type.__args__:
-            if type(None) is possible_type:
-                continue
-            return True, possible_type
+        if type(None) in object_type.__args__ and len(object_type.__args__) == 2:
+            for possible_type in object_type.__args__:
+                if type(None) is possible_type:
+                    continue
+                return True, possible_type
+        return type(None) not in object_type.__args__, object_type
     return False, object_type
 
 
@@ -158,7 +158,8 @@ class ObjectTypeHandler(ABC):
         object type handler.
         """
 
-    def get_type_fields(self, object_type) -> List[FieldInfo]:
+    @abstractmethod
+    def get_type_fields(self, object_type, register_type) -> List[FieldInfo]:
         """
         Returns a set of fields to be used for the handled type.
         """
@@ -168,7 +169,7 @@ class DataClassTypeHandler(ObjectTypeHandler):
     def handles_type(self, object_type) -> bool:
         return is_dataclass(object_type)
 
-    def get_type_fields(self, object_type) -> List[FieldInfo]:
+    def get_type_fields(self, object_type, register_type) -> List[FieldInfo]:
         return [FieldInfo(field.name, field.type) for field in fields(object_type)]
 
 
@@ -196,14 +197,59 @@ class PydanticModelTypeHandler(ObjectTypeHandler):
             return True
         return False
 
+    def _allow_none(self, field_info):
+        try:
+            # Pydantic v1
+            return field_info.allow_none
+        except AttributeError:
+            # Pydantic v2
+            return not field_info.is_required()
+
+    def _handle_any_of(self, any_of, types_args, register_type):
+        for item in any_of:
+            if "$ref" in item:
+                obj_type_name = item["$ref"].split("/")[-1]
+                corresponding_type = next(
+                    obj for obj in types_args if obj.__name__ == obj_type_name
+                )
+                register_type(corresponding_type)
+                item["$ref"] = f"#/components/schemas/{obj_type_name}"
+            yield item
+
     def _open_api_v2_field_schema_to_type(
-        self, field_info, schema
+        self, field_info, schema, register_type=None
     ) -> Union[Type, Schema]:
         if "$ref" in schema:
-            return field_info.outer_type_
+            if field_info is None:
+                return Schema()
+            try:
+                # Pydantic v1
+                return field_info.outer_type_
+            except AttributeError:
+                # Pydantic v2
+                return field_info.annotation
 
         value_type = schema.get("type")
-        nullable = field_info.allow_none if field_info is not None else False
+        nullable = self._allow_none(field_info) if field_info is not None else False
+
+        if value_type == "null":
+            # OpenAPI 3.1
+            return Schema(type="null")
+
+        if value_type is None:
+            if "anyOf" in schema:
+                any_of = schema["anyOf"]
+
+                if any("$ref" in item for item in any_of):
+                    any_of = list(
+                        self._handle_any_of(
+                            any_of, field_info.annotation.__args__, register_type
+                        )
+                    )
+                return Schema(
+                    any_of=any_of,
+                    title=schema.get("title"),
+                )
 
         if value_type == "string":
             return Schema(
@@ -243,30 +289,56 @@ class PydanticModelTypeHandler(ObjectTypeHandler):
 
         if value_type == "array":
             if field_info is not None:
-                return field_info.outer_type_
+                return self._get_array_outer_type(field_info)
             else:
                 return list
 
         return Schema()
 
-    def get_type_fields(self, object_type) -> List[FieldInfo]:
+    def _get_array_outer_type(self, field_info):
+        try:
+            # Pydantic v1
+            # example: typing.List[tests.test_openapi_v3.PydCat]
+            return field_info.outer_type_
+        except AttributeError:
+            # Pydantic v2
+            # Here we support only simple types
+            return List[field_info.annotation.__args__[0]]
+
+    def _get_fields_info(self, object_type):
+        try:
+            if hasattr(object_type, "model_fields"):
+                # Pydantic v2
+                return object_type.model_fields
+            else:
+                # Pydantic v1
+                return object_type.__fields__
+        except AttributeError:
+            return dict()
+
+    def _get_object_schema(self, object_type):
+        if hasattr(object_type, "model_json_schema"):
+            # Pydantic v2
+            return object_type.model_json_schema()
+        else:
+            # Pydantic v1
+            return object_type.schema()
+
+    def get_type_fields(self, object_type, register_type) -> List[FieldInfo]:
         # to support all pydantic special types, here we rely on its schema,
         # but since we want to be able to generate OpenAPI Documentation V3 (or V2 for
         # that matter), we extract basic information we need; also using the type's
         # __fields__ when available
-        schema = object_type.schema()
+        schema = self._get_object_schema(object_type)
         properties = schema["properties"]
 
-        try:
-            fields_info = object_type.__fields__
-        except AttributeError:
-            fields_info = dict()
+        fields_info = self._get_fields_info(object_type)
 
         return [
             FieldInfo(
                 name,
                 self._open_api_v2_field_schema_to_type(
-                    fields_info.get(name, None), value
+                    fields_info.get(name, None), value, register_type
                 ),
             )
             for name, value in properties.items()
@@ -416,6 +488,10 @@ class OpenAPIHandler(APIDocsHandler[OpenAPI]):
             return self.get_type_name_for_generic(object_type, context_type_args)
         if hasattr(object_type, "__name__"):
             return object_type.__name__
+        if object_type is Union:
+            # Python 3.9 and 3.8 would fall here, Union type has a "_name" property but
+            # no "__name__"
+            return "Union"
         raise ValueError(
             f"Cannot obtain a name for object_type parameter: {object_type!r}"
         )
@@ -508,15 +584,21 @@ class OpenAPIHandler(APIDocsHandler[OpenAPI]):
         required: List[str],
         context_type_args: Optional[Dict[Any, Type]] = None,
     ) -> Reference:
-        type_name = self.get_type_name(object_type, context_type_args)
-        reference = self._register_schema(
+        return self._handle_object_type_schema(
+            object_type,
+            context_type_args,
             Schema(
                 type=ValueType.OBJECT,
                 required=required or None,
                 properties=properties,
             ),
-            type_name,
         )
+
+    def _handle_object_type_schema(
+        self, object_type, context_type_args, schema: Schema
+    ):
+        type_name = self.get_type_name(object_type, context_type_args)
+        reference = self._register_schema(schema, type_name)
         self._objects_references[object_type] = reference
         self._objects_references[type_name] = reference
         return reference
@@ -644,7 +726,9 @@ class OpenAPIHandler(APIDocsHandler[OpenAPI]):
     def get_fields(self, object_type: Any) -> List[FieldInfo]:
         for handler in self._object_types_handlers:
             if handler.handles_type(object_type):
-                return handler.get_type_fields(object_type)
+                return handler.get_type_fields(
+                    object_type, self.register_schema_for_type
+                )
 
         return []
 
@@ -653,6 +737,18 @@ class OpenAPIHandler(APIDocsHandler[OpenAPI]):
     ) -> Optional[Reference]:
         origin = get_origin(object_type)
 
+        if origin is Union:
+            schema = Schema(
+                ValueType.OBJECT,
+                any_of=[
+                    self.get_schema_by_type(child_type, context_type_args)
+                    for child_type in object_type.__args__
+                ],
+            )
+            return self._handle_object_type_schema(
+                object_type, context_type_args, schema
+            )
+
         required: List[str] = []
         properties: Dict[str, Union[Schema, Reference]] = {}
 


Test patch:
diff --git a/tests/test_openapi_v3.py b/tests/test_openapi_v3.py
index 371dd99..07d9dff 100644
--- a/tests/test_openapi_v3.py
+++ b/tests/test_openapi_v3.py
@@ -20,8 +20,7 @@ from openapidocs.v3 import (
     ValueFormat,
     ValueType,
 )
-from pydantic import BaseModel, HttpUrl, validator
-from pydantic.generics import GenericModel
+from pydantic import BaseModel, HttpUrl
 from pydantic.types import NegativeFloat, PositiveInt, condecimal, confloat, conint
 
 from blacksheep.server.application import Application
@@ -34,10 +33,7 @@ from blacksheep.server.openapi.common import (
     ResponseInfo,
     SecurityInfo,
 )
-from blacksheep.server.openapi.exceptions import (
-    DuplicatedContentTypeDocsException,
-    UnsupportedUnionTypeException,
-)
+from blacksheep.server.openapi.exceptions import DuplicatedContentTypeDocsException
 from blacksheep.server.openapi.v3 import (
     DataClassTypeHandler,
     OpenAPIHandler,
@@ -47,6 +43,23 @@ from blacksheep.server.openapi.v3 import (
 )
 from blacksheep.server.routing import RoutesRegistry
 
+try:
+    from pydantic.generics import GenericModel
+except ImportError:
+    # Pydantic v2
+    # https://docs.pydantic.dev/latest/blog/pydantic-v2/#other-improvements
+    GenericModel = BaseModel
+    PYDANTIC_VERSION = 2
+else:
+    PYDANTIC_VERSION = 1
+
+try:
+    from pydantic import field_validator
+except ImportError:
+    # Pydantic v1
+    from pydantic import validator as field_validator
+
+
 T = TypeVar("T")
 U = TypeVar("U")
 
@@ -179,7 +192,7 @@ class PydResponse(GenericModel, Generic[T]):
     data: Optional[T]
     error: Optional[Error]
 
-    @validator("error", always=True)
+    @field_validator("error")
     def check_consistency(cls, v, values):
         if v is not None and values["data"] is not None:
             raise ValueError("must not provide both data and error")
@@ -280,11 +293,6 @@ async def test_raises_for_duplicated_content_example(docs):
         await app.start()
 
 
-def test_raises_for_union_type(docs):
-    with pytest.raises(UnsupportedUnionTypeException):
-        docs.get_schema_by_type(Union[Foo, Ufo])
-
-
 @pytest.mark.parametrize(
     "annotation,expected_result",
     [
@@ -1694,9 +1702,8 @@ async def test_pydantic_generic(docs: OpenAPIHandler, serializer: Serializer):
 
     yaml = serializer.to_yaml(docs.generate_documentation(app))
 
-    assert (
-        yaml.strip()
-        == """
+    if PYDANTIC_VERSION == 1:
+        expected_result = """
 openapi: 3.0.3
 info:
     title: Example
@@ -1752,7 +1759,70 @@ components:
                     $ref: '#/components/schemas/Error'
 tags: []
 """.strip()
-    )
+    elif PYDANTIC_VERSION == 2:
+        expected_result = """
+openapi: 3.0.3
+info:
+    title: Example
+    version: 0.0.1
+paths:
+    /:
+        get:
+            responses:
+                '200':
+                    description: Success response
+                    content:
+                        application/json:
+                            schema:
+                                $ref: '#/components/schemas/PydResponse[PydCat]'
+            operationId: home
+components:
+    schemas:
+        PydCat:
+            type: object
+            required:
+            - id
+            - name
+            properties:
+                id:
+                    type: integer
+                    format: int64
+                    nullable: false
+                name:
+                    type: string
+                    nullable: false
+        Error:
+            type: object
+            required:
+            - code
+            - message
+            properties:
+                code:
+                    type: integer
+                    format: int64
+                    nullable: false
+                message:
+                    type: string
+                    nullable: false
+        PydResponse[PydCat]:
+            type: object
+            required:
+            - data
+            - error
+            properties:
+                data:
+                    anyOf:
+                    -   $ref: '#/components/schemas/PydCat'
+                    -   type: 'null'
+                error:
+                    anyOf:
+                    -   $ref: '#/components/schemas/Error'
+                    -   type: 'null'
+tags: []
+""".strip()
+    else:
+        raise RuntimeError("Missing expected_result")
+    assert yaml.strip() == expected_result
 
 
 @pytest.mark.asyncio
@@ -1768,9 +1838,78 @@ async def test_pydantic_constrained_types(docs: OpenAPIHandler, serializer: Seri
 
     yaml = serializer.to_yaml(docs.generate_documentation(app))
 
-    assert (
-        yaml.strip()
-        == """
+    expected_result = (
+        """
+openapi: 3.0.3
+info:
+    title: Example
+    version: 0.0.1
+paths:
+    /:
+        get:
+            responses:
+                '200':
+                    description: Success response
+                    content:
+                        application/json:
+                            schema:
+                                $ref: '#/components/schemas/PydConstrained'
+            operationId: home
+components:
+    schemas:
+        PydConstrained:
+            type: object
+            required:
+            - a
+            - b
+            - big_int
+            - big_float
+            - unit_interval
+            - decimal_positive
+            - decimal_negative
+            properties:
+                a:
+                    type: integer
+                    format: int64
+                    minimum: 0
+                    nullable: false
+                b:
+                    type: number
+                    format: float
+                    maximum: 0
+                    nullable: false
+                big_int:
+                    type: integer
+                    format: int64
+                    maximum: 1024
+                    minimum: 1000
+                    nullable: false
+                big_float:
+                    type: number
+                    format: float
+                    maximum: 1024
+                    minimum: 1000
+                    nullable: false
+                unit_interval:
+                    type: number
+                    format: float
+                    nullable: false
+                decimal_positive:
+                    title: Decimal Positive
+                    anyOf:
+                    -   exclusiveMinimum: 0.0
+                        type: number
+                    -   type: string
+                decimal_negative:
+                    title: Decimal Negative
+                    anyOf:
+                    -   exclusiveMaximum: 0.0
+                        type: number
+                    -   type: string
+tags: []
+""".strip()
+        if PYDANTIC_VERSION == 2
+        else """
 openapi: 3.0.3
 info:
     title: Example
@@ -1839,6 +1978,8 @@ tags: []
 """.strip()
     )
 
+    assert yaml.strip() == expected_result
+
 
 def test_pydantic_model_handler_does_not_raise_for_array_without_field_info():
     handler = PydanticModelTypeHandler()
@@ -1867,7 +2008,7 @@ def test_pydantic_model_handler_handles_type_without__fields__():
         def schema():
             return {"properties": {"foo": {"type": "boolean"}}}
 
-    handler.get_type_fields(Foo)
+    handler.get_type_fields(Foo, None)
 
 
 @pytest.mark.asyncio
@@ -2888,3 +3029,226 @@ tags:
 -   name: Parrots
 """.strip()
     )
+
+
+@dataclass
+class A:
+    a_prop: int
+
+
+@dataclass
+class B:
+    b_prop: str
+
+
+@dataclass
+class C:
+    c_prop: str
+
+
+@dataclass
+class D:
+    d_prop: float
+
+
+@dataclass
+class E:
+    e_prop: int
+
+
+@dataclass
+class F:
+    f_prop: str
+    f_prop2: A
+
+
+@dataclass
+class AnyOfTestClass:
+    sub_prop: Union[A, B, C]
+
+
+@dataclass
+class AnyOfResponseTestClass:
+    data: Union[D, E, F]
+
+
+class APyd(BaseModel):
+    a_prop: int
+
+
+class BPyd(BaseModel):
+    b_prop: str
+
+
+class CPyd(BaseModel):
+    c_prop: str
+
+
+class DPyd(BaseModel):
+    d_prop: float
+
+
+class EPyd(BaseModel):
+    e_prop: int
+
+
+class FPyd(BaseModel):
+    f_prop: str
+    f_prop2: APyd
+
+
+class AnyOfTestClassPyd(BaseModel):
+    sub_prop: Union[APyd, BPyd, CPyd]
+
+
+class AnyOfResponseTestClassPyd(BaseModel):
+    data: Union[DPyd, EPyd, FPyd]
+
+
+@pytest.mark.asyncio
+async def test_any_of_dataclasses(docs: OpenAPIHandler, serializer: Serializer):
+    app = get_app()
+    docs.bind_app(app)
+
+    @app.router.post("/one")
+    def one(data: AnyOfTestClass) -> AnyOfResponseTestClass:
+        ...
+
+    await app.start()
+
+    yaml = serializer.to_yaml(docs.generate_documentation(app))
+
+    expected_fragments = [
+        """
+    /one:
+        post:
+            responses:
+                '200':
+                    description: Success response
+                    content:
+                        application/json:
+                            schema:
+                                $ref: '#/components/schemas/AnyOfResponseTestClass'
+            operationId: one
+            parameters: []
+            requestBody:
+                content:
+                    application/json:
+                        schema:
+                            $ref: '#/components/schemas/AnyOfTestClass'
+                required: true
+        """,
+        """
+        D:
+            type: object
+            required:
+            - d_prop
+            properties:
+                d_prop:
+                    type: number
+                    format: float
+                    nullable: false
+        """,
+        """
+        UnionOfDAndEAndF:
+            type: object
+            anyOf:
+            -   $ref: '#/components/schemas/D'
+            -   $ref: '#/components/schemas/E'
+            -   $ref: '#/components/schemas/F'
+        """,
+        """
+        AnyOfResponseTestClass:
+            type: object
+            properties:
+                data:
+                    $ref: '#/components/schemas/UnionOfDAndEAndF'
+        """,
+        """
+        UnionOfAAndBAndC:
+            type: object
+            anyOf:
+            -   $ref: '#/components/schemas/A'
+            -   $ref: '#/components/schemas/B'
+            -   $ref: '#/components/schemas/C'
+        """,
+    ]
+
+    for fragment in expected_fragments:
+        assert fragment.strip() in yaml
+
+
+@pytest.mark.asyncio
+async def test_any_of_pydantic_models(docs: OpenAPIHandler, serializer: Serializer):
+    app = get_app()
+    docs.bind_app(app)
+
+    @app.router.post("/one")
+    def one(data: AnyOfTestClassPyd) -> AnyOfResponseTestClassPyd:
+        ...
+
+    await app.start()
+
+    yaml = serializer.to_yaml(docs.generate_documentation(app))
+
+    expected_fragments = [
+        """
+    /one:
+        post:
+            responses:
+                '200':
+                    description: Success response
+                    content:
+                        application/json:
+                            schema:
+                                $ref: '#/components/schemas/AnyOfResponseTestClassPyd'
+            operationId: one
+            parameters: []
+            requestBody:
+                content:
+                    application/json:
+                        schema:
+                            $ref: '#/components/schemas/AnyOfTestClassPyd'
+                required: true
+        """,
+        """
+        DPyd:
+            type: object
+            required:
+            - d_prop
+            properties:
+                d_prop:
+                    type: number
+                    format: float
+                    nullable: false
+        """,
+        """
+        AnyOfResponseTestClassPyd:
+            type: object
+            required:
+            - data
+            properties:
+                data:
+                    title: Data
+                    anyOf:
+                    -   $ref: '#/components/schemas/DPyd'
+                    -   $ref: '#/components/schemas/EPyd'
+                    -   $ref: '#/components/schemas/FPyd'
+        """,
+        """
+        AnyOfTestClassPyd:
+            type: object
+            required:
+            - sub_prop
+            properties:
+                sub_prop:
+                    title: Sub Prop
+                    anyOf:
+                    -   $ref: '#/components/schemas/APyd'
+                    -   $ref: '#/components/schemas/BPyd'
+                    -   $ref: '#/components/schemas/CPyd'
+        """,
+    ]
+
+    for fragment in expected_fragments:
+        assert fragment.strip() in yaml