]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
:white_check_mark: Add docs, tests and fixes for extra data types
authorSebastián Ramírez <tiangolo@gmail.com>
Sat, 22 Dec 2018 10:35:48 +0000 (14:35 +0400)
committerSebastián Ramírez <tiangolo@gmail.com>
Sat, 22 Dec 2018 10:35:48 +0000 (14:35 +0400)
including refactor of jsonable_encoder to allow other object and model types

docs/src/extra_data_types/tutorial001.py [new file with mode: 0644]
docs/tutorial/body-nested-models.md
docs/tutorial/extra-data-types.md [new file with mode: 0644]
docs/tutorial/path-params.md
fastapi/dependencies/utils.py
fastapi/encoders.py
fastapi/routing.py
mkdocs.yml
tests/test_tutorial/test_extra_data_types/__init__.py [new file with mode: 0644]
tests/test_tutorial/test_extra_data_types/test_tutorial001.py [new file with mode: 0644]

diff --git a/docs/src/extra_data_types/tutorial001.py b/docs/src/extra_data_types/tutorial001.py
new file mode 100644 (file)
index 0000000..2b6d2f7
--- /dev/null
@@ -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,
+    }
index 27934dbd54622020dabcbeaded02c3a8a84cceba..a91489e52c6fcfc3a2453beae9362bd1f718a284 100644 (file)
@@ -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 <a href="https://pydantic-docs.helpmanual.io/#exotic-types" target="_blank">Pydantic's exotic types</a>.
+To see all the options you have, checkout the docs for <a href="https://pydantic-docs.helpmanual.io/#exotic-types" target="_blank">Pydantic's exotic types</a>. 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 (file)
index 0000000..d2194ea
--- /dev/null
@@ -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", <a href="https://pydantic-docs.helpmanual.io/#json-serialisation" target="_blank">see the docs for more info</a>.
+* `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!}
+```
index 77d963256ac024cdd46443e116f90e4036e5b0bb..34563b28c06baceb6c2e354aad975fc7573f085b 100644 (file)
@@ -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 <abbr title="also known as: serialization, parsing, marshalling">conversion</abbr>
 
 If you run this example and open your browser at <a href="http://127.0.0.1:8000/items/3" target="_blank">http://127.0.0.1:8000/items/3</a>, you will see a response of:
 
index ddf4f06dbff599661bd63ce2868008cb0c365ba7..c183b8971795a973f2c212b33b2d4ea0af23eb9d 100644 (file)
@@ -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,
index 3234f8927306febe96463313cdf437e2679d58e1..25ef6dc124573c222cf6ab8a1abc046bafe90b0d 100644 (file)
@@ -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
         ]
index 0045f3a79416dcf339f3d1718896e6aedbf83192..254f8f999b8b893a50638c12ce947b49f6134ae9 100644 (file)
@@ -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
 
index d9640301603577fee0e5d9164941c417182e585f..3baf86c3a3b57b04abe29877e3eeeded7bd046af 100644 (file)
@@ -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 (file)
index 0000000..e69de29
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 (file)
index 0000000..be05be6
--- /dev/null
@@ -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