Skip to content

Commit 19a2c3b

Browse files
authored
✨ Enable Pydantic's serialization mode for responses, add support for Pydantic's computed_field, better OpenAPI for response models, proper required attributes, better generated clients (#10011)
* ✨ Enable Pydantic's serialization mode for responses * ✅ Update tests with new Pydantic v2 serialization mode * ✅ Add a test for Pydantic v2's computed_field
1 parent d943e02 commit 19a2c3b

31 files changed

+1446
-256
lines changed

fastapi/routing.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -448,9 +448,7 @@ def __init__(
448448
self.response_field = create_response_field(
449449
name=response_name,
450450
type_=self.response_model,
451-
# TODO: This should actually set mode='serialization', just, that changes the schemas
452-
# mode="serialization",
453-
mode="validation",
451+
mode="serialization",
454452
)
455453
# Create a clone of the field, so that a Pydantic submodel is not returned
456454
# as is just because it's an instance of a subclass of a more limited class

tests/test_computed_fields.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import pytest
2+
from fastapi import FastAPI
3+
from fastapi.testclient import TestClient
4+
5+
from .utils import needs_pydanticv2
6+
7+
8+
@pytest.fixture(name="client")
9+
def get_client():
10+
app = FastAPI()
11+
12+
from pydantic import BaseModel, computed_field
13+
14+
class Rectangle(BaseModel):
15+
width: int
16+
length: int
17+
18+
@computed_field
19+
@property
20+
def area(self) -> int:
21+
return self.width * self.length
22+
23+
@app.get("/")
24+
def read_root() -> Rectangle:
25+
return Rectangle(width=3, length=4)
26+
27+
client = TestClient(app)
28+
return client
29+
30+
31+
@needs_pydanticv2
32+
def test_get(client: TestClient):
33+
response = client.get("/")
34+
assert response.status_code == 200, response.text
35+
assert response.json() == {"width": 3, "length": 4, "area": 12}
36+
37+
38+
@needs_pydanticv2
39+
def test_openapi_schema(client: TestClient):
40+
response = client.get("/openapi.json")
41+
assert response.status_code == 200, response.text
42+
assert response.json() == {
43+
"openapi": "3.1.0",
44+
"info": {"title": "FastAPI", "version": "0.1.0"},
45+
"paths": {
46+
"/": {
47+
"get": {
48+
"summary": "Read Root",
49+
"operationId": "read_root__get",
50+
"responses": {
51+
"200": {
52+
"description": "Successful Response",
53+
"content": {
54+
"application/json": {
55+
"schema": {"$ref": "#/components/schemas/Rectangle"}
56+
}
57+
},
58+
}
59+
},
60+
}
61+
}
62+
},
63+
"components": {
64+
"schemas": {
65+
"Rectangle": {
66+
"properties": {
67+
"width": {"type": "integer", "title": "Width"},
68+
"length": {"type": "integer", "title": "Length"},
69+
"area": {"type": "integer", "title": "Area", "readOnly": True},
70+
},
71+
"type": "object",
72+
"required": ["width", "length", "area"],
73+
"title": "Rectangle",
74+
}
75+
}
76+
},
77+
}

tests/test_filter_pydantic_sub_model_pv2.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from typing import Optional
22

33
import pytest
4-
from dirty_equals import HasRepr, IsDict
4+
from dirty_equals import HasRepr, IsDict, IsOneOf
55
from fastapi import Depends, FastAPI
66
from fastapi.exceptions import ResponseValidationError
77
from fastapi.testclient import TestClient
@@ -139,7 +139,11 @@ def test_openapi_schema(client: TestClient):
139139
},
140140
"ModelA": {
141141
"title": "ModelA",
142-
"required": ["name", "foo"],
142+
"required": IsOneOf(
143+
["name", "description", "foo"],
144+
# TODO remove when deprecating Pydantic v1
145+
["name", "foo"],
146+
),
143147
"type": "object",
144148
"properties": {
145149
"name": {"title": "Name", "type": "string"},

tests/test_tutorial/test_body_updates/test_tutorial001.py

Lines changed: 179 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import pytest
2-
from dirty_equals import IsDict
32
from fastapi.testclient import TestClient
43

4+
from ...utils import needs_pydanticv1, needs_pydanticv2
5+
56

67
@pytest.fixture(name="client")
78
def get_client():
@@ -36,7 +37,181 @@ def test_put(client: TestClient):
3637
}
3738

3839

40+
@needs_pydanticv2
3941
def test_openapi_schema(client: TestClient):
42+
response = client.get("/openapi.json")
43+
assert response.status_code == 200, response.text
44+
assert response.json() == {
45+
"openapi": "3.1.0",
46+
"info": {"title": "FastAPI", "version": "0.1.0"},
47+
"paths": {
48+
"/items/{item_id}": {
49+
"get": {
50+
"responses": {
51+
"200": {
52+
"description": "Successful Response",
53+
"content": {
54+
"application/json": {
55+
"schema": {
56+
"$ref": "#/components/schemas/ItemOutput"
57+
}
58+
}
59+
},
60+
},
61+
"422": {
62+
"description": "Validation Error",
63+
"content": {
64+
"application/json": {
65+
"schema": {
66+
"$ref": "#/components/schemas/HTTPValidationError"
67+
}
68+
}
69+
},
70+
},
71+
},
72+
"summary": "Read Item",
73+
"operationId": "read_item_items__item_id__get",
74+
"parameters": [
75+
{
76+
"required": True,
77+
"schema": {"title": "Item Id", "type": "string"},
78+
"name": "item_id",
79+
"in": "path",
80+
}
81+
],
82+
},
83+
"put": {
84+
"responses": {
85+
"200": {
86+
"description": "Successful Response",
87+
"content": {
88+
"application/json": {
89+
"schema": {
90+
"$ref": "#/components/schemas/ItemOutput"
91+
}
92+
}
93+
},
94+
},
95+
"422": {
96+
"description": "Validation Error",
97+
"content": {
98+
"application/json": {
99+
"schema": {
100+
"$ref": "#/components/schemas/HTTPValidationError"
101+
}
102+
}
103+
},
104+
},
105+
},
106+
"summary": "Update Item",
107+
"operationId": "update_item_items__item_id__put",
108+
"parameters": [
109+
{
110+
"required": True,
111+
"schema": {"title": "Item Id", "type": "string"},
112+
"name": "item_id",
113+
"in": "path",
114+
}
115+
],
116+
"requestBody": {
117+
"content": {
118+
"application/json": {
119+
"schema": {"$ref": "#/components/schemas/ItemInput"}
120+
}
121+
},
122+
"required": True,
123+
},
124+
},
125+
}
126+
},
127+
"components": {
128+
"schemas": {
129+
"ItemInput": {
130+
"title": "Item",
131+
"type": "object",
132+
"properties": {
133+
"name": {
134+
"title": "Name",
135+
"anyOf": [{"type": "string"}, {"type": "null"}],
136+
},
137+
"description": {
138+
"title": "Description",
139+
"anyOf": [{"type": "string"}, {"type": "null"}],
140+
},
141+
"price": {
142+
"title": "Price",
143+
"anyOf": [{"type": "number"}, {"type": "null"}],
144+
},
145+
"tax": {"title": "Tax", "type": "number", "default": 10.5},
146+
"tags": {
147+
"title": "Tags",
148+
"type": "array",
149+
"items": {"type": "string"},
150+
"default": [],
151+
},
152+
},
153+
},
154+
"ItemOutput": {
155+
"title": "Item",
156+
"type": "object",
157+
"required": ["name", "description", "price", "tax", "tags"],
158+
"properties": {
159+
"name": {
160+
"anyOf": [{"type": "string"}, {"type": "null"}],
161+
"title": "Name",
162+
},
163+
"description": {
164+
"anyOf": [{"type": "string"}, {"type": "null"}],
165+
"title": "Description",
166+
},
167+
"price": {
168+
"anyOf": [{"type": "number"}, {"type": "null"}],
169+
"title": "Price",
170+
},
171+
"tax": {"title": "Tax", "type": "number", "default": 10.5},
172+
"tags": {
173+
"title": "Tags",
174+
"type": "array",
175+
"items": {"type": "string"},
176+
"default": [],
177+
},
178+
},
179+
},
180+
"ValidationError": {
181+
"title": "ValidationError",
182+
"required": ["loc", "msg", "type"],
183+
"type": "object",
184+
"properties": {
185+
"loc": {
186+
"title": "Location",
187+
"type": "array",
188+
"items": {
189+
"anyOf": [{"type": "string"}, {"type": "integer"}]
190+
},
191+
},
192+
"msg": {"title": "Message", "type": "string"},
193+
"type": {"title": "Error Type", "type": "string"},
194+
},
195+
},
196+
"HTTPValidationError": {
197+
"title": "HTTPValidationError",
198+
"type": "object",
199+
"properties": {
200+
"detail": {
201+
"title": "Detail",
202+
"type": "array",
203+
"items": {"$ref": "#/components/schemas/ValidationError"},
204+
}
205+
},
206+
},
207+
}
208+
},
209+
}
210+
211+
212+
# TODO: remove when deprecating Pydantic v1
213+
@needs_pydanticv1
214+
def test_openapi_schema_pv1(client: TestClient):
40215
response = client.get("/openapi.json")
41216
assert response.status_code == 200, response.text
42217
assert response.json() == {
@@ -124,36 +299,9 @@ def test_openapi_schema(client: TestClient):
124299
"title": "Item",
125300
"type": "object",
126301
"properties": {
127-
"name": IsDict(
128-
{
129-
"title": "Name",
130-
"anyOf": [{"type": "string"}, {"type": "null"}],
131-
}
132-
)
133-
| IsDict(
134-
# TODO: remove when deprecating Pydantic v1
135-
{"title": "Name", "type": "string"}
136-
),
137-
"description": IsDict(
138-
{
139-
"title": "Description",
140-
"anyOf": [{"type": "string"}, {"type": "null"}],
141-
}
142-
)
143-
| IsDict(
144-
# TODO: remove when deprecating Pydantic v1
145-
{"title": "Description", "type": "string"}
146-
),
147-
"price": IsDict(
148-
{
149-
"title": "Price",
150-
"anyOf": [{"type": "number"}, {"type": "null"}],
151-
}
152-
)
153-
| IsDict(
154-
# TODO: remove when deprecating Pydantic v1
155-
{"title": "Price", "type": "number"}
156-
),
302+
"name": {"title": "Name", "type": "string"},
303+
"description": {"title": "Description", "type": "string"},
304+
"price": {"title": "Price", "type": "number"},
157305
"tax": {"title": "Tax", "type": "number", "default": 10.5},
158306
"tags": {
159307
"title": "Tags",

0 commit comments

Comments
 (0)