]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
✨ Add support for disabling the separation of input and output JSON Schemas in OpenAP...
authorSebastián Ramírez <tiangolo@gmail.com>
Fri, 25 Aug 2023 19:10:22 +0000 (21:10 +0200)
committerGitHub <noreply@github.com>
Fri, 25 Aug 2023 19:10:22 +0000 (21:10 +0200)
* 📝 Add docs for Separate OpenAPI Schemas for Input and Output

* 🔧 Add new docs page to MkDocs config

* ✨ Add separate_input_output_schemas parameter to FastAPI class

* 📝 Add source examples for separating OpenAPI schemas

* ✅ Add tests for separated OpenAPI schemas

* 📝 Add source examples for Python 3.10, 3.9, and 3.7+

* 📝 Update docs for Separate OpenAPI Schemas with new multi-version examples

* ✅ Add and update tests for different Python versions

* ✅ Add tests for corner cases with separate_input_output_schemas

* 📝 Update tutorial to use Union instead of Optional

* 🐛 Fix type annotations

* 🐛 Fix correct import in test

* 💄 Add CSS to simulate browser windows for screenshots

* ➕ Add playwright as a dev dependency to automate generating screenshots

* 🔨 Add Playwright scripts to generate screenshots for new docs

* 📝 Update docs, tweak text to match screenshots

* 🍱 Add screenshots for new docs

31 files changed:
docs/en/docs/css/custom.css
docs/en/docs/how-to/separate-openapi-schemas.md [new file with mode: 0644]
docs/en/docs/img/tutorial/separate-openapi-schemas/image01.png [new file with mode: 0644]
docs/en/docs/img/tutorial/separate-openapi-schemas/image02.png [new file with mode: 0644]
docs/en/docs/img/tutorial/separate-openapi-schemas/image03.png [new file with mode: 0644]
docs/en/docs/img/tutorial/separate-openapi-schemas/image04.png [new file with mode: 0644]
docs/en/docs/img/tutorial/separate-openapi-schemas/image05.png [new file with mode: 0644]
docs/en/mkdocs.yml
docs_src/separate_openapi_schemas/tutorial001.py [new file with mode: 0644]
docs_src/separate_openapi_schemas/tutorial001_py310.py [new file with mode: 0644]
docs_src/separate_openapi_schemas/tutorial001_py39.py [new file with mode: 0644]
docs_src/separate_openapi_schemas/tutorial002.py [new file with mode: 0644]
docs_src/separate_openapi_schemas/tutorial002_py310.py [new file with mode: 0644]
docs_src/separate_openapi_schemas/tutorial002_py39.py [new file with mode: 0644]
fastapi/_compat.py
fastapi/applications.py
fastapi/openapi/utils.py
requirements.txt
scripts/playwright/separate_openapi_schemas/image01.py [new file with mode: 0644]
scripts/playwright/separate_openapi_schemas/image02.py [new file with mode: 0644]
scripts/playwright/separate_openapi_schemas/image03.py [new file with mode: 0644]
scripts/playwright/separate_openapi_schemas/image04.py [new file with mode: 0644]
scripts/playwright/separate_openapi_schemas/image05.py [new file with mode: 0644]
tests/test_openapi_separate_input_output_schemas.py [new file with mode: 0644]
tests/test_tutorial/test_separate_openapi_schemas/__init__.py [new file with mode: 0644]
tests/test_tutorial/test_separate_openapi_schemas/test_tutorial001.py [new file with mode: 0644]
tests/test_tutorial/test_separate_openapi_schemas/test_tutorial001_py310.py [new file with mode: 0644]
tests/test_tutorial/test_separate_openapi_schemas/test_tutorial001_py39.py [new file with mode: 0644]
tests/test_tutorial/test_separate_openapi_schemas/test_tutorial002.py [new file with mode: 0644]
tests/test_tutorial/test_separate_openapi_schemas/test_tutorial002_py310.py [new file with mode: 0644]
tests/test_tutorial/test_separate_openapi_schemas/test_tutorial002_py39.py [new file with mode: 0644]

index 066b51725ecce56f30e1b2c245e4b24b402ee0f2..187040792b1e8206f39c7feef59ec61a744a992e 100644 (file)
@@ -144,3 +144,39 @@ code {
   margin-top: 2em;
   margin-bottom: 2em;
 }
