]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
✨ Add support for `dataclasses` in request bodies and `response_model` (#3577)
authorSebastián Ramírez <tiangolo@gmail.com>
Wed, 21 Jul 2021 17:54:08 +0000 (19:54 +0200)
committerGitHub <noreply@github.com>
Wed, 21 Jul 2021 17:54:08 +0000 (19:54 +0200)
docs/en/docs/advanced/dataclasses.md [new file with mode: 0644]
docs/en/docs/img/tutorial/dataclasses/image01.png [new file with mode: 0644]
docs/en/mkdocs.yml
docs_src/dataclasses/tutorial001.py [new file with mode: 0644]
docs_src/dataclasses/tutorial002.py [new file with mode: 0644]
docs_src/dataclasses/tutorial003.py [new file with mode: 0644]
fastapi/dependencies/utils.py
tests/test_tutorial/test_dataclasses/test_tutorial001.py [new file with mode: 0644]
tests/test_tutorial/test_dataclasses/test_tutorial002.py [new file with mode: 0644]
tests/test_tutorial/test_dataclasses/test_tutorial003.py [new file with mode: 0644]

diff --git a/docs/en/docs/advanced/dataclasses.md b/docs/en/docs/advanced/dataclasses.md
new file mode 100644 (file)
index 0000000..80a063b
--- /dev/null
@@ -0,0 +1,98 @@
+# Using Dataclasses
+
+FastAPI is built on top of **Pydantic**, and I have been showing you how to use Pydantic models to declare requests and responses.
+
+But FastAPI also supports using <a href="https://docs.python.org/3/library/dataclasses.html" class="external-link" target="_blank">`dataclasses`</a> the same way:
+
+```Python hl_lines="1  7-12  19-20"
+{!../../../docs_src/dataclasses/tutorial001.py!}
+```
+
+This is still thanks to **Pydantic**, as it has <a href="https://pydantic-docs.helpmanual.io/usage/dataclasses/#use-of-stdlib-dataclasses-with-basemodel" class="external-link" target="_blank">internal support for `dataclasses`</a>.
+
+So, even with the code above that doesn't use Pydantic explicitly, FastAPI is using Pydantic to convert those standard dataclasses to Pydantic's own flavor of dataclasses.
+
+And of course, it supports the same:
+
+* data validation
+* data serialization
+* data documentation, etc.
+
+This works the same way as with Pydantic models. And it is actually achieved in the same way underneath, using Pydantic.
+
+!!! info
+    Have in mind that dataclasses can't do everything Pydantic models can do.
+
+    So, you might still need to use Pydantic models.
+
+    But if you have a bunch of dataclasses laying around, this is a nice trick to use them to power a web API using FastAPI. 🤓
+
+## Dataclasses in `response_model`
+
+You can also use `dataclasses` in the `response_model` parameter:
+
+```Python hl_lines="1  7-13  19"
+{!../../../docs_src/dataclasses/tutorial002.py!}
+```
+
+The dataclass will be automatically converted to a Pydantic dataclass.
+
+This way, its schema will show up in the API docs user interface:
+
+<img src="/img/tutorial/dataclasses/image01.png">
+
+## Dataclasses in Nested Data Structures
+
+You can also combine `dataclasses` with other type annotations to make nested data structures.
+
+In some cases, you might still have to use Pydantic's version of `dataclasses`. For example, if you have errors with the automatically generated API documentation.
+
+In that case, you can simply swap the standard `dataclasses` with `pydantic.dataclasses`, which is a drop-in replacement:
+
+```{ .python .annotate hl_lines="1  5  8-11  14-17  23-25  28" }
+{!../../../docs_src/dataclasses/tutorial003.py!}
+```
+
+1. We still import `field` from standard `dataclasses`.
+
+2. `pydantic.dataclasses` is a drop-in replacement for `dataclasses`.
+
+3. The `Author` dataclass includes a list of `Item` dataclasses.
+
+4. The `Author` dataclass is used as the `response_model` parameter.
+
+5. You can use other standard type annotations with dataclasses as the request body.
+
+    In this case, it's a list of `Item` dataclasses.
+
+6. Here we are returning a dictionary that contains `items` which is a list of dataclasses.
+
+    FastAPI is still capable of <abbr title="converting the data to a format that can be transmitted">serializing</abbr> the data to JSON.
+
+7. Here the `response_model` is using a type annotation of a list of `Author` dataclasses.
+
+    Again, you can combine `dataclasses` with standard type annotations.
+
+8. Notice that this *path operation function* uses regular `def` instead of `async def`.
+
+    As always, in FastAPI you can combine `def` and `async def` as needed.
+
+    If you need a refresher about when to use which, check out the section _"In a hurry?"_ in the docs about <a href="https://fastapi.tiangolo.com/async/#in-a-hurry" target="_blank" class="internal-link">`async` and `await`</a>.
+
+9. This *path operation function* is not returning dataclasses (although it could), but a list of dictionaries with internal data.
+
+    FastAPI will use the `response_model` parameter (that includes dataclasses) to convert the response.
+
+You can combine `dataclasses` with other type annotations in many different combinations to form complex data structures.
+
+Check the in-code annotation tips above to see more specific details.
+
+## Learn More
+
+You can also combine `dataclasses` with other Pydantic models, inherit from them, include them in your own models, etc.
+
+To learn more, check the <a href="https://pydantic-docs.helpmanual.io/usage/dataclasses/" class="external-link" target="_blank">Pydantic docs about dataclasses</a>.
+
+## Version
+
+This is available since FastAPI version `0.67.0`. 🔖
diff --git a/docs/en/docs/img/tutorial/dataclasses/image01.png b/docs/en/docs/img/tutorial/dataclasses/image01.png
new file mode 100644 (file)
index 0000000..7815f40
Binary files /dev/null and b/docs/en/docs/img/tutorial/dataclasses/image01.png differ
index d86ea1c3911f1a708c4eb63682059f3f5b430873..a927bdb3b42f322c760f829ba6349b2c51e91152 100644 (file)
@@ -119,6 +119,7 @@ nav:
     - advanced/security/oauth2-scopes.md
     - advanced/security/http-basic-auth.md
   - advanced/using-request-directly.md
+  - advanced/dataclasses.md
   - advanced/middleware.md
   - advanced/sql-databases-peewee.md
   - advanced/async-sql-databases.md
diff --git a/docs_src/dataclasses/tutorial001.py b/docs_src/dataclasses/tutorial001.py
new file mode 100644 (file)
index 0000000..43015eb
--- /dev/null
@@ -0,0 +1,20 @@
+from dataclasses import dataclass
+from typing import Optional
+
+from fastapi import FastAPI
+
+
+@dataclass
+class Item:
+    name: str
+    price: float
+    description: Optional[str] = None
+    tax: Optional[float] = None
+
+
+app = FastAPI()
+
+
+@app.post("/items/")
+async def create_item(item: Item):
+    return item
diff --git a/docs_src/dataclasses/tutorial002.py b/docs_src/dataclasses/tutorial002.py
new file mode 100644 (file)
index 0000000..aaa7b87
--- /dev/null
@@ -0,0 +1,26 @@
+from dataclasses import dataclass, field
+from typing import List, Optional
+
+from fastapi import FastAPI
+
+
+@dataclass
+class Item:
+    name: str
+    price: float
+    tags: List[str] = field(default_factory=list)
+    description: Optional[str] = None
+    tax: Optional[float] = None
+
+
+app = FastAPI()
+
+
+@app.get("/items/next", response_model=Item)
+async def read_next_item():
+    return {
+        "name": "Island In The Moon",
+        "price": 12.99,
+        "description": "A place to be be playin' and havin' fun",
+        "tags": ["breater"],
+    }
diff --git a/docs_src/dataclasses/tutorial003.py b/docs_src/dataclasses/tutorial003.py
new file mode 100644 (file)
index 0000000..2c1fccd
--- /dev/null
@@ -0,0 +1,55 @@
+from dataclasses import field  # (1)
+from typing import List, Optional
+
+from fastapi import FastAPI
+from pydantic.dataclasses import dataclass  # (2)
+
+
+@dataclass
+class Item:
+    name: str
+    description: Optional[str] = None
+
+
+@dataclass
+class Author:
+    name: str
+    items: List[Item] = field(default_factory=list)  # (3)
+
+
+app = FastAPI()
+
+
+@app.post("/authors/{author_id}/items/", response_model=Author)  # (4)
+async def create_author_items(author_id: str, items: List[Item]):  # (5)
+    return {"name": author_id, "items": items}  # (6)
+
+
+@app.get("/authors/", response_model=List[Author])  # (7)
+def get_authors():  # (8)
+    return [  # (9)
+        {
+            "name": "Breaters",
+            "items": [
+                {
+                    "name": "Island In The Moon",
+                    "description": "A place to be be playin' and havin' fun",
+                },
+                {"name": "Holy Buddies"},
+            ],
+        },
+        {
+            "name": "System of an Up",
+            "items": [
+                {
+                    "name": "Salt",
+                    "description": "The kombucha mushroom people's favorite",
+                },
+                {"name": "Pad Thai"},
+                {
+                    "name": "Lonely Night",
+                    "description": "The mostests lonliest nightiest of allest",
+                },
+            ],
+        },
+    ]
index 923669b94ba85c5cb1205df56913d9cd4b3fae84..95049d40ea82b9259bee214b99c514ca79e3a2f4 100644 (file)
@@ -1,4 +1,5 @@
 import asyncio
+import dataclasses
 import inspect
 from contextlib import contextmanager
 from copy import deepcopy
@@ -217,6 +218,7 @@ def is_scalar_field(field: ModelField) -> bool:
         field.shape == SHAPE_SINGLETON
         and not lenient_issubclass(field.type_, BaseModel)
         and not lenient_issubclass(field.type_, sequence_types + (dict,))
+        and not dataclasses.is_dataclass(field.type_)
         and not isinstance(field_info, params.Body)
     ):
         return False
