diff --git a/docs/src/body_updates/tutorial001.py b/docs/src/body_updates/tutorial001.py new file mode 100644 index 000000000..bf9249327 --- /dev/null +++ b/docs/src/body_updates/tutorial001.py @@ -0,0 +1,34 @@ +from typing import List + +from fastapi import FastAPI +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel + +app = FastAPI() + + +class Item(BaseModel): + name: str = None + description: str = None + price: float = None + 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) +async def read_item(item_id: str): + return items[item_id] + + +@app.put("/items/{item_id}", response_model=Item) +async def update_item(item_id: str, item: Item): + update_item_encoded = jsonable_encoder(item) + items[item_id] = update_item_encoded + return update_item_encoded diff --git a/docs/src/body_updates/tutorial002.py b/docs/src/body_updates/tutorial002.py new file mode 100644 index 000000000..e10fbb921 --- /dev/null +++ b/docs/src/body_updates/tutorial002.py @@ -0,0 +1,37 @@ +from typing import List + +from fastapi import FastAPI +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel + +app = FastAPI() + + +class Item(BaseModel): + name: str = None + description: str = None + price: float = None + 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) +async def read_item(item_id: str): + return items[item_id] + + +@app.patch("/items/{item_id}", response_model=Item) +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] = jsonable_encoder(updated_item) + return updated_item diff --git a/docs/src/response_model/tutorial004.py b/docs/src/response_model/tutorial004.py index 30ad2184b..bbfd855bb 100644 --- a/docs/src/response_model/tutorial004.py +++ b/docs/src/response_model/tutorial004.py @@ -22,15 +22,5 @@ items = { @app.get("/items/{item_id}", response_model=Item, response_model_skip_defaults=True) -def read_item(item_id: str): +async 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/src/response_model/tutorial005.py b/docs/src/response_model/tutorial005.py new file mode 100644 index 000000000..65343f072 --- /dev/null +++ b/docs/src/response_model/tutorial005.py @@ -0,0 +1,37 @@ +from fastapi import FastAPI +from pydantic import BaseModel + +app = FastAPI() + + +class Item(BaseModel): + name: str + description: str = None + price: float + tax: float = 10.5 + + +items = { + "foo": {"name": "Foo", "price": 50.2}, + "bar": {"name": "Bar", "description": "The Bar fighters", "price": 62, "tax": 20.2}, + "baz": { + "name": "Baz", + "description": "There goes my baz", + "price": 50.2, + "tax": 10.5, + }, +} + + +@app.get( + "/items/{item_id}/name", + response_model=Item, + response_model_include={"name", "description"}, +) +async def read_item_name(item_id: str): + return items[item_id] + + +@app.get("/items/{item_id}/public", response_model=Item, response_model_exclude={"tax"}) +async def read_item_public_data(item_id: str): + return items[item_id] diff --git a/docs/src/response_model/tutorial006.py b/docs/src/response_model/tutorial006.py new file mode 100644 index 000000000..127c6f7cf --- /dev/null +++ b/docs/src/response_model/tutorial006.py @@ -0,0 +1,37 @@ +from fastapi import FastAPI +from pydantic import BaseModel + +app = FastAPI() + + +class Item(BaseModel): + name: str + description: str = None + price: float + tax: float = 10.5 + + +items = { + "foo": {"name": "Foo", "price": 50.2}, + "bar": {"name": "Bar", "description": "The Bar fighters", "price": 62, "tax": 20.2}, + "baz": { + "name": "Baz", + "description": "There goes my baz", + "price": 50.2, + "tax": 10.5, + }, +} + + +@app.get( + "/items/{item_id}/name", + response_model=Item, + response_model_include=["name", "description"], +) +async def read_item_name(item_id: str): + return items[item_id] + + +@app.get("/items/{item_id}/public", response_model=Item, response_model_exclude=["tax"]) +async def read_item_public_data(item_id: str): + return items[item_id] diff --git a/docs/tutorial/body-updates.md b/docs/tutorial/body-updates.md new file mode 100644 index 000000000..0850d5d44 --- /dev/null +++ b/docs/tutorial/body-updates.md @@ -0,0 +1,97 @@ +## Update replacing with `PUT` + +To update an item you can use the [HTTP `PUT`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT) operation. + +You can use the `jsonable_encoder` to convert the input data to data that can be stored as JSON (e.g. with a NoSQL database). For example, converting `datetime` to `str`. + +```Python hl_lines="30 31 32 33 34 35" +{!./src/body_updates/tutorial001.py!} +``` + +`PUT` is used to receive data that should replace the existing data. + +### Warning about replacing + +That means that if you want to update the item `bar` using `PUT` with a body containing: + +```Python +{ + "name": "Barz", + "price": 3, + "description": None, +} +``` + +because it doesn't include the already stored attribute `"tax": 20.2`, the input model would take the default value of `"tax": 10.5`. + +And the data would be saved with that "new" `tax` of `10.5`. + +## Partial updates with `PATCH` + +You can also use the [HTTP `PATCH`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PATCH) operation to *partially* update data. + +This means that you can send only the data that you want to update, leaving the rest intact. + +!!! Note + `PATCH` is less commonly used and known than `PUT`. + + And many teams use only `PUT`, even for partial updates. + + You are **free** to use them however you want, **FastAPI** doesn't impose any restrictions. + + But this guide shows you, more or less, how they are intended to be used. + +### Using Pydantic's `skip_defaults` parameter + +If you want to receive partial updates, it's very useful to use the parameter `skip_defaults` in Pydantic's model's `.dict()`. + +Like `item.dict(skip_defaults=True)`. + +That would generate a `dict` with only the data that was set when creating the `item` model, excluding default values. + +Then you can use this to generate a `dict` with only the data that was set, omitting default values: + +```Python hl_lines="34" +{!./src/body_updates/tutorial002.py!} +``` + +### Using Pydantic's `update` parameter + +Now, you can create a copy of the existing model using `.copy()`, and pass the `update` parameter with a `dict` containing the data to update. + +Like `stored_item_model.copy(update=update_data)`: + +```Python hl_lines="35" +{!./src/body_updates/tutorial002.py!} +``` + +### Partial updates recap + +In summary, to apply partial updates you would: + +* (Optionally) use `PATCH` instead of `PUT`. +* Retrieve the stored data. +* Put that data in a Pydantic model. +* Generate a `dict` without default values from the input model (using `skip_defaults`). + * This way you can update only the values actually set by the user, instead of overriding values already stored with default values in your model. +* Create a copy of the stored model, updating it's attributes with the received partial updates (using the `update` parameter). +* Convert the copied model to something that can be stored in your DB (for example, using the `jsonable_encoder`). + * This is comparable to using the model's `.dict()` method again, but it makes sure (and converts) the values to data types that can be converted to JSON, for example, `datetime` to `str`. +* Save the data to your DB. +* Return the updated model. + +```Python hl_lines="30 31 32 33 34 35 36 37" +{!./src/body_updates/tutorial002.py!} +``` + +!!! tip + You can actually use this same technique with an HTTP `PUT` operation. + + But the example here uses `PATCH` because it was created for these use cases. + +!!! note + Notice that the input model is still validated. + + So, if you want to receive partial updates that can omit all the attributes, you need to have a model with all the attributes marked as optional (with default values or `None`). + + To distinguish from the models with all optional values for **updates** and models with required values for **creation**, you can use the ideas described in Extra Models. diff --git a/docs/tutorial/response-model.md b/docs/tutorial/response-model.md index 1402a6a8f..8b389f236 100644 --- a/docs/tutorial/response-model.md +++ b/docs/tutorial/response-model.md @@ -13,12 +13,14 @@ You can declare the model used for the response with the parameter `response_mod !!! note Notice that `response_model` is a parameter of the "decorator" method (`get`, `post`, etc). Not of your path operation function, like all the parameters and body. -It receives a standard Pydantic model and will: +It receives the same type you would declare for a Pydantic model attribute, so, it can be a Pydantic model, but it can also be, e.g. a `list` of Pydantic models, like `List[Item]`. -* Convert the output data to the type declarations of the model -* Validate the data -* Add a JSON Schema for the response, in the OpenAPI path operation -* Will be used by the automatic documentation systems +FastAPI will use this `response_model` to: + +* Convert the output data to its type declaration. +* Validate the data. +* Add a JSON Schema for the response, in the OpenAPI path operation. +* Will be used by the automatic documentation systems. But most importantly: @@ -45,7 +47,7 @@ Now, whenever a browser is creating a user with a password, the API will return In this case, it might not be a problem, because the user himself is sending the password. -But if we use the same model for another path operation, we could be sending the passwords of our users to every client. +But if we use the same model for another path operation, we could be sending our user's passwords to every client. !!! danger Never send the plain password of a user in a response. @@ -84,7 +86,7 @@ And both models will be used for the interactive API documentation: ## Response Model encoding parameters -If your response model has default values, like: +Your response model could have default values, like: ```Python hl_lines="11 13 14" {!./src/response_model/tutorial004.py!} @@ -94,6 +96,12 @@ If your response model has default values, like: * `tax: float = None` has a default of `None`. * `tags: List[str] = []` has a default of an empty list: `[]`. +but you might want to omit them from the result if they were not actually stored. + +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. + +### Use the `response_model_skip_defaults` parameter + You can set the *path operation decorator* parameter `response_model_skip_defaults=True`: ```Python hl_lines="24" @@ -114,7 +122,7 @@ So, if you send a request to that *path operation* for the item with ID `foo`, t !!! info FastAPI uses Pydantic model's `.dict()` with its `skip_defaults` parameter to achieve this. -### Data with values for fields with defaults +#### 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`: @@ -129,7 +137,7 @@ But if your data has values for the model's fields with default values, like the they will be included in the response. -### Data with the same values as the defaults +#### 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`: @@ -152,34 +160,35 @@ So, they will be included in the JSON response. They can be a list (`[]`), a `float` of `10.5`, etc. -### Use cases +### `response_model_include` and `response_model_exclude` -This is very useful in several scenarios. +You can also use the *path operation decorator* parameters `response_model_include` and `response_model_exclude`. -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. +They take a `set` of `str` with the name of the attributes to include (omitting the rest) or to exclude (including the rest). -### Using Pydantic's `skip_defaults` directly +This can be used as a quick shortcut if you have only one Pydantic model and want to remove some data from the output. -You can also use your model's `.dict(skip_defaults=True)` in your code. +!!! tip + But it is still recommended to use the ideas above, using multiple classes, instead of these parameters. -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: + This is because the JSON Schema generated in your app's OpenAPI (and the docs) will still be the one for the complete model, even if you use `response_model_include` or `response_model_exclude` to omit some attributes. -```Python hl_lines="31 32 33 34 35" -{!./src/response_model/tutorial004.py!} +```Python hl_lines="29 35" +{!./src/response_model/tutorial005.py!} ``` !!! tip - It's common to use the HTTP `PUT` operation to update data. + The syntax `{"name", "description"}` creates a `set` with those two values. - In theory, `PUT` should be used to "replace" the entire contents. + It is equivalent to `set(["name", "description"])`. - The less known HTTP `PATCH` operation is also used to update data. +#### Using `list`s instead of `set`s - But `PATCH` is expected to be used when *partially* updating data. Instead of *replacing* the entire content. +If you forget to use a `set` and use a `list` or `tuple` instead, FastAPI will still convert it to a `set` and it will work correctly: - 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. +```Python hl_lines="29 35" +{!./src/response_model/tutorial006.py!} +``` ## Recap diff --git a/fastapi/applications.py b/fastapi/applications.py index 3d6071ae6..0e35543dc 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -1,4 +1,4 @@ -from typing import Any, Callable, Dict, List, Optional, Type, Union +from typing import Any, Callable, Dict, List, Optional, Set, Type, Union from fastapi import routing from fastapi.openapi.docs import ( @@ -138,6 +138,9 @@ class FastAPI(Starlette): deprecated: bool = None, methods: List[str] = None, operation_id: str = None, + response_model_include: Set[str] = None, + response_model_exclude: Set[str] = set(), + response_model_by_alias: bool = True, response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, @@ -157,6 +160,9 @@ class FastAPI(Starlette): deprecated=deprecated, methods=methods, operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, @@ -178,6 +184,9 @@ class FastAPI(Starlette): deprecated: bool = None, methods: List[str] = None, operation_id: str = None, + response_model_include: Set[str] = None, + response_model_exclude: Set[str] = set(), + response_model_by_alias: bool = True, response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, @@ -198,6 +207,9 @@ class FastAPI(Starlette): deprecated=deprecated, methods=methods, operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, @@ -250,6 +262,9 @@ class FastAPI(Starlette): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_include: Set[str] = None, + response_model_exclude: Set[str] = set(), + response_model_by_alias: bool = True, response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, @@ -267,6 +282,9 @@ class FastAPI(Starlette): responses=responses or {}, deprecated=deprecated, operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, @@ -287,6 +305,9 @@ class FastAPI(Starlette): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_include: Set[str] = None, + response_model_exclude: Set[str] = set(), + response_model_by_alias: bool = True, response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, @@ -304,6 +325,9 @@ class FastAPI(Starlette): responses=responses or {}, deprecated=deprecated, operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, @@ -324,6 +348,9 @@ class FastAPI(Starlette): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_include: Set[str] = None, + response_model_exclude: Set[str] = set(), + response_model_by_alias: bool = True, response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, @@ -341,6 +368,9 @@ class FastAPI(Starlette): responses=responses or {}, deprecated=deprecated, operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, @@ -361,6 +391,9 @@ class FastAPI(Starlette): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_include: Set[str] = None, + response_model_exclude: Set[str] = set(), + response_model_by_alias: bool = True, response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, @@ -377,6 +410,9 @@ class FastAPI(Starlette): response_description=response_description, responses=responses or {}, deprecated=deprecated, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, operation_id=operation_id, response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, @@ -398,6 +434,9 @@ class FastAPI(Starlette): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_include: Set[str] = None, + response_model_exclude: Set[str] = set(), + response_model_by_alias: bool = True, response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, @@ -415,6 +454,9 @@ class FastAPI(Starlette): responses=responses or {}, deprecated=deprecated, operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, @@ -435,6 +477,9 @@ class FastAPI(Starlette): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_include: Set[str] = None, + response_model_exclude: Set[str] = set(), + response_model_by_alias: bool = True, response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, @@ -452,6 +497,9 @@ class FastAPI(Starlette): responses=responses or {}, deprecated=deprecated, operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, @@ -472,6 +520,9 @@ class FastAPI(Starlette): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_include: Set[str] = None, + response_model_exclude: Set[str] = set(), + response_model_by_alias: bool = True, response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, @@ -489,6 +540,9 @@ class FastAPI(Starlette): responses=responses or {}, deprecated=deprecated, operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, @@ -509,6 +563,9 @@ class FastAPI(Starlette): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_include: Set[str] = None, + response_model_exclude: Set[str] = set(), + response_model_by_alias: bool = True, response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, @@ -526,6 +583,9 @@ class FastAPI(Starlette): responses=responses or {}, deprecated=deprecated, operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 25ab19fa3..f5ff57f61 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -16,6 +16,10 @@ def jsonable_encoder( custom_encoder: dict = {}, sqlalchemy_safe: bool = True, ) -> Any: + if include is not None and not isinstance(include, set): + include = set(include) + if exclude is not None and not isinstance(exclude, set): + exclude = set(exclude) if isinstance(obj, BaseModel): encoder = getattr(obj.Config, "json_encoders", custom_encoder) return jsonable_encoder( diff --git a/fastapi/routing.py b/fastapi/routing.py index 2ae6d1e09..26d052c34 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -2,7 +2,7 @@ import asyncio import inspect import logging import re -from typing import Any, Callable, Dict, List, Optional, Type, Union +from typing import Any, Callable, Dict, List, Optional, Set, Type, Union from fastapi import params from fastapi.dependencies.models import Dependant @@ -33,10 +33,22 @@ from starlette.websockets import WebSocket def serialize_response( - *, field: Field = None, response: Response, skip_defaults: bool = False + *, + field: Field = None, + response: Response, + include: Set[str] = None, + exclude: Set[str] = set(), + by_alias: bool = True, + skip_defaults: bool = False, ) -> Any: - encoded = jsonable_encoder(response, skip_defaults=skip_defaults) + encoded = jsonable_encoder( + response, + include=include, + exclude=exclude, + by_alias=by_alias, + skip_defaults=skip_defaults, + ) if field: errors = [] value, errors_ = field.validate(encoded, {}, loc=("response",)) @@ -46,7 +58,13 @@ def serialize_response( errors.extend(errors_) if errors: raise ValidationError(errors) - return jsonable_encoder(value, skip_defaults=skip_defaults) + return jsonable_encoder( + value, + include=include, + exclude=exclude, + by_alias=by_alias, + skip_defaults=skip_defaults, + ) else: return encoded @@ -57,7 +75,10 @@ def get_app( status_code: int = 200, response_class: Type[Response] = JSONResponse, response_field: Field = None, - skip_defaults: bool = False, + response_model_include: Set[str] = None, + response_model_exclude: Set[str] = set(), + response_model_by_alias: bool = True, + response_model_skip_defaults: bool = False, ) -> Callable: assert dependant.call is not None, "dependant.call must be a function" is_coroutine = asyncio.iscoroutinefunction(dependant.call) @@ -97,7 +118,12 @@ def get_app( raw_response.background = background_tasks return raw_response response_data = serialize_response( - field=response_field, response=raw_response, skip_defaults=skip_defaults + field=response_field, + response=raw_response, + include=response_model_include, + exclude=response_model_exclude, + by_alias=response_model_by_alias, + skip_defaults=response_model_skip_defaults, ) return response_class( content=response_data, @@ -155,6 +181,9 @@ class APIRoute(routing.Route): name: str = None, methods: List[str] = None, operation_id: str = None, + response_model_include: Set[str] = None, + response_model_exclude: Set[str] = set(), + response_model_by_alias: bool = True, response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, @@ -215,6 +244,9 @@ class APIRoute(routing.Route): methods = ["GET"] self.methods = methods self.operation_id = operation_id + self.response_model_include = response_model_include + self.response_model_exclude = response_model_exclude + self.response_model_by_alias = response_model_by_alias self.response_model_skip_defaults = response_model_skip_defaults self.include_in_schema = include_in_schema self.response_class = response_class @@ -236,7 +268,10 @@ 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, + response_model_include=self.response_model_include, + response_model_exclude=self.response_model_exclude, + response_model_by_alias=self.response_model_by_alias, + response_model_skip_defaults=self.response_model_skip_defaults, ) ) @@ -258,6 +293,9 @@ class APIRouter(routing.Router): deprecated: bool = None, methods: List[str] = None, operation_id: str = None, + response_model_include: Set[str] = None, + response_model_exclude: Set[str] = set(), + response_model_by_alias: bool = True, response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, @@ -277,6 +315,9 @@ class APIRouter(routing.Router): deprecated=deprecated, methods=methods, operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, @@ -299,6 +340,9 @@ class APIRouter(routing.Router): deprecated: bool = None, methods: List[str] = None, operation_id: str = None, + response_model_include: Set[str] = None, + response_model_exclude: Set[str] = set(), + response_model_by_alias: bool = True, response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, @@ -319,6 +363,9 @@ class APIRouter(routing.Router): deprecated=deprecated, methods=methods, operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, @@ -374,6 +421,9 @@ class APIRouter(routing.Router): deprecated=route.deprecated, methods=route.methods, operation_id=route.operation_id, + response_model_include=route.response_model_include, + response_model_exclude=route.response_model_exclude, + response_model_by_alias=route.response_model_by_alias, response_model_skip_defaults=route.response_model_skip_defaults, include_in_schema=route.include_in_schema, response_class=route.response_class, @@ -410,6 +460,9 @@ class APIRouter(routing.Router): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_include: Set[str] = None, + response_model_exclude: Set[str] = set(), + response_model_by_alias: bool = True, response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, @@ -429,6 +482,9 @@ class APIRouter(routing.Router): deprecated=deprecated, methods=["GET"], operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, @@ -449,6 +505,9 @@ class APIRouter(routing.Router): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_include: Set[str] = None, + response_model_exclude: Set[str] = set(), + response_model_by_alias: bool = True, response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, @@ -467,6 +526,9 @@ class APIRouter(routing.Router): deprecated=deprecated, methods=["PUT"], operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, @@ -487,6 +549,9 @@ class APIRouter(routing.Router): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_include: Set[str] = None, + response_model_exclude: Set[str] = set(), + response_model_by_alias: bool = True, response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, @@ -505,6 +570,9 @@ class APIRouter(routing.Router): deprecated=deprecated, methods=["POST"], operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, @@ -525,6 +593,9 @@ class APIRouter(routing.Router): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_include: Set[str] = None, + response_model_exclude: Set[str] = set(), + response_model_by_alias: bool = True, response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, @@ -543,6 +614,9 @@ class APIRouter(routing.Router): deprecated=deprecated, methods=["DELETE"], operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, @@ -563,6 +637,9 @@ class APIRouter(routing.Router): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_include: Set[str] = None, + response_model_exclude: Set[str] = set(), + response_model_by_alias: bool = True, response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, @@ -581,6 +658,9 @@ class APIRouter(routing.Router): deprecated=deprecated, methods=["OPTIONS"], operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, @@ -601,6 +681,9 @@ class APIRouter(routing.Router): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_include: Set[str] = None, + response_model_exclude: Set[str] = set(), + response_model_by_alias: bool = True, response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, @@ -619,6 +702,9 @@ class APIRouter(routing.Router): deprecated=deprecated, methods=["HEAD"], operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, @@ -639,6 +725,9 @@ class APIRouter(routing.Router): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_include: Set[str] = None, + response_model_exclude: Set[str] = set(), + response_model_by_alias: bool = True, response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, @@ -657,6 +746,9 @@ class APIRouter(routing.Router): deprecated=deprecated, methods=["PATCH"], operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, @@ -677,6 +769,9 @@ class APIRouter(routing.Router): responses: Dict[Union[int, str], Dict[str, Any]] = None, deprecated: bool = None, operation_id: str = None, + response_model_include: Set[str] = None, + response_model_exclude: Set[str] = set(), + response_model_by_alias: bool = True, response_model_skip_defaults: bool = False, include_in_schema: bool = True, response_class: Type[Response] = JSONResponse, @@ -695,6 +790,9 @@ class APIRouter(routing.Router): deprecated=deprecated, methods=["TRACE"], operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, response_model_skip_defaults=response_model_skip_defaults, include_in_schema=include_in_schema, response_class=response_class, diff --git a/mkdocs.yml b/mkdocs.yml index 8fa41d012..6954eb044 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -45,6 +45,7 @@ nav: - Path Operation Advanced Configuration: 'tutorial/path-operation-advanced-configuration.md' - Additional Status Codes: 'tutorial/additional-status-codes.md' - JSON compatible encoder: 'tutorial/encoder.md' + - Body - updates: 'tutorial/body-updates.md' - Return a Response directly: 'tutorial/response-directly.md' - Custom Response Class: 'tutorial/custom-response.md' - Additional Responses in OpenAPI: 'tutorial/additional-responses.md' diff --git a/tests/test_operations_signatures.py b/tests/test_operations_signatures.py new file mode 100644 index 000000000..1a749651d --- /dev/null +++ b/tests/test_operations_signatures.py @@ -0,0 +1,22 @@ +import inspect + +from fastapi import APIRouter, FastAPI + +method_names = ["get", "put", "post", "delete", "options", "head", "patch", "trace"] + + +def test_signatures_consistency(): + base_sig = inspect.signature(APIRouter.get) + for method_name in method_names: + router_method = getattr(APIRouter, method_name) + app_method = getattr(FastAPI, method_name) + router_sig = inspect.signature(router_method) + app_sig = inspect.signature(app_method) + param: inspect.Parameter + for key, param in base_sig.parameters.items(): + router_param: inspect.Parameter = router_sig.parameters[key] + app_param: inspect.Parameter = app_sig.parameters[key] + assert param.annotation == router_param.annotation + assert param.annotation == app_param.annotation + assert param.default == router_param.default + assert param.default == app_param.default diff --git a/tests/test_tutorial/test_body_updates/__init__.py b/tests/test_tutorial/test_body_updates/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_tutorial/test_body_updates/test_tutorial001.py b/tests/test_tutorial/test_body_updates/test_tutorial001.py new file mode 100644 index 000000000..76a368c66 --- /dev/null +++ b/tests/test_tutorial/test_body_updates/test_tutorial001.py @@ -0,0 +1,162 @@ +from starlette.testclient import TestClient + +from body_updates.tutorial001 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", + } + ], + }, + "put": { + "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__put", + "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", + "type": "object", + "properties": { + "name": {"title": "Name", "type": "string"}, + "description": {"title": "Description", "type": "string"}, + "price": {"title": "Price", "type": "number"}, + "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 + + +def test_get(): + response = client.get("/items/baz") + assert response.status_code == 200 + assert response.json() == { + "name": "Baz", + "description": None, + "price": 50.2, + "tax": 10.5, + "tags": [], + } + + +def test_put(): + response = client.put( + "/items/bar", json={"name": "Barz", "price": 3, "description": None} + ) + assert response.json() == { + "name": "Barz", + "description": None, + "price": 3, + "tax": 10.5, + "tags": [], + } diff --git a/tests/test_tutorial/test_response_model/test_tutorial004.py b/tests/test_tutorial/test_response_model/test_tutorial004.py index 152e291e4..c609d690d 100644 --- a/tests/test_tutorial/test_response_model/test_tutorial004.py +++ b/tests/test_tutorial/test_response_model/test_tutorial004.py @@ -41,47 +41,7 @@ openapi_schema = { "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": { @@ -163,15 +123,3 @@ 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, - } diff --git a/tests/test_tutorial/test_response_model/test_tutorial005.py b/tests/test_tutorial/test_response_model/test_tutorial005.py new file mode 100644 index 000000000..b087395ff --- /dev/null +++ b/tests/test_tutorial/test_response_model/test_tutorial005.py @@ -0,0 +1,142 @@ +from starlette.testclient import TestClient + +from response_model.tutorial005 import app + +client = TestClient(app) + +openapi_schema = { + "openapi": "3.0.2", + "info": {"title": "Fast API", "version": "0.1.0"}, + "paths": { + "/items/{item_id}/name": { + "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 Name", + "operationId": "read_item_name_items__item_id__name_get", + "parameters": [ + { + "required": True, + "schema": {"title": "Item_Id", "type": "string"}, + "name": "item_id", + "in": "path", + } + ], + } + }, + "/items/{item_id}/public": { + "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 Public Data", + "operationId": "read_item_public_data_items__item_id__public_get", + "parameters": [ + { + "required": True, + "schema": {"title": "Item_Id", "type": "string"}, + "name": "item_id", + "in": "path", + } + ], + } + }, + }, + "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}, + }, + }, + "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 + + +def test_read_item_name(): + response = client.get("/items/bar/name") + assert response.status_code == 200 + assert response.json() == {"name": "Bar", "description": "The Bar fighters"} + + +def test_read_item_public_data(): + response = client.get("/items/bar/public") + assert response.status_code == 200 + assert response.json() == { + "name": "Bar", + "description": "The Bar fighters", + "price": 62, + } diff --git a/tests/test_tutorial/test_response_model/test_tutorial006.py b/tests/test_tutorial/test_response_model/test_tutorial006.py new file mode 100644 index 000000000..d7d2bce82 --- /dev/null +++ b/tests/test_tutorial/test_response_model/test_tutorial006.py @@ -0,0 +1,142 @@ +from starlette.testclient import TestClient + +from response_model.tutorial006 import app + +client = TestClient(app) + +openapi_schema = { + "openapi": "3.0.2", + "info": {"title": "Fast API", "version": "0.1.0"}, + "paths": { + "/items/{item_id}/name": { + "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 Name", + "operationId": "read_item_name_items__item_id__name_get", + "parameters": [ + { + "required": True, + "schema": {"title": "Item_Id", "type": "string"}, + "name": "item_id", + "in": "path", + } + ], + } + }, + "/items/{item_id}/public": { + "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 Public Data", + "operationId": "read_item_public_data_items__item_id__public_get", + "parameters": [ + { + "required": True, + "schema": {"title": "Item_Id", "type": "string"}, + "name": "item_id", + "in": "path", + } + ], + } + }, + }, + "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}, + }, + }, + "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 + + +def test_read_item_name(): + response = client.get("/items/bar/name") + assert response.status_code == 200 + assert response.json() == {"name": "Bar", "description": "The Bar fighters"} + + +def test_read_item_public_data(): + response = client.get("/items/bar/public") + assert response.status_code == 200 + assert response.json() == { + "name": "Bar", + "description": "The Bar fighters", + "price": 62, + }