+
+/* Screenshots */
+/*
+Simulate a browser window frame.
+Inspired by Termynal's CSS tricks with modifications
+*/
+
+.screenshot {
+  display: block;
+  background-color: #d3e0de;
+  border-radius: 4px;
+  padding: 45px 5px 5px;
+  position: relative;
+  -webkit-box-sizing: border-box;
+          box-sizing: border-box;
+}
+
+.screenshot img {
+  display: block;
+  border-radius: 2px;
+}
+
+.screenshot:before {
+  content: '';
+  position: absolute;
+  top: 15px;
+  left: 15px;
+  display: inline-block;
+  width: 15px;
+  height: 15px;
+  border-radius: 50%;
+  /* A little hack to display the window buttons in one pseudo element. */
+  background: #d9515d;
+  -webkit-box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930;
+          box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930;
+}
diff --git a/docs/en/docs/how-to/separate-openapi-schemas.md b/docs/en/docs/how-to/separate-openapi-schemas.md
new file mode 100644 (file)
index 0000000..39d96ea
--- /dev/null
@@ -0,0 +1,228 @@
+# Separate OpenAPI Schemas for Input and Output or Not
+
+When using **Pydantic v2**, the generated OpenAPI is a bit more exact and **correct** than before. 😎
+
+In fact, in some cases, it will even have **two JSON Schemas** in OpenAPI for the same Pydantic model, for input and output, depending on if they have **default values**.
+
+Let's see how that works and how to change it if you need to do that.
+
+## Pydantic Models for Input and Output
+
+Let's say you have a Pydantic model with default values, like this one:
+
+=== "Python 3.10+"
+
+    ```Python hl_lines="7"
+    {!> ../../../docs_src/separate_openapi_schemas/tutorial001_py310.py[ln:1-7]!}
+
+    # Code below omitted 👇
+    ```
+
+    <details>
+    <summary>👀 Full file preview</summary>
+
+    ```Python
+    {!> ../../../docs_src/separate_openapi_schemas/tutorial001_py310.py!}
+    ```
+
+    </details>
+
+=== "Python 3.9+"
+
+    ```Python hl_lines="9"
+    {!> ../../../docs_src/separate_openapi_schemas/tutorial001_py39.py[ln:1-9]!}
+
+    # Code below omitted 👇
+    ```
+
+    <details>
+    <summary>👀 Full file preview</summary>
+
+    ```Python
+    {!> ../../../docs_src/separate_openapi_schemas/tutorial001_py39.py!}
+    ```
+
+    </details>
+
+=== "Python 3.7+"
+
+    ```Python hl_lines="9"
+    {!> ../../../docs_src/separate_openapi_schemas/tutorial001.py[ln:1-9]!}
+
+    # Code below omitted 👇
+    ```
+
+    <details>
+    <summary>👀 Full file preview</summary>
+
+    ```Python
+    {!> ../../../docs_src/separate_openapi_schemas/tutorial001.py!}
+    ```
+
+    </details>
+
+### Model for Input
+
+If you use this model as an input like here:
+
+=== "Python 3.10+"
+
+    ```Python hl_lines="14"
+    {!> ../../../docs_src/separate_openapi_schemas/tutorial001_py310.py[ln:1-15]!}
+
+    # Code below omitted 👇
+    ```
+
+    <details>
+    <summary>👀 Full file preview</summary>
+
+    ```Python
+    {!> ../../../docs_src/separate_openapi_schemas/tutorial001_py310.py!}
+    ```
+
+    </details>
+
+=== "Python 3.9+"
+
+    ```Python hl_lines="16"
+    {!> ../../../docs_src/separate_openapi_schemas/tutorial001_py39.py[ln:1-17]!}
+
+    # Code below omitted 👇
+    ```
+
+    <details>
+    <summary>👀 Full file preview</summary>
+
+    ```Python
+    {!> ../../../docs_src/separate_openapi_schemas/tutorial001_py39.py!}
+    ```
+
+    </details>
+
+=== "Python 3.7+"
+
+    ```Python hl_lines="16"
+    {!> ../../../docs_src/separate_openapi_schemas/tutorial001.py[ln:1-17]!}
+
+    # Code below omitted 👇
+    ```
+
+    <details>
+    <summary>👀 Full file preview</summary>
+
+    ```Python
+    {!> ../../../docs_src/separate_openapi_schemas/tutorial001.py!}
+    ```
+
+    </details>
+
+...then the `description` field will **not be required**. Because it has a default value of `None`.
+
+### Input Model in Docs
+
+You can confirm that in the docs, the `description` field doesn't have a **red asterisk**, it's not marked as required:
+
+<div class="screenshot">
+<img src="/img/tutorial/separate-openapi-schemas/image01.png">
+</div>
+
+### Model for Output
+
+But if you use the same model as an output, like here:
+
+=== "Python 3.10+"
+
+    ```Python hl_lines="19"
+    {!> ../../../docs_src/separate_openapi_schemas/tutorial001_py310.py!}
+    ```
+
+=== "Python 3.9+"
+
+    ```Python hl_lines="21"
+    {!> ../../../docs_src/separate_openapi_schemas/tutorial001_py39.py!}
+    ```
+
+=== "Python 3.7+"
+
+    ```Python hl_lines="21"
+    {!> ../../../docs_src/separate_openapi_schemas/tutorial001.py!}
+    ```
+
+...then because `description` has a default value, if you **don't return anything** for that field, it will still have that **default value**.
+
+### Model for Output Response Data
+
+If you interact with the docs and check the response, even though the code didn't add anything in one of the `description` fields, the JSON response contains the default value (`null`):
+
+<div class="screenshot">
+<img src="/img/tutorial/separate-openapi-schemas/image02.png">
+</div>
+
+This means that it will **always have a value**, it's just that sometimes the value could be `None` (or `null` in JSON).
+
+That means that, clients using your API don't have to check if the value exists or not, they can **asume the field will always be there**, but just that in some cases it will have the default value of `None`.
+
+The way to describe this in OpenAPI, is to mark that field as **required**, because it will always be there.
+
+Because of that, the JSON Schema for a model can be different depending on if it's used for **input or output**:
+
+* for **input** the `description` will **not be required**
+* for **output** it will be **required** (and possibly `None`, or in JSON terms, `null`)
+
+### Model for Output in Docs
+
+You can check the output model in the docs too, **both** `name` and `description` are marked as **required** with a **red asterisk**:
+
+<div class="screenshot">
+<img src="/img/tutorial/separate-openapi-schemas/image03.png">
+</div>
+
+### Model for Input and Output in Docs
+
+And if you check all the available Schemas (JSON Schemas) in OpenAPI, you will see that there are two, one `Item-Input` and one `Item-Output`.
+
+For `Item-Input`, `description` is **not required**, it doesn't have a red asterisk.
+
+But for `Item-Output`, `description` is **required**, it has a red asterisk.
+
+<div class="screenshot">
+<img src="/img/tutorial/separate-openapi-schemas/image04.png">
+</div>
+
+With this feature from **Pydantic v2**, your API documentation is more **precise**, and if you have autogenerated clients and SDKs, they will be more precise too, with a better **developer experience** and consistency. 🎉
+
+## Do not Separate Schemas
+
+Now, there are some cases where you might want to have the **same schema for input and output**.
+
+Probably the main use case for this is if you already have some autogenerated client code/SDKs and you don't want to update all the autogenerated client code/SDKs yet, you probably will want to do it at some point, but maybe not right now.
+
+In that case, you can disable this feature in **FastAPI**, with the parameter `separate_input_output_schemas=False`.
+
+=== "Python 3.10+"
+
+    ```Python hl_lines="10"
+    {!> ../../../docs_src/separate_openapi_schemas/tutorial002_py310.py!}
+    ```
+
+=== "Python 3.9+"
+
+    ```Python hl_lines="12"
+    {!> ../../../docs_src/separate_openapi_schemas/tutorial002_py39.py!}
+    ```
+
+=== "Python 3.7+"
+
+    ```Python hl_lines="12"
+    {!> ../../../docs_src/separate_openapi_schemas/tutorial002.py!}
+    ```
+
+### Same Schema for Input and Output Models in Docs
+
+And now there will be one single schema for input and output for the model, only `Item`, and it will have `description` as **not required**:
+
+<div class="screenshot">
+<img src="/img/tutorial/separate-openapi-schemas/image05.png">
+</div>
+
+This is the same behavior as in Pydantic v1. 🤓
diff --git a/docs/en/docs/img/tutorial/separate-openapi-schemas/image01.png b/docs/en/docs/img/tutorial/separate-openapi-schemas/image01.png
new file mode 100644 (file)
index 0000000..aa085f8
Binary files /dev/null and b/docs/en/docs/img/tutorial/separate-openapi-schemas/image01.png differ
diff --git a/docs/en/docs/img/tutorial/separate-openapi-schemas/image02.png b/docs/en/docs/img/tutorial/separate-openapi-schemas/image02.png
new file mode 100644 (file)
index 0000000..672ef1d
Binary files /dev/null and b/docs/en/docs/img/tutorial/separate-openapi-schemas/image02.png differ
diff --git a/docs/en/docs/img/tutorial/separate-openapi-schemas/image03.png b/docs/en/docs/img/tutorial/separate-openapi-schemas/image03.png
new file mode 100644 (file)
index 0000000..81340fb
Binary files /dev/null and b/docs/en/docs/img/tutorial/separate-openapi-schemas/image03.png differ
diff --git a/docs/en/docs/img/tutorial/separate-openapi-schemas/image04.png b/docs/en/docs/img/tutorial/separate-openapi-schemas/image04.png
new file mode 100644 (file)
index 0000000..fc2302a
Binary files /dev/null and b/docs/en/docs/img/tutorial/separate-openapi-schemas/image04.png differ
diff --git a/docs/en/docs/img/tutorial/separate-openapi-schemas/image05.png b/docs/en/docs/img/tutorial/separate-openapi-schemas/image05.png
new file mode 100644 (file)
index 0000000..674dd0b
Binary files /dev/null and b/docs/en/docs/img/tutorial/separate-openapi-schemas/image05.png differ
index f75b84ff5b5520390a026234ea71369cbffd6244..c56e4c9426f8fb3ba2ad9efd59f211e5e87170b0 100644 (file)
@@ -176,6 +176,7 @@ nav:
   - how-to/custom-request-and-route.md
   - how-to/conditional-openapi.md
   - how-to/extending-openapi.md
+  - how-to/separate-openapi-schemas.md
   - how-to/custom-docs-ui-assets.md
   - how-to/configure-swagger-ui.md
 - project-generation.md
