From a75d0801241dc59590d928c48da7665856a52963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 23 Jan 2022 15:56:14 +0100 Subject: [PATCH 001/142] =?UTF-8?q?=F0=9F=94=A7=20Add=20sponsor=20Dropbase?= =?UTF-8?q?=20(#4465)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + docs/en/data/sponsors.yml | 3 + docs/en/data/sponsors_badge.yml | 1 + docs/en/docs/img/sponsors/dropbase-banner.svg | 117 +++++++++++++++++ docs/en/docs/img/sponsors/dropbase.svg | 124 ++++++++++++++++++ docs/en/overrides/main.html | 6 + 6 files changed, 252 insertions(+) create mode 100644 docs/en/docs/img/sponsors/dropbase-banner.svg create mode 100644 docs/en/docs/img/sponsors/dropbase.svg diff --git a/README.md b/README.md index 53de38bd99b65..c9c69d3e88a25 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ The key features are: + diff --git a/docs/en/data/sponsors.yml b/docs/en/data/sponsors.yml index baa2e440d9cbc..b98e68b655dad 100644 --- a/docs/en/data/sponsors.yml +++ b/docs/en/data/sponsors.yml @@ -5,6 +5,9 @@ gold: - url: https://cryptapi.io/ title: "CryptAPI: Your easy to use, secure and privacy oriented payment gateway." img: https://fastapi.tiangolo.com/img/sponsors/cryptapi.svg + - url: https://www.dropbase.io/careers + title: Dropbase - seamlessly collect, clean, and centralize data. + img: https://fastapi.tiangolo.com/img/sponsors/dropbase.svg silver: - url: https://www.deta.sh/?ref=fastapi title: The launchpad for all your (team's) ideas diff --git a/docs/en/data/sponsors_badge.yml b/docs/en/data/sponsors_badge.yml index 759748728ec23..0c4e716d70029 100644 --- a/docs/en/data/sponsors_badge.yml +++ b/docs/en/data/sponsors_badge.yml @@ -7,3 +7,4 @@ logins: - koaning - deepset-ai - cryptapi + - DropbaseHQ diff --git a/docs/en/docs/img/sponsors/dropbase-banner.svg b/docs/en/docs/img/sponsors/dropbase-banner.svg new file mode 100644 index 0000000000000..d65abf1d9a22f --- /dev/null +++ b/docs/en/docs/img/sponsors/dropbase-banner.svg @@ -0,0 +1,117 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/en/docs/img/sponsors/dropbase.svg b/docs/en/docs/img/sponsors/dropbase.svg new file mode 100644 index 0000000000000..d0defb4df2d62 --- /dev/null +++ b/docs/en/docs/img/sponsors/dropbase.svg @@ -0,0 +1,124 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/en/overrides/main.html b/docs/en/overrides/main.html index 70b0253bd5fa4..0f452b515c515 100644 --- a/docs/en/overrides/main.html +++ b/docs/en/overrides/main.html @@ -46,6 +46,12 @@ +
+ + + + +
{% endblock %} From 347e391271e09244c3d95ac46dd5493a9b472ee4 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 23 Jan 2022 14:56:44 +0000 Subject: [PATCH 002/142] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 2dfb47c125ea6..41fa8493e3e72 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -2,6 +2,7 @@ ## Latest Changes +* 🔧 Add sponsor Dropbase. PR [#4465](https://github.com/tiangolo/fastapi/pull/4465) by [@tiangolo](https://github.com/tiangolo). ## 0.72.0 From ca5d57ea799028d771101bd711ca87a301dd45d8 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 23 Jan 2022 23:54:59 +0800 Subject: [PATCH 003/142] =?UTF-8?q?=E2=9C=A8=20Allow=20hiding=20from=20Ope?= =?UTF-8?q?nAPI=20(and=20Swagger=20UI)=20`Query`,=20`Cookie`,=20`Header`,?= =?UTF-8?q?=20and=20`Path`=20parameters=20(#3144)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sebastián Ramírez --- .../tutorial/query-params-str-validations.md | 16 ++ .../tutorial014.py | 15 ++ .../tutorial014_py310.py | 11 + fastapi/openapi/utils.py | 2 + fastapi/param_functions.py | 8 + fastapi/params.py | 10 + tests/test_param_include_in_schema.py | 239 ++++++++++++++++++ .../test_tutorial014.py | 82 ++++++ .../test_tutorial014_py310.py | 91 +++++++ 9 files changed, 474 insertions(+) create mode 100644 docs_src/query_params_str_validations/tutorial014.py create mode 100644 docs_src/query_params_str_validations/tutorial014_py310.py create mode 100644 tests/test_param_include_in_schema.py create mode 100644 tests/test_tutorial/test_query_params_str_validations/test_tutorial014.py create mode 100644 tests/test_tutorial/test_query_params_str_validations/test_tutorial014_py310.py diff --git a/docs/en/docs/tutorial/query-params-str-validations.md b/docs/en/docs/tutorial/query-params-str-validations.md index fcac1a4e0e91d..ee62b97181028 100644 --- a/docs/en/docs/tutorial/query-params-str-validations.md +++ b/docs/en/docs/tutorial/query-params-str-validations.md @@ -387,6 +387,22 @@ The docs will show it like this: +## Exclude from OpenAPI + +To exclude a query parameter from the generated OpenAPI schema (and thus, from the automatic documentation systems), set the parameter `include_in_schema` of `Query` to `False`: + +=== "Python 3.6 and above" + + ```Python hl_lines="10" + {!> ../../../docs_src/query_params_str_validations/tutorial014.py!} + ``` + +=== "Python 3.10 and above" + + ```Python hl_lines="7" + {!> ../../../docs_src/query_params_str_validations/tutorial014_py310.py!} + ``` + ## Recap You can declare additional validations and metadata for your parameters. diff --git a/docs_src/query_params_str_validations/tutorial014.py b/docs_src/query_params_str_validations/tutorial014.py new file mode 100644 index 0000000000000..fb50bc27b5639 --- /dev/null +++ b/docs_src/query_params_str_validations/tutorial014.py @@ -0,0 +1,15 @@ +from typing import Optional + +from fastapi import FastAPI, Query + +app = FastAPI() + + +@app.get("/items/") +async def read_items( + hidden_query: Optional[str] = Query(None, include_in_schema=False) +): + if hidden_query: + return {"hidden_query": hidden_query} + else: + return {"hidden_query": "Not found"} diff --git a/docs_src/query_params_str_validations/tutorial014_py310.py b/docs_src/query_params_str_validations/tutorial014_py310.py new file mode 100644 index 0000000000000..7ae39c7f97e16 --- /dev/null +++ b/docs_src/query_params_str_validations/tutorial014_py310.py @@ -0,0 +1,11 @@ +from fastapi import FastAPI, Query + +app = FastAPI() + + +@app.get("/items/") +async def read_items(hidden_query: str | None = Query(None, include_in_schema=False)): + if hidden_query: + return {"hidden_query": hidden_query} + else: + return {"hidden_query": "Not found"} diff --git a/fastapi/openapi/utils.py b/fastapi/openapi/utils.py index 0e73e21bf88b9..aff76b15edfed 100644 --- a/fastapi/openapi/utils.py +++ b/fastapi/openapi/utils.py @@ -92,6 +92,8 @@ def get_openapi_operation_parameters( for param in all_route_params: field_info = param.field_info field_info = cast(Param, field_info) + if not field_info.include_in_schema: + continue parameter = { "name": param.alias, "in": field_info.in_.value, diff --git a/fastapi/param_functions.py b/fastapi/param_functions.py index ff65d7271281b..a553a1461f8d7 100644 --- a/fastapi/param_functions.py +++ b/fastapi/param_functions.py @@ -20,6 +20,7 @@ def Path( # noqa: N802 example: Any = Undefined, examples: Optional[Dict[str, Any]] = None, deprecated: Optional[bool] = None, + include_in_schema: bool = True, **extra: Any, ) -> Any: return params.Path( @@ -37,6 +38,7 @@ def Path( # noqa: N802 example=example, examples=examples, deprecated=deprecated, + include_in_schema=include_in_schema, **extra, ) @@ -57,6 +59,7 @@ def Query( # noqa: N802 example: Any = Undefined, examples: Optional[Dict[str, Any]] = None, deprecated: Optional[bool] = None, + include_in_schema: bool = True, **extra: Any, ) -> Any: return params.Query( @@ -74,6 +77,7 @@ def Query( # noqa: N802 example=example, examples=examples, deprecated=deprecated, + include_in_schema=include_in_schema, **extra, ) @@ -95,6 +99,7 @@ def Header( # noqa: N802 example: Any = Undefined, examples: Optional[Dict[str, Any]] = None, deprecated: Optional[bool] = None, + include_in_schema: bool = True, **extra: Any, ) -> Any: return params.Header( @@ -113,6 +118,7 @@ def Header( # noqa: N802 example=example, examples=examples, deprecated=deprecated, + include_in_schema=include_in_schema, **extra, ) @@ -133,6 +139,7 @@ def Cookie( # noqa: N802 example: Any = Undefined, examples: Optional[Dict[str, Any]] = None, deprecated: Optional[bool] = None, + include_in_schema: bool = True, **extra: Any, ) -> Any: return params.Cookie( @@ -150,6 +157,7 @@ def Cookie( # noqa: N802 example=example, examples=examples, deprecated=deprecated, + include_in_schema=include_in_schema, **extra, ) diff --git a/fastapi/params.py b/fastapi/params.py index 3cab98b78dff1..042bbd42ff8b0 100644 --- a/fastapi/params.py +++ b/fastapi/params.py @@ -31,11 +31,13 @@ def __init__( example: Any = Undefined, examples: Optional[Dict[str, Any]] = None, deprecated: Optional[bool] = None, + include_in_schema: bool = True, **extra: Any, ): self.deprecated = deprecated self.example = example self.examples = examples + self.include_in_schema = include_in_schema super().__init__( default, alias=alias, @@ -75,6 +77,7 @@ def __init__( example: Any = Undefined, examples: Optional[Dict[str, Any]] = None, deprecated: Optional[bool] = None, + include_in_schema: bool = True, **extra: Any, ): self.in_ = self.in_ @@ -93,6 +96,7 @@ def __init__( deprecated=deprecated, example=example, examples=examples, + include_in_schema=include_in_schema, **extra, ) @@ -117,6 +121,7 @@ def __init__( example: Any = Undefined, examples: Optional[Dict[str, Any]] = None, deprecated: Optional[bool] = None, + include_in_schema: bool = True, **extra: Any, ): super().__init__( @@ -134,6 +139,7 @@ def __init__( deprecated=deprecated, example=example, examples=examples, + include_in_schema=include_in_schema, **extra, ) @@ -159,6 +165,7 @@ def __init__( example: Any = Undefined, examples: Optional[Dict[str, Any]] = None, deprecated: Optional[bool] = None, + include_in_schema: bool = True, **extra: Any, ): self.convert_underscores = convert_underscores @@ -177,6 +184,7 @@ def __init__( deprecated=deprecated, example=example, examples=examples, + include_in_schema=include_in_schema, **extra, ) @@ -201,6 +209,7 @@ def __init__( example: Any = Undefined, examples: Optional[Dict[str, Any]] = None, deprecated: Optional[bool] = None, + include_in_schema: bool = True, **extra: Any, ): super().__init__( @@ -218,6 +227,7 @@ def __init__( deprecated=deprecated, example=example, examples=examples, + include_in_schema=include_in_schema, **extra, ) diff --git a/tests/test_param_include_in_schema.py b/tests/test_param_include_in_schema.py new file mode 100644 index 0000000000000..4eaac72d87e50 --- /dev/null +++ b/tests/test_param_include_in_schema.py @@ -0,0 +1,239 @@ +from typing import Optional + +import pytest +from fastapi import Cookie, FastAPI, Header, Path, Query +from fastapi.testclient import TestClient + +app = FastAPI() + + +@app.get("/hidden_cookie") +async def hidden_cookie( + hidden_cookie: Optional[str] = Cookie(None, include_in_schema=False) +): + return {"hidden_cookie": hidden_cookie} + + +@app.get("/hidden_header") +async def hidden_header( + hidden_header: Optional[str] = Header(None, include_in_schema=False) +): + return {"hidden_header": hidden_header} + + +@app.get("/hidden_path/{hidden_path}") +async def hidden_path(hidden_path: str = Path(..., include_in_schema=False)): + return {"hidden_path": hidden_path} + + +@app.get("/hidden_query") +async def hidden_query( + hidden_query: Optional[str] = Query(None, include_in_schema=False) +): + return {"hidden_query": hidden_query} + + +client = TestClient(app) + +openapi_shema = { + "openapi": "3.0.2", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/hidden_cookie": { + "get": { + "summary": "Hidden Cookie", + "operationId": "hidden_cookie_hidden_cookie_get", + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + "/hidden_header": { + "get": { + "summary": "Hidden Header", + "operationId": "hidden_header_hidden_header_get", + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + "/hidden_path/{hidden_path}": { + "get": { + "summary": "Hidden Path", + "operationId": "hidden_path_hidden_path__hidden_path__get", + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + "/hidden_query": { + "get": { + "summary": "Hidden Query", + "operationId": "hidden_query_hidden_query_get", + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + }, + "components": { + "schemas": { + "HTTPValidationError": { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "detail": { + "title": "Detail", + "type": "array", + "items": {"$ref": "#/components/schemas/ValidationError"}, + } + }, + }, + "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"}, + }, + }, + } + }, +} + + +def test_openapi_schema(): + response = client.get("/openapi.json") + assert response.status_code == 200 + assert response.json() == openapi_shema + + +@pytest.mark.parametrize( + "path,cookies,expected_status,expected_response", + [ + ( + "/hidden_cookie", + {}, + 200, + {"hidden_cookie": None}, + ), + ( + "/hidden_cookie", + {"hidden_cookie": "somevalue"}, + 200, + {"hidden_cookie": "somevalue"}, + ), + ], +) +def test_hidden_cookie(path, cookies, expected_status, expected_response): + response = client.get(path, cookies=cookies) + assert response.status_code == expected_status + assert response.json() == expected_response + + +@pytest.mark.parametrize( + "path,headers,expected_status,expected_response", + [ + ( + "/hidden_header", + {}, + 200, + {"hidden_header": None}, + ), + ( + "/hidden_header", + {"Hidden-Header": "somevalue"}, + 200, + {"hidden_header": "somevalue"}, + ), + ], +) +def test_hidden_header(path, headers, expected_status, expected_response): + response = client.get(path, headers=headers) + assert response.status_code == expected_status + assert response.json() == expected_response + + +def test_hidden_path(): + response = client.get("/hidden_path/hidden_path") + assert response.status_code == 200 + assert response.json() == {"hidden_path": "hidden_path"} + + +@pytest.mark.parametrize( + "path,expected_status,expected_response", + [ + ( + "/hidden_query", + 200, + {"hidden_query": None}, + ), + ( + "/hidden_query?hidden_query=somevalue", + 200, + {"hidden_query": "somevalue"}, + ), + ], +) +def test_hidden_query(path, expected_status, expected_response): + response = client.get(path) + assert response.status_code == expected_status + assert response.json() == expected_response diff --git a/tests/test_tutorial/test_query_params_str_validations/test_tutorial014.py b/tests/test_tutorial/test_query_params_str_validations/test_tutorial014.py new file mode 100644 index 0000000000000..98ae5a6842d4d --- /dev/null +++ b/tests/test_tutorial/test_query_params_str_validations/test_tutorial014.py @@ -0,0 +1,82 @@ +from fastapi.testclient import TestClient + +from docs_src.query_params_str_validations.tutorial014 import app + +client = TestClient(app) + + +openapi_schema = { + "openapi": "3.0.2", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/items/": { + "get": { + "summary": "Read Items", + "operationId": "read_items_items__get", + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + } + }, + "components": { + "schemas": { + "HTTPValidationError": { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "detail": { + "title": "Detail", + "type": "array", + "items": {"$ref": "#/components/schemas/ValidationError"}, + } + }, + }, + "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"}, + }, + }, + } + }, +} + + +def test_openapi_schema(): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == openapi_schema + + +def test_hidden_query(): + response = client.get("/items?hidden_query=somevalue") + assert response.status_code == 200, response.text + assert response.json() == {"hidden_query": "somevalue"} + + +def test_no_hidden_query(): + response = client.get("/items") + assert response.status_code == 200, response.text + assert response.json() == {"hidden_query": "Not found"} diff --git a/tests/test_tutorial/test_query_params_str_validations/test_tutorial014_py310.py b/tests/test_tutorial/test_query_params_str_validations/test_tutorial014_py310.py new file mode 100644 index 0000000000000..33f3d5f773c4c --- /dev/null +++ b/tests/test_tutorial/test_query_params_str_validations/test_tutorial014_py310.py @@ -0,0 +1,91 @@ +import pytest +from fastapi.testclient import TestClient + +from ...utils import needs_py310 + +openapi_schema = { + "openapi": "3.0.2", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/items/": { + "get": { + "summary": "Read Items", + "operationId": "read_items_items__get", + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + } + }, + "components": { + "schemas": { + "HTTPValidationError": { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "detail": { + "title": "Detail", + "type": "array", + "items": {"$ref": "#/components/schemas/ValidationError"}, + } + }, + }, + "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"}, + }, + }, + } + }, +} + + +@pytest.fixture(name="client") +def get_client(): + from docs_src.query_params_str_validations.tutorial014_py310 import app + + client = TestClient(app) + return client + + +@needs_py310 +def test_openapi_schema(client: TestClient): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == openapi_schema + + +@needs_py310 +def test_hidden_query(client: TestClient): + response = client.get("/items?hidden_query=somevalue") + assert response.status_code == 200, response.text + assert response.json() == {"hidden_query": "somevalue"} + + +@needs_py310 +def test_no_hidden_query(client: TestClient): + response = client.get("/items") + assert response.status_code == 200, response.text + assert response.json() == {"hidden_query": "Not found"} From 85518bc58b131ddc8d7f251c9349f473d194a9b2 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 23 Jan 2022 15:55:36 +0000 Subject: [PATCH 004/142] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 41fa8493e3e72..afd109d88185b 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -2,6 +2,7 @@ ## Latest Changes +* ✨ Allow hiding from OpenAPI (and Swagger UI) `Query`, `Cookie`, `Header`, and `Path` parameters. PR [#3144](https://github.com/tiangolo/fastapi/pull/3144) by [@astraldawn](https://github.com/astraldawn). * 🔧 Add sponsor Dropbase. PR [#4465](https://github.com/tiangolo/fastapi/pull/4465) by [@tiangolo](https://github.com/tiangolo). ## 0.72.0 From 3de0fb82bf17fa4179caa38c3126786a7d99cf67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 23 Jan 2022 17:13:49 +0100 Subject: [PATCH 005/142] =?UTF-8?q?=F0=9F=90=9B=20Fix=20docs=20dependencie?= =?UTF-8?q?s=20cache,=20to=20get=20the=20latest=20Material=20for=20MkDocs?= =?UTF-8?q?=20(#4466)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index eba5fc57e555f..ccf964486a391 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -20,7 +20,7 @@ jobs: id: cache with: path: ${{ env.pythonLocation }} - key: ${{ runner.os }}-python-${{ env.pythonLocation }}-${{ hashFiles('pyproject.toml') }}-docs + key: ${{ runner.os }}-python-${{ env.pythonLocation }}-${{ hashFiles('pyproject.toml') }}-docs-v2 - name: Install Flit if: steps.cache.outputs.cache-hit != 'true' run: python3.7 -m pip install flit From 699b5ef84198a352a332beea9953fe1db33315b6 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 23 Jan 2022 16:14:28 +0000 Subject: [PATCH 006/142] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index afd109d88185b..997fb752981a4 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -2,6 +2,7 @@ ## Latest Changes +* 🐛 Fix docs dependencies cache, to get the latest Material for MkDocs. PR [#4466](https://github.com/tiangolo/fastapi/pull/4466) by [@tiangolo](https://github.com/tiangolo). * ✨ Allow hiding from OpenAPI (and Swagger UI) `Query`, `Cookie`, `Header`, and `Path` parameters. PR [#3144](https://github.com/tiangolo/fastapi/pull/3144) by [@astraldawn](https://github.com/astraldawn). * 🔧 Add sponsor Dropbase. PR [#4465](https://github.com/tiangolo/fastapi/pull/4465) by [@tiangolo](https://github.com/tiangolo). From d4608a00cf4855021dfb1a780556e24dedc94b14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 23 Jan 2022 17:32:04 +0100 Subject: [PATCH 007/142] =?UTF-8?q?=F0=9F=90=9B=20Prefer=20custom=20encode?= =?UTF-8?q?r=20over=20defaults=20if=20specified=20in=20`jsonable=5Fencoder?= =?UTF-8?q?`=20(#4467)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Vivek Sunder --- fastapi/encoders.py | 18 +++++++++--------- tests/test_jsonable_encoder.py | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 3f599c9faa045..4b7ffe313fa6b 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -34,9 +34,17 @@ def jsonable_encoder( exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, - custom_encoder: Dict[Any, Callable[[Any], Any]] = {}, + custom_encoder: Optional[Dict[Any, Callable[[Any], Any]]] = None, sqlalchemy_safe: bool = True, ) -> Any: + custom_encoder = custom_encoder or {} + if custom_encoder: + if type(obj) in custom_encoder: + return custom_encoder[type(obj)](obj) + else: + for encoder_type, encoder_instance in custom_encoder.items(): + if isinstance(obj, encoder_type): + return encoder_instance(obj) if include is not None and not isinstance(include, (set, dict)): include = set(include) if exclude is not None and not isinstance(exclude, (set, dict)): @@ -118,14 +126,6 @@ def jsonable_encoder( ) return encoded_list - if custom_encoder: - if type(obj) in custom_encoder: - return custom_encoder[type(obj)](obj) - else: - for encoder_type, encoder in custom_encoder.items(): - if isinstance(obj, encoder_type): - return encoder(obj) - if type(obj) in ENCODERS_BY_TYPE: return ENCODERS_BY_TYPE[type(obj)](obj) for encoder, classes_tuple in encoders_by_class_tuples.items(): diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py index e2aa8adf8448a..fa82b5ea83585 100644 --- a/tests/test_jsonable_encoder.py +++ b/tests/test_jsonable_encoder.py @@ -161,6 +161,21 @@ class MyModel(BaseModel): assert encoded_instance["dt_field"] == instance.dt_field.isoformat() +def test_custom_enum_encoders(): + def custom_enum_encoder(v: Enum): + return v.value.lower() + + class MyEnum(Enum): + ENUM_VAL_1 = "ENUM_VAL_1" + + instance = MyEnum.ENUM_VAL_1 + + encoded_instance = jsonable_encoder( + instance, custom_encoder={MyEnum: custom_enum_encoder} + ) + assert encoded_instance == custom_enum_encoder(instance) + + def test_encode_model_with_path(model_with_path): if isinstance(model_with_path.path, PureWindowsPath): expected = "\\foo\\bar" From f4963f05bf4295e02cce1a28386712a5e6776fc4 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 23 Jan 2022 16:32:35 +0000 Subject: [PATCH 008/142] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 997fb752981a4..49747539dce08 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -2,6 +2,7 @@ ## Latest Changes +* 🐛 Prefer custom encoder over defaults if specified in `jsonable_encoder`. PR [#4467](https://github.com/tiangolo/fastapi/pull/4467) by [@tiangolo](https://github.com/tiangolo). * 🐛 Fix docs dependencies cache, to get the latest Material for MkDocs. PR [#4466](https://github.com/tiangolo/fastapi/pull/4466) by [@tiangolo](https://github.com/tiangolo). * ✨ Allow hiding from OpenAPI (and Swagger UI) `Query`, `Cookie`, `Header`, and `Path` parameters. PR [#3144](https://github.com/tiangolo/fastapi/pull/3144) by [@astraldawn](https://github.com/astraldawn). * 🔧 Add sponsor Dropbase. PR [#4465](https://github.com/tiangolo/fastapi/pull/4465) by [@tiangolo](https://github.com/tiangolo). From 94ca8c1e290a9c587608d001d7b1ca76bd570ec8 Mon Sep 17 00:00:00 2001 From: Vivek Sunder Date: Sun, 23 Jan 2022 11:34:18 -0500 Subject: [PATCH 009/142] =?UTF-8?q?=F0=9F=90=9B=20Prefer=20custom=20encode?= =?UTF-8?q?r=20over=20defaults=20if=20specified=20in=20`jsonable=5Fencoder?= =?UTF-8?q?`=20(#2061)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sebastián Ramírez From 6215fdd39e54422561d51d7e6159c219053d41cb Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 23 Jan 2022 16:34:52 +0000 Subject: [PATCH 010/142] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 49747539dce08..468d450a8195e 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -2,6 +2,7 @@ ## Latest Changes +* 🐛 Prefer custom encoder over defaults if specified in `jsonable_encoder`. PR [#2061](https://github.com/tiangolo/fastapi/pull/2061) by [@viveksunder](https://github.com/viveksunder). * 🐛 Prefer custom encoder over defaults if specified in `jsonable_encoder`. PR [#4467](https://github.com/tiangolo/fastapi/pull/4467) by [@tiangolo](https://github.com/tiangolo). * 🐛 Fix docs dependencies cache, to get the latest Material for MkDocs. PR [#4466](https://github.com/tiangolo/fastapi/pull/4466) by [@tiangolo](https://github.com/tiangolo). * ✨ Allow hiding from OpenAPI (and Swagger UI) `Query`, `Cookie`, `Header`, and `Path` parameters. PR [#3144](https://github.com/tiangolo/fastapi/pull/3144) by [@astraldawn](https://github.com/astraldawn). From 0f8349fcb7c57921d28e78296a7dc8d0504459e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 23 Jan 2022 18:03:42 +0100 Subject: [PATCH 011/142] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 468d450a8195e..54017ceb33b3b 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -3,7 +3,7 @@ ## Latest Changes * 🐛 Prefer custom encoder over defaults if specified in `jsonable_encoder`. PR [#2061](https://github.com/tiangolo/fastapi/pull/2061) by [@viveksunder](https://github.com/viveksunder). -* 🐛 Prefer custom encoder over defaults if specified in `jsonable_encoder`. PR [#4467](https://github.com/tiangolo/fastapi/pull/4467) by [@tiangolo](https://github.com/tiangolo). + * 💚 Duplicate PR to trigger CI. PR [#4467](https://github.com/tiangolo/fastapi/pull/4467) by [@tiangolo](https://github.com/tiangolo). * 🐛 Fix docs dependencies cache, to get the latest Material for MkDocs. PR [#4466](https://github.com/tiangolo/fastapi/pull/4466) by [@tiangolo](https://github.com/tiangolo). * ✨ Allow hiding from OpenAPI (and Swagger UI) `Query`, `Cookie`, `Header`, and `Path` parameters. PR [#3144](https://github.com/tiangolo/fastapi/pull/3144) by [@astraldawn](https://github.com/astraldawn). * 🔧 Add sponsor Dropbase. PR [#4465](https://github.com/tiangolo/fastapi/pull/4465) by [@tiangolo](https://github.com/tiangolo). From 569afb4378c80e0bff5dc4a45f26d012e498eda6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 23 Jan 2022 18:43:04 +0100 Subject: [PATCH 012/142] =?UTF-8?q?=E2=9C=A8=20Add=20support=20for=20tags?= =?UTF-8?q?=20with=20Enums=20(#4468)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tutorial/path-operation-configuration.md | 12 ++++ .../tutorial002b.py | 20 +++++++ fastapi/applications.py | 23 ++++---- fastapi/routing.py | 32 +++++------ .../test_tutorial002b.py | 56 +++++++++++++++++++ 5 files changed, 116 insertions(+), 27 deletions(-) create mode 100644 docs_src/path_operation_configuration/tutorial002b.py create mode 100644 tests/test_tutorial/test_path_operation_configurations/test_tutorial002b.py diff --git a/docs/en/docs/tutorial/path-operation-configuration.md b/docs/en/docs/tutorial/path-operation-configuration.md index 1ff448e768d50..884a762e24a03 100644 --- a/docs/en/docs/tutorial/path-operation-configuration.md +++ b/docs/en/docs/tutorial/path-operation-configuration.md @@ -64,6 +64,18 @@ They will be added to the OpenAPI schema and used by the automatic documentation +### Tags with Enums + +If you have a big application, you might end up accumulating **several tags**, and you would want to make sure you always use the **same tag** for related *path operations*. + +In these cases, it could make sense to store the tags in an `Enum`. + +**FastAPI** supports that the same way as with plain strings: + +```Python hl_lines="1 8-10 13 18" +{!../../../docs_src/path_operation_configuration/tutorial002b.py!} +``` + ## Summary and description You can add a `summary` and `description`: diff --git a/docs_src/path_operation_configuration/tutorial002b.py b/docs_src/path_operation_configuration/tutorial002b.py new file mode 100644 index 0000000000000..d53b4d817d131 --- /dev/null +++ b/docs_src/path_operation_configuration/tutorial002b.py @@ -0,0 +1,20 @@ +from enum import Enum + +from fastapi import FastAPI + +app = FastAPI() + + +class Tags(Enum): + items = "items" + users = "users" + + +@app.get("/items/", tags=[Tags.items]) +async def get_items(): + return ["Portal gun", "Plumbus"] + + +@app.get("/users/", tags=[Tags.users]) +async def read_users(): + return ["Rick", "Morty"] diff --git a/fastapi/applications.py b/fastapi/applications.py index d71d4190abc10..dbfd76fb9f3cd 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -1,3 +1,4 @@ +from enum import Enum from typing import Any, Callable, Coroutine, Dict, List, Optional, Sequence, Type, Union from fastapi import routing @@ -219,7 +220,7 @@ def add_api_route( *, response_model: Optional[Type[Any]] = None, status_code: Optional[int] = None, - tags: Optional[List[str]] = None, + tags: Optional[List[Union[str, Enum]]] = None, dependencies: Optional[Sequence[Depends]] = None, summary: Optional[str] = None, description: Optional[str] = None, @@ -273,7 +274,7 @@ def api_route( *, response_model: Optional[Type[Any]] = None, status_code: Optional[int] = None, - tags: Optional[List[str]] = None, + tags: Optional[List[Union[str, Enum]]] = None, dependencies: Optional[Sequence[Depends]] = None, summary: Optional[str] = None, description: Optional[str] = None, @@ -342,7 +343,7 @@ def include_router( router: routing.APIRouter, *, prefix: str = "", - tags: Optional[List[str]] = None, + tags: Optional[List[Union[str, Enum]]] = None, dependencies: Optional[Sequence[Depends]] = None, responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, deprecated: Optional[bool] = None, @@ -368,7 +369,7 @@ def get( *, response_model: Optional[Type[Any]] = None, status_code: Optional[int] = None, - tags: Optional[List[str]] = None, + tags: Optional[List[Union[str, Enum]]] = None, dependencies: Optional[Sequence[Depends]] = None, summary: Optional[str] = None, description: Optional[str] = None, @@ -419,7 +420,7 @@ def put( *, response_model: Optional[Type[Any]] = None, status_code: Optional[int] = None, - tags: Optional[List[str]] = None, + tags: Optional[List[Union[str, Enum]]] = None, dependencies: Optional[Sequence[Depends]] = None, summary: Optional[str] = None, description: Optional[str] = None, @@ -470,7 +471,7 @@ def post( *, response_model: Optional[Type[Any]] = None, status_code: Optional[int] = None, - tags: Optional[List[str]] = None, + tags: Optional[List[Union[str, Enum]]] = None, dependencies: Optional[Sequence[Depends]] = None, summary: Optional[str] = None, description: Optional[str] = None, @@ -521,7 +522,7 @@ def delete( *, response_model: Optional[Type[Any]] = None, status_code: Optional[int] = None, - tags: Optional[List[str]] = None, + tags: Optional[List[Union[str, Enum]]] = None, dependencies: Optional[Sequence[Depends]] = None, summary: Optional[str] = None, description: Optional[str] = None, @@ -572,7 +573,7 @@ def options( *, response_model: Optional[Type[Any]] = None, status_code: Optional[int] = None, - tags: Optional[List[str]] = None, + tags: Optional[List[Union[str, Enum]]] = None, dependencies: Optional[Sequence[Depends]] = None, summary: Optional[str] = None, description: Optional[str] = None, @@ -623,7 +624,7 @@ def head( *, response_model: Optional[Type[Any]] = None, status_code: Optional[int] = None, - tags: Optional[List[str]] = None, + tags: Optional[List[Union[str, Enum]]] = None, dependencies: Optional[Sequence[Depends]] = None, summary: Optional[str] = None, description: Optional[str] = None, @@ -674,7 +675,7 @@ def patch( *, response_model: Optional[Type[Any]] = None, status_code: Optional[int] = None, - tags: Optional[List[str]] = None, + tags: Optional[List[Union[str, Enum]]] = None, dependencies: Optional[Sequence[Depends]] = None, summary: Optional[str] = None, description: Optional[str] = None, @@ -725,7 +726,7 @@ def trace( *, response_model: Optional[Type[Any]] = None, status_code: Optional[int] = None, - tags: Optional[List[str]] = None, + tags: Optional[List[Union[str, Enum]]] = None, dependencies: Optional[Sequence[Depends]] = None, summary: Optional[str] = None, description: Optional[str] = None, diff --git a/fastapi/routing.py b/fastapi/routing.py index 63ad7296409f0..f6d5370d6a3c7 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -1,9 +1,9 @@ import asyncio import dataclasses import email.message -import enum import inspect import json +from enum import Enum, IntEnum from typing import ( Any, Callable, @@ -305,7 +305,7 @@ def __init__( *, response_model: Optional[Type[Any]] = None, status_code: Optional[int] = None, - tags: Optional[List[str]] = None, + tags: Optional[List[Union[str, Enum]]] = None, dependencies: Optional[Sequence[params.Depends]] = None, summary: Optional[str] = None, description: Optional[str] = None, @@ -330,7 +330,7 @@ def __init__( openapi_extra: Optional[Dict[str, Any]] = None, ) -> None: # normalise enums e.g. http.HTTPStatus - if isinstance(status_code, enum.IntEnum): + if isinstance(status_code, IntEnum): status_code = int(status_code) self.path = path self.endpoint = endpoint @@ -438,7 +438,7 @@ def __init__( self, *, prefix: str = "", - tags: Optional[List[str]] = None, + tags: Optional[List[Union[str, Enum]]] = None, dependencies: Optional[Sequence[params.Depends]] = None, default_response_class: Type[Response] = Default(JSONResponse), responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, @@ -466,7 +466,7 @@ def __init__( "/" ), "A path prefix must not end with '/', as the routes will start with '/'" self.prefix = prefix - self.tags: List[str] = tags or [] + self.tags: List[Union[str, Enum]] = tags or [] self.dependencies = list(dependencies or []) or [] self.deprecated = deprecated self.include_in_schema = include_in_schema @@ -483,7 +483,7 @@ def add_api_route( *, response_model: Optional[Type[Any]] = None, status_code: Optional[int] = None, - tags: Optional[List[str]] = None, + tags: Optional[List[Union[str, Enum]]] = None, dependencies: Optional[Sequence[params.Depends]] = None, summary: Optional[str] = None, description: Optional[str] = None, @@ -557,7 +557,7 @@ def api_route( *, response_model: Optional[Type[Any]] = None, status_code: Optional[int] = None, - tags: Optional[List[str]] = None, + tags: Optional[List[Union[str, Enum]]] = None, dependencies: Optional[Sequence[params.Depends]] = None, summary: Optional[str] = None, description: Optional[str] = None, @@ -634,7 +634,7 @@ def include_router( router: "APIRouter", *, prefix: str = "", - tags: Optional[List[str]] = None, + tags: Optional[List[Union[str, Enum]]] = None, dependencies: Optional[Sequence[params.Depends]] = None, default_response_class: Type[Response] = Default(JSONResponse), responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, @@ -738,7 +738,7 @@ def get( *, response_model: Optional[Type[Any]] = None, status_code: Optional[int] = None, - tags: Optional[List[str]] = None, + tags: Optional[List[Union[str, Enum]]] = None, dependencies: Optional[Sequence[params.Depends]] = None, summary: Optional[str] = None, description: Optional[str] = None, @@ -790,7 +790,7 @@ def put( *, response_model: Optional[Type[Any]] = None, status_code: Optional[int] = None, - tags: Optional[List[str]] = None, + tags: Optional[List[Union[str, Enum]]] = None, dependencies: Optional[Sequence[params.Depends]] = None, summary: Optional[str] = None, description: Optional[str] = None, @@ -842,7 +842,7 @@ def post( *, response_model: Optional[Type[Any]] = None, status_code: Optional[int] = None, - tags: Optional[List[str]] = None, + tags: Optional[List[Union[str, Enum]]] = None, dependencies: Optional[Sequence[params.Depends]] = None, summary: Optional[str] = None, description: Optional[str] = None, @@ -894,7 +894,7 @@ def delete( *, response_model: Optional[Type[Any]] = None, status_code: Optional[int] = None, - tags: Optional[List[str]] = None, + tags: Optional[List[Union[str, Enum]]] = None, dependencies: Optional[Sequence[params.Depends]] = None, summary: Optional[str] = None, description: Optional[str] = None, @@ -946,7 +946,7 @@ def options( *, response_model: Optional[Type[Any]] = None, status_code: Optional[int] = None, - tags: Optional[List[str]] = None, + tags: Optional[List[Union[str, Enum]]] = None, dependencies: Optional[Sequence[params.Depends]] = None, summary: Optional[str] = None, description: Optional[str] = None, @@ -998,7 +998,7 @@ def head( *, response_model: Optional[Type[Any]] = None, status_code: Optional[int] = None, - tags: Optional[List[str]] = None, + tags: Optional[List[Union[str, Enum]]] = None, dependencies: Optional[Sequence[params.Depends]] = None, summary: Optional[str] = None, description: Optional[str] = None, @@ -1050,7 +1050,7 @@ def patch( *, response_model: Optional[Type[Any]] = None, status_code: Optional[int] = None, - tags: Optional[List[str]] = None, + tags: Optional[List[Union[str, Enum]]] = None, dependencies: Optional[Sequence[params.Depends]] = None, summary: Optional[str] = None, description: Optional[str] = None, @@ -1102,7 +1102,7 @@ def trace( *, response_model: Optional[Type[Any]] = None, status_code: Optional[int] = None, - tags: Optional[List[str]] = None, + tags: Optional[List[Union[str, Enum]]] = None, dependencies: Optional[Sequence[params.Depends]] = None, summary: Optional[str] = None, description: Optional[str] = None, diff --git a/tests/test_tutorial/test_path_operation_configurations/test_tutorial002b.py b/tests/test_tutorial/test_path_operation_configurations/test_tutorial002b.py new file mode 100644 index 0000000000000..be9f2afecf4ce --- /dev/null +++ b/tests/test_tutorial/test_path_operation_configurations/test_tutorial002b.py @@ -0,0 +1,56 @@ +from fastapi.testclient import TestClient + +from docs_src.path_operation_configuration.tutorial002b import app + +client = TestClient(app) + +openapi_schema = { + "openapi": "3.0.2", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/items/": { + "get": { + "tags": ["items"], + "summary": "Get Items", + "operationId": "get_items_items__get", + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + } + }, + } + }, + "/users/": { + "get": { + "tags": ["users"], + "summary": "Read Users", + "operationId": "read_users_users__get", + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + } + }, + } + }, + }, +} + + +def test_openapi_schema(): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == openapi_schema + + +def test_get_items(): + response = client.get("/items/") + assert response.status_code == 200, response.text + assert response.json() == ["Portal gun", "Plumbus"] + + +def test_get_users(): + response = client.get("/users/") + assert response.status_code == 200, response.text + assert response.json() == ["Rick", "Morty"] From 59b1f353b3fe77cf801242e3d120372ad8519710 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 23 Jan 2022 17:43:36 +0000 Subject: [PATCH 013/142] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 54017ceb33b3b..7026d0eb1a8d1 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -2,6 +2,7 @@ ## Latest Changes +* ✨ Add support for tags with Enums. PR [#4468](https://github.com/tiangolo/fastapi/pull/4468) by [@tiangolo](https://github.com/tiangolo). * 🐛 Prefer custom encoder over defaults if specified in `jsonable_encoder`. PR [#2061](https://github.com/tiangolo/fastapi/pull/2061) by [@viveksunder](https://github.com/viveksunder). * 💚 Duplicate PR to trigger CI. PR [#4467](https://github.com/tiangolo/fastapi/pull/4467) by [@tiangolo](https://github.com/tiangolo). * 🐛 Fix docs dependencies cache, to get the latest Material for MkDocs. PR [#4466](https://github.com/tiangolo/fastapi/pull/4466) by [@tiangolo](https://github.com/tiangolo). From 1bf55200a90b04229f665cd2ee83edde91e936e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 23 Jan 2022 20:14:13 +0100 Subject: [PATCH 014/142] =?UTF-8?q?=E2=9C=A8=20Add=20support=20for=20decla?= =?UTF-8?q?ring=20`UploadFile`=20parameters=20without=20explicit=20`File()?= =?UTF-8?q?`=20(#4469)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/tutorial/request-files.md | 47 +++- docs_src/request_files/tutorial001.py | 2 +- docs_src/request_files/tutorial001_02.py | 21 ++ .../request_files/tutorial001_02_py310.py | 19 ++ docs_src/request_files/tutorial001_03.py | 15 ++ docs_src/request_files/tutorial002.py | 2 +- docs_src/request_files/tutorial002_py39.py | 2 +- docs_src/request_files/tutorial003.py | 37 +++ docs_src/request_files/tutorial003_py39.py | 35 +++ fastapi/datastructures.py | 6 +- fastapi/dependencies/utils.py | 28 +-- .../test_request_files/test_tutorial001_02.py | 157 ++++++++++++ .../test_tutorial001_02_py310.py | 169 +++++++++++++ .../test_request_files/test_tutorial001_03.py | 159 +++++++++++++ .../test_request_files/test_tutorial003.py | 194 +++++++++++++++ .../test_tutorial003_py39.py | 223 ++++++++++++++++++ 16 files changed, 1086 insertions(+), 30 deletions(-) create mode 100644 docs_src/request_files/tutorial001_02.py create mode 100644 docs_src/request_files/tutorial001_02_py310.py create mode 100644 docs_src/request_files/tutorial001_03.py create mode 100644 docs_src/request_files/tutorial003.py create mode 100644 docs_src/request_files/tutorial003_py39.py create mode 100644 tests/test_tutorial/test_request_files/test_tutorial001_02.py create mode 100644 tests/test_tutorial/test_request_files/test_tutorial001_02_py310.py create mode 100644 tests/test_tutorial/test_request_files/test_tutorial001_03.py create mode 100644 tests/test_tutorial/test_request_files/test_tutorial003.py create mode 100644 tests/test_tutorial/test_request_files/test_tutorial003_py39.py diff --git a/docs/en/docs/tutorial/request-files.md b/docs/en/docs/tutorial/request-files.md index b7257c7eb6866..ed2c8b6af5ac9 100644 --- a/docs/en/docs/tutorial/request-files.md +++ b/docs/en/docs/tutorial/request-files.md @@ -17,7 +17,7 @@ Import `File` and `UploadFile` from `fastapi`: {!../../../docs_src/request_files/tutorial001.py!} ``` -## Define `File` parameters +## Define `File` Parameters Create file parameters the same way you would for `Body` or `Form`: @@ -41,7 +41,7 @@ Have in mind that this means that the whole contents will be stored in memory. T But there are several cases in which you might benefit from using `UploadFile`. -## `File` parameters with `UploadFile` +## `File` Parameters with `UploadFile` Define a `File` parameter with a type of `UploadFile`: @@ -51,6 +51,7 @@ Define a `File` parameter with a type of `UploadFile`: Using `UploadFile` has several advantages over `bytes`: +* You don't have to use `File()` in the default value. * It uses a "spooled" file: * A file stored in memory up to a maximum size limit, and after passing this limit it will be stored in disk. * This means that it will work well for large files like images, videos, large binaries, etc. without consuming all the memory. @@ -113,7 +114,31 @@ The way HTML forms (`
`) sends the data to the server normally uses This is not a limitation of **FastAPI**, it's part of the HTTP protocol. -## Multiple file uploads +## Optional File Upload + +You can make a file optional by using standard type annotations: + +=== "Python 3.6 and above" + + ```Python hl_lines="9 17" + {!> ../../../docs_src/request_files/tutorial001_02.py!} + ``` + +=== "Python 3.9 and above" + + ```Python hl_lines="7 14" + {!> ../../../docs_src/request_files/tutorial001_02_py310.py!} + ``` + +## `UploadFile` with Additional Metadata + +You can also use `File()` with `UploadFile` to set additional parameters in `File()`, for example additional metadata: + +```Python hl_lines="13" +{!../../../docs_src/request_files/tutorial001_03.py!} +``` + +## Multiple File Uploads It's possible to upload several files at the same time. @@ -140,6 +165,22 @@ You will receive, as declared, a `list` of `bytes` or `UploadFile`s. **FastAPI** provides the same `starlette.responses` as `fastapi.responses` just as a convenience for you, the developer. But most of the available responses come directly from Starlette. +### Multiple File Uploads with Additional Metadata + +And the same way as before, you can use `File()` to set additional parameters, even for `UploadFile`: + +=== "Python 3.6 and above" + + ```Python hl_lines="18" + {!> ../../../docs_src/request_files/tutorial003.py!} + ``` + +=== "Python 3.9 and above" + + ```Python hl_lines="16" + {!> ../../../docs_src/request_files/tutorial003_py39.py!} + ``` + ## Recap Use `File` to declare files to be uploaded as input parameters (as form data). diff --git a/docs_src/request_files/tutorial001.py b/docs_src/request_files/tutorial001.py index fffb56af8630f..0fb1dd571b1b0 100644 --- a/docs_src/request_files/tutorial001.py +++ b/docs_src/request_files/tutorial001.py @@ -9,5 +9,5 @@ async def create_file(file: bytes = File(...)): @app.post("/uploadfile/") -async def create_upload_file(file: UploadFile = File(...)): +async def create_upload_file(file: UploadFile): return {"filename": file.filename} diff --git a/docs_src/request_files/tutorial001_02.py b/docs_src/request_files/tutorial001_02.py new file mode 100644 index 0000000000000..26a4c9cbf069d --- /dev/null +++ b/docs_src/request_files/tutorial001_02.py @@ -0,0 +1,21 @@ +from typing import Optional + +from fastapi import FastAPI, File, UploadFile + +app = FastAPI() + + +@app.post("/files/") +async def create_file(file: Optional[bytes] = File(None)): + if not file: + return {"message": "No file sent"} + else: + return {"file_size": len(file)} + + +@app.post("/uploadfile/") +async def create_upload_file(file: Optional[UploadFile] = None): + if not file: + return {"message": "No upload file sent"} + else: + return {"filename": file.filename} diff --git a/docs_src/request_files/tutorial001_02_py310.py b/docs_src/request_files/tutorial001_02_py310.py new file mode 100644 index 0000000000000..0e576251b57b3 --- /dev/null +++ b/docs_src/request_files/tutorial001_02_py310.py @@ -0,0 +1,19 @@ +from fastapi import FastAPI, File, UploadFile + +app = FastAPI() + + +@app.post("/files/") +async def create_file(file: bytes | None = File(None)): + if not file: + return {"message": "No file sent"} + else: + return {"file_size": len(file)} + + +@app.post("/uploadfile/") +async def create_upload_file(file: UploadFile | None = None): + if not file: + return {"message": "No upload file sent"} + else: + return {"filename": file.filename} diff --git a/docs_src/request_files/tutorial001_03.py b/docs_src/request_files/tutorial001_03.py new file mode 100644 index 0000000000000..abcac9e4c1cb2 --- /dev/null +++ b/docs_src/request_files/tutorial001_03.py @@ -0,0 +1,15 @@ +from fastapi import FastAPI, File, UploadFile + +app = FastAPI() + + +@app.post("/files/") +async def create_file(file: bytes = File(..., description="A file read as bytes")): + return {"file_size": len(file)} + + +@app.post("/uploadfile/") +async def create_upload_file( + file: UploadFile = File(..., description="A file read as UploadFile") +): + return {"filename": file.filename} diff --git a/docs_src/request_files/tutorial002.py b/docs_src/request_files/tutorial002.py index 6fdf16a751213..94abb7c6c0492 100644 --- a/docs_src/request_files/tutorial002.py +++ b/docs_src/request_files/tutorial002.py @@ -12,7 +12,7 @@ async def create_files(files: List[bytes] = File(...)): @app.post("/uploadfiles/") -async def create_upload_files(files: List[UploadFile] = File(...)): +async def create_upload_files(files: List[UploadFile]): return {"filenames": [file.filename for file in files]} diff --git a/docs_src/request_files/tutorial002_py39.py b/docs_src/request_files/tutorial002_py39.py index 26cd5676905e4..2779618bde83f 100644 --- a/docs_src/request_files/tutorial002_py39.py +++ b/docs_src/request_files/tutorial002_py39.py @@ -10,7 +10,7 @@ async def create_files(files: list[bytes] = File(...)): @app.post("/uploadfiles/") -async def create_upload_files(files: list[UploadFile] = File(...)): +async def create_upload_files(files: list[UploadFile]): return {"filenames": [file.filename for file in files]} diff --git a/docs_src/request_files/tutorial003.py b/docs_src/request_files/tutorial003.py new file mode 100644 index 0000000000000..4a91b7a8bc611 --- /dev/null +++ b/docs_src/request_files/tutorial003.py @@ -0,0 +1,37 @@ +from typing import List + +from fastapi import FastAPI, File, UploadFile +from fastapi.responses import HTMLResponse + +app = FastAPI() + + +@app.post("/files/") +async def create_files( + files: List[bytes] = File(..., description="Multiple files as bytes") +): + return {"file_sizes": [len(file) for file in files]} + + +@app.post("/uploadfiles/") +async def create_upload_files( + files: List[UploadFile] = File(..., description="Multiple files as UploadFile") +): + return {"filenames": [file.filename for file in files]} + + +@app.get("/") +async def main(): + content = """ + +
+ + +
+
+ + +
+ + """ + return HTMLResponse(content=content) diff --git a/docs_src/request_files/tutorial003_py39.py b/docs_src/request_files/tutorial003_py39.py new file mode 100644 index 0000000000000..d853f48d11357 --- /dev/null +++ b/docs_src/request_files/tutorial003_py39.py @@ -0,0 +1,35 @@ +from fastapi import FastAPI, File, UploadFile +from fastapi.responses import HTMLResponse + +app = FastAPI() + + +@app.post("/files/") +async def create_files( + files: list[bytes] = File(..., description="Multiple files as bytes") +): + return {"file_sizes": [len(file) for file in files]} + + +@app.post("/uploadfiles/") +async def create_upload_files( + files: list[UploadFile] = File(..., description="Multiple files as UploadFile") +): + return {"filenames": [file.filename for file in files]} + + +@app.get("/") +async def main(): + content = """ + +
+ + +
+
+ + +
+ + """ + return HTMLResponse(content=content) diff --git a/fastapi/datastructures.py b/fastapi/datastructures.py index b1317128707b6..b20a25ab6ed09 100644 --- a/fastapi/datastructures.py +++ b/fastapi/datastructures.py @@ -1,4 +1,4 @@ -from typing import Any, Callable, Iterable, Type, TypeVar +from typing import Any, Callable, Dict, Iterable, Type, TypeVar from starlette.datastructures import URL as URL # noqa: F401 from starlette.datastructures import Address as Address # noqa: F401 @@ -20,6 +20,10 @@ def validate(cls: Type["UploadFile"], v: Any) -> Any: raise ValueError(f"Expected UploadFile, received: {type(v)}") return v + @classmethod + def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: + field_schema.update({"type": "string", "format": "binary"}) + class DefaultPlaceholder: """ diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 35ba44aabfd93..d4028d067b882 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -390,6 +390,8 @@ def get_param_field( field.required = required if not had_schema and not is_scalar_field(field=field): field.field_info = params.Body(field_info.default) + if not had_schema and lenient_issubclass(field.type_, UploadFile): + field.field_info = params.File(field_info.default) return field @@ -701,25 +703,6 @@ def get_missing_field_error(loc: Tuple[str, ...]) -> ErrorWrapper: return missing_field_error -def get_schema_compatible_field(*, field: ModelField) -> ModelField: - out_field = field - if lenient_issubclass(field.type_, UploadFile): - use_type: type = bytes - if field.shape in sequence_shapes: - use_type = List[bytes] - out_field = create_response_field( - name=field.name, - type_=use_type, - class_validators=field.class_validators, - model_config=field.model_config, - default=field.default, - required=field.required, - alias=field.alias, - field_info=field.field_info, - ) - return out_field - - def get_body_field(*, dependant: Dependant, name: str) -> Optional[ModelField]: flat_dependant = get_flat_dependant(dependant) if not flat_dependant.body_params: @@ -729,9 +712,8 @@ def get_body_field(*, dependant: Dependant, name: str) -> Optional[ModelField]: embed = getattr(field_info, "embed", None) body_param_names_set = {param.name for param in flat_dependant.body_params} if len(body_param_names_set) == 1 and not embed: - final_field = get_schema_compatible_field(field=first_param) - check_file_field(final_field) - return final_field + check_file_field(first_param) + return first_param # If one field requires to embed, all have to be embedded # in case a sub-dependency is evaluated with a single unique body field # That is combined (embedded) with other body fields @@ -740,7 +722,7 @@ def get_body_field(*, dependant: Dependant, name: str) -> Optional[ModelField]: model_name = "Body_" + name BodyModel: Type[BaseModel] = create_model(model_name) for f in flat_dependant.body_params: - BodyModel.__fields__[f.name] = get_schema_compatible_field(field=f) + BodyModel.__fields__[f.name] = f required = any(True for f in flat_dependant.body_params if f.required) BodyFieldInfo_kwargs: Dict[str, Any] = dict(default=None) diff --git a/tests/test_tutorial/test_request_files/test_tutorial001_02.py b/tests/test_tutorial/test_request_files/test_tutorial001_02.py new file mode 100644 index 0000000000000..e852a1b31336b --- /dev/null +++ b/tests/test_tutorial/test_request_files/test_tutorial001_02.py @@ -0,0 +1,157 @@ +from fastapi.testclient import TestClient + +from docs_src.request_files.tutorial001_02 import app + +client = TestClient(app) + +openapi_schema = { + "openapi": "3.0.2", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/files/": { + "post": { + "summary": "Create File", + "operationId": "create_file_files__post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_create_file_files__post" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + "/uploadfile/": { + "post": { + "summary": "Create Upload File", + "operationId": "create_upload_file_uploadfile__post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_create_upload_file_uploadfile__post" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + }, + "components": { + "schemas": { + "Body_create_file_files__post": { + "title": "Body_create_file_files__post", + "type": "object", + "properties": { + "file": {"title": "File", "type": "string", "format": "binary"} + }, + }, + "Body_create_upload_file_uploadfile__post": { + "title": "Body_create_upload_file_uploadfile__post", + "type": "object", + "properties": { + "file": {"title": "File", "type": "string", "format": "binary"} + }, + }, + "HTTPValidationError": { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "detail": { + "title": "Detail", + "type": "array", + "items": {"$ref": "#/components/schemas/ValidationError"}, + } + }, + }, + "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"}, + }, + }, + } + }, +} + + +def test_openapi_schema(): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == openapi_schema + + +def test_post_form_no_body(): + response = client.post("/files/") + assert response.status_code == 200, response.text + assert response.json() == {"message": "No file sent"} + + +def test_post_uploadfile_no_body(): + response = client.post("/uploadfile/") + assert response.status_code == 200, response.text + assert response.json() == {"message": "No upload file sent"} + + +def test_post_file(tmp_path): + path = tmp_path / "test.txt" + path.write_bytes(b"") + + client = TestClient(app) + with path.open("rb") as file: + response = client.post("/files/", files={"file": file}) + assert response.status_code == 200, response.text + assert response.json() == {"file_size": 14} + + +def test_post_upload_file(tmp_path): + path = tmp_path / "test.txt" + path.write_bytes(b"") + + client = TestClient(app) + with path.open("rb") as file: + response = client.post("/uploadfile/", files={"file": file}) + assert response.status_code == 200, response.text + assert response.json() == {"filename": "test.txt"} diff --git a/tests/test_tutorial/test_request_files/test_tutorial001_02_py310.py b/tests/test_tutorial/test_request_files/test_tutorial001_02_py310.py new file mode 100644 index 0000000000000..62e9f98d0bf35 --- /dev/null +++ b/tests/test_tutorial/test_request_files/test_tutorial001_02_py310.py @@ -0,0 +1,169 @@ +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from ...utils import needs_py310 + +openapi_schema = { + "openapi": "3.0.2", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/files/": { + "post": { + "summary": "Create File", + "operationId": "create_file_files__post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_create_file_files__post" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + "/uploadfile/": { + "post": { + "summary": "Create Upload File", + "operationId": "create_upload_file_uploadfile__post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_create_upload_file_uploadfile__post" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + }, + "components": { + "schemas": { + "Body_create_file_files__post": { + "title": "Body_create_file_files__post", + "type": "object", + "properties": { + "file": {"title": "File", "type": "string", "format": "binary"} + }, + }, + "Body_create_upload_file_uploadfile__post": { + "title": "Body_create_upload_file_uploadfile__post", + "type": "object", + "properties": { + "file": {"title": "File", "type": "string", "format": "binary"} + }, + }, + "HTTPValidationError": { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "detail": { + "title": "Detail", + "type": "array", + "items": {"$ref": "#/components/schemas/ValidationError"}, + } + }, + }, + "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"}, + }, + }, + } + }, +} + + +@pytest.fixture(name="client") +def get_client(): + from docs_src.request_files.tutorial001_02_py310 import app + + client = TestClient(app) + return client + + +@needs_py310 +def test_openapi_schema(client: TestClient): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == openapi_schema + + +@needs_py310 +def test_post_form_no_body(client: TestClient): + response = client.post("/files/") + assert response.status_code == 200, response.text + assert response.json() == {"message": "No file sent"} + + +@needs_py310 +def test_post_uploadfile_no_body(client: TestClient): + response = client.post("/uploadfile/") + assert response.status_code == 200, response.text + assert response.json() == {"message": "No upload file sent"} + + +@needs_py310 +def test_post_file(tmp_path: Path, client: TestClient): + path = tmp_path / "test.txt" + path.write_bytes(b"") + + with path.open("rb") as file: + response = client.post("/files/", files={"file": file}) + assert response.status_code == 200, response.text + assert response.json() == {"file_size": 14} + + +@needs_py310 +def test_post_upload_file(tmp_path: Path, client: TestClient): + path = tmp_path / "test.txt" + path.write_bytes(b"") + + with path.open("rb") as file: + response = client.post("/uploadfile/", files={"file": file}) + assert response.status_code == 200, response.text + assert response.json() == {"filename": "test.txt"} diff --git a/tests/test_tutorial/test_request_files/test_tutorial001_03.py b/tests/test_tutorial/test_request_files/test_tutorial001_03.py new file mode 100644 index 0000000000000..ec7509ea29624 --- /dev/null +++ b/tests/test_tutorial/test_request_files/test_tutorial001_03.py @@ -0,0 +1,159 @@ +from fastapi.testclient import TestClient + +from docs_src.request_files.tutorial001_03 import app + +client = TestClient(app) + +openapi_schema = { + "openapi": "3.0.2", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/files/": { + "post": { + "summary": "Create File", + "operationId": "create_file_files__post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_create_file_files__post" + } + } + }, + "required": True, + }, + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + "/uploadfile/": { + "post": { + "summary": "Create Upload File", + "operationId": "create_upload_file_uploadfile__post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_create_upload_file_uploadfile__post" + } + } + }, + "required": True, + }, + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + }, + "components": { + "schemas": { + "Body_create_file_files__post": { + "title": "Body_create_file_files__post", + "required": ["file"], + "type": "object", + "properties": { + "file": { + "title": "File", + "type": "string", + "description": "A file read as bytes", + "format": "binary", + } + }, + }, + "Body_create_upload_file_uploadfile__post": { + "title": "Body_create_upload_file_uploadfile__post", + "required": ["file"], + "type": "object", + "properties": { + "file": { + "title": "File", + "type": "string", + "description": "A file read as UploadFile", + "format": "binary", + } + }, + }, + "HTTPValidationError": { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "detail": { + "title": "Detail", + "type": "array", + "items": {"$ref": "#/components/schemas/ValidationError"}, + } + }, + }, + "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"}, + }, + }, + } + }, +} + + +def test_openapi_schema(): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == openapi_schema + + +def test_post_file(tmp_path): + path = tmp_path / "test.txt" + path.write_bytes(b"") + + client = TestClient(app) + with path.open("rb") as file: + response = client.post("/files/", files={"file": file}) + assert response.status_code == 200, response.text + assert response.json() == {"file_size": 14} + + +def test_post_upload_file(tmp_path): + path = tmp_path / "test.txt" + path.write_bytes(b"") + + client = TestClient(app) + with path.open("rb") as file: + response = client.post("/uploadfile/", files={"file": file}) + assert response.status_code == 200, response.text + assert response.json() == {"filename": "test.txt"} diff --git a/tests/test_tutorial/test_request_files/test_tutorial003.py b/tests/test_tutorial/test_request_files/test_tutorial003.py new file mode 100644 index 0000000000000..943b235ab88ab --- /dev/null +++ b/tests/test_tutorial/test_request_files/test_tutorial003.py @@ -0,0 +1,194 @@ +from fastapi.testclient import TestClient + +from docs_src.request_files.tutorial003 import app + +client = TestClient(app) + +openapi_schema = { + "openapi": "3.0.2", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/files/": { + "post": { + "summary": "Create Files", + "operationId": "create_files_files__post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_create_files_files__post" + } + } + }, + "required": True, + }, + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + "/uploadfiles/": { + "post": { + "summary": "Create Upload Files", + "operationId": "create_upload_files_uploadfiles__post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_create_upload_files_uploadfiles__post" + } + } + }, + "required": True, + }, + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + "/": { + "get": { + "summary": "Main", + "operationId": "main__get", + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + } + }, + } + }, + }, + "components": { + "schemas": { + "Body_create_files_files__post": { + "title": "Body_create_files_files__post", + "required": ["files"], + "type": "object", + "properties": { + "files": { + "title": "Files", + "type": "array", + "items": {"type": "string", "format": "binary"}, + "description": "Multiple files as bytes", + } + }, + }, + "Body_create_upload_files_uploadfiles__post": { + "title": "Body_create_upload_files_uploadfiles__post", + "required": ["files"], + "type": "object", + "properties": { + "files": { + "title": "Files", + "type": "array", + "items": {"type": "string", "format": "binary"}, + "description": "Multiple files as UploadFile", + } + }, + }, + "HTTPValidationError": { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "detail": { + "title": "Detail", + "type": "array", + "items": {"$ref": "#/components/schemas/ValidationError"}, + } + }, + }, + "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"}, + }, + }, + } + }, +} + + +def test_openapi_schema(): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == openapi_schema + + +def test_post_files(tmp_path): + path = tmp_path / "test.txt" + path.write_bytes(b"") + path2 = tmp_path / "test2.txt" + path2.write_bytes(b"") + + client = TestClient(app) + with path.open("rb") as file, path2.open("rb") as file2: + response = client.post( + "/files/", + files=( + ("files", ("test.txt", file)), + ("files", ("test2.txt", file2)), + ), + ) + assert response.status_code == 200, response.text + assert response.json() == {"file_sizes": [14, 15]} + + +def test_post_upload_file(tmp_path): + path = tmp_path / "test.txt" + path.write_bytes(b"") + path2 = tmp_path / "test2.txt" + path2.write_bytes(b"") + + client = TestClient(app) + with path.open("rb") as file, path2.open("rb") as file2: + response = client.post( + "/uploadfiles/", + files=( + ("files", ("test.txt", file)), + ("files", ("test2.txt", file2)), + ), + ) + assert response.status_code == 200, response.text + assert response.json() == {"filenames": ["test.txt", "test2.txt"]} + + +def test_get_root(): + client = TestClient(app) + response = client.get("/") + assert response.status_code == 200, response.text + assert b"") + path2 = tmp_path / "test2.txt" + path2.write_bytes(b"") + + client = TestClient(app) + with path.open("rb") as file, path2.open("rb") as file2: + response = client.post( + "/files/", + files=( + ("files", ("test.txt", file)), + ("files", ("test2.txt", file2)), + ), + ) + assert response.status_code == 200, response.text + assert response.json() == {"file_sizes": [14, 15]} + + +@needs_py39 +def test_post_upload_file(tmp_path, app: FastAPI): + path = tmp_path / "test.txt" + path.write_bytes(b"") + path2 = tmp_path / "test2.txt" + path2.write_bytes(b"") + + client = TestClient(app) + with path.open("rb") as file, path2.open("rb") as file2: + response = client.post( + "/uploadfiles/", + files=( + ("files", ("test.txt", file)), + ("files", ("test2.txt", file2)), + ), + ) + assert response.status_code == 200, response.text + assert response.json() == {"filenames": ["test.txt", "test2.txt"]} + + +@needs_py39 +def test_get_root(app: FastAPI): + client = TestClient(app) + response = client.get("/") + assert response.status_code == 200, response.text + assert b" Date: Sun, 23 Jan 2022 19:14:47 +0000 Subject: [PATCH 015/142] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 7026d0eb1a8d1..b7593ee3c05f0 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -2,6 +2,7 @@ ## Latest Changes +* ✨ Add support for declaring `UploadFile` parameters without explicit `File()`. PR [#4469](https://github.com/tiangolo/fastapi/pull/4469) by [@tiangolo](https://github.com/tiangolo). * ✨ Add support for tags with Enums. PR [#4468](https://github.com/tiangolo/fastapi/pull/4468) by [@tiangolo](https://github.com/tiangolo). * 🐛 Prefer custom encoder over defaults if specified in `jsonable_encoder`. PR [#2061](https://github.com/tiangolo/fastapi/pull/2061) by [@viveksunder](https://github.com/viveksunder). * 💚 Duplicate PR to trigger CI. PR [#4467](https://github.com/tiangolo/fastapi/pull/4467) by [@tiangolo](https://github.com/tiangolo). From f8d4d040155a58ecfdbc2ed58f0739b07e417516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 23 Jan 2022 22:30:35 +0100 Subject: [PATCH 016/142] =?UTF-8?q?=F0=9F=93=9D=20Tweak=20and=20improve=20?= =?UTF-8?q?docs=20for=20Request=20Files=20(#4470)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/tutorial/request-files.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/en/docs/tutorial/request-files.md b/docs/en/docs/tutorial/request-files.md index ed2c8b6af5ac9..3ca471a91b866 100644 --- a/docs/en/docs/tutorial/request-files.md +++ b/docs/en/docs/tutorial/request-files.md @@ -41,9 +41,9 @@ Have in mind that this means that the whole contents will be stored in memory. T But there are several cases in which you might benefit from using `UploadFile`. -## `File` Parameters with `UploadFile` +## File Parameters with `UploadFile` -Define a `File` parameter with a type of `UploadFile`: +Define a file parameter with a type of `UploadFile`: ```Python hl_lines="12" {!../../../docs_src/request_files/tutorial001.py!} @@ -51,7 +51,7 @@ Define a `File` parameter with a type of `UploadFile`: Using `UploadFile` has several advantages over `bytes`: -* You don't have to use `File()` in the default value. +* You don't have to use `File()` in the default value of the parameter. * It uses a "spooled" file: * A file stored in memory up to a maximum size limit, and after passing this limit it will be stored in disk. * This means that it will work well for large files like images, videos, large binaries, etc. without consuming all the memory. @@ -116,7 +116,7 @@ The way HTML forms (`
`) sends the data to the server normally uses ## Optional File Upload -You can make a file optional by using standard type annotations: +You can make a file optional by using standard type annotations and setting a default value of `None`: === "Python 3.6 and above" @@ -132,7 +132,7 @@ You can make a file optional by using standard type annotations: ## `UploadFile` with Additional Metadata -You can also use `File()` with `UploadFile` to set additional parameters in `File()`, for example additional metadata: +You can also use `File()` with `UploadFile`, for example, to set additional metadata: ```Python hl_lines="13" {!../../../docs_src/request_files/tutorial001_03.py!} @@ -183,4 +183,4 @@ And the same way as before, you can use `File()` to set additional parameters, e ## Recap -Use `File` to declare files to be uploaded as input parameters (as form data). +Use `File`, `bytes`, and `UploadFile` to declare files to be uploaded in the request, sent as form data. From dba9ea81208078bdd91fbfff4fdbfe203dcc303f Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 23 Jan 2022 21:31:08 +0000 Subject: [PATCH 017/142] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index b7593ee3c05f0..84908dda5387e 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -2,6 +2,7 @@ ## Latest Changes +* 📝 Tweak and improve docs for Request Files. PR [#4470](https://github.com/tiangolo/fastapi/pull/4470) by [@tiangolo](https://github.com/tiangolo). * ✨ Add support for declaring `UploadFile` parameters without explicit `File()`. PR [#4469](https://github.com/tiangolo/fastapi/pull/4469) by [@tiangolo](https://github.com/tiangolo). * ✨ Add support for tags with Enums. PR [#4468](https://github.com/tiangolo/fastapi/pull/4468) by [@tiangolo](https://github.com/tiangolo). * 🐛 Prefer custom encoder over defaults if specified in `jsonable_encoder`. PR [#2061](https://github.com/tiangolo/fastapi/pull/2061) by [@viveksunder](https://github.com/viveksunder). From a698908ed65d887a17245a580ecbf3bf3c848406 Mon Sep 17 00:00:00 2001 From: Victor Benichoux Date: Sun, 23 Jan 2022 23:13:55 +0100 Subject: [PATCH 018/142] =?UTF-8?q?=F0=9F=90=9B=20Fix=20bug=20preventing?= =?UTF-8?q?=20to=20use=20OpenAPI=20when=20using=20tuples=20(#3874)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sebastián Ramírez --- fastapi/openapi/models.py | 2 +- tests/test_tuples.py | 267 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 tests/test_tuples.py diff --git a/fastapi/openapi/models.py b/fastapi/openapi/models.py index 361c7500547b0..9c6598d2d1a7b 100644 --- a/fastapi/openapi/models.py +++ b/fastapi/openapi/models.py @@ -123,7 +123,7 @@ class Schema(BaseModel): oneOf: Optional[List["Schema"]] = None anyOf: Optional[List["Schema"]] = None not_: Optional["Schema"] = Field(None, alias="not") - items: Optional["Schema"] = None + items: Optional[Union["Schema", List["Schema"]]] = None properties: Optional[Dict[str, "Schema"]] = None additionalProperties: Optional[Union["Schema", Reference, bool]] = None description: Optional[str] = None diff --git a/tests/test_tuples.py b/tests/test_tuples.py new file mode 100644 index 0000000000000..4cd5ee3afbe05 --- /dev/null +++ b/tests/test_tuples.py @@ -0,0 +1,267 @@ +from typing import List, Tuple + +from fastapi import FastAPI, Form +from fastapi.testclient import TestClient +from pydantic import BaseModel + +app = FastAPI() + + +class ItemGroup(BaseModel): + items: List[Tuple[str, str]] + + +class Coordinate(BaseModel): + x: float + y: float + + +@app.post("/model-with-tuple/") +def post_model_with_tuple(item_group: ItemGroup): + return item_group + + +@app.post("/tuple-of-models/") +def post_tuple_of_models(square: Tuple[Coordinate, Coordinate]): + return square + + +@app.post("/tuple-form/") +def hello(values: Tuple[int, int] = Form(...)): + return values + + +client = TestClient(app) + +openapi_schema = { + "openapi": "3.0.2", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/model-with-tuple/": { + "post": { + "summary": "Post Model With Tuple", + "operationId": "post_model_with_tuple_model_with_tuple__post", + "requestBody": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ItemGroup"} + } + }, + "required": True, + }, + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + "/tuple-of-models/": { + "post": { + "summary": "Post Tuple Of Models", + "operationId": "post_tuple_of_models_tuple_of_models__post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "title": "Square", + "maxItems": 2, + "minItems": 2, + "type": "array", + "items": [ + {"$ref": "#/components/schemas/Coordinate"}, + {"$ref": "#/components/schemas/Coordinate"}, + ], + } + } + }, + "required": True, + }, + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + "/tuple-form/": { + "post": { + "summary": "Hello", + "operationId": "hello_tuple_form__post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_hello_tuple_form__post" + } + } + }, + "required": True, + }, + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + }, + "components": { + "schemas": { + "Body_hello_tuple_form__post": { + "title": "Body_hello_tuple_form__post", + "required": ["values"], + "type": "object", + "properties": { + "values": { + "title": "Values", + "maxItems": 2, + "minItems": 2, + "type": "array", + "items": [{"type": "integer"}, {"type": "integer"}], + } + }, + }, + "Coordinate": { + "title": "Coordinate", + "required": ["x", "y"], + "type": "object", + "properties": { + "x": {"title": "X", "type": "number"}, + "y": {"title": "Y", "type": "number"}, + }, + }, + "HTTPValidationError": { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "detail": { + "title": "Detail", + "type": "array", + "items": {"$ref": "#/components/schemas/ValidationError"}, + } + }, + }, + "ItemGroup": { + "title": "ItemGroup", + "required": ["items"], + "type": "object", + "properties": { + "items": { + "title": "Items", + "type": "array", + "items": { + "maxItems": 2, + "minItems": 2, + "type": "array", + "items": [{"type": "string"}, {"type": "string"}], + }, + } + }, + }, + "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"}, + }, + }, + } + }, +} + + +def test_openapi_schema(): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == openapi_schema + + +def test_model_with_tuple_valid(): + data = {"items": [["foo", "bar"], ["baz", "whatelse"]]} + response = client.post("/model-with-tuple/", json=data) + assert response.status_code == 200, response.text + assert response.json() == data + + +def test_model_with_tuple_invalid(): + data = {"items": [["foo", "bar"], ["baz", "whatelse", "too", "much"]]} + response = client.post("/model-with-tuple/", json=data) + assert response.status_code == 422, response.text + + data = {"items": [["foo", "bar"], ["baz"]]} + response = client.post("/model-with-tuple/", json=data) + assert response.status_code == 422, response.text + + +def test_tuple_with_model_valid(): + data = [{"x": 1, "y": 2}, {"x": 3, "y": 4}] + response = client.post("/tuple-of-models/", json=data) + assert response.status_code == 200, response.text + assert response.json() == data + + +def test_tuple_with_model_invalid(): + data = [{"x": 1, "y": 2}, {"x": 3, "y": 4}, {"x": 5, "y": 6}] + response = client.post("/tuple-of-models/", json=data) + assert response.status_code == 422, response.text + + data = [{"x": 1, "y": 2}] + response = client.post("/tuple-of-models/", json=data) + assert response.status_code == 422, response.text + + +def test_tuple_form_valid(): + response = client.post("/tuple-form/", data=[("values", "1"), ("values", "2")]) + assert response.status_code == 200, response.text + assert response.json() == [1, 2] + + +def test_tuple_form_invalid(): + response = client.post( + "/tuple-form/", data=[("values", "1"), ("values", "2"), ("values", "3")] + ) + assert response.status_code == 422, response.text + + response = client.post("/tuple-form/", data=[("values", "1")]) + assert response.status_code == 422, response.text From af18d5c49fde32e79e4dfd7a82819a2c642c6c17 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 23 Jan 2022 22:14:28 +0000 Subject: [PATCH 019/142] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 84908dda5387e..f9f3aabde428d 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -2,6 +2,7 @@ ## Latest Changes +* 🐛 Fix bug preventing to use OpenAPI when using tuples. PR [#3874](https://github.com/tiangolo/fastapi/pull/3874) by [@victorbenichoux](https://github.com/victorbenichoux). * 📝 Tweak and improve docs for Request Files. PR [#4470](https://github.com/tiangolo/fastapi/pull/4470) by [@tiangolo](https://github.com/tiangolo). * ✨ Add support for declaring `UploadFile` parameters without explicit `File()`. PR [#4469](https://github.com/tiangolo/fastapi/pull/4469) by [@tiangolo](https://github.com/tiangolo). * ✨ Add support for tags with Enums. PR [#4468](https://github.com/tiangolo/fastapi/pull/4468) by [@tiangolo](https://github.com/tiangolo). From cbe8d552c1eee5b48f8ab0eab6b517e98ae8523b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 23 Jan 2022 23:37:48 +0100 Subject: [PATCH 020/142] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index f9f3aabde428d..e75d46706b898 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -2,14 +2,25 @@ ## Latest Changes -* 🐛 Fix bug preventing to use OpenAPI when using tuples. PR [#3874](https://github.com/tiangolo/fastapi/pull/3874) by [@victorbenichoux](https://github.com/victorbenichoux). +### Features + +* ✨ Add support for declaring `UploadFile` parameters without explicit `File()`. PR [#4469](https://github.com/tiangolo/fastapi/pull/4469) by [@tiangolo](https://github.com/tiangolo). New docs: [Request Files - File Parameters with UploadFile](https://fastapi.tiangolo.com/tutorial/request-files/#file-parameters-with-uploadfile). +* ✨ Add support for tags with Enums. PR [#4468](https://github.com/tiangolo/fastapi/pull/4468) by [@tiangolo](https://github.com/tiangolo). New docs: [Path Operation Configuration - Tags with Enums](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags-with-enums). +* ✨ Allow hiding from OpenAPI (and Swagger UI) `Query`, `Cookie`, `Header`, and `Path` parameters. PR [#3144](https://github.com/tiangolo/fastapi/pull/3144) by [@astraldawn](https://github.com/astraldawn). New docs: [Query Parameters and String Validations - Exclude from OpenAPI](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#exclude-from-openapi). + +### Docs + * 📝 Tweak and improve docs for Request Files. PR [#4470](https://github.com/tiangolo/fastapi/pull/4470) by [@tiangolo](https://github.com/tiangolo). -* ✨ Add support for declaring `UploadFile` parameters without explicit `File()`. PR [#4469](https://github.com/tiangolo/fastapi/pull/4469) by [@tiangolo](https://github.com/tiangolo). -* ✨ Add support for tags with Enums. PR [#4468](https://github.com/tiangolo/fastapi/pull/4468) by [@tiangolo](https://github.com/tiangolo). + +### Fixes + +* 🐛 Fix bug preventing to use OpenAPI when using tuples. PR [#3874](https://github.com/tiangolo/fastapi/pull/3874) by [@victorbenichoux](https://github.com/victorbenichoux). * 🐛 Prefer custom encoder over defaults if specified in `jsonable_encoder`. PR [#2061](https://github.com/tiangolo/fastapi/pull/2061) by [@viveksunder](https://github.com/viveksunder). * 💚 Duplicate PR to trigger CI. PR [#4467](https://github.com/tiangolo/fastapi/pull/4467) by [@tiangolo](https://github.com/tiangolo). + +### Internal + * 🐛 Fix docs dependencies cache, to get the latest Material for MkDocs. PR [#4466](https://github.com/tiangolo/fastapi/pull/4466) by [@tiangolo](https://github.com/tiangolo). -* ✨ Allow hiding from OpenAPI (and Swagger UI) `Query`, `Cookie`, `Header`, and `Path` parameters. PR [#3144](https://github.com/tiangolo/fastapi/pull/3144) by [@astraldawn](https://github.com/astraldawn). * 🔧 Add sponsor Dropbase. PR [#4465](https://github.com/tiangolo/fastapi/pull/4465) by [@tiangolo](https://github.com/tiangolo). ## 0.72.0 From 291180bf2d8c39e84860c2426b1d58b6c80f6fef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 23 Jan 2022 23:38:51 +0100 Subject: [PATCH 021/142] =?UTF-8?q?=F0=9F=94=96=20Release=20version=200.73?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 2 ++ fastapi/__init__.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index e75d46706b898..68b75e70281a2 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -2,6 +2,8 @@ ## Latest Changes +## 0.73.0 + ### Features * ✨ Add support for declaring `UploadFile` parameters without explicit `File()`. PR [#4469](https://github.com/tiangolo/fastapi/pull/4469) by [@tiangolo](https://github.com/tiangolo). New docs: [Request Files - File Parameters with UploadFile](https://fastapi.tiangolo.com/tutorial/request-files/#file-parameters-with-uploadfile). diff --git a/fastapi/__init__.py b/fastapi/__init__.py index d83fe6fbd0c92..8718788fa284d 100644 --- a/fastapi/__init__.py +++ b/fastapi/__init__.py @@ -1,6 +1,6 @@ """FastAPI framework, high performance, easy to learn, fast to code, ready for production""" -__version__ = "0.72.0" +__version__ = "0.73.0" from starlette import status as status From 618c99d77444e383e7b95ebe32ededbd95155c43 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Feb 2022 15:27:34 +0100 Subject: [PATCH 022/142] =?UTF-8?q?=F0=9F=91=A5=20Update=20FastAPI=20Peopl?= =?UTF-8?q?e=20(#4502)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-actions --- docs/en/data/people.yml | 190 ++++++++++++++++++++++------------------ 1 file changed, 105 insertions(+), 85 deletions(-) diff --git a/docs/en/data/people.yml b/docs/en/data/people.yml index df088f39fae89..ebbe446eed4a9 100644 --- a/docs/en/data/people.yml +++ b/docs/en/data/people.yml @@ -1,12 +1,12 @@ maintainers: - login: tiangolo - answers: 1230 - prs: 269 + answers: 1237 + prs: 280 avatarUrl: https://avatars.githubusercontent.com/u/1326112?u=5cad72c846b7aba2e960546af490edc7375dafc4&v=4 url: https://github.com/tiangolo experts: - login: Kludex - count: 316 + count: 319 avatarUrl: https://avatars.githubusercontent.com/u/7353520?u=3682d9b9b93bef272f379ab623dc031c8d71432e&v=4 url: https://github.com/Kludex - login: dmontagu @@ -14,7 +14,7 @@ experts: avatarUrl: https://avatars.githubusercontent.com/u/35119617?u=58ed2a45798a4339700e2f62b2e12e6e54bf0396&v=4 url: https://github.com/dmontagu - login: ycd - count: 219 + count: 221 avatarUrl: https://avatars.githubusercontent.com/u/62724709?u=826f228edf0bab0d19ad1d5c4ba4df1047ccffef&v=4 url: https://github.com/ycd - login: Mause @@ -34,7 +34,7 @@ experts: avatarUrl: https://avatars.githubusercontent.com/u/31127044?u=81a84af39c89b898b0fbc5a04e8834f60f23e55a&v=4 url: https://github.com/ArcLightSlavik - login: raphaelauv - count: 67 + count: 68 avatarUrl: https://avatars.githubusercontent.com/u/10202690?u=e6f86f5c0c3026a15d6b51792fa3e532b12f1371&v=4 url: https://github.com/raphaelauv - login: falkben @@ -57,18 +57,22 @@ experts: count: 38 avatarUrl: https://avatars.githubusercontent.com/u/11836741?u=8bd5ef7e62fe6a82055e33c4c0e0a7879ff8cfb6&v=4 url: https://github.com/includeamin +- login: STeveShary + count: 37 + avatarUrl: https://avatars.githubusercontent.com/u/5167622?u=de8f597c81d6336fcebc37b32dfd61a3f877160c&v=4 + url: https://github.com/STeveShary - login: prostomarkeloff count: 33 avatarUrl: https://avatars.githubusercontent.com/u/28061158?u=72309cc1f2e04e40fa38b29969cb4e9d3f722e7b&v=4 url: https://github.com/prostomarkeloff -- login: STeveShary - count: 32 - avatarUrl: https://avatars.githubusercontent.com/u/5167622?u=de8f597c81d6336fcebc37b32dfd61a3f877160c&v=4 - url: https://github.com/STeveShary - login: krishnardt count: 31 avatarUrl: https://avatars.githubusercontent.com/u/31960541?u=47f4829c77f4962ab437ffb7995951e41eeebe9b&v=4 url: https://github.com/krishnardt +- login: adriangb + count: 30 + avatarUrl: https://avatars.githubusercontent.com/u/1755071?u=81f0262df34e1460ca546fbd0c211169c2478532&v=4 + url: https://github.com/adriangb - login: wshayes count: 29 avatarUrl: https://avatars.githubusercontent.com/u/365303?u=07ca03c5ee811eb0920e633cc3c3db73dbec1aa5&v=4 @@ -77,10 +81,10 @@ experts: count: 29 avatarUrl: https://avatars.githubusercontent.com/u/1144727?u=85c025e3fcc7bd79a5665c63ee87cdf8aae13374&v=4 url: https://github.com/frankie567 -- login: adriangb - count: 28 - avatarUrl: https://avatars.githubusercontent.com/u/1755071?u=81f0262df34e1460ca546fbd0c211169c2478532&v=4 - url: https://github.com/adriangb +- login: chbndrhnns + count: 25 + avatarUrl: https://avatars.githubusercontent.com/u/7534547?v=4 + url: https://github.com/chbndrhnns - login: ghandic count: 25 avatarUrl: https://avatars.githubusercontent.com/u/23500353?u=e2e1d736f924d9be81e8bfc565b6d8836ba99773&v=4 @@ -89,18 +93,14 @@ experts: count: 25 avatarUrl: https://avatars.githubusercontent.com/u/43723790?u=9bcce836bbce55835291c5b2ac93a4e311f4b3c3&v=4 url: https://github.com/dbanty -- login: chbndrhnns - count: 24 - avatarUrl: https://avatars.githubusercontent.com/u/7534547?v=4 - url: https://github.com/chbndrhnns +- login: panla + count: 25 + avatarUrl: https://avatars.githubusercontent.com/u/41326348?u=ba2fda6b30110411ecbf406d187907e2b420ac19&v=4 + url: https://github.com/panla - login: SirTelemak count: 24 avatarUrl: https://avatars.githubusercontent.com/u/9435877?u=719327b7d2c4c62212456d771bfa7c6b8dbb9eac&v=4 url: https://github.com/SirTelemak -- login: panla - count: 23 - avatarUrl: https://avatars.githubusercontent.com/u/41326348?u=ba2fda6b30110411ecbf406d187907e2b420ac19&v=4 - url: https://github.com/panla - login: acnebs count: 22 avatarUrl: https://avatars.githubusercontent.com/u/9054108?u=c27e50269f1ef8ea950cc6f0268c8ec5cebbe9c9&v=4 @@ -129,22 +129,34 @@ experts: count: 17 avatarUrl: https://avatars.githubusercontent.com/u/28262306?u=66ee21316275ef356081c2efc4ed7a4572e690dc&v=4 url: https://github.com/nkhitrov +- login: acidjunk + count: 16 + avatarUrl: https://avatars.githubusercontent.com/u/685002?u=b5094ab4527fc84b006c0ac9ff54367bdebb2267&v=4 + url: https://github.com/acidjunk - login: waynerv count: 16 avatarUrl: https://avatars.githubusercontent.com/u/39515546?u=ec35139777597cdbbbddda29bf8b9d4396b429a9&v=4 url: https://github.com/waynerv -- login: acidjunk - count: 15 - avatarUrl: https://avatars.githubusercontent.com/u/685002?u=b5094ab4527fc84b006c0ac9ff54367bdebb2267&v=4 - url: https://github.com/acidjunk - login: dstlny - count: 14 + count: 16 avatarUrl: https://avatars.githubusercontent.com/u/41964673?u=9f2174f9d61c15c6e3a4c9e3aeee66f711ce311f&v=4 url: https://github.com/dstlny +- login: jgould22 + count: 14 + avatarUrl: https://avatars.githubusercontent.com/u/4335847?u=ed77f67e0bb069084639b24d812dbb2a2b1dc554&v=4 + url: https://github.com/jgould22 +- login: harunyasar + count: 14 + avatarUrl: https://avatars.githubusercontent.com/u/1765494?u=5b1ab7c582db4b4016fa31affe977d10af108ad4&v=4 + url: https://github.com/harunyasar - login: haizaar count: 13 avatarUrl: https://avatars.githubusercontent.com/u/58201?u=4f1f9843d69433ca0d380d95146cfe119e5fdac4&v=4 url: https://github.com/haizaar +- login: hellocoldworld + count: 12 + avatarUrl: https://avatars.githubusercontent.com/u/47581948?v=4 + url: https://github.com/hellocoldworld - login: David-Lor count: 12 avatarUrl: https://avatars.githubusercontent.com/u/17401854?u=474680c02b94cba810cb9032fb7eb787d9cc9d22&v=4 @@ -173,39 +185,47 @@ experts: count: 10 avatarUrl: https://avatars.githubusercontent.com/u/20441825?u=ee1e59446b98f8ec2363caeda4c17164d0d9cc7d&v=4 url: https://github.com/stefanondisponibile -- login: hellocoldworld - count: 10 - avatarUrl: https://avatars.githubusercontent.com/u/47581948?v=4 - url: https://github.com/hellocoldworld - login: oligond count: 10 avatarUrl: https://avatars.githubusercontent.com/u/2858306?u=1bb1182a5944e93624b7fb26585f22c8f7a9d76e&v=4 url: https://github.com/oligond last_month_active: -- login: insomnes +- login: harunyasar count: 10 - avatarUrl: https://avatars.githubusercontent.com/u/16958893?u=f8be7088d5076d963984a21f95f44e559192d912&v=4 - url: https://github.com/insomnes -- login: raphaelauv - count: 6 - avatarUrl: https://avatars.githubusercontent.com/u/10202690?u=e6f86f5c0c3026a15d6b51792fa3e532b12f1371&v=4 - url: https://github.com/raphaelauv + avatarUrl: https://avatars.githubusercontent.com/u/1765494?u=5b1ab7c582db4b4016fa31affe977d10af108ad4&v=4 + url: https://github.com/harunyasar - login: jgould22 - count: 4 + count: 10 avatarUrl: https://avatars.githubusercontent.com/u/4335847?u=ed77f67e0bb069084639b24d812dbb2a2b1dc554&v=4 url: https://github.com/jgould22 -- login: harunyasar - count: 4 - avatarUrl: https://avatars.githubusercontent.com/u/1765494?u=5b1ab7c582db4b4016fa31affe977d10af108ad4&v=4 - url: https://github.com/harunyasar +- login: rafsaf + count: 9 + avatarUrl: https://avatars.githubusercontent.com/u/51059348?u=be9f06b8ced2d2b677297decc781fa8ce4f7ddbd&v=4 + url: https://github.com/rafsaf +- login: STeveShary + count: 5 + avatarUrl: https://avatars.githubusercontent.com/u/5167622?u=de8f597c81d6336fcebc37b32dfd61a3f877160c&v=4 + url: https://github.com/STeveShary +- login: ahnaf-zamil + count: 3 + avatarUrl: https://avatars.githubusercontent.com/u/57180217?u=849128b146771ace47beca5b5ff68eb82905dd6d&v=4 + url: https://github.com/ahnaf-zamil +- login: lucastosetto + count: 3 + avatarUrl: https://avatars.githubusercontent.com/u/89307132?u=56326696423df7126c9e7c702ee58f294db69a2a&v=4 + url: https://github.com/lucastosetto +- login: blokje + count: 3 + avatarUrl: https://avatars.githubusercontent.com/u/851418?v=4 + url: https://github.com/blokje +- login: MatthijsKok + count: 3 + avatarUrl: https://avatars.githubusercontent.com/u/7658129?u=1243e32d57e13abc45e3f5235ed5b9197e0d2b41&v=4 + url: https://github.com/MatthijsKok - login: Kludex count: 3 avatarUrl: https://avatars.githubusercontent.com/u/7353520?u=3682d9b9b93bef272f379ab623dc031c8d71432e&v=4 url: https://github.com/Kludex -- login: panla - count: 3 - avatarUrl: https://avatars.githubusercontent.com/u/41326348?u=ba2fda6b30110411ecbf406d187907e2b420ac19&v=4 - url: https://github.com/panla top_contributors: - login: waynerv count: 25 @@ -219,14 +239,14 @@ top_contributors: count: 16 avatarUrl: https://avatars.githubusercontent.com/u/35119617?u=58ed2a45798a4339700e2f62b2e12e6e54bf0396&v=4 url: https://github.com/dmontagu +- login: jaystone776 + count: 15 + avatarUrl: https://avatars.githubusercontent.com/u/11191137?u=299205a95e9b6817a43144a48b643346a5aac5cc&v=4 + url: https://github.com/jaystone776 - login: euri10 count: 13 avatarUrl: https://avatars.githubusercontent.com/u/1104190?u=321a2e953e6645a7d09b732786c7a8061e0f8a8b&v=4 url: https://github.com/euri10 -- login: jaystone776 - count: 13 - avatarUrl: https://avatars.githubusercontent.com/u/11191137?u=299205a95e9b6817a43144a48b643346a5aac5cc&v=4 - url: https://github.com/jaystone776 - login: mariacamilagl count: 12 avatarUrl: https://avatars.githubusercontent.com/u/11489395?u=4adb6986bf3debfc2b8216ae701f2bd47d73da7d&v=4 @@ -285,7 +305,7 @@ top_contributors: url: https://github.com/NinaHwang top_reviewers: - login: Kludex - count: 91 + count: 93 avatarUrl: https://avatars.githubusercontent.com/u/7353520?u=3682d9b9b93bef272f379ab623dc031c8d71432e&v=4 url: https://github.com/Kludex - login: waynerv @@ -301,7 +321,7 @@ top_reviewers: avatarUrl: https://avatars.githubusercontent.com/u/41147016?u=55010621aece725aa702270b54fed829b6a1fe60&v=4 url: https://github.com/tokusumi - login: ycd - count: 44 + count: 45 avatarUrl: https://avatars.githubusercontent.com/u/62724709?u=826f228edf0bab0d19ad1d5c4ba4df1047ccffef&v=4 url: https://github.com/ycd - login: AdrianDeAnda @@ -312,12 +332,16 @@ top_reviewers: count: 31 avatarUrl: https://avatars.githubusercontent.com/u/31127044?u=81a84af39c89b898b0fbc5a04e8834f60f23e55a&v=4 url: https://github.com/ArcLightSlavik +- login: cikay + count: 24 + avatarUrl: https://avatars.githubusercontent.com/u/24587499?u=e772190a051ab0eaa9c8542fcff1892471638f2b&v=4 + url: https://github.com/cikay - login: dmontagu count: 23 avatarUrl: https://avatars.githubusercontent.com/u/35119617?u=58ed2a45798a4339700e2f62b2e12e6e54bf0396&v=4 url: https://github.com/dmontagu - login: cassiobotaro - count: 22 + count: 23 avatarUrl: https://avatars.githubusercontent.com/u/3127847?u=b0a652331da17efeb85cd6e3a4969182e5004804&v=4 url: https://github.com/cassiobotaro - login: komtaki @@ -336,6 +360,10 @@ top_reviewers: count: 16 avatarUrl: https://avatars.githubusercontent.com/u/21978760?v=4 url: https://github.com/yanever +- login: lsglucas + count: 16 + avatarUrl: https://avatars.githubusercontent.com/u/61513630?u=320e43fe4dc7bc6efc64e9b8f325f8075634fd20&v=4 + url: https://github.com/lsglucas - login: SwftAlpc count: 16 avatarUrl: https://avatars.githubusercontent.com/u/52768429?u=6a3aa15277406520ad37f6236e89466ed44bc5b8&v=4 @@ -356,26 +384,22 @@ top_reviewers: count: 15 avatarUrl: https://avatars.githubusercontent.com/u/63476957?u=6c86e59b48e0394d4db230f37fc9ad4d7e2c27c7&v=4 url: https://github.com/delhi09 -- login: lsglucas - count: 14 - avatarUrl: https://avatars.githubusercontent.com/u/61513630?u=320e43fe4dc7bc6efc64e9b8f325f8075634fd20&v=4 - url: https://github.com/lsglucas - login: rjNemo - count: 13 + count: 14 avatarUrl: https://avatars.githubusercontent.com/u/56785022?u=d5c3a02567c8649e146fcfc51b6060ccaf8adef8&v=4 url: https://github.com/rjNemo - login: RunningIkkyu count: 12 avatarUrl: https://avatars.githubusercontent.com/u/31848542?u=706e1ee3f248245f2d68b976d149d06fd5a2010d&v=4 url: https://github.com/RunningIkkyu +- login: yezz123 + count: 12 + avatarUrl: https://avatars.githubusercontent.com/u/52716203?u=636b4f79645176df4527dd45c12d5dbb5a4193cf&v=4 + url: https://github.com/yezz123 - login: sh0nk count: 12 avatarUrl: https://avatars.githubusercontent.com/u/6478810?u=af15d724875cec682ed8088a86d36b2798f981c0&v=4 url: https://github.com/sh0nk -- login: yezz123 - count: 11 - avatarUrl: https://avatars.githubusercontent.com/u/52716203?u=636b4f79645176df4527dd45c12d5dbb5a4193cf&v=4 - url: https://github.com/yezz123 - login: mariacamilagl count: 10 avatarUrl: https://avatars.githubusercontent.com/u/11489395?u=4adb6986bf3debfc2b8216ae701f2bd47d73da7d&v=4 @@ -400,6 +424,10 @@ top_reviewers: count: 9 avatarUrl: https://avatars.githubusercontent.com/u/49435654?v=4 url: https://github.com/kty4119 +- login: zy7y + count: 9 + avatarUrl: https://avatars.githubusercontent.com/u/67154681?u=5d634834cc514028ea3f9115f7030b99a1f4d5a4&v=4 + url: https://github.com/zy7y - login: bezaca count: 9 avatarUrl: https://avatars.githubusercontent.com/u/69092910?u=4ac58eab99bd37d663f3d23551df96d4fbdbf760&v=4 @@ -444,39 +472,31 @@ top_reviewers: count: 7 avatarUrl: https://avatars.githubusercontent.com/u/34248814?v=4 url: https://github.com/krocdort +- login: dimaqq + count: 7 + avatarUrl: https://avatars.githubusercontent.com/u/662249?v=4 + url: https://github.com/dimaqq - login: jovicon count: 6 avatarUrl: https://avatars.githubusercontent.com/u/21287303?u=b049eac3e51a4c0473c2efe66b4d28a7d8f2b572&v=4 url: https://github.com/jovicon +- login: NinaHwang + count: 6 + avatarUrl: https://avatars.githubusercontent.com/u/79563565?u=1741703bd6c8f491503354b363a86e879b4c1cab&v=4 + url: https://github.com/NinaHwang - login: diogoduartec count: 5 avatarUrl: https://avatars.githubusercontent.com/u/31852339?u=b50fc11c531e9b77922e19edfc9e7233d4d7b92e&v=4 url: https://github.com/diogoduartec -- login: nimctl +- login: n25a count: 5 - avatarUrl: https://avatars.githubusercontent.com/u/49960770?u=e39b11d47188744ee07b2a1c7ce1a1bdf3c80760&v=4 - url: https://github.com/nimctl + avatarUrl: https://avatars.githubusercontent.com/u/49960770?u=eb3c95338741c78fff7d9d5d7ace9617e53eee4a&v=4 + url: https://github.com/n25a +- login: izaguerreiro + count: 5 + avatarUrl: https://avatars.githubusercontent.com/u/2241504?v=4 + url: https://github.com/izaguerreiro - login: israteneda count: 5 avatarUrl: https://avatars.githubusercontent.com/u/20668624?u=d7b2961d330aca65fbce5bdb26a0800a3d23ed2d&v=4 url: https://github.com/israteneda -- login: juntatalor - count: 5 - avatarUrl: https://avatars.githubusercontent.com/u/8134632?v=4 - url: https://github.com/juntatalor -- login: SnkSynthesis - count: 5 - avatarUrl: https://avatars.githubusercontent.com/u/63564282?u=0078826509dbecb2fdb543f4e881c9cd06157893&v=4 - url: https://github.com/SnkSynthesis -- login: anthonycepeda - count: 5 - avatarUrl: https://avatars.githubusercontent.com/u/72019805?u=892f700c79f9732211bd5221bf16eec32356a732&v=4 - url: https://github.com/anthonycepeda -- login: oandersonmagalhaes - count: 5 - avatarUrl: https://avatars.githubusercontent.com/u/83456692?v=4 - url: https://github.com/oandersonmagalhaes -- login: qysfblog - count: 5 - avatarUrl: https://avatars.githubusercontent.com/u/52229895?v=4 - url: https://github.com/qysfblog From b93f8a709ab3923d1268dbc845f41985c0302b33 Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 1 Feb 2022 14:28:16 +0000 Subject: [PATCH 023/142] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 68b75e70281a2..6d5ee8ea11d42 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -2,6 +2,7 @@ ## Latest Changes +* 👥 Update FastAPI People. PR [#4502](https://github.com/tiangolo/fastapi/pull/4502) by [@github-actions[bot]](https://github.com/apps/github-actions). ## 0.73.0 ### Features From 6034f80687302fa4f7da73cc3142750c3e239401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 13 Feb 2022 18:18:38 +0100 Subject: [PATCH 024/142] =?UTF-8?q?=F0=9F=92=9A=20Only=20build=20docs=20on?= =?UTF-8?q?=20push=20when=20on=20master=20to=20avoid=20duplicate=20runs=20?= =?UTF-8?q?from=20PRs=20(#4564)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-docs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index ccf964486a391..2482660f3bdca 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -1,6 +1,8 @@ name: Build Docs on: push: + branches: + - master pull_request: types: [opened, synchronize] jobs: From 78b07cb809e97f400e196ff3d89862b9d5bd5dc2 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 13 Feb 2022 17:19:09 +0000 Subject: [PATCH 025/142] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 6d5ee8ea11d42..8c368e6766cdd 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -2,6 +2,7 @@ ## Latest Changes +* 💚 Only build docs on push when on master to avoid duplicate runs from PRs. PR [#4564](https://github.com/tiangolo/fastapi/pull/4564) by [@tiangolo](https://github.com/tiangolo). * 👥 Update FastAPI People. PR [#4502](https://github.com/tiangolo/fastapi/pull/4502) by [@github-actions[bot]](https://github.com/apps/github-actions). ## 0.73.0 From 9d56a3cb59d59896bc38293b9fa54ae69b7cd36c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Thu, 17 Feb 2022 13:40:12 +0100 Subject: [PATCH 026/142] =?UTF-8?q?=E2=9C=A8=20Update=20internal=20`AsyncE?= =?UTF-8?q?xitStack`=20to=20fix=20context=20for=20dependencies=20with=20`y?= =?UTF-8?q?ield`=20(#4575)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dependencies/dependencies-with-yield.md | 10 +-- fastapi/applications.py | 61 +++++++++++++--- fastapi/middleware/asyncexitstack.py | 28 ++++++++ tests/test_dependency_contextmanager.py | 44 ++++++++++-- tests/test_dependency_contextvars.py | 51 +++++++++++++ tests/test_dependency_normal_exceptions.py | 71 +++++++++++++++++++ tests/test_exception_handlers.py | 23 ++++++ 7 files changed, 272 insertions(+), 16 deletions(-) create mode 100644 fastapi/middleware/asyncexitstack.py create mode 100644 tests/test_dependency_contextvars.py create mode 100644 tests/test_dependency_normal_exceptions.py diff --git a/docs/en/docs/tutorial/dependencies/dependencies-with-yield.md b/docs/en/docs/tutorial/dependencies/dependencies-with-yield.md index 82553afae52dd..ac2e9cb8cb4c4 100644 --- a/docs/en/docs/tutorial/dependencies/dependencies-with-yield.md +++ b/docs/en/docs/tutorial/dependencies/dependencies-with-yield.md @@ -99,7 +99,7 @@ You saw that you can use dependencies with `yield` and have `try` blocks that ca It might be tempting to raise an `HTTPException` or similar in the exit code, after the `yield`. But **it won't work**. -The exit code in dependencies with `yield` is executed *after* [Exception Handlers](../handling-errors.md#install-custom-exception-handlers){.internal-link target=_blank}. There's nothing catching exceptions thrown by your dependencies in the exit code (after the `yield`). +The exit code in dependencies with `yield` is executed *after* the response is sent, so [Exception Handlers](../handling-errors.md#install-custom-exception-handlers){.internal-link target=_blank} will have already run. There's nothing catching exceptions thrown by your dependencies in the exit code (after the `yield`). So, if you raise an `HTTPException` after the `yield`, the default (or any custom) exception handler that catches `HTTPException`s and returns an HTTP 400 response won't be there to catch that exception anymore. @@ -138,9 +138,11 @@ participant tasks as Background tasks end dep ->> operation: Run dependency, e.g. DB session opt raise - operation -->> handler: Raise HTTPException + operation -->> dep: Raise HTTPException + dep -->> handler: Auto forward exception handler -->> client: HTTP error response operation -->> dep: Raise other exception + dep -->> handler: Auto forward exception end operation ->> client: Return response to client Note over client,operation: Response is already sent, can't change it anymore @@ -162,9 +164,9 @@ participant tasks as Background tasks After one of those responses is sent, no other response can be sent. !!! tip - This diagram shows `HTTPException`, but you could also raise any other exception for which you create a [Custom Exception Handler](../handling-errors.md#install-custom-exception-handlers){.internal-link target=_blank}. And that exception would be handled by that custom exception handler instead of the dependency exit code. + This diagram shows `HTTPException`, but you could also raise any other exception for which you create a [Custom Exception Handler](../handling-errors.md#install-custom-exception-handlers){.internal-link target=_blank}. - But if you raise an exception that is not handled by the exception handlers, it will be handled by the exit code of the dependency. + If you raise any exception, it will be passed to the dependencies with yield, including `HTTPException`, and then **again** to the exception handlers. If there's no exception handler for that exception, it will then be handled by the default internal `ServerErrorMiddleware`, returning a 500 HTTP status code, to let the client know that there was an error in the server. ## Context Managers diff --git a/fastapi/applications.py b/fastapi/applications.py index dbfd76fb9f3cd..9fb78719c31c4 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -2,7 +2,6 @@ from typing import Any, Callable, Coroutine, Dict, List, Optional, Sequence, Type, Union from fastapi import routing -from fastapi.concurrency import AsyncExitStack from fastapi.datastructures import Default, DefaultPlaceholder from fastapi.encoders import DictIntStrAny, SetIntStr from fastapi.exception_handlers import ( @@ -11,6 +10,7 @@ ) from fastapi.exceptions import RequestValidationError from fastapi.logger import logger +from fastapi.middleware.asyncexitstack import AsyncExitStackMiddleware from fastapi.openapi.docs import ( get_redoc_html, get_swagger_ui_html, @@ -21,8 +21,9 @@ from fastapi.types import DecoratedCallable from starlette.applications import Starlette from starlette.datastructures import State -from starlette.exceptions import HTTPException +from starlette.exceptions import ExceptionMiddleware, HTTPException from starlette.middleware import Middleware +from starlette.middleware.errors import ServerErrorMiddleware from starlette.requests import Request from starlette.responses import HTMLResponse, JSONResponse, Response from starlette.routing import BaseRoute @@ -134,6 +135,55 @@ def __init__( self.openapi_schema: Optional[Dict[str, Any]] = None self.setup() + def build_middleware_stack(self) -> ASGIApp: + # Duplicate/override from Starlette to add AsyncExitStackMiddleware + # inside of ExceptionMiddleware, inside of custom user middlewares + debug = self.debug + error_handler = None + exception_handlers = {} + + for key, value in self.exception_handlers.items(): + if key in (500, Exception): + error_handler = value + else: + exception_handlers[key] = value + + middleware = ( + [Middleware(ServerErrorMiddleware, handler=error_handler, debug=debug)] + + self.user_middleware + + [ + Middleware( + ExceptionMiddleware, handlers=exception_handlers, debug=debug + ), + # Add FastAPI-specific AsyncExitStackMiddleware for dependencies with + # contextvars. + # This needs to happen after user middlewares because those create a + # new contextvars context copy by using a new AnyIO task group. + # The initial part of dependencies with yield is executed in the + # FastAPI code, inside all the middlewares, but the teardown part + # (after yield) is executed in the AsyncExitStack in this middleware, + # if the AsyncExitStack lived outside of the custom middlewares and + # contextvars were set in a dependency with yield in that internal + # contextvars context, the values would not be available in the + # outside context of the AsyncExitStack. + # By putting the middleware and the AsyncExitStack here, inside all + # user middlewares, the code before and after yield in dependencies + # with yield is executed in the same contextvars context, so all values + # set in contextvars before yield is still available after yield as + # would be expected. + # Additionally, by having this AsyncExitStack here, after the + # ExceptionMiddleware, now dependencies can catch handled exceptions, + # e.g. HTTPException, to customize the teardown code (e.g. DB session + # rollback). + Middleware(AsyncExitStackMiddleware), + ] + ) + + app = self.router + for cls, options in reversed(middleware): + app = cls(app=app, **options) + return app + def openapi(self) -> Dict[str, Any]: if not self.openapi_schema: self.openapi_schema = get_openapi( @@ -206,12 +256,7 @@ async def redoc_html(req: Request) -> HTMLResponse: async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if self.root_path: scope["root_path"] = self.root_path - if AsyncExitStack: - async with AsyncExitStack() as stack: - scope["fastapi_astack"] = stack - await super().__call__(scope, receive, send) - else: - await super().__call__(scope, receive, send) # pragma: no cover + await super().__call__(scope, receive, send) def add_api_route( self, diff --git a/fastapi/middleware/asyncexitstack.py b/fastapi/middleware/asyncexitstack.py new file mode 100644 index 0000000000000..503a68ac732c4 --- /dev/null +++ b/fastapi/middleware/asyncexitstack.py @@ -0,0 +1,28 @@ +from typing import Optional + +from fastapi.concurrency import AsyncExitStack +from starlette.types import ASGIApp, Receive, Scope, Send + + +class AsyncExitStackMiddleware: + def __init__(self, app: ASGIApp, context_name: str = "fastapi_astack") -> None: + self.app = app + self.context_name = context_name + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if AsyncExitStack: + dependency_exception: Optional[Exception] = None + async with AsyncExitStack() as stack: + scope[self.context_name] = stack + try: + await self.app(scope, receive, send) + except Exception as e: + dependency_exception = e + raise e + if dependency_exception: + # This exception was possibly handled by the dependency but it should + # still bubble up so that the ServerErrorMiddleware can return a 500 + # or the ExceptionMiddleware can catch and handle any other exceptions + raise dependency_exception + else: + await self.app(scope, receive, send) # pragma: no cover diff --git a/tests/test_dependency_contextmanager.py b/tests/test_dependency_contextmanager.py index 3e42b47f7edf3..03ef56c4d7e5b 100644 --- a/tests/test_dependency_contextmanager.py +++ b/tests/test_dependency_contextmanager.py @@ -235,7 +235,16 @@ def test_sync_raise_other(): assert "/sync_raise" not in errors -def test_async_raise(): +def test_async_raise_raises(): + with pytest.raises(AsyncDependencyError): + client.get("/async_raise") + assert state["/async_raise"] == "asyncgen raise finalized" + assert "/async_raise" in errors + errors.clear() + + +def test_async_raise_server_error(): + client = TestClient(app, raise_server_exceptions=False) response = client.get("/async_raise") assert response.status_code == 500, response.text assert state["/async_raise"] == "asyncgen raise finalized" @@ -270,7 +279,16 @@ def test_background_tasks(): assert state["bg"] == "bg set - b: started b - a: started a" -def test_sync_raise(): +def test_sync_raise_raises(): + with pytest.raises(SyncDependencyError): + client.get("/sync_raise") + assert state["/sync_raise"] == "generator raise finalized" + assert "/sync_raise" in errors + errors.clear() + + +def test_sync_raise_server_error(): + client = TestClient(app, raise_server_exceptions=False) response = client.get("/sync_raise") assert response.status_code == 500, response.text assert state["/sync_raise"] == "generator raise finalized" @@ -306,7 +324,16 @@ def test_sync_sync_raise_other(): assert "/sync_raise" not in errors -def test_sync_async_raise(): +def test_sync_async_raise_raises(): + with pytest.raises(AsyncDependencyError): + client.get("/sync_async_raise") + assert state["/async_raise"] == "asyncgen raise finalized" + assert "/async_raise" in errors + errors.clear() + + +def test_sync_async_raise_server_error(): + client = TestClient(app, raise_server_exceptions=False) response = client.get("/sync_async_raise") assert response.status_code == 500, response.text assert state["/async_raise"] == "asyncgen raise finalized" @@ -314,7 +341,16 @@ def test_sync_async_raise(): errors.clear() -def test_sync_sync_raise(): +def test_sync_sync_raise_raises(): + with pytest.raises(SyncDependencyError): + client.get("/sync_sync_raise") + assert state["/sync_raise"] == "generator raise finalized" + assert "/sync_raise" in errors + errors.clear() + + +def test_sync_sync_raise_server_error(): + client = TestClient(app, raise_server_exceptions=False) response = client.get("/sync_sync_raise") assert response.status_code == 500, response.text assert state["/sync_raise"] == "generator raise finalized" diff --git a/tests/test_dependency_contextvars.py b/tests/test_dependency_contextvars.py new file mode 100644 index 0000000000000..076802df8444c --- /dev/null +++ b/tests/test_dependency_contextvars.py @@ -0,0 +1,51 @@ +from contextvars import ContextVar +from typing import Any, Awaitable, Callable, Dict, Optional + +from fastapi import Depends, FastAPI, Request, Response +from fastapi.testclient import TestClient + +legacy_request_state_context_var: ContextVar[Optional[Dict[str, Any]]] = ContextVar( + "legacy_request_state_context_var", default=None +) + +app = FastAPI() + + +async def set_up_request_state_dependency(): + request_state = {"user": "deadpond"} + contextvar_token = legacy_request_state_context_var.set(request_state) + yield request_state + legacy_request_state_context_var.reset(contextvar_token) + + +@app.middleware("http") +async def custom_middleware( + request: Request, call_next: Callable[[Request], Awaitable[Response]] +): + response = await call_next(request) + response.headers["custom"] = "foo" + return response + + +@app.get("/user", dependencies=[Depends(set_up_request_state_dependency)]) +def get_user(): + request_state = legacy_request_state_context_var.get() + assert request_state + return request_state["user"] + + +client = TestClient(app) + + +def test_dependency_contextvars(): + """ + Check that custom middlewares don't affect the contextvar context for dependencies. + + The code before yield and the code after yield should be run in the same contextvar + context, so that request_state_context_var.reset(contextvar_token). + + If they are run in a different context, that raises an error. + """ + response = client.get("/user") + assert response.json() == "deadpond" + assert response.headers["custom"] == "foo" diff --git a/tests/test_dependency_normal_exceptions.py b/tests/test_dependency_normal_exceptions.py new file mode 100644 index 0000000000000..49a19f460cc04 --- /dev/null +++ b/tests/test_dependency_normal_exceptions.py @@ -0,0 +1,71 @@ +import pytest +from fastapi import Body, Depends, FastAPI, HTTPException +from fastapi.testclient import TestClient + +initial_fake_database = {"rick": "Rick Sanchez"} + +fake_database = initial_fake_database.copy() + +initial_state = {"except": False, "finally": False} + +state = initial_state.copy() + +app = FastAPI() + + +async def get_database(): + temp_database = fake_database.copy() + try: + yield temp_database + fake_database.update(temp_database) + except HTTPException: + state["except"] = True + finally: + state["finally"] = True + + +@app.put("/invalid-user/{user_id}") +def put_invalid_user( + user_id: str, name: str = Body(...), db: dict = Depends(get_database) +): + db[user_id] = name + raise HTTPException(status_code=400, detail="Invalid user") + + +@app.put("/user/{user_id}") +def put_user(user_id: str, name: str = Body(...), db: dict = Depends(get_database)): + db[user_id] = name + return {"message": "OK"} + + +@pytest.fixture(autouse=True) +def reset_state_and_db(): + global fake_database + global state + fake_database = initial_fake_database.copy() + state = initial_state.copy() + + +client = TestClient(app) + + +def test_dependency_gets_exception(): + assert state["except"] is False + assert state["finally"] is False + response = client.put("/invalid-user/rick", json="Morty") + assert response.status_code == 400, response.text + assert response.json() == {"detail": "Invalid user"} + assert state["except"] is True + assert state["finally"] is True + assert fake_database["rick"] == "Rick Sanchez" + + +def test_dependency_no_exception(): + assert state["except"] is False + assert state["finally"] is False + response = client.put("/user/rick", json="Morty") + assert response.status_code == 200, response.text + assert response.json() == {"message": "OK"} + assert state["except"] is False + assert state["finally"] is True + assert fake_database["rick"] == "Morty" diff --git a/tests/test_exception_handlers.py b/tests/test_exception_handlers.py index 6153f7ab925ed..67a4becec7adc 100644 --- a/tests/test_exception_handlers.py +++ b/tests/test_exception_handlers.py @@ -1,3 +1,4 @@ +import pytest from fastapi import FastAPI, HTTPException from fastapi.exceptions import RequestValidationError from fastapi.testclient import TestClient @@ -12,10 +13,15 @@ def request_validation_exception_handler(request, exception): return JSONResponse({"exception": "request-validation"}) +def server_error_exception_handler(request, exception): + return JSONResponse(status_code=500, content={"exception": "server-error"}) + + app = FastAPI( exception_handlers={ HTTPException: http_exception_handler, RequestValidationError: request_validation_exception_handler, + Exception: server_error_exception_handler, } ) @@ -32,6 +38,11 @@ def route_with_request_validation_exception(param: int): pass # pragma: no cover +@app.get("/server-error") +def route_with_server_error(): + raise RuntimeError("Oops!") + + def test_override_http_exception(): response = client.get("/http-exception") assert response.status_code == 200 @@ -42,3 +53,15 @@ def test_override_request_validation_exception(): response = client.get("/request-validation/invalid") assert response.status_code == 200 assert response.json() == {"exception": "request-validation"} + + +def test_override_server_error_exception_raises(): + with pytest.raises(RuntimeError): + client.get("/server-error") + + +def test_override_server_error_exception_response(): + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/server-error") + assert response.status_code == 500 + assert response.json() == {"exception": "server-error"} From 4fcb00328c2b6c2ab5167359b7f62b1c481e1faf Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 17 Feb 2022 12:40:46 +0000 Subject: [PATCH 027/142] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 8c368e6766cdd..b23dc1b9838b8 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -2,6 +2,7 @@ ## Latest Changes +* ✨ Update internal `AsyncExitStack` to fix context for dependencies with `yield`. PR [#4575](https://github.com/tiangolo/fastapi/pull/4575) by [@tiangolo](https://github.com/tiangolo). * 💚 Only build docs on push when on master to avoid duplicate runs from PRs. PR [#4564](https://github.com/tiangolo/fastapi/pull/4564) by [@tiangolo](https://github.com/tiangolo). * 👥 Update FastAPI People. PR [#4502](https://github.com/tiangolo/fastapi/pull/4502) by [@github-actions[bot]](https://github.com/apps/github-actions). ## 0.73.0 From 59e36481dc86673968c21cf9ea636eb3f379dc64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Thu, 17 Feb 2022 15:18:01 +0100 Subject: [PATCH 028/142] =?UTF-8?q?=F0=9F=94=A7=20Add=20Striveworks=20spon?= =?UTF-8?q?sor=20(#4596)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + docs/en/data/sponsors.yml | 3 +++ docs/en/data/sponsors_badge.yml | 1 + .../en/docs/img/sponsors/striveworks-banner.png | Bin 0 -> 9681 bytes docs/en/docs/img/sponsors/striveworks.png | Bin 0 -> 22639 bytes docs/en/overrides/main.html | 6 ++++++ 6 files changed, 11 insertions(+) create mode 100644 docs/en/docs/img/sponsors/striveworks-banner.png create mode 100644 docs/en/docs/img/sponsors/striveworks.png diff --git a/README.md b/README.md index c9c69d3e88a25..bec58aad14f66 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ The key features are: + diff --git a/docs/en/data/sponsors.yml b/docs/en/data/sponsors.yml index b98e68b655dad..4d63a72887790 100644 --- a/docs/en/data/sponsors.yml +++ b/docs/en/data/sponsors.yml @@ -8,6 +8,9 @@ gold: - url: https://www.dropbase.io/careers title: Dropbase - seamlessly collect, clean, and centralize data. img: https://fastapi.tiangolo.com/img/sponsors/dropbase.svg + - url: https://striveworks.us/careers?utm_source=fastapi&utm_medium=sponsor_banner&utm_campaign=feb_march#openings + title: https://striveworks.us/careers + img: https://fastapi.tiangolo.com/img/sponsors/striveworks.png silver: - url: https://www.deta.sh/?ref=fastapi title: The launchpad for all your (team's) ideas diff --git a/docs/en/data/sponsors_badge.yml b/docs/en/data/sponsors_badge.yml index 0c4e716d70029..dbf69c1b3551c 100644 --- a/docs/en/data/sponsors_badge.yml +++ b/docs/en/data/sponsors_badge.yml @@ -8,3 +8,4 @@ logins: - deepset-ai - cryptapi - DropbaseHQ + - Striveworks diff --git a/docs/en/docs/img/sponsors/striveworks-banner.png b/docs/en/docs/img/sponsors/striveworks-banner.png new file mode 100644 index 0000000000000000000000000000000000000000..5206744b7a6e45b8c541ea55941f47f84a63eaec GIT binary patch literal 9681 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 literal 0 HcmV?d00001 diff --git a/docs/en/docs/img/sponsors/striveworks.png b/docs/en/docs/img/sponsors/striveworks.png new file mode 100644 index 0000000000000000000000000000000000000000..435ac536c14eacdd5c4f578d56495281c53261d6 GIT binary patch literal 22639 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