Browse Source
When using this response class, json serialization is done using Pydantic's built-in json serialization (`dump_json(r)`) instead of generating an intermediate dict that is later serialized using a json library (`json.dumps(dump_python(r))`). In my testing this is 3-4x faster than using the standard json library (the default) and 50% faster than using orjson, without requiring any extra dependencies. This also allows configuring serialization behavior per model using Pydantic's model_config.pull/14299/head
5 changed files with 241 additions and 1 deletions
@ -0,0 +1,182 @@ |
|||
import math |
|||
from typing import List, Optional |
|||
|
|||
import pytest |
|||
from dirty_equals import IsFloatNan |
|||
from fastapi import FastAPI |
|||
from fastapi._compat import PYDANTIC_V2 |
|||
from fastapi.responses import PydanticJSONResponse |
|||
from fastapi.testclient import TestClient |
|||
from pydantic import BaseModel, Field |
|||
|
|||
from .utils import needs_py_lt_314, needs_pydanticv2 |
|||
|
|||
app = FastAPI(default_response_class=PydanticJSONResponse) |
|||
|
|||
|
|||
class CustomResponse(PydanticJSONResponse): |
|||
media_type = "application/x-custom" |
|||
|
|||
|
|||
class Item(BaseModel): |
|||
name: str |
|||
price: float |
|||
category: str = Field("food", alias="CAT") |
|||
tax: float = 8.875 |
|||
description: Optional[str] = None |
|||
|
|||
|
|||
@app.get("/response-model", response_model=Item) |
|||
@app.get( |
|||
"/response-model-include", |
|||
response_model=Item, |
|||
response_model_include={"name", "category"}, |
|||
) |
|||
@app.get( |
|||
"/response-model-exclude", |
|||
response_model=Item, |
|||
response_model_exclude={"tax", "description"}, |
|||
) |
|||
@app.get( |
|||
"/response-model-by-alias-false", |
|||
response_model=Item, |
|||
response_model_by_alias=False, |
|||
) |
|||
@app.get( |
|||
"/response-model-exclude-unset", |
|||
response_model=Item, |
|||
response_model_exclude_unset=True, |
|||
) |
|||
@app.get( |
|||
"/response-model-exclude-defaults", |
|||
response_model=Item, |
|||
response_model_exclude_defaults=True, |
|||
) |
|||
@app.get( |
|||
"/response-model-exclude-none", |
|||
response_model=Item, |
|||
response_model_exclude_none=True, |
|||
) |
|||
def get_response_model_params(): |
|||
return {"name": "cheese", "price": 1.23, "tax": 8.875, "description": None} |
|||
|
|||
|
|||
class FloatsNone(BaseModel): |
|||
# pydantic converts inf/nan to None by default |
|||
numbers: List[float] |
|||
|
|||
|
|||
class FloatsNum(FloatsNone): |
|||
model_config = {"ser_json_inf_nan": "constants"} |
|||
|
|||
|
|||
class FloatsStr(FloatsNone): |
|||
model_config = {"ser_json_inf_nan": "strings"} |
|||
|
|||
|
|||
@app.get("/floats-none", response_model=FloatsNone) |
|||
@app.get("/floats-num", response_model=FloatsNum) |
|||
@app.get("/floats-str", response_model=FloatsStr) |
|||
@app.get("/custom-class", response_class=CustomResponse, response_model=FloatsStr) |
|||
def get_floats(): |
|||
return {"numbers": [3.14, math.inf, math.nan, 2.72]} |
|||
|
|||
|
|||
client = TestClient(app) |
|||
|
|||
|
|||
@needs_pydanticv2 |
|||
@pytest.mark.parametrize( |
|||
"path,expected_response", |
|||
[ |
|||
( |
|||
"/response-model", |
|||
{ |
|||
"name": "cheese", |
|||
"price": 1.23, |
|||
"CAT": "food", |
|||
"tax": 8.875, |
|||
"description": None, |
|||
}, |
|||
), |
|||
("/response-model-include", {"name": "cheese", "CAT": "food"}), |
|||
("/response-model-exclude", {"name": "cheese", "price": 1.23, "CAT": "food"}), |
|||
( |
|||
"/response-model-by-alias-false", |
|||
{ |
|||
"name": "cheese", |
|||
"price": 1.23, |
|||
"category": "food", |
|||
"tax": 8.875, |
|||
"description": None, |
|||
}, |
|||
), |
|||
( |
|||
"/response-model-exclude-unset", |
|||
{ |
|||
"name": "cheese", |
|||
"price": 1.23, |
|||
"tax": 8.875, |
|||
"description": None, |
|||
}, |
|||
), |
|||
("/response-model-exclude-defaults", {"name": "cheese", "price": 1.23}), |
|||
( |
|||
"/response-model-exclude-none", |
|||
{ |
|||
"name": "cheese", |
|||
"price": 1.23, |
|||
"CAT": "food", |
|||
"tax": 8.875, |
|||
}, |
|||
), |
|||
], |
|||
) |
|||
def test_response_model_params(path: str, expected_response: dict): |
|||
response = client.get(path) |
|||
assert response.status_code == 200 |
|||
assert response.json() == expected_response |
|||
|
|||
|
|||
@needs_pydanticv2 |
|||
@pytest.mark.parametrize( |
|||
"path,expected_numbers", |
|||
[ |
|||
("/floats-none", [3.14, None, None, 2.72]), |
|||
("/floats-num", [3.14, math.inf, IsFloatNan, 2.72]), |
|||
("/floats-str", [3.14, "Infinity", "NaN", 2.72]), |
|||
], |
|||
) |
|||
def test_floats(path: str, expected_numbers: list): |
|||
response = client.get(path) |
|||
assert response.status_code == 200 |
|||
assert response.json() == {"numbers": expected_numbers} |
|||
|
|||
|
|||
@needs_pydanticv2 |
|||
def test_custom_response_class(): |
|||
response = client.get("/custom-class") |
|||
assert response.status_code == 200 |
|||
assert response.headers["content-type"] == "application/x-custom" |
|||
assert response.json() == {"numbers": [3.14, "Infinity", "NaN", 2.72]} |
|||
|
|||
|
|||
@needs_py_lt_314 |
|||
def test_requires_pydantic_v2_model(): |
|||
if PYDANTIC_V2: |
|||
from pydantic.v1 import BaseModel as BaseModelV1 |
|||
else: |
|||
from pydantic import BaseModel as BaseModelV1 |
|||
|
|||
app = FastAPI(default_response_class=PydanticJSONResponse) |
|||
|
|||
class ModelV1(BaseModelV1): |
|||
data: str |
|||
|
|||
@app.get("/model-v1") |
|||
def get_model_v1() -> ModelV1: |
|||
return ModelV1(data="hello") |
|||
|
|||
client = TestClient(app) |
|||
with pytest.raises(AssertionError, match="requires a pydantic v2 model"): |
|||
client.get("/model-v1") |
|||
Loading…
Reference in new issue