diff --git a/docs_src/separate_openapi_schemas/tutorial001.py b/docs_src/separate_openapi_schemas/tutorial001.py
new file mode 100644 (file)
index 0000000..415eef8
--- /dev/null
@@ -0,0 +1,28 @@
+from typing import List, Union
+
+from fastapi import FastAPI
+from pydantic import BaseModel
+
+
+class Item(BaseModel):
+    name: str
+    description: Union[str, None] = None
+
+
+app = FastAPI()
+
+
+@app.post("/items/")
+def create_item(item: Item):
+    return item
+
+
+@app.get("/items/")
+def read_items() -> List[Item]:
+    return [
+        Item(
+            name="Portal Gun",
+            description="Device to travel through the multi-rick-verse",
+        ),
+        Item(name="Plumbus"),
+    ]
diff --git a/docs_src/separate_openapi_schemas/tutorial001_py310.py b/docs_src/separate_openapi_schemas/tutorial001_py310.py
new file mode 100644 (file)
index 0000000..289cb54
--- /dev/null
@@ -0,0 +1,26 @@
+from fastapi import FastAPI
+from pydantic import BaseModel
+
+
+class Item(BaseModel):
+    name: str
+    description: str | None = None
+
+
+app = FastAPI()
+
+
+@app.post("/items/")
+def create_item(item: Item):
+    return item
+
+
+@app.get("/items/")
+def read_items() -> list[Item]:
+    return [
+        Item(
+            name="Portal Gun",
+            description="Device to travel through the multi-rick-verse",
+        ),
+        Item(name="Plumbus"),
+    ]
diff --git a/docs_src/separate_openapi_schemas/tutorial001_py39.py b/docs_src/separate_openapi_schemas/tutorial001_py39.py
new file mode 100644 (file)
index 0000000..63cffd1
--- /dev/null
@@ -0,0 +1,28 @@
+from typing import Optional
+
+from fastapi import FastAPI
+from pydantic import BaseModel
+
+
+class Item(BaseModel):
+    name: str
+    description: Optional[str] = None
+
+
+app = FastAPI()
+
+
+@app.post("/items/")
+def create_item(item: Item):
+    return item
+
+
+@app.get("/items/")
+def read_items() -> list[Item]:
+    return [
+        Item(
+            name="Portal Gun",
+            description="Device to travel through the multi-rick-verse",
+        ),
+        Item(name="Plumbus"),
+    ]
diff --git a/docs_src/separate_openapi_schemas/tutorial002.py b/docs_src/separate_openapi_schemas/tutorial002.py
new file mode 100644 (file)
index 0000000..7df9378
--- /dev/null
@@ -0,0 +1,28 @@
+from typing import List, Union
+
+from fastapi import FastAPI
+from pydantic import BaseModel
+
+
+class Item(BaseModel):
+    name: str
+    description: Union[str, None] = None
+
+
+app = FastAPI(separate_input_output_schemas=False)
+
+
+@app.post("/items/")
+def create_item(item: Item):
+    return item
+
+
+@app.get("/items/")
+def read_items() -> List[Item]:
+    return [
+        Item(
+            name="Portal Gun",
+            description="Device to travel through the multi-rick-verse",
+        ),
+        Item(name="Plumbus"),
+    ]
diff --git a/docs_src/separate_openapi_schemas/tutorial002_py310.py b/docs_src/separate_openapi_schemas/tutorial002_py310.py
new file mode 100644 (file)
index 0000000..5db2108
--- /dev/null
@@ -0,0 +1,26 @@
+from fastapi import FastAPI
+from pydantic import BaseModel
+
+
+class Item(BaseModel):
+    name: str
+    description: str | None = None
+
+
+app = FastAPI(separate_input_output_schemas=False)
+
+
+@app.post("/items/")
+def create_item(item: Item):
+    return item
+
+
+@app.get("/items/")
+def read_items() -> list[Item]:
+    return [
+        Item(
+            name="Portal Gun",
+            description="Device to travel through the multi-rick-verse",
+        ),
+        Item(name="Plumbus"),
+    ]
diff --git a/docs_src/separate_openapi_schemas/tutorial002_py39.py b/docs_src/separate_openapi_schemas/tutorial002_py39.py
new file mode 100644 (file)
index 0000000..50d997d
--- /dev/null
@@ -0,0 +1,28 @@
+from typing import Optional
+
+from fastapi import FastAPI
+from pydantic import BaseModel
+
+
+class Item(BaseModel):
+    name: str
+    description: Optional[str] = None
+
+
+app = FastAPI(separate_input_output_schemas=False)
+
+
+@app.post("/items/")
+def create_item(item: Item):
+    return item
+
+
+@app.get("/items/")
+def read_items() -> list[Item]:
+    return [
+        Item(
+            name="Portal Gun",
+            description="Device to travel through the multi-rick-verse",
+        ),
+        Item(name="Plumbus"),
+    ]
index 9ffcaf40925eaa82254a861dd244c5c577890a47..eb55b08f2e6e3b30452eee78cffbbc886b28b355 100644 (file)
@@ -181,9 +181,13 @@ if PYDANTIC_V2:
         field_mapping: Dict[
             Tuple[ModelField, Literal["validation", "serialization"]], JsonSchemaValue
         ],
+        separate_input_output_schemas: bool = True,
     ) -> Dict[str, Any]:
+        override_mode: Union[Literal["validation"], None] = (
+            None if separate_input_output_schemas else "validation"
+        )
         # This expects that GenerateJsonSchema was already used to generate the definitions
-        json_schema = field_mapping[(field, field.mode)]
+        json_schema = field_mapping[(field, override_mode or field.mode)]
         if "$ref" not in json_schema:
             # TODO remove when deprecating Pydantic v1
             # Ref: https://github.com/pydantic/pydantic/blob/d61792cc42c80b13b23e3ffa74bc37ec7c77f7d1/pydantic/schema.py#L207
@@ -200,14 +204,19 @@ if PYDANTIC_V2:
         fields: List[ModelField],
         schema_generator: GenerateJsonSchema,
         model_name_map: ModelNameMap,
+        separate_input_output_schemas: bool = True,
     ) -> Tuple[
         Dict[
             Tuple[ModelField, Literal["validation", "serialization"]], JsonSchemaValue
         ],
         Dict[str, Dict[str, Any]],
     ]:
+        override_mode: Union[Literal["validation"], None] = (
+            None if separate_input_output_schemas else "validation"
+        )
         inputs = [
-            (field, field.mode, field._type_adapter.core_schema) for field in fields
+            (field, override_mode or field.mode, field._type_adapter.core_schema)
+            for field in fields
         ]
         field_mapping, definitions = schema_generator.generate_definitions(
             inputs=inputs
@@ -429,6 +438,7 @@ else:
         field_mapping: Dict[
             Tuple[ModelField, Literal["validation", "serialization"]], JsonSchemaValue
         ],
+        separate_input_output_schemas: bool = True,
     ) -> Dict[str, Any]:
         # This expects that GenerateJsonSchema was already used to generate the definitions
         return field_schema(  # type: ignore[no-any-return]
@@ -444,6 +454,7 @@ else:
         fields: List[ModelField],
         schema_generator: GenerateJsonSchema,
         model_name_map: ModelNameMap,
+        separate_input_output_schemas: bool = True,
     ) -> Tuple[
         Dict[
             Tuple[ModelField, Literal["validation", "serialization"]], JsonSchemaValue
index e32cfa03d20cbfd8ee588b943d15cf1b38e2b951..b681e50b395d7225224e72fc963ae9eaaf6f49e0 100644 (file)
@@ -92,6 +92,7 @@ class FastAPI(Starlette):
         generate_unique_id_function: Callable[[routing.APIRoute], str] = Default(
             generate_unique_id
         ),
+        separate_input_output_schemas: bool = True,
         **extra: Any,
     ) -> None:
         self.debug = debug
@@ -111,6 +112,7 @@ class FastAPI(Starlette):
         self.swagger_ui_init_oauth = swagger_ui_init_oauth
         self.swagger_ui_parameters = swagger_ui_parameters
         self.servers = servers or []
+        self.separate_input_output_schemas = separate_input_output_schemas
         self.extra = extra
         self.openapi_version = "3.1.0"
         self.openapi_schema: Optional[Dict[str, Any]] = None
@@ -227,6 +229,7 @@ class FastAPI(Starlette):
                 webhooks=self.webhooks.routes,
                 tags=self.openapi_tags,
                 servers=self.servers,
+                separate_input_output_schemas=self.separate_input_output_schemas,
             )
         return self.openapi_schema
 
index e295361e6a9a1483722095ad5558c2d977200408..9498375fefa081bd38a74ee35773027a60e78460 100644 (file)
@@ -95,6 +95,7 @@ def get_openapi_operation_parameters(
     field_mapping: Dict[
         Tuple[ModelField, Literal["validation", "serialization"]], JsonSchemaValue
     ],
+    separate_input_output_schemas: bool = True,
 ) -> List[Dict[str, Any]]:
     parameters = []
     for param in all_route_params:
