diff --git a/docs/src/response_model/tutorial001.py b/docs/src/response_model/tutorial001.py index 86dadcbda..4fe9aeb50 100644 --- a/docs/src/response_model/tutorial001.py +++ b/docs/src/response_model/tutorial001.py @@ -1,4 +1,4 @@ -from typing import Set +from typing import List from fastapi import FastAPI from pydantic import BaseModel @@ -11,9 +11,9 @@ class Item(BaseModel): description: str = None price: float tax: float = None - tags: Set[str] = [] + tags: List[str] = [] @app.post("/items/", response_model=Item) -async def create_item(*, item: Item): +async def create_item(item: Item): return item diff --git a/docs/src/response_model/tutorial004.py b/docs/src/response_model/tutorial004.py new file mode 100644 index 000000000..30ad2184b --- /dev/null +++ b/docs/src/response_model/tutorial004.py @@ -0,0 +1,36 @@ +from typing import List + +from fastapi import FastAPI +from pydantic import BaseModel + +app = FastAPI() + + +class Item(BaseModel): + name: str + description: str = None + price: float + tax: float = 10.5 + tags: List[str] = [] + + +items = { + "foo": {"name": "Foo", "price": 50.2}, + "bar": {"name": "Bar", "description": "The bartenders", "price": 62, "tax": 20.2}, + "baz": {"name": "Baz", "description": None, "price": 50.2, "tax": 10.5, "tags": []}, +} + + +@app.get("/items/{item_id}", response_model=Item, response_model_skip_defaults=True) +def read_item(item_id: str): + return items[item_id] + + +@app.patch("/items/{item_id}", response_model=Item, response_model_skip_defaults=True) +async def update_item(item_id: str, item: Item): + stored_item_data = items[item_id] + stored_item_model = Item(**stored_item_data) + update_data = item.dict(skip_defaults=True) + updated_item = stored_item_model.copy(update=update_data) + items[item_id] = updated_item + return updated_item diff --git a/docs/tutorial/response-model.md b/docs/tutorial/response-model.md index 21b885ebb..1402a6a8f 100644 --- a/docs/tutorial/response-model.md +++ b/docs/tutorial/response-model.md @@ -82,6 +82,107 @@ And both models will be used for the interactive API documentation: +## Response Model encoding parameters + +If your response model has default values, like: + +```Python hl_lines="11 13 14" +{!./src/response_model/tutorial004.py!} +``` + +* `description: str = None` has a default of `None`. +* `tax: float = None` has a default of `None`. +* `tags: List[str] = []` has a default of an empty list: `[]`. + +You can set the *path operation decorator* parameter `response_model_skip_defaults=True`: + +```Python hl_lines="24" +{!./src/response_model/tutorial004.py!} +``` + +and those default values won't be included in the response. + +So, if you send a request to that *path operation* for the item with ID `foo`, the response (not including default values) will be: + +```JSON +{ + "name": "Foo", + "price": 50.2 +} +``` + +!!! info + FastAPI uses Pydantic model's `.dict()` with its `skip_defaults` parameter to achieve this. + +### Data with values for fields with defaults + +But if your data has values for the model's fields with default values, like the item with ID `bar`: + +```Python hl_lines="3 5" +{ + "name": "Bar", + "description": "The bartenders", + "price": 62, + "tax": 20.2 +} +``` + +they will be included in the response. + +### Data with the same values as the defaults + +If the data has the same values as the default ones, like the item with ID `baz`: + +```Python hl_lines="3 5 6" +{ + "name": "Baz", + "description": None, + "price": 50.2, + "tax": 10.5, + "tags": [] +} +``` + +FastAPI is smart enough (actually, Pydantic is smart enough) to realize that, even though `description`, `tax`, and `tags` have the same values as the defaults, they were set explicitly (instead of taken from the defaults). + +So, they will be included in the JSON response. + +!!! tip + Notice that the default values can be anything, not only `None`. + + They can be a list (`[]`), a `float` of `10.5`, etc. + +### Use cases + +This is very useful in several scenarios. + +For example if you have models with many optional attributes in a NoSQL database, but you don't want to send very long JSON responses full of default values. + +### Using Pydantic's `skip_defaults` directly + +You can also use your model's `.dict(skip_defaults=True)` in your code. + +For example, you could receive a model object as a body payload, and update your stored data using only the attributes set, not the default ones: + +```Python hl_lines="31 32 33 34 35" +{!./src/response_model/tutorial004.py!} +``` + +!!! tip + It's common to use the HTTP `PUT` operation to update data. + + In theory, `PUT` should be used to "replace" the entire contents. + + The less known HTTP `PATCH` operation is also used to update data. + + But `PATCH` is expected to be used when *partially* updating data. Instead of *replacing* the entire content. + + Still, this is just a small detail, and many teams and code bases use `PUT` instead of `PATCH` for all updates, including to *partially* update contents. + + You can use `PUT` or `PATCH` however you wish. + ## Recap Use the path operation decorator's parameter `response_model` to define response models and especially to ensure private data is filtered out. + +Use `response_model_skip_defaults` to return only the values explicitly set. diff --git a/fastapi/applications.py b/fastapi/applications.py index 7041e91d6..3d6071ae6 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -138,6 +138,7 @@ class FastAPI(Starlette): deprecated: bool = None, methods: List[str] = None, operation_id: str = None, + response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, name: str = None, @@ -156,6 +157,7 @@ class FastAPI(Starlette): deprecated=deprecated, methods=methods, operation_id=operation_id, + response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, name=name, @@ -176,6 +178,7 @@ class FastAPI(Starlette): deprecated: bool = None, methods: List[str] = None, operation_id: str = None, + response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, name: str = None, @@ -195,6 +198,7 @@ class FastAPI(Starlette): deprecated=deprecated, methods=methods, operation_id=operation_id, + response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, name=name, @@ -246,6 +250,7 @@ class FastAPI(Starlette): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, name: str = None, @@ -262,6 +267,7 @@ class FastAPI(Starlette): responses=responses or {}, deprecated=deprecated, operation_id=operation_id, + response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, name=name, @@ -281,6 +287,7 @@ class FastAPI(Starlette): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, name: str = None, @@ -297,6 +304,7 @@ class FastAPI(Starlette): responses=responses or {}, deprecated=deprecated, operation_id=operation_id, + response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, name=name, @@ -316,6 +324,7 @@ class FastAPI(Starlette): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, name: str = None, @@ -332,6 +341,7 @@ class FastAPI(Starlette): responses=responses or {}, deprecated=deprecated, operation_id=operation_id, + response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, name=name, @@ -351,6 +361,7 @@ class FastAPI(Starlette): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, name: str = None, @@ -367,6 +378,7 @@ class FastAPI(Starlette): responses=responses or {}, deprecated=deprecated, operation_id=operation_id, + response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, name=name, @@ -386,6 +398,7 @@ class FastAPI(Starlette): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, name: str = None, @@ -402,6 +415,7 @@ class FastAPI(Starlette): responses=responses or {}, deprecated=deprecated, operation_id=operation_id, + response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, name=name, @@ -421,6 +435,7 @@ class FastAPI(Starlette): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, name: str = None, @@ -437,6 +452,7 @@ class FastAPI(Starlette): responses=responses or {}, deprecated=deprecated, operation_id=operation_id, + response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, name=name, @@ -456,6 +472,7 @@ class FastAPI(Starlette): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, name: str = None, @@ -472,6 +489,7 @@ class FastAPI(Starlette): responses=responses or {}, deprecated=deprecated, operation_id=operation_id, + response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, name=name, @@ -491,6 +509,7 @@ class FastAPI(Starlette): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, name: str = None, @@ -507,6 +526,7 @@ class FastAPI(Starlette): responses=responses or {}, deprecated=deprecated, operation_id=operation_id, + response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, name=name, diff --git a/fastapi/encoders.py b/fastapi/encoders.py index fc289c936..25ab19fa3 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -11,6 +11,7 @@ def jsonable_encoder( include: Set[str] = None, exclude: Set[str] = set(), by_alias: bool = True, + skip_defaults: bool = False, include_none: bool = True, custom_encoder: dict = {}, sqlalchemy_safe: bool = True, @@ -18,7 +19,12 @@ def jsonable_encoder( if isinstance(obj, BaseModel): encoder = getattr(obj.Config, "json_encoders", custom_encoder) return jsonable_encoder( - obj.dict(include=include, exclude=exclude, by_alias=by_alias), + obj.dict( + include=include, + exclude=exclude, + by_alias=by_alias, + skip_defaults=skip_defaults, + ), include_none=include_none, custom_encoder=encoder, sqlalchemy_safe=sqlalchemy_safe, @@ -42,6 +48,7 @@ def jsonable_encoder( encoded_key = jsonable_encoder( key, by_alias=by_alias, + skip_defaults=skip_defaults, include_none=include_none, custom_encoder=custom_encoder, sqlalchemy_safe=sqlalchemy_safe, @@ -49,6 +56,7 @@ def jsonable_encoder( encoded_value = jsonable_encoder( value, by_alias=by_alias, + skip_defaults=skip_defaults, include_none=include_none, custom_encoder=custom_encoder, sqlalchemy_safe=sqlalchemy_safe, @@ -64,6 +72,7 @@ def jsonable_encoder( include=include, exclude=exclude, by_alias=by_alias, + skip_defaults=skip_defaults, include_none=include_none, custom_encoder=custom_encoder, sqlalchemy_safe=sqlalchemy_safe, @@ -91,6 +100,7 @@ def jsonable_encoder( return jsonable_encoder( data, by_alias=by_alias, + skip_defaults=skip_defaults, include_none=include_none, custom_encoder=custom_encoder, sqlalchemy_safe=sqlalchemy_safe, diff --git a/fastapi/routing.py b/fastapi/routing.py index c902bb2ad..2ae6d1e09 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -32,8 +32,11 @@ from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY, WS_1008_POLICY_VIOLA from starlette.websockets import WebSocket -def serialize_response(*, field: Field = None, response: Response) -> Any: - encoded = jsonable_encoder(response) +def serialize_response( + *, field: Field = None, response: Response, skip_defaults: bool = False +) -> Any: + + encoded = jsonable_encoder(response, skip_defaults=skip_defaults) if field: errors = [] value, errors_ = field.validate(encoded, {}, loc=("response",)) @@ -43,7 +46,7 @@ def serialize_response(*, field: Field = None, response: Response) -> Any: errors.extend(errors_) if errors: raise ValidationError(errors) - return jsonable_encoder(value) + return jsonable_encoder(value, skip_defaults=skip_defaults) else: return encoded @@ -54,6 +57,7 @@ def get_app( status_code: int = 200, response_class: Type[Response] = JSONResponse, response_field: Field = None, + skip_defaults: bool = False, ) -> Callable: assert dependant.call is not None, "dependant.call must be a function" is_coroutine = asyncio.iscoroutinefunction(dependant.call) @@ -93,7 +97,7 @@ def get_app( raw_response.background = background_tasks return raw_response response_data = serialize_response( - field=response_field, response=raw_response + field=response_field, response=raw_response, skip_defaults=skip_defaults ) return response_class( content=response_data, @@ -151,6 +155,7 @@ class APIRoute(routing.Route): name: str = None, methods: List[str] = None, operation_id: str = None, + response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, ) -> None: @@ -210,6 +215,7 @@ class APIRoute(routing.Route): methods = ["GET"] self.methods = methods self.operation_id = operation_id + self.response_model_skip_defaults = response_model_skip_defaults self.include_in_schema = include_in_schema self.response_class = response_class @@ -230,6 +236,7 @@ class APIRoute(routing.Route): status_code=self.status_code, response_class=self.response_class, response_field=self.response_field, + skip_defaults=self.response_model_skip_defaults, ) ) @@ -251,6 +258,7 @@ class APIRouter(routing.Router): deprecated: bool = None, methods: List[str] = None, operation_id: str = None, + response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, name: str = None, @@ -269,6 +277,7 @@ class APIRouter(routing.Router): deprecated=deprecated, methods=methods, operation_id=operation_id, + response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, name=name, @@ -290,6 +299,7 @@ class APIRouter(routing.Router): deprecated: bool = None, methods: List[str] = None, operation_id: str = None, + response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, name: str = None, @@ -309,6 +319,7 @@ class APIRouter(routing.Router): deprecated=deprecated, methods=methods, operation_id=operation_id, + response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, name=name, @@ -363,6 +374,7 @@ class APIRouter(routing.Router): deprecated=route.deprecated, methods=route.methods, operation_id=route.operation_id, + response_model_skip_defaults=route.response_model_skip_defaults, include_in_schema=route.include_in_schema, response_class=route.response_class, name=route.name, @@ -398,10 +410,12 @@ class APIRouter(routing.Router): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, name: str = None, ) -> Callable: + return self.api_route( path=path, response_model=response_model, @@ -415,6 +429,7 @@ class APIRouter(routing.Router): deprecated=deprecated, methods=["GET"], operation_id=operation_id, + response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, name=name, @@ -434,6 +449,7 @@ class APIRouter(routing.Router): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, name: str = None, @@ -451,6 +467,7 @@ class APIRouter(routing.Router): deprecated=deprecated, methods=["PUT"], operation_id=operation_id, + response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, name=name, @@ -470,6 +487,7 @@ class APIRouter(routing.Router): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, name: str = None, @@ -487,6 +505,7 @@ class APIRouter(routing.Router): deprecated=deprecated, methods=["POST"], operation_id=operation_id, + response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, name=name, @@ -506,6 +525,7 @@ class APIRouter(routing.Router): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, name: str = None, @@ -523,6 +543,7 @@ class APIRouter(routing.Router): deprecated=deprecated, methods=["DELETE"], operation_id=operation_id, + response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, name=name, @@ -542,6 +563,7 @@ class APIRouter(routing.Router): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, name: str = None, @@ -559,6 +581,7 @@ class APIRouter(routing.Router): deprecated=deprecated, methods=["OPTIONS"], operation_id=operation_id, + response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, name=name, @@ -578,6 +601,7 @@ class APIRouter(routing.Router): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, name: str = None, @@ -595,6 +619,7 @@ class APIRouter(routing.Router): deprecated=deprecated, methods=["HEAD"], operation_id=operation_id, + response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, name=name, @@ -614,6 +639,7 @@ class APIRouter(routing.Router): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, name: str = None, @@ -631,6 +657,7 @@ class APIRouter(routing.Router): deprecated=deprecated, methods=["PATCH"], operation_id=operation_id, + response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, name=name, @@ -650,6 +677,7 @@ class APIRouter(routing.Router): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, name: str = None, @@ -667,6 +695,7 @@ class APIRouter(routing.Router): deprecated=deprecated, methods=["TRACE"], operation_id=operation_id, + response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, name=name, diff --git a/tests/test_tutorial/test_response_model/test_tutorial004.py b/tests/test_tutorial/test_response_model/test_tutorial004.py new file mode 100644 index 000000000..152e291e4 --- /dev/null +++ b/tests/test_tutorial/test_response_model/test_tutorial004.py @@ -0,0 +1,177 @@ +import pytest +from starlette.testclient import TestClient + +from response_model.tutorial004 import app + +client = TestClient(app) + +openapi_schema = { + "openapi": "3.0.2", + "info": {"title": "Fast API", "version": "0.1.0"}, + "paths": { + "/items/{item_id}": { + "get": { + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Item"} + } + }, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + "summary": "Read Item", + "operationId": "read_item_items__item_id__get", + "parameters": [ + { + "required": True, + "schema": {"title": "Item_Id", "type": "string"}, + "name": "item_id", + "in": "path", + } + ], + }, + "patch": { + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Item"} + } + }, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + "summary": "Update Item", + "operationId": "update_item_items__item_id__patch", + "parameters": [ + { + "required": True, + "schema": {"title": "Item_Id", "type": "string"}, + "name": "item_id", + "in": "path", + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Item"} + } + }, + "required": True, + }, + }, + } + }, + "components": { + "schemas": { + "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", "default": 10.5}, + "tags": { + "title": "Tags", + "type": "array", + "items": {"type": "string"}, + "default": [], + }, + }, + }, + "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"}, + }, + }, + "HTTPValidationError": { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "detail": { + "title": "Detail", + "type": "array", + "items": {"$ref": "#/components/schemas/ValidationError"}, + } + }, + }, + } + }, +} + + +def test_openapi_schema(): + response = client.get("/openapi.json") + assert response.status_code == 200 + assert response.json() == openapi_schema + + +@pytest.mark.parametrize( + "url,data", + [ + ("/items/foo", {"name": "Foo", "price": 50.2}), + ( + "/items/bar", + {"name": "Bar", "description": "The bartenders", "price": 62, "tax": 20.2}, + ), + ( + "/items/baz", + { + "name": "Baz", + "description": None, + "price": 50.2, + "tax": 10.5, + "tags": [], + }, + ), + ], +) +def test_get(url, data): + response = client.get(url) + assert response.status_code == 200 + assert response.json() == data + + +def test_patch(): + response = client.patch( + "/items/bar", json={"name": "Barz", "price": 3, "description": None} + ) + assert response.json() == { + "name": "Barz", + "description": None, + "price": 3, + "tax": 20.2, + }