diff --git a/tests/test_tutorial/test_dataclasses/test_tutorial001.py b/tests/test_tutorial/test_dataclasses/test_tutorial001.py
new file mode 100644 (file)
index 0000000..3e3fc9a
--- /dev/null
@@ -0,0 +1,113 @@
+from fastapi.testclient import TestClient
+
+from docs_src.dataclasses.tutorial001 import app
+
+client = TestClient(app)
+
+openapi_schema = {
+    "openapi": "3.0.2",
+    "info": {"title": "FastAPI", "version": "0.1.0"},
+    "paths": {
+        "/items/": {
+            "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": {
+                "title": "HTTPValidationError",
+                "type": "object",
+                "properties": {
+                    "detail": {
+                        "title": "Detail",
+                        "type": "array",
+                        "items": {"$ref": "#/components/schemas/ValidationError"},
+                    }
+                },
+            },
+            "Item": {
+                "title": "Item",
+                "required": ["name", "price"],
+                "type": "object",
+                "properties": {
+                    "name": {"title": "Name", "type": "string"},
+                    "price": {"title": "Price", "type": "number"},
+                    "description": {"title": "Description", "type": "string"},
+                    "tax": {"title": "Tax", "type": "number"},
+                },
+            },
+            "ValidationError": {
+                "title": "ValidationError",
+                "required": ["loc", "msg", "type"],
+                "type": "object",
+                "properties": {
+                    "loc": {
+                        "title": "Location",
+                        "type": "array",
+                        "items": {"type": "string"},
+                    },
+                    "msg": {"title": "Message", "type": "string"},
+                    "type": {"title": "Error Type", "type": "string"},
+                },
+            },
+        }
+    },
+}
+
+
+def test_openapi_schema():
+    response = client.get("/openapi.json")
+    assert response.status_code == 200
+    assert response.json() == openapi_schema
+
+
+def test_post_item():
+    response = client.post("/items/", json={"name": "Foo", "price": 3})
+    assert response.status_code == 200
+    assert response.json() == {
+        "name": "Foo",
+        "price": 3,
+        "description": None,
+        "tax": None,
+    }
+
+
+def test_post_invalid_item():
+    response = client.post("/items/", json={"name": "Foo", "price": "invalid price"})
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "loc": ["body", "price"],
+                "msg": "value is not a valid float",
+                "type": "type_error.float",
+            }
+        ]
+    }
diff --git a/tests/test_tutorial/test_dataclasses/test_tutorial002.py b/tests/test_tutorial/test_dataclasses/test_tutorial002.py
new file mode 100644 (file)
index 0000000..10d8d22
--- /dev/null
@@ -0,0 +1,66 @@
+from fastapi.testclient import TestClient
+
+from docs_src.dataclasses.tutorial002 import app
+
+client = TestClient(app)
+
+openapi_schema = {
+    "openapi": "3.0.2",
+    "info": {"title": "FastAPI", "version": "0.1.0"},
+    "paths": {
+        "/items/next": {
+            "get": {
+                "summary": "Read Next Item",
+                "operationId": "read_next_item_items_next_get",
+                "responses": {
+                    "200": {
+                        "description": "Successful Response",
+                        "content": {
+                            "application/json": {
+                                "schema": {"$ref": "#/components/schemas/Item"}
+                            }
+                        },
+                    }
+                },
+            }
+        }
+    },
+    "components": {
+        "schemas": {
+            "Item": {
+                "title": "Item",
+                "required": ["name", "price", "tags"],
+                "type": "object",
+                "properties": {
+                    "name": {"title": "Name", "type": "string"},
+                    "price": {"title": "Price", "type": "number"},
+                    "tags": {
+                        "title": "Tags",
+                        "type": "array",
+                        "items": {"type": "string"},
+                    },
+                    "description": {"title": "Description", "type": "string"},
+                    "tax": {"title": "Tax", "type": "number"},
+                },
+            }
+        }
+    },
+}
+
+
+def test_openapi_schema():
+    response = client.get("/openapi.json")
+    assert response.status_code == 200
+    assert response.json() == openapi_schema
+
+
+def test_get_item():
+    response = client.get("/items/next")
+    assert response.status_code == 200
+    assert response.json() == {
+        "name": "Island In The Moon",
+        "price": 12.99,
+        "description": "A place to be be playin' and havin' fun",
+        "tags": ["breater"],
+        "tax": None,
+    }
diff --git a/tests/test_tutorial/test_dataclasses/test_tutorial003.py b/tests/test_tutorial/test_dataclasses/test_tutorial003.py
new file mode 100644 (file)
index 0000000..dd0f1f2
--- /dev/null
@@ -0,0 +1,181 @@
+from fastapi.testclient import TestClient
+
+from docs_src.dataclasses.tutorial003 import app
+
+client = TestClient(app)
+
+openapi_schema = {
+    "openapi": "3.0.2",
+    "info": {"title": "FastAPI", "version": "0.1.0"},
+    "paths": {
+        "/authors/{author_id}/items/": {
+            "post": {
+                "summary": "Create Author Items",
+                "operationId": "create_author_items_authors__author_id__items__post",
+                "parameters": [
+                    {
+                        "required": True,
+                        "schema": {"title": "Author Id", "type": "string"},
+                        "name": "author_id",
+                        "in": "path",
+                    }
+                ],
+                "requestBody": {
+                    "content": {
+                        "application/json": {
+                            "schema": {
+                                "title": "Items",
+                                "type": "array",
+                                "items": {"$ref": "#/components/schemas/Item"},
+                            }
+                        }
+                    },
+                    "required": True,
+                },
+                "responses": {
+                    "200": {
+                        "description": "Successful Response",
+                        "content": {
+                            "application/json": {
+                                "schema": {"$ref": "#/components/schemas/Author"}
+                            }
+                        },
+                    },
+                    "422": {
+                        "description": "Validation Error",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/HTTPValidationError"
+                                }
+                            }
+                        },
+                    },
+                },
+            }
+        },
+        "/authors/": {
+            "get": {
+                "summary": "Get Authors",
+                "operationId": "get_authors_authors__get",
+                "responses": {
+                    "200": {
+                        "description": "Successful Response",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "title": "Response Get Authors Authors  Get",
+                                    "type": "array",
+                                    "items": {"$ref": "#/components/schemas/Author"},
+                                }
+                            }
+                        },
+                    }
+                },
+            }
+        },
+    },
+    "components": {
+        "schemas": {
+            "Author": {
+                "title": "Author",
+                "required": ["name"],
+                "type": "object",
+                "properties": {
+                    "name": {"title": "Name", "type": "string"},
+                    "items": {
+                        "title": "Items",
+                        "type": "array",
+                        "items": {"$ref": "#/components/schemas/Item"},
+                    },
+                },
+            },
+            "HTTPValidationError": {
+                "title": "HTTPValidationError",
+                "type": "object",
+                "properties": {
+                    "detail": {
+                        "title": "Detail",
+                        "type": "array",
+                        "items": {"$ref": "#/components/schemas/ValidationError"},
+                    }
+                },
+            },
+            "Item": {
+                "title": "Item",
+                "required": ["name"],
+                "type": "object",
+                "properties": {
+                    "name": {"title": "Name", "type": "string"},
+                    "description": {"title": "Description", "type": "string"},
+                },
+            },
+            "ValidationError": {
+                "title": "ValidationError",
+                "required": ["loc", "msg", "type"],
+                "type": "object",
+                "properties": {
+                    "loc": {
+                        "title": "Location",
+                        "type": "array",
+                        "items": {"type": "string"},
+                    },
+                    "msg": {"title": "Message", "type": "string"},
+                    "type": {"title": "Error Type", "type": "string"},
+                },
+            },
+        }
+    },
+}
+
+
+def test_openapi_schema():
+    response = client.get("/openapi.json")
+    assert response.status_code == 200
+    assert response.json() == openapi_schema
+
+
+def test_post_authors_item():
+    response = client.post(
+        "/authors/foo/items/",
+        json=[{"name": "Bar"}, {"name": "Baz", "description": "Drop the Baz"}],
+    )
+    assert response.status_code == 200
+    assert response.json() == {
+        "name": "foo",
+        "items": [
+            {"name": "Bar", "description": None},
+            {"name": "Baz", "description": "Drop the Baz"},
+        ],
+    }
+
+
+def test_get_authors():
+    response = client.get("/authors/")
+    assert response.status_code == 200
+    assert response.json() == [
+        {
+            "name": "Breaters",
+            "items": [
+                {
+                    "name": "Island In The Moon",
+                    "description": "A place to be be playin' and havin' fun",
+                },
+                {"name": "Holy Buddies", "description": None},
+            ],
+        },
+        {
+            "name": "System of an Up",
+            "items": [
+                {
+                    "name": "Salt",
+                    "description": "The kombucha mushroom people's favorite",
+                },
+                {"name": "Pad Thai", "description": None},
+                {
+                    "name": "Lonely Night",
+                    "description": "The mostests lonliest nightiest of allest",
+                },
+            ],
+        },
+    ]