@@ -107,6 +108,7 @@ def get_openapi_operation_parameters(
             schema_generator=schema_generator,
             model_name_map=model_name_map,
             field_mapping=field_mapping,
+            separate_input_output_schemas=separate_input_output_schemas,
         )
         parameter = {
             "name": param.alias,
@@ -132,6 +134,7 @@ def get_openapi_operation_request_body(
     field_mapping: Dict[
         Tuple[ModelField, Literal["validation", "serialization"]], JsonSchemaValue
     ],
+    separate_input_output_schemas: bool = True,
 ) -> Optional[Dict[str, Any]]:
     if not body_field:
         return None
@@ -141,6 +144,7 @@ def get_openapi_operation_request_body(
         schema_generator=schema_generator,
         model_name_map=model_name_map,
         field_mapping=field_mapping,
+        separate_input_output_schemas=separate_input_output_schemas,
     )
     field_info = cast(Body, body_field.field_info)
     request_media_type = field_info.media_type
@@ -211,6 +215,7 @@ def get_openapi_path(
     field_mapping: Dict[
         Tuple[ModelField, Literal["validation", "serialization"]], JsonSchemaValue
     ],
+    separate_input_output_schemas: bool = True,
 ) -> Tuple[Dict[str, Any], Dict[str, Any], Dict[str, Any]]:
     path = {}
     security_schemes: Dict[str, Any] = {}
@@ -242,6 +247,7 @@ def get_openapi_path(
                 schema_generator=schema_generator,
                 model_name_map=model_name_map,
                 field_mapping=field_mapping,
+                separate_input_output_schemas=separate_input_output_schemas,
             )
             parameters.extend(operation_parameters)
             if parameters:
@@ -263,6 +269,7 @@ def get_openapi_path(
                     schema_generator=schema_generator,
                     model_name_map=model_name_map,
                     field_mapping=field_mapping,
+                    separate_input_output_schemas=separate_input_output_schemas,
                 )
                 if request_body_oai:
                     operation["requestBody"] = request_body_oai
@@ -280,6 +287,7 @@ def get_openapi_path(
                             schema_generator=schema_generator,
                             model_name_map=model_name_map,
                             field_mapping=field_mapping,
+                            separate_input_output_schemas=separate_input_output_schemas,
                         )
                         callbacks[callback.name] = {callback.path: cb_path}
                 operation["callbacks"] = callbacks
@@ -310,6 +318,7 @@ def get_openapi_path(
                             schema_generator=schema_generator,
                             model_name_map=model_name_map,
                             field_mapping=field_mapping,
+                            separate_input_output_schemas=separate_input_output_schemas,
                         )
                     else:
                         response_schema = {}
@@ -343,6 +352,7 @@ def get_openapi_path(
                             schema_generator=schema_generator,
                             model_name_map=model_name_map,
                             field_mapping=field_mapping,
+                            separate_input_output_schemas=separate_input_output_schemas,
                         )
                         media_type = route_response_media_type or "application/json"
                         additional_schema = (
@@ -433,6 +443,7 @@ def get_openapi(
     terms_of_service: Optional[str] = None,
     contact: Optional[Dict[str, Union[str, Any]]] = None,
     license_info: Optional[Dict[str, Union[str, Any]]] = None,
+    separate_input_output_schemas: bool = True,
 ) -> Dict[str, Any]:
     info: Dict[str, Any] = {"title": title, "version": version}
     if summary:
@@ -459,6 +470,7 @@ def get_openapi(
         fields=all_fields,
         schema_generator=schema_generator,
         model_name_map=model_name_map,
+        separate_input_output_schemas=separate_input_output_schemas,
     )
     for route in routes or []:
         if isinstance(route, routing.APIRoute):
@@ -468,6 +480,7 @@ def get_openapi(
                 schema_generator=schema_generator,
                 model_name_map=model_name_map,
                 field_mapping=field_mapping,
+                separate_input_output_schemas=separate_input_output_schemas,
             )
             if result:
                 path, security_schemes, path_definitions = result
@@ -487,6 +500,7 @@ def get_openapi(
                 schema_generator=schema_generator,
                 model_name_map=model_name_map,
                 field_mapping=field_mapping,
+                separate_input_output_schemas=separate_input_output_schemas,
             )
             if result:
                 path, security_schemes, path_definitions = result
index 7e746016a42de2f8923164ae14bcf87941a5efe1..ef25ec483fccb9259fca06f6215578595c475f37 100644 (file)
@@ -3,3 +3,5 @@
 -r requirements-docs.txt
 uvicorn[standard] >=0.12.0,<0.23.0
 pre-commit >=2.17.0,<4.0.0
+# For generating screenshots
+playwright
diff --git a/scripts/playwright/separate_openapi_schemas/image01.py b/scripts/playwright/separate_openapi_schemas/image01.py
new file mode 100644 (file)
index 0000000..0b40f3b
--- /dev/null
@@ -0,0 +1,29 @@
+import subprocess
+
+from playwright.sync_api import Playwright, sync_playwright
+
+
+def run(playwright: Playwright) -> None:
+    browser = playwright.chromium.launch(headless=False)
+    context = browser.new_context(viewport={"width": 960, "height": 1080})
+    page = context.new_page()
+    page.goto("http://localhost:8000/docs")
+    page.get_by_text("POST/items/Create Item").click()
+    page.get_by_role("tab", name="Schema").first.click()
+    page.screenshot(
+        path="docs/en/docs/img/tutorial/separate-openapi-schemas/image01.png"
+    )
+
+    # ---------------------
+    context.close()
+    browser.close()
+
+
+process = subprocess.Popen(
+    ["uvicorn", "docs_src.separate_openapi_schemas.tutorial001:app"]
+)
+try:
+    with sync_playwright() as playwright:
+        run(playwright)
+finally:
+    process.terminate()
diff --git a/scripts/playwright/separate_openapi_schemas/image02.py b/scripts/playwright/separate_openapi_schemas/image02.py
new file mode 100644 (file)
index 0000000..f76af7e
--- /dev/null
@@ -0,0 +1,30 @@
+import subprocess
+
+from playwright.sync_api import Playwright, sync_playwright
+
+
+def run(playwright: Playwright) -> None:
+    browser = playwright.chromium.launch(headless=False)
+    context = browser.new_context(viewport={"width": 960, "height": 1080})
+    page = context.new_page()
+    page.goto("http://localhost:8000/docs")
+    page.get_by_text("GET/items/Read Items").click()
+    page.get_by_role("button", name="Try it out").click()
+    page.get_by_role("button", name="Execute").click()
+    page.screenshot(
+        path="docs/en/docs/img/tutorial/separate-openapi-schemas/image02.png"
+    )
+
+    # ---------------------
+    context.close()
+    browser.close()
+
+
+process = subprocess.Popen(
+    ["uvicorn", "docs_src.separate_openapi_schemas.tutorial001:app"]
+)
+try:
+    with sync_playwright() as playwright:
+        run(playwright)
+finally:
+    process.terminate()
diff --git a/scripts/playwright/separate_openapi_schemas/image03.py b/scripts/playwright/separate_openapi_schemas/image03.py
new file mode 100644 (file)
index 0000000..127f5c4
--- /dev/null
@@ -0,0 +1,30 @@
+import subprocess
+
+from playwright.sync_api import Playwright, sync_playwright
+
+
+def run(playwright: Playwright) -> None:
+    browser = playwright.chromium.launch(headless=False)
+    context = browser.new_context(viewport={"width": 960, "height": 1080})
+    page = context.new_page()
+    page.goto("http://localhost:8000/docs")
+    page.get_by_text("GET/items/Read Items").click()
+    page.get_by_role("tab", name="Schema").click()
+    page.get_by_label("Schema").get_by_role("button", name="Expand all").click()
+    page.screenshot(
+        path="docs/en/docs/img/tutorial/separate-openapi-schemas/image03.png"
+    )
+
+    # ---------------------
+    context.close()
+    browser.close()
+
+
+process = subprocess.Popen(
+    ["uvicorn", "docs_src.separate_openapi_schemas.tutorial001:app"]
+)
+try:
+    with sync_playwright() as playwright:
+        run(playwright)
+finally:
+    process.terminate()
diff --git a/scripts/playwright/separate_openapi_schemas/image04.py b/scripts/playwright/separate_openapi_schemas/image04.py
new file mode 100644 (file)
index 0000000..208eaf8
--- /dev/null
@@ -0,0 +1,29 @@
+import subprocess
+
+from playwright.sync_api import Playwright, sync_playwright
+
+
+def run(playwright: Playwright) -> None:
+    browser = playwright.chromium.launch(headless=False)
+    context = browser.new_context(viewport={"width": 960, "height": 1080})
+    page = context.new_page()
+    page.goto("http://localhost:8000/docs")
+    page.get_by_role("button", name="Item-Input").click()
+    page.get_by_role("button", name="Item-Output").click()
+    page.set_viewport_size({"width": 960, "height": 820})
+    page.screenshot(
+        path="docs/en/docs/img/tutorial/separate-openapi-schemas/image04.png"
+    )
+    # ---------------------
+    context.close()
+    browser.close()
+
+
+process = subprocess.Popen(
+    ["uvicorn", "docs_src.separate_openapi_schemas.tutorial001:app"]
+)
+try:
+    with sync_playwright() as playwright:
+        run(playwright)
+finally:
+    process.terminate()
diff --git a/scripts/playwright/separate_openapi_schemas/image05.py b/scripts/playwright/separate_openapi_schemas/image05.py
new file mode 100644 (file)
index 0000000..83966b4
--- /dev/null
@@ -0,0 +1,29 @@
+import subprocess
+
+from playwright.sync_api import Playwright, sync_playwright
+
+
+def run(playwright: Playwright) -> None:
+    browser = playwright.chromium.launch(headless=False)
+    context = browser.new_context(viewport={"width": 960, "height": 1080})
+    page = context.new_page()
+    page.goto("http://localhost:8000/docs")
+    page.get_by_role("button", name="Item", exact=True).click()
+    page.set_viewport_size({"width": 960, "height": 700})
+    page.screenshot(
+        path="docs/en/docs/img/tutorial/separate-openapi-schemas/image05.png"
+    )
+
+    # ---------------------
+    context.close()
+    browser.close()
+
+
+process = subprocess.Popen(
+    ["uvicorn", "docs_src.separate_openapi_schemas.tutorial002:app"]
+)
+try:
+    with sync_playwright() as playwright:
+        run(playwright)
+finally:
+    process.terminate()
diff --git a/tests/test_openapi_separate_input_output_schemas.py b/tests/test_openapi_separate_input_output_schemas.py
new file mode 100644 (file)
index 0000000..70f4b90
--- /dev/null
@@ -0,0 +1,490 @@
+from typing import List, Optional
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+from pydantic import BaseModel
+
+from .utils import needs_pydanticv2
+
+
+class SubItem(BaseModel):
+    subname: str
+    sub_description: Optional[str] = None
+    tags: List[str] = []
+
+
+class Item(BaseModel):
+    name: str
+    description: Optional[str] = None
+    sub: Optional[SubItem] = None
+
+
+def get_app_client(separate_input_output_schemas: bool = True) -> TestClient:
+    app = FastAPI(separate_input_output_schemas=separate_input_output_schemas)
+
+    @app.post("/items/")
+    def create_item(item: Item):
+        return item
+
+    @app.post("/items-list/")
+    def create_item_list(item: List[Item]):
+        return item
+
+    @app.get("/items/")
+    def read_items() -> List[Item]:
+        return [
+            Item(
+                name="Portal Gun",
+                description="Device to travel through the multi-rick-verse",
+                sub=SubItem(subname="subname"),
+            ),
+            Item(name="Plumbus"),
+        ]
+
+    client = TestClient(app)
+    return client
+
+
+def test_create_item():
+    client = get_app_client()
+    client_no = get_app_client(separate_input_output_schemas=False)
+    response = client.post("/items/", json={"name": "Plumbus"})
+    response2 = client_no.post("/items/", json={"name": "Plumbus"})
+    assert response.status_code == response2.status_code == 200, response.text
+    assert (
+        response.json()
+        == response2.json()
+        == {"name": "Plumbus", "description": None, "sub": None}
+    )
+
+
+def test_create_item_with_sub():
+    client = get_app_client()
+    client_no = get_app_client(separate_input_output_schemas=False)
+    data = {
+        "name": "Plumbus",
+        "sub": {"subname": "SubPlumbus", "sub_description": "Sub WTF"},
+    }
+    response = client.post("/items/", json=data)
+    response2 = client_no.post("/items/", json=data)
+    assert response.status_code == response2.status_code == 200, response.text
+    assert (
+        response.json()
+        == response2.json()
+        == {
+            "name": "Plumbus",
+            "description": None,
+            "sub": {"subname": "SubPlumbus", "sub_description": "Sub WTF", "tags": []},
+        }
+    )
+
+
+def test_create_item_list():
+    client = get_app_client()
+    client_no = get_app_client(separate_input_output_schemas=False)
+    data = [
+        {"name": "Plumbus"},
+        {
+            "name": "Portal Gun",
+            "description": "Device to travel through the multi-rick-verse",
+        },
+    ]
+    response = client.post("/items-list/", json=data)
+    response2 = client_no.post("/items-list/", json=data)
+    assert response.status_code == response2.status_code == 200, response.text
+    assert (
+        response.json()
+        == response2.json()
+        == [
+            {"name": "Plumbus", "description": None, "sub": None},
+            {
+                "name": "Portal Gun",
+                "description": "Device to travel through the multi-rick-verse",
+                "sub": None,
+            },
+        ]
+    )
+
+
+def test_read_items():
+    client = get_app_client()
+    client_no = get_app_client(separate_input_output_schemas=False)
+    response = client.get("/items/")
+    response2 = client_no.get("/items/")
+    assert response.status_code == response2.status_code == 200, response.text
+    assert (
+        response.json()
+        == response2.json()
+        == [
+            {
+                "name": "Portal Gun",
+                "description": "Device to travel through the multi-rick-verse",
+                "sub": {"subname": "subname", "sub_description": None, "tags": []},
+            },
+            {"name": "Plumbus", "description": None, "sub": None},
+        ]
+    )
+
+
+@needs_pydanticv2
+def test_openapi_schema():
+    client = get_app_client()
+    response = client.get("/openapi.json")
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "openapi": "3.1.0",
+        "info": {"title": "FastAPI", "version": "0.1.0"},
+        "paths": {
+            "/items/": {
+                "get": {
+                    "summary": "Read Items",
+                    "operationId": "read_items_items__get",
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "items": {
+                                            "$ref": "#/components/schemas/Item-Output"
+                                        },
+                                        "type": "array",
+                                        "title": "Response Read Items Items  Get",
+                                    }
+                                }
+                            },
+                        }
+                    },
+                },
+                "post": {
+                    "summary": "Create Item",
+                    "operationId": "create_item_items__post",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {"$ref": "#/components/schemas/Item-Input"}
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {"application/json": {"schema": {}}},
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                },
+            },
+            "/items-list/": {
+                "post": {
+                    "summary": "Create Item List",
+                    "operationId": "create_item_list_items_list__post",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "items": {
+                                        "$ref": "#/components/schemas/Item-Input"
+                                    },
+                                    "type": "array",
+                                    "title": "Item",
+                                }
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {"application/json": {"schema": {}}},
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                }
+            },
+        },
+        "components": {
+            "schemas": {
+                "HTTPValidationError": {
+                    "properties": {
+                        "detail": {
+                            "items": {"$ref": "#/components/schemas/ValidationError"},
+                            "type": "array",
+                            "title": "Detail",
+                        }
+                    },
+                    "type": "object",
+                    "title": "HTTPValidationError",
+                },
+                "Item-Input": {
+                    "properties": {
+                        "name": {"type": "string", "title": "Name"},
+                        "description": {
+                            "anyOf": [{"type": "string"}, {"type": "null"}],
+                            "title": "Description",
+                        },
+                        "sub": {
+                            "anyOf": [
+                                {"$ref": "#/components/schemas/SubItem-Input"},
+                                {"type": "null"},
+                            ]
+                        },
+                    },
+                    "type": "object",
+                    "required": ["name"],
+                    "title": "Item",
+                },
+                "Item-Output": {
+                    "properties": {
+                        "name": {"type": "string", "title": "Name"},
+                        "description": {
+                            "anyOf": [{"type": "string"}, {"type": "null"}],
+                            "title": "Description",
+                        },
+                        "sub": {
+                            "anyOf": [
+                                {"$ref": "#/components/schemas/SubItem-Output"},
+                                {"type": "null"},
+                            ]
+                        },
+                    },
+                    "type": "object",
+                    "required": ["name", "description", "sub"],
+                    "title": "Item",
+                },
+                "SubItem-Input": {
+                    "properties": {
+                        "subname": {"type": "string", "title": "Subname"},
+                        "sub_description": {
+                            "anyOf": [{"type": "string"}, {"type": "null"}],
+                            "title": "Sub Description",
+                        },
+                        "tags": {
+                            "items": {"type": "string"},
+                            "type": "array",
+                            "title": "Tags",
+                            "default": [],
+                        },
+                    },
+                    "type": "object",
+                    "required": ["subname"],
+                    "title": "SubItem",
+                },
+                "SubItem-Output": {
+                    "properties": {
+                        "subname": {"type": "string", "title": "Subname"},
+                        "sub_description": {
+                            "anyOf": [{"type": "string"}, {"type": "null"}],
+                            "title": "Sub Description",
+                        },
+                        "tags": {
+                            "items": {"type": "string"},
+                            "type": "array",
+                            "title": "Tags",
+                            "default": [],
+                        },
+                    },
+                    "type": "object",
+                    "required": ["subname", "sub_description", "tags"],
+                    "title": "SubItem",
+                },
+                "ValidationError": {
+                    "properties": {
+                        "loc": {
+                            "items": {
+                                "anyOf": [{"type": "string"}, {"type": "integer"}]
+                            },
+                            "type": "array",
+                            "title": "Location",
+                        },
+                        "msg": {"type": "string", "title": "Message"},
+                        "type": {"type": "string", "title": "Error Type"},
+                    },
+                    "type": "object",
+                    "required": ["loc", "msg", "type"],
+                    "title": "ValidationError",
+                },
+            }
+        },
+    }
+
+
+@needs_pydanticv2
+def test_openapi_schema_no_separate():
+    client = get_app_client(separate_input_output_schemas=False)
+    response = client.get("/openapi.json")
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "openapi": "3.1.0",
+        "info": {"title": "FastAPI", "version": "0.1.0"},
+        "paths": {
+            "/items/": {
+                "get": {
+                    "summary": "Read Items",
+                    "operationId": "read_items_items__get",
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "items": {"$ref": "#/components/schemas/Item"},
+                                        "type": "array",
+                                        "title": "Response Read Items Items  Get",
+                                    }
+                                }
+                            },
+                        }
+                    },
+                },
+                "post": {
+                    "summary": "Create Item",
+                    "operationId": "create_item_items__post",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {"$ref": "#/components/schemas/Item"}
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {"application/json": {"schema": {}}},
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                },
+            },
+            "/items-list/": {
+                "post": {
+                    "summary": "Create Item List",
+                    "operationId": "create_item_list_items_list__post",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "items": {"$ref": "#/components/schemas/Item"},
+                                    "type": "array",
+                                    "title": "Item",
+                                }
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {"application/json": {"schema": {}}},
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                }
+            },
+        },
+        "components": {
+            "schemas": {
+                "HTTPValidationError": {
+                    "properties": {
+                        "detail": {
+                            "items": {"$ref": "#/components/schemas/ValidationError"},
+                            "type": "array",
+                            "title": "Detail",
+                        }
+                    },
+                    "type": "object",
+                    "title": "HTTPValidationError",
+                },
+                "Item": {
+                    "properties": {
+                        "name": {"type": "string", "title": "Name"},
+                        "description": {
+                            "anyOf": [{"type": "string"}, {"type": "null"}],
+                            "title": "Description",
+                        },
+                        "sub": {
+                            "anyOf": [
+                                {"$ref": "#/components/schemas/SubItem"},
+                                {"type": "null"},
+                            ]
+                        },
+                    },
+                    "type": "object",
+                    "required": ["name"],
+                    "title": "Item",
+                },
+                "SubItem": {
+                    "properties": {
+                        "subname": {"type": "string", "title": "Subname"},
+                        "sub_description": {
+                            "anyOf": [{"type": "string"}, {"type": "null"}],
+                            "title": "Sub Description",
+                        },
+                        "tags": {
+                            "items": {"type": "string"},
+                            "type": "array",
+                            "title": "Tags",
+                            "default": [],
+                        },
+                    },
+                    "type": "object",
+                    "required": ["subname"],
+                    "title": "SubItem",
+                },
+                "ValidationError": {
+                    "properties": {
+                        "loc": {
+                            "items": {
+                                "anyOf": [{"type": "string"}, {"type": "integer"}]
+                            },
+                            "type": "array",
+                            "title": "Location",
+                        },
+                        "msg": {"type": "string", "title": "Message"},
+                        "type": {"type": "string", "title": "Error Type"},
+                    },
+                    "type": "object",
+                    "required": ["loc", "msg", "type"],
+                    "title": "ValidationError",
+                },
+            }
+        },
+    }
diff --git a/tests/test_tutorial/test_separate_openapi_schemas/__init__.py b/tests/test_tutorial/test_separate_openapi_schemas/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tests/test_tutorial/test_separate_openapi_schemas/test_tutorial001.py b/tests/test_tutorial/test_separate_openapi_schemas/test_tutorial001.py
new file mode 100644 (file)
index 0000000..8079c11
--- /dev/null
@@ -0,0 +1,147 @@
+import pytest
+from fastapi.testclient import TestClient
+
+from ...utils import needs_pydanticv2
+
+
+@pytest.fixture(name="client")
+def get_client() -> TestClient:
+    from docs_src.separate_openapi_schemas.tutorial001 import app
+
+    client = TestClient(app)
+    return client
+
+
+def test_create_item(client: TestClient) -> None:
+    response = client.post("/items/", json={"name": "Foo"})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"name": "Foo", "description": None}
+
+
+def test_read_items(client: TestClient) -> None:
+    response = client.get("/items/")
+    assert response.status_code == 200, response.text
+    assert response.json() == [
+        {
+            "name": "Portal Gun",
+            "description": "Device to travel through the multi-rick-verse",
+        },
+        {"name": "Plumbus", "description": None},
+    ]
+
+
+@needs_pydanticv2
+def test_openapi_schema(client: TestClient) -> None:
+    response = client.get("/openapi.json")
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "openapi": "3.1.0",
+        "info": {"title": "FastAPI", "version": "0.1.0"},
+        "paths": {
+            "/items/": {
+                "get": {
+                    "summary": "Read Items",
+                    "operationId": "read_items_items__get",
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "items": {
+                                            "$ref": "#/components/schemas/Item-Output"
+                                        },
+                                        "type": "array",
+                                        "title": "Response Read Items Items  Get",
+                                    }
+                                }
+                            },
+                        }
+                    },
+                },
+                "post": {
+                    "summary": "Create Item",
+                    "operationId": "create_item_items__post",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {"$ref": "#/components/schemas/Item-Input"}
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {"application/json": {"schema": {}}},
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                },
+            }
+        },
+        "components": {
+            "schemas": {
+                "HTTPValidationError": {
+                    "properties": {
+                        "detail": {
+                            "items": {"$ref": "#/components/schemas/ValidationError"},
+                            "type": "array",
+                            "title": "Detail",
+                        }
+                    },
+                    "type": "object",
+                    "title": "HTTPValidationError",
+                },
+                "Item-Input": {
+                    "properties": {
+                        "name": {"type": "string", "title": "Name"},
+                        "description": {
+                            "anyOf": [{"type": "string"}, {"type": "null"}],
+                            "title": "Description",
+                        },
+                    },
+                    "type": "object",
+                    "required": ["name"],
+                    "title": "Item",
+                },
+                "Item-Output": {
+                    "properties": {
+                        "name": {"type": "string", "title": "Name"},
+                        "description": {
+                            "anyOf": [{"type": "string"}, {"type": "null"}],
+                            "title": "Description",
+                        },
+                    },
+                    "type": "object",
+                    "required": ["name", "description"],
+                    "title": "Item",
+                },
+                "ValidationError": {
+                    "properties": {
+                        "loc": {
+                            "items": {
+                                "anyOf": [{"type": "string"}, {"type": "integer"}]
+                            },
+                            "type": "array",
+                            "title": "Location",
+                        },
+                        "msg": {"type": "string", "title": "Message"},
+                        "type": {"type": "string", "title": "Error Type"},
+                    },
+                    "type": "object",
+                    "required": ["loc", "msg", "type"],
+                    "title": "ValidationError",
+                },
+            }
+        },
+    }
diff --git a/tests/test_tutorial/test_separate_openapi_schemas/test_tutorial001_py310.py b/tests/test_tutorial/test_separate_openapi_schemas/test_tutorial001_py310.py
new file mode 100644 (file)
index 0000000..4fa98cc
--- /dev/null
@@ -0,0 +1,150 @@
+import pytest
+from fastapi.testclient import TestClient
+
+from ...utils import needs_py310, needs_pydanticv2
+
+
+@pytest.fixture(name="client")
+def get_client() -> TestClient:
+    from docs_src.separate_openapi_schemas.tutorial001_py310 import app
+
+    client = TestClient(app)
+    return client
+
+
+@needs_py310
+def test_create_item(client: TestClient) -> None:
+    response = client.post("/items/", json={"name": "Foo"})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"name": "Foo", "description": None}
+
+
+@needs_py310
+def test_read_items(client: TestClient) -> None:
+    response = client.get("/items/")
+    assert response.status_code == 200, response.text
+    assert response.json() == [
+        {
+            "name": "Portal Gun",
+            "description": "Device to travel through the multi-rick-verse",
+        },
+        {"name": "Plumbus", "description": None},
+    ]
+
+
+@needs_py310
+@needs_pydanticv2
+def test_openapi_schema(client: TestClient) -> None:
+    response = client.get("/openapi.json")
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "openapi": "3.1.0",
+        "info": {"title": "FastAPI", "version": "0.1.0"},
+        "paths": {
+            "/items/": {
+                "get": {
+                    "summary": "Read Items",
+                    "operationId": "read_items_items__get",
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "items": {
+                                            "$ref": "#/components/schemas/Item-Output"
+                                        },
+                                        "type": "array",
+                                        "title": "Response Read Items Items  Get",
+                                    }
+                                }
+                            },
+                        }
+                    },
+                },
+                "post": {
+                    "summary": "Create Item",
+                    "operationId": "create_item_items__post",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {"$ref": "#/components/schemas/Item-Input"}
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {"application/json": {"schema": {}}},
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                },
+            }
+        },
+        "components": {
+            "schemas": {
+                "HTTPValidationError": {
+                    "properties": {
+                        "detail": {
+                            "items": {"$ref": "#/components/schemas/ValidationError"},
+                            "type": "array",
+                            "title": "Detail",
+                        }
+                    },
+                    "type": "object",
+                    "title": "HTTPValidationError",
+                },
+                "Item-Input": {
+                    "properties": {
+                        "name": {"type": "string", "title": "Name"},
+                        "description": {
+                            "anyOf": [{"type": "string"}, {"type": "null"}],
+                            "title": "Description",
+                        },
+                    },
+                    "type": "object",
+                    "required": ["name"],
+                    "title": "Item",
+                },
+                "Item-Output": {
+                    "properties": {
+                        "name": {"type": "string", "title": "Name"},
+                        "description": {
+                            "anyOf": [{"type": "string"}, {"type": "null"}],
+                            "title": "Description",
+                        },
+                    },
+                    "type": "object",
+                    "required": ["name", "description"],
+                    "title": "Item",
+                },
+                "ValidationError": {
+                    "properties": {
+                        "loc": {
+                            "items": {
+                                "anyOf": [{"type": "string"}, {"type": "integer"}]
+                            },
+                            "type": "array",
+                            "title": "Location",
+                        },
+                        "msg": {"type": "string", "title": "Message"},
+                        "type": {"type": "string", "title": "Error Type"},
+                    },
+                    "type": "object",
+                    "required": ["loc", "msg", "type"],
+                    "title": "ValidationError",
+                },
+            }
+        },
+    }
diff --git a/tests/test_tutorial/test_separate_openapi_schemas/test_tutorial001_py39.py b/tests/test_tutorial/test_separate_openapi_schemas/test_tutorial001_py39.py
new file mode 100644 (file)
index 0000000..ad36582
--- /dev/null
@@ -0,0 +1,150 @@
+import pytest
+from fastapi.testclient import TestClient
+
+from ...utils import needs_py39, needs_pydanticv2
+
+
+@pytest.fixture(name="client")
+def get_client() -> TestClient:
+    from docs_src.separate_openapi_schemas.tutorial001_py39 import app
+
+    client = TestClient(app)
+    return client
+
+
+@needs_py39
+def test_create_item(client: TestClient) -> None:
+    response = client.post("/items/", json={"name": "Foo"})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"name": "Foo", "description": None}
+
+
+@needs_py39
+def test_read_items(client: TestClient) -> None:
+    response = client.get("/items/")
+    assert response.status_code == 200, response.text
+    assert response.json() == [
+        {
+            "name": "Portal Gun",
+            "description": "Device to travel through the multi-rick-verse",
+        },
+        {"name": "Plumbus", "description": None},
+    ]
+
+
+@needs_py39
+@needs_pydanticv2
+def test_openapi_schema(client: TestClient) -> None:
+    response = client.get("/openapi.json")
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "openapi": "3.1.0",
+        "info": {"title": "FastAPI", "version": "0.1.0"},
+        "paths": {
+            "/items/": {
+                "get": {
+                    "summary": "Read Items",
+                    "operationId": "read_items_items__get",
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "items": {
+                                            "$ref": "#/components/schemas/Item-Output"
+                                        },
+                                        "type": "array",
+                                        "title": "Response Read Items Items  Get",
+                                    }
+                                }
+                            },
+                        }
+                    },
+                },
+                "post": {
+                    "summary": "Create Item",
+                    "operationId": "create_item_items__post",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {"$ref": "#/components/schemas/Item-Input"}
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {"application/json": {"schema": {}}},
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                },
+            }
+        },
+        "components": {
+            "schemas": {
+                "HTTPValidationError": {
+                    "properties": {
+                        "detail": {
+                            "items": {"$ref": "#/components/schemas/ValidationError"},
+                            "type": "array",
+                            "title": "Detail",
+                        }
+                    },
+                    "type": "object",
+                    "title": "HTTPValidationError",
+                },
+                "Item-Input": {
+                    "properties": {
+                        "name": {"type": "string", "title": "Name"},
+                        "description": {
+                            "anyOf": [{"type": "string"}, {"type": "null"}],
+                            "title": "Description",
+                        },
+                    },
+                    "type": "object",
+                    "required": ["name"],
+                    "title": "Item",
+                },
+                "Item-Output": {
+                    "properties": {
+                        "name": {"type": "string", "title": "Name"},
+                        "description": {
+                            "anyOf": [{"type": "string"}, {"type": "null"}],
+                            "title": "Description",
+                        },
+                    },
+                    "type": "object",
+                    "required": ["name", "description"],
+                    "title": "Item",
+                },
+                "ValidationError": {
+                    "properties": {
+                        "loc": {
+                            "items": {
+                                "anyOf": [{"type": "string"}, {"type": "integer"}]
+                            },
+                            "type": "array",
+                            "title": "Location",
+                        },
+                        "msg": {"type": "string", "title": "Message"},
+                        "type": {"type": "string", "title": "Error Type"},
+                    },
+                    "type": "object",
+                    "required": ["loc", "msg", "type"],
+                    "title": "ValidationError",
+                },
+            }
+        },
+    }
diff --git a/tests/test_tutorial/test_separate_openapi_schemas/test_tutorial002.py b/tests/test_tutorial/test_separate_openapi_schemas/test_tutorial002.py
new file mode 100644 (file)
index 0000000..d2cf794
--- /dev/null
@@ -0,0 +1,133 @@
+import pytest
+from fastapi.testclient import TestClient
+
+from ...utils import needs_pydanticv2
+
+
+@pytest.fixture(name="client")
+def get_client() -> TestClient:
+    from docs_src.separate_openapi_schemas.tutorial002 import app
+
+    client = TestClient(app)
+    return client
+
+
+def test_create_item(client: TestClient) -> None:
+    response = client.post("/items/", json={"name": "Foo"})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"name": "Foo", "description": None}
+
+
+def test_read_items(client: TestClient) -> None:
+    response = client.get("/items/")
+    assert response.status_code == 200, response.text
+    assert response.json() == [
+        {
+            "name": "Portal Gun",
+            "description": "Device to travel through the multi-rick-verse",
+        },
+        {"name": "Plumbus", "description": None},
+    ]
+
+
+@needs_pydanticv2
+def test_openapi_schema(client: TestClient) -> None:
+    response = client.get("/openapi.json")
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "openapi": "3.1.0",
+        "info": {"title": "FastAPI", "version": "0.1.0"},
+        "paths": {
+            "/items/": {
+                "get": {
+                    "summary": "Read Items",
+                    "operationId": "read_items_items__get",
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "items": {"$ref": "#/components/schemas/Item"},
+                                        "type": "array",
+                                        "title": "Response Read Items Items  Get",
+                                    }
+                                }
+                            },
+                        }
+                    },
+                },
+                "post": {
+                    "summary": "Create Item",
+                    "operationId": "create_item_items__post",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {"$ref": "#/components/schemas/Item"}
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {"application/json": {"schema": {}}},
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                },
+            }
+        },
+        "components": {
+            "schemas": {
+                "HTTPValidationError": {
+                    "properties": {
+                        "detail": {
+                            "items": {"$ref": "#/components/schemas/ValidationError"},
+                            "type": "array",
+                            "title": "Detail",
+                        }
+                    },
+                    "type": "object",
+                    "title": "HTTPValidationError",
+                },
+                "Item": {
+                    "properties": {
+                        "name": {"type": "string", "title": "Name"},
+                        "description": {
+                            "anyOf": [{"type": "string"}, {"type": "null"}],
+                            "title": "Description",
+                        },
+                    },
+                    "type": "object",
+                    "required": ["name"],
+                    "title": "Item",
+                },
+                "ValidationError": {
+                    "properties": {
+                        "loc": {
+                            "items": {
+                                "anyOf": [{"type": "string"}, {"type": "integer"}]
+                            },
+                            "type": "array",
+                            "title": "Location",
+                        },
+                        "msg": {"type": "string", "title": "Message"},
+                        "type": {"type": "string", "title": "Error Type"},
+                    },
+                    "type": "object",
+                    "required": ["loc", "msg", "type"],
+                    "title": "ValidationError",
+                },
+            }
+        },
+    }
diff --git a/tests/test_tutorial/test_separate_openapi_schemas/test_tutorial002_py310.py b/tests/test_tutorial/test_separate_openapi_schemas/test_tutorial002_py310.py
new file mode 100644 (file)
index 0000000..89c9ce9
--- /dev/null
@@ -0,0 +1,136 @@
+import pytest
+from fastapi.testclient import TestClient
+
+from ...utils import needs_py310, needs_pydanticv2
+
+
+@pytest.fixture(name="client")
+def get_client() -> TestClient:
+    from docs_src.separate_openapi_schemas.tutorial002_py310 import app
+
+    client = TestClient(app)
+    return client
+
+
+@needs_py310
+def test_create_item(client: TestClient) -> None:
+    response = client.post("/items/", json={"name": "Foo"})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"name": "Foo", "description": None}
+
+
+@needs_py310
+def test_read_items(client: TestClient) -> None:
+    response = client.get("/items/")
+    assert response.status_code == 200, response.text
+    assert response.json() == [
+        {
+            "name": "Portal Gun",
+            "description": "Device to travel through the multi-rick-verse",
+        },
+        {"name": "Plumbus", "description": None},
+    ]
+
+
+@needs_py310
+@needs_pydanticv2
+def test_openapi_schema(client: TestClient) -> None:
+    response = client.get("/openapi.json")
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "openapi": "3.1.0",
+        "info": {"title": "FastAPI", "version": "0.1.0"},
+        "paths": {
+            "/items/": {
+                "get": {
+                    "summary": "Read Items",
+                    "operationId": "read_items_items__get",
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "items": {"$ref": "#/components/schemas/Item"},
+                                        "type": "array",
+                                        "title": "Response Read Items Items  Get",
+                                    }
+                                }
+                            },
+                        }
+                    },
+                },
+                "post": {
+                    "summary": "Create Item",
+                    "operationId": "create_item_items__post",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {"$ref": "#/components/schemas/Item"}
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {"application/json": {"schema": {}}},
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                },
+            }
+        },
+        "components": {
+            "schemas": {
+                "HTTPValidationError": {
+                    "properties": {
+                        "detail": {
+                            "items": {"$ref": "#/components/schemas/ValidationError"},
+                            "type": "array",
+                            "title": "Detail",
+                        }
+                    },
+                    "type": "object",
+                    "title": "HTTPValidationError",
+                },
+                "Item": {
+                    "properties": {
+                        "name": {"type": "string", "title": "Name"},
+                        "description": {
+                            "anyOf": [{"type": "string"}, {"type": "null"}],
+                            "title": "Description",
+                        },
+                    },
+                    "type": "object",
+                    "required": ["name"],
+                    "title": "Item",
+                },
+                "ValidationError": {
+                    "properties": {
+                        "loc": {
+                            "items": {
+                                "anyOf": [{"type": "string"}, {"type": "integer"}]
+                            },
+                            "type": "array",
+                            "title": "Location",
+                        },
+                        "msg": {"type": "string", "title": "Message"},
+                        "type": {"type": "string", "title": "Error Type"},
+                    },
+                    "type": "object",
+                    "required": ["loc", "msg", "type"],
+                    "title": "ValidationError",
+                },
+            }
+        },
+    }
diff --git a/tests/test_tutorial/test_separate_openapi_schemas/test_tutorial002_py39.py b/tests/test_tutorial/test_separate_openapi_schemas/test_tutorial002_py39.py
new file mode 100644 (file)
index 0000000..6ac3d8f
--- /dev/null
@@ -0,0 +1,136 @@
+import pytest
+from fastapi.testclient import TestClient
+
+from ...utils import needs_py39, needs_pydanticv2
+
+
+@pytest.fixture(name="client")
+def get_client() -> TestClient:
+    from docs_src.separate_openapi_schemas.tutorial002_py39 import app
+
+    client = TestClient(app)
+    return client
+
+
+@needs_py39
+def test_create_item(client: TestClient) -> None:
+    response = client.post("/items/", json={"name": "Foo"})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"name": "Foo", "description": None}
+
+
+@needs_py39
+def test_read_items(client: TestClient) -> None:
+    response = client.get("/items/")
+    assert response.status_code == 200, response.text
+    assert response.json() == [
+        {
+            "name": "Portal Gun",
+            "description": "Device to travel through the multi-rick-verse",
+        },
+        {"name": "Plumbus", "description": None},
+    ]
+
+
+@needs_py39
+@needs_pydanticv2
+def test_openapi_schema(client: TestClient) -> None:
+    response = client.get("/openapi.json")
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "openapi": "3.1.0",
+        "info": {"title": "FastAPI", "version": "0.1.0"},
+        "paths": {
+            "/items/": {
+                "get": {
+                    "summary": "Read Items",
+                    "operationId": "read_items_items__get",
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "items": {"$ref": "#/components/schemas/Item"},
+                                        "type": "array",
+                                        "title": "Response Read Items Items  Get",
+                                    }
+                                }
+                            },
+                        }
+                    },
+                },
+                "post": {
+                    "summary": "Create Item",
+                    "operationId": "create_item_items__post",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {"$ref": "#/components/schemas/Item"}
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {"application/json": {"schema": {}}},
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                },
+            }
+        },
+        "components": {
+            "schemas": {
+                "HTTPValidationError": {
+                    "properties": {
+                        "detail": {
+                            "items": {"$ref": "#/components/schemas/ValidationError"},
+                            "type": "array",
+                            "title": "Detail",
+                        }
+                    },
+                    "type": "object",
+                    "title": "HTTPValidationError",
+                },
+                "Item": {
+                    "properties": {
+                        "name": {"type": "string", "title": "Name"},
+                        "description": {
+                            "anyOf": [{"type": "string"}, {"type": "null"}],
+                            "title": "Description",
+                        },
+                    },
+                    "type": "object",
+                    "required": ["name"],
+                    "title": "Item",
+                },
+                "ValidationError": {
+                    "properties": {
+                        "loc": {
+                            "items": {
+                                "anyOf": [{"type": "string"}, {"type": "integer"}]
+                            },
+                            "type": "array",
+                            "title": "Location",
+                        },
+                        "msg": {"type": "string", "title": "Message"},
+                        "type": {"type": "string", "title": "Error Type"},
+                    },
+                    "type": "object",
+                    "required": ["loc", "msg", "type"],
+                    "title": "ValidationError",
+                },
+            }
+        },
+    }