From e46bce35363f860ce3676adfd97658564248ee52 Mon Sep 17 00:00:00 2001 From: Patrick Wang Date: Thu, 1 Oct 2020 23:17:10 -0400 Subject: [PATCH 01/11] Check Content-Type before assuming JSON Don't try to convert to JSON unless Content-Type == application/json. --- fastapi/routing.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/fastapi/routing.py b/fastapi/routing.py index ac5e19d99835a..25c78cfa4c8bf 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -174,14 +174,20 @@ def get_request_handler( async def app(request: Request) -> Response: try: - body = None + body: Any = None if body_field: if is_body_form: body = await request.form() else: body_bytes = await request.body() if body_bytes: - body = await request.json() + if ( + request.headers.get("Content-Type", "application/json") + == "application/json" + ): + body = await request.json() + else: + body = body_bytes except json.JSONDecodeError as e: raise RequestValidationError([ErrorWrapper(e, ("body", e.pos))], body=e.doc) except Exception as e: From df4fdf0c7e092550d1e5b1633f10f41d713bbd66 Mon Sep 17 00:00:00 2001 From: Patrick Wang Date: Thu, 1 Oct 2020 23:17:41 -0400 Subject: [PATCH 02/11] Remove now-unneccessary exception handling --- fastapi/routing.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/fastapi/routing.py b/fastapi/routing.py index 25c78cfa4c8bf..7f34fa7ca6e50 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -1,7 +1,6 @@ import asyncio import enum import inspect -import json from typing import ( Any, Callable, @@ -188,8 +187,6 @@ async def app(request: Request) -> Response: body = await request.json() else: body = body_bytes - except json.JSONDecodeError as e: - raise RequestValidationError([ErrorWrapper(e, ("body", e.pos))], body=e.doc) except Exception as e: raise HTTPException( status_code=400, detail="There was an error parsing the body" From 4e8605e5bf2c32082c7292be92838532d734d29b Mon Sep 17 00:00:00 2001 From: Patrick Wang Date: Thu, 1 Oct 2020 23:18:03 -0400 Subject: [PATCH 03/11] Fix broken-body test --- tests/test_tutorial/test_body/test_tutorial001.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/tests/test_tutorial/test_body/test_tutorial001.py b/tests/test_tutorial/test_body/test_tutorial001.py index 38c6dbe876b26..382b57650916d 100644 --- a/tests/test_tutorial/test_body/test_tutorial001.py +++ b/tests/test_tutorial/test_body/test_tutorial001.py @@ -178,16 +178,9 @@ def test_post_broken_body(): assert response.json() == { "detail": [ { - "ctx": { - "colno": 1, - "doc": "name=Foo&price=50.5", - "lineno": 1, - "msg": "Expecting value", - "pos": 0, - }, - "loc": ["body", 0], - "msg": "Expecting value: line 1 column 1 (char 0)", - "type": "value_error.jsondecode", + "loc": ["body"], + "msg": "value is not a valid dict", + "type": "type_error.dict", } ] } From c7191f50e5908afa49b9d92fe0a9401d8e689e0a Mon Sep 17 00:00:00 2001 From: Patrick Wang Date: Fri, 2 Oct 2020 10:34:39 -0400 Subject: [PATCH 04/11] Assume "Content-Type": "application/octet-stream" Per RFC 7231, the "Content-Type" header is not strictly required, and if it is not present, "application/octet-stream" should be assumed. --- fastapi/routing.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/fastapi/routing.py b/fastapi/routing.py index 7f34fa7ca6e50..56dc926fdcac6 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -181,7 +181,9 @@ async def app(request: Request) -> Response: body_bytes = await request.body() if body_bytes: if ( - request.headers.get("Content-Type", "application/json") + request.headers.get( + "Content-Type", "application/octet-stream" + ) == "application/json" ): body = await request.json() From c169258692988cf768c45a97f4edd96aaa06be77 Mon Sep 17 00:00:00 2001 From: Patrick Wang Date: Fri, 2 Oct 2020 10:36:17 -0400 Subject: [PATCH 05/11] Explicitly specify Content-Type in gzip test --- .../test_custom_request_and_route/test_tutorial001.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_tutorial/test_custom_request_and_route/test_tutorial001.py b/tests/test_tutorial/test_custom_request_and_route/test_tutorial001.py index cc85a8a82a5ac..3eb5822e28816 100644 --- a/tests/test_tutorial/test_custom_request_and_route/test_tutorial001.py +++ b/tests/test_tutorial/test_custom_request_and_route/test_tutorial001.py @@ -25,6 +25,7 @@ def test_gzip_request(compress): if compress: data = gzip.compress(data) headers["Content-Encoding"] = "gzip" + headers["Content-Type"] = "application/json" response = client.post("/sum", data=data, headers=headers) assert response.json() == {"sum": n} From d644c3060befbd211994d047a3b6b3ee0f6ba8e9 Mon Sep 17 00:00:00 2001 From: Patrick Wang Date: Fri, 2 Oct 2020 12:54:51 -0400 Subject: [PATCH 06/11] Interpret as JSON any mimetype ending with "json" --- fastapi/routing.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/fastapi/routing.py b/fastapi/routing.py index 56dc926fdcac6..8b185d1eaf5e7 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -180,12 +180,9 @@ async def app(request: Request) -> Response: else: body_bytes = await request.body() if body_bytes: - if ( - request.headers.get( - "Content-Type", "application/octet-stream" - ) - == "application/json" - ): + if request.headers.get( + "Content-Type", "application/octet-stream" + ).endswith("json"): body = await request.json() else: body = body_bytes From 6ecfba9a2b3a1cdcbc04179f452c5e03d5c394f2 Mon Sep 17 00:00:00 2001 From: Patrick Wang Date: Sat, 3 Oct 2020 13:38:02 -0400 Subject: [PATCH 07/11] Generalize media type parsing to handle parameters --- fastapi/routing.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/fastapi/routing.py b/fastapi/routing.py index 8b185d1eaf5e7..57fbbf9662cea 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -180,9 +180,13 @@ async def app(request: Request) -> Response: else: body_bytes = await request.body() if body_bytes: - if request.headers.get( - "Content-Type", "application/octet-stream" - ).endswith("json"): + if ( + request.headers.get( + "Content-Type", "application/octet-stream" + ) + .split(";")[0] + .endswith("json") + ): body = await request.json() else: body = body_bytes From d752efdff426d779919ccabd7bfa85b8019ec255 Mon Sep 17 00:00:00 2001 From: Patrick Wang Date: Wed, 20 Jan 2021 15:30:15 -0500 Subject: [PATCH 08/11] Add test for handling various Content-Type headers --- .../test_body/test_tutorial001.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_tutorial/test_body/test_tutorial001.py b/tests/test_tutorial/test_body/test_tutorial001.py index 382b57650916d..f50e415cf6644 100644 --- a/tests/test_tutorial/test_body/test_tutorial001.py +++ b/tests/test_tutorial/test_body/test_tutorial001.py @@ -188,3 +188,37 @@ def test_post_broken_body(): response = client.post("/items/", json={"test": "test2"}) assert response.status_code == 400, response.text assert response.json() == {"detail": "There was an error parsing the body"} + + +def test_explicit_content_type(): + data = '{"name": "Foo", "price": 50.5}' + response = client.post( + "/items/", data=data, headers={"Content-Type": "applications/json"} + ) + assert response.status_code == 200, response.text + + data = '{"name": "Foo", "price": 50.5}' + response = client.post( + "/items/", data=data, headers={"Content-Type": "applications/geo+json"} + ) + assert response.status_code == 200, response.text + + invalid_dict = { + "detail": [ + { + "loc": ["body"], + "msg": "value is not a valid dict", + "type": "type_error.dict", + } + ] + } + + response = client.post("/items/", data=data, headers={"Content-Type": "text/plain"}) + assert response.status_code == 422, response.text + assert response.json() == invalid_dict + + response = client.post( + "/items/", data=data, headers={"Content-Type": "application/geo+json-seq"} + ) + assert response.status_code == 422, response.text + assert response.json() == invalid_dict From f80305de0b03ebc2547736e5f41ac26c0ca961e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Mon, 7 Jun 2021 12:27:03 +0200 Subject: [PATCH 09/11] =?UTF-8?q?=E2=9C=85=20Update=20tests=20for=20conten?= =?UTF-8?q?t-type=20headers=20and=20include=20extra=20corner=20case?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_body/test_tutorial001.py | 51 +++++++++++++++---- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/tests/test_tutorial/test_body/test_tutorial001.py b/tests/test_tutorial/test_body/test_tutorial001.py index f50e415cf6644..0f6ae7648223f 100644 --- a/tests/test_tutorial/test_body/test_tutorial001.py +++ b/tests/test_tutorial/test_body/test_tutorial001.py @@ -1,5 +1,3 @@ -from unittest.mock import patch - import pytest from fastapi.testclient import TestClient @@ -173,6 +171,31 @@ def test_post_body(path, body, expected_status, expected_response): def test_post_broken_body(): + response = client.post( + "/items/", + headers={"content-type": "application/json"}, + data="{some broken json}", + ) + assert response.status_code == 422, response.text + assert response.json() == { + "detail": [ + { + "loc": ["body", 1], + "msg": "Expecting property name enclosed in double quotes: line 1 column 2 (char 1)", + "type": "value_error.jsondecode", + "ctx": { + "msg": "Expecting property name enclosed in double quotes", + "doc": "{some broken json}", + "pos": 1, + "lineno": 1, + "colno": 2, + }, + } + ] + } + + +def test_post_form_for_json(): response = client.post("/items/", data={"name": "Foo", "price": 50.5}) assert response.status_code == 422, response.text assert response.json() == { @@ -184,25 +207,28 @@ def test_post_broken_body(): } ] } - with patch("json.loads", side_effect=Exception): - response = client.post("/items/", json={"test": "test2"}) - assert response.status_code == 400, response.text - assert response.json() == {"detail": "There was an error parsing the body"} def test_explicit_content_type(): - data = '{"name": "Foo", "price": 50.5}' response = client.post( - "/items/", data=data, headers={"Content-Type": "applications/json"} + "/items/", + data='{"name": "Foo", "price": 50.5}', + headers={"Content-Type": "application/json"}, ) assert response.status_code == 200, response.text - data = '{"name": "Foo", "price": 50.5}' + +def test_geo_json(): response = client.post( - "/items/", data=data, headers={"Content-Type": "applications/geo+json"} + "/items/", + data='{"name": "Foo", "price": 50.5}', + headers={"Content-Type": "application/geo+json"}, ) assert response.status_code == 200, response.text + +def test_wrong_headers(): + data = '{"name": "Foo", "price": 50.5}' invalid_dict = { "detail": [ { @@ -222,3 +248,8 @@ def test_explicit_content_type(): ) assert response.status_code == 422, response.text assert response.json() == invalid_dict + response = client.post( + "/items/", data=data, headers={"Content-Type": "application/not-really-json"} + ) + assert response.status_code == 422, response.text + assert response.json() == invalid_dict From 344ae3f84364979681962033fc9192202c1bf242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Mon, 7 Jun 2021 12:28:13 +0200 Subject: [PATCH 10/11] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Re-implement=20JSON?= =?UTF-8?q?=20content-type=20parsing=20with=20Python's=20standard=20librar?= =?UTF-8?q?y=20email=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit to cover corner cases --- fastapi/routing.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/fastapi/routing.py b/fastapi/routing.py index 57fbbf9662cea..9b51f03cac562 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -1,6 +1,8 @@ import asyncio +import email.message import enum import inspect +import json from typing import ( Any, Callable, @@ -35,7 +37,7 @@ ) from pydantic import BaseModel from pydantic.error_wrappers import ErrorWrapper, ValidationError -from pydantic.fields import ModelField +from pydantic.fields import ModelField, Undefined from starlette import routing from starlette.concurrency import run_in_threadpool from starlette.exceptions import HTTPException @@ -180,16 +182,21 @@ async def app(request: Request) -> Response: else: body_bytes = await request.body() if body_bytes: - if ( - request.headers.get( - "Content-Type", "application/octet-stream" - ) - .split(";")[0] - .endswith("json") - ): - body = await request.json() + json_body: Any = Undefined + content_type_value = request.headers.get("content-type") + if content_type_value: + message = email.message.Message() + message["content-type"] = content_type_value + if message.get_content_maintype() == "application": + subtype = message.get_content_subtype() + if subtype == "json" or subtype.endswith("+json"): + json_body = await request.json() + if json_body != Undefined: + body = json_body else: body = body_bytes + except json.JSONDecodeError as e: + raise RequestValidationError([ErrorWrapper(e, ("body", e.pos))], body=e.doc) except Exception as e: raise HTTPException( status_code=400, detail="There was an error parsing the body" From 5729e8a1671a19c23f472c10e620d1d3a568e9e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Mon, 7 Jun 2021 12:41:26 +0200 Subject: [PATCH 11/11] =?UTF-8?q?=E2=9C=85=20Include=20again=20test=20with?= =?UTF-8?q?=20patch=20to=20force=20exception,=20for=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_tutorial/test_body/test_tutorial001.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_tutorial/test_body/test_tutorial001.py b/tests/test_tutorial/test_body/test_tutorial001.py index 0f6ae7648223f..c90240ae4c349 100644 --- a/tests/test_tutorial/test_body/test_tutorial001.py +++ b/tests/test_tutorial/test_body/test_tutorial001.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + import pytest from fastapi.testclient import TestClient @@ -253,3 +255,9 @@ def test_wrong_headers(): ) assert response.status_code == 422, response.text assert response.json() == invalid_dict + + +def test_other_exceptions(): + with patch("json.loads", side_effect=Exception): + response = client.post("/items/", json={"test": "test2"}) + assert response.status_code == 400, response.text