From a73709507c0778f97584ce67683901ec9da28d2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sat, 22 Dec 2018 14:35:48 +0400 Subject: [PATCH] :white_check_mark: Add docs, tests and fixes for extra data types including refactor of jsonable_encoder to allow other object and model types --- docs/src/extra_data_types/tutorial001.py | 27 ++++ docs/tutorial/body-nested-models.md | 2 +- docs/tutorial/extra-data-types.md | 64 +++++++++ docs/tutorial/path-params.md | 2 +- fastapi/dependencies/utils.py | 18 ++- fastapi/encoders.py | 60 +++++++- fastapi/routing.py | 43 +----- mkdocs.yml | 1 + .../test_extra_data_types/__init__.py | 0 .../test_extra_data_types/test_tutorial001.py | 136 ++++++++++++++++++ 10 files changed, 311 insertions(+), 42 deletions(-) create mode 100644 docs/src/extra_data_types/tutorial001.py create mode 100644 docs/tutorial/extra-data-types.md create mode 100644 tests/test_tutorial/test_extra_data_types/__init__.py create mode 100644 tests/test_tutorial/test_extra_data_types/test_tutorial001.py diff --git a/docs/src/extra_data_types/tutorial001.py b/docs/src/extra_data_types/tutorial001.py new file mode 100644 index 000000000..2b6d2f7b9 --- /dev/null +++ b/docs/src/extra_data_types/tutorial001.py @@ -0,0 +1,27 @@ +from datetime import datetime, time, timedelta +from uuid import UUID + +from fastapi import Body, FastAPI + +app = FastAPI() + + +@app.put("/items/{item_id}") +async def read_items( + item_id: UUID, + start_datetime: datetime = Body(None), + end_datetime: datetime = Body(None), + repeat_at: time = Body(None), + process_after: timedelta = Body(None), +): + start_process = start_datetime + process_after + duration = end_datetime - start_process + return { + "item_id": item_id, + "start_datetime": start_datetime, + "end_datetime": end_datetime, + "repeat_at": repeat_at, + "process_after": process_after, + "start_process": start_process, + "duration": duration, + } diff --git a/docs/tutorial/body-nested-models.md b/docs/tutorial/body-nested-models.md index 27934dbd5..a91489e52 100644 --- a/docs/tutorial/body-nested-models.md +++ b/docs/tutorial/body-nested-models.md @@ -116,7 +116,7 @@ Again, doing just that declaration, with **FastAPI** you get: Apart from normal singular types like `str`, `int`, `float`, etc. You can use more complex singular types that inherit from `str`. -To see all the options you have, checkout the docs for Pydantic's exotic types. +To see all the options you have, checkout the docs for Pydantic's exotic types. You will see some examples in the next chapter. For example, as in the `Image` model we have a `url` field, we can declare it to be instead of a `str`, a Pydantic's `UrlStr`: diff --git a/docs/tutorial/extra-data-types.md b/docs/tutorial/extra-data-types.md new file mode 100644 index 000000000..d2194eac9 --- /dev/null +++ b/docs/tutorial/extra-data-types.md @@ -0,0 +1,64 @@ +Up to now, you have been using common data types, like: + +* `int` +* `float` +* `str` +* `bool` + +But you can also use more complex data types. + +And you will still have the same features as seen up to now: + +* Great editor support. +* Data conversion from incoming requests. +* Data conversion for response data. +* Data validation. +* Automatic annotation and documentation. + +## Other data types + +Here are some of the additional data types you can use: + +* `UUID`: + * A standard "Universally Unique Identifier", common as an ID in many databases and systems. + * In requests and responses will be represented as a `str`. +* `datetime.datetime`: + * A Python `datetime.datetime`. + * In requests and responses will be represented as a `str` in ISO 8601 format, like: `2008-09-15T15:53:00+05:00`. +* `datetime.date`: + * Python `datetime.date`. + * In requests and responses will be represented as a `str` in ISO 8601 format, like: `2008-09-15`. +* `datetime.time`: + * A Python `datetime.time`. + * In requests and responses will be represented as a `str` in ISO 8601 format, like: `14:23:55.003`. +* `datetime.timedelta`: + * A Python `datetime.timedelta`. + * In requests and responses will be represented as a `float` of total seconds. + * Pydantic also allows representing it as a "ISO 8601 time diff encoding", see the docs for more info. +* `frozenset`: + * In requests and responses, treated the same as a `set`: + * In requests, a list will be read, eliminating duplicates and converting it to a `set`. + * In responses, the `set` will be converted to a `list`. + * The generated schema will specify that the `set` values are unique (using JSON Schema's `uniqueItems`). +* `bytes`: + * Standard Python `bytes`. + * In requests and responses will be treated as `str`. + * The generated schema will specify that it's a `str` with `binary` "format". +* `Decimal`: + * Standard Python `Decimal`. + * In requests and responses, handled the same as a `float`. + + +## Example + +Here's an example path operation with parameters using some of the above types. + +```Python hl_lines="1 2 11 12 13 14 15" +{!./src/extra_data_types/tutorial001.py!} +``` + +Note that the parameters inside the function have their natural data type, and you can, for example, perform normal date manipulations, like: + +```Python hl_lines="17 18" +{!./src/extra_data_types/tutorial001.py!} +``` diff --git a/docs/tutorial/path-params.md b/docs/tutorial/path-params.md index 77d963256..34563b28c 100644 --- a/docs/tutorial/path-params.md +++ b/docs/tutorial/path-params.md @@ -25,7 +25,7 @@ In this case, `item_id` is declared to be an `int`. !!! check This will give you editor support inside of your function, with error checks, completion, etc. -## Data "parsing" +## Data conversion If you run this example and open your browser at http://127.0.0.1:8000/items/3, you will see a response of: diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index ddf4f06db..c183b8971 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -1,7 +1,10 @@ import asyncio import inspect from copy import deepcopy +from datetime import date, datetime, time, timedelta +from decimal import Decimal from typing import Any, Callable, Dict, List, Mapping, Sequence, Tuple, Type +from uuid import UUID from fastapi import params from fastapi.dependencies.models import Dependant, SecurityRequirement @@ -16,7 +19,18 @@ from pydantic.utils import lenient_issubclass from starlette.concurrency import run_in_threadpool from starlette.requests import Request -param_supported_types = (str, int, float, bool) +param_supported_types = ( + str, + int, + float, + bool, + UUID, + date, + datetime, + time, + timedelta, + Decimal, +) def get_sub_dependant(*, param: inspect.Parameter, path: str) -> Dependant: @@ -74,7 +88,7 @@ def get_dependant(*, path: str, call: Callable, name: str = None) -> Dependant: assert ( lenient_issubclass(param.annotation, param_supported_types) or param.annotation == param.empty - ), f"Path params must be of type str, int, float or boot: {param}" + ), f"Path params must be of one of the supported types" param = signature_params[param_name] add_param_to_fields( param=param, diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 3234f8927..25ef6dc12 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -12,11 +12,64 @@ def jsonable_encoder( exclude: Set[str] = set(), by_alias: bool = False, include_none: bool = True, + root_encoder: bool = True, +) -> Any: + errors = [] + try: + return known_data_encoder( + obj, + include=include, + exclude=exclude, + by_alias=by_alias, + include_none=include_none, + ) + except Exception as e: + if not root_encoder: + raise e + errors.append(e) + try: + data = dict(obj) + return jsonable_encoder( + data, + include=include, + exclude=exclude, + by_alias=by_alias, + include_none=include_none, + root_encoder=False, + ) + except Exception as e: + if not root_encoder: + raise e + errors.append(e) + try: + data = vars(obj) + return jsonable_encoder( + data, + include=include, + exclude=exclude, + by_alias=by_alias, + include_none=include_none, + root_encoder=False, + ) + except Exception as e: + if not root_encoder: + raise e + errors.append(e) + raise ValueError(errors) + + +def known_data_encoder( + obj: Any, + include: Set[str] = None, + exclude: Set[str] = set(), + by_alias: bool = False, + include_none: bool = True, ) -> Any: if isinstance(obj, BaseModel): return jsonable_encoder( obj.dict(include=include, exclude=exclude, by_alias=by_alias), include_none=include_none, + root_encoder=False, ) if isinstance(obj, Enum): return obj.value @@ -25,8 +78,10 @@ def jsonable_encoder( if isinstance(obj, dict): return { jsonable_encoder( - key, by_alias=by_alias, include_none=include_none - ): jsonable_encoder(value, by_alias=by_alias, include_none=include_none) + key, by_alias=by_alias, include_none=include_none, root_encoder=False + ): jsonable_encoder( + value, by_alias=by_alias, include_none=include_none, root_encoder=False + ) for key, value in obj.items() if value is not None or include_none } @@ -38,6 +93,7 @@ def jsonable_encoder( exclude=exclude, by_alias=by_alias, include_none=include_none, + root_encoder=False, ) for item in obj ] diff --git a/fastapi/routing.py b/fastapi/routing.py index 0045f3a79..254f8f999 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -22,9 +22,10 @@ from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY def serialize_response(*, field: Field = None, response: Response) -> Any: + encoded = jsonable_encoder(response) if field: errors = [] - value, errors_ = field.validate(response, {}, loc=("response",)) + value, errors_ = field.validate(encoded, {}, loc=("response",)) if isinstance(errors_, ErrorWrapper): errors.append(errors_) elif isinstance(errors_, list): @@ -33,7 +34,7 @@ def serialize_response(*, field: Field = None, response: Response) -> Any: raise ValidationError(errors) return jsonable_encoder(value) else: - return jsonable_encoder(response) + return encoded def get_app( @@ -86,40 +87,10 @@ def get_app( raw_response = await run_in_threadpool(dependant.call, **values) if isinstance(raw_response, Response): return raw_response - if isinstance(raw_response, BaseModel): - return content_type( - content=serialize_response( - field=response_field, response=raw_response - ), - status_code=status_code, - ) - errors = [] - try: - return content_type( - content=serialize_response( - field=response_field, response=raw_response - ), - status_code=status_code, - ) - except Exception as e: - errors.append(e) - try: - response = dict(raw_response) - return content_type( - content=serialize_response(field=response_field, response=response), - status_code=status_code, - ) - except Exception as e: - errors.append(e) - try: - response = vars(raw_response) - return content_type( - content=serialize_response(field=response_field, response=response), - status_code=status_code, - ) - except Exception as e: - errors.append(e) - raise ValueError(errors) + response_data = serialize_response( + field=response_field, response=raw_response + ) + return content_type(content=response_data, status_code=status_code) return app diff --git a/mkdocs.yml b/mkdocs.yml index d96403016..3baf86c3a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -28,6 +28,7 @@ nav: - Body - Multiple Parameters: 'tutorial/body-multiple-params.md' - Body - Schema: 'tutorial/body-schema.md' - Body - Nested Models: 'tutorial/body-nested-models.md' + - Extra data types: 'tutorial/extra-data-types.md' - Cookie Parameters: 'tutorial/cookie-params.md' - Header Parameters: 'tutorial/header-params.md' - Response Model: 'tutorial/response-model.md' diff --git a/tests/test_tutorial/test_extra_data_types/__init__.py b/tests/test_tutorial/test_extra_data_types/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_tutorial/test_extra_data_types/test_tutorial001.py b/tests/test_tutorial/test_extra_data_types/test_tutorial001.py new file mode 100644 index 000000000..be05be662 --- /dev/null +++ b/tests/test_tutorial/test_extra_data_types/test_tutorial001.py @@ -0,0 +1,136 @@ +from starlette.testclient import TestClient + +from extra_data_types.tutorial001 import app + +client = TestClient(app) + + +openapi_schema = { + "openapi": "3.0.2", + "info": {"title": "Fast API", "version": "0.1.0"}, + "paths": { + "/items/{item_id}": { + "put": { + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + "summary": "Read Items Put", + "operationId": "read_items_items__item_id__put", + "parameters": [ + { + "required": True, + "schema": { + "title": "Item_Id", + "type": "string", + "format": "uuid", + }, + "name": "item_id", + "in": "path", + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Body_read_items"} + } + } + }, + } + } + }, + "components": { + "schemas": { + "Body_read_items": { + "title": "Body_read_items", + "type": "object", + "properties": { + "start_datetime": { + "title": "Start_Datetime", + "type": "string", + "format": "date-time", + }, + "end_datetime": { + "title": "End_Datetime", + "type": "string", + "format": "date-time", + }, + "repeat_at": { + "title": "Repeat_At", + "type": "string", + "format": "time", + }, + "process_after": { + "title": "Process_After", + "type": "string", + "format": "time-delta", + }, + }, + }, + "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_extra_types(): + item_id = "ff97dd87-a4a5-4a12-b412-cde99f33e00e" + data = { + "start_datetime": "2018-12-22T14:00:00+00:00", + "end_datetime": "2018-12-24T15:00:00+00:00", + "repeat_at": "15:30:00", + "process_after": 300, + } + expected_response = data.copy() + expected_response.update( + { + "start_process": "2018-12-22T14:05:00+00:00", + "duration": 176_100, + "item_id": item_id, + } + ) + response = client.put(f"/items/{item_id}", json=data) + assert response.status_code == 200 + assert response.json() == expected_response