]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
✨ Add support for forbidding extra form fields with Pydantic models (#12134)
authorSebastián Ramírez <tiangolo@gmail.com>
Fri, 6 Sep 2024 17:31:18 +0000 (19:31 +0200)
committerGitHub <noreply@github.com>
Fri, 6 Sep 2024 17:31:18 +0000 (19:31 +0200)
Co-authored-by: Sofie Van Landeghem <svlandeg@users.noreply.github.com>
14 files changed:
docs/en/docs/tutorial/request-form-models.md
docs_src/request_form_models/tutorial002.py [new file with mode: 0644]
docs_src/request_form_models/tutorial002_an.py [new file with mode: 0644]
docs_src/request_form_models/tutorial002_an_py39.py [new file with mode: 0644]
docs_src/request_form_models/tutorial002_pv1.py [new file with mode: 0644]
docs_src/request_form_models/tutorial002_pv1_an.py [new file with mode: 0644]
docs_src/request_form_models/tutorial002_pv1_an_py39.py [new file with mode: 0644]
fastapi/dependencies/utils.py
tests/test_tutorial/test_request_form_models/test_tutorial002.py [new file with mode: 0644]
tests/test_tutorial/test_request_form_models/test_tutorial002_an.py [new file with mode: 0644]
tests/test_tutorial/test_request_form_models/test_tutorial002_an_py39.py [new file with mode: 0644]
tests/test_tutorial/test_request_form_models/test_tutorial002_pv1.py [new file with mode: 0644]
tests/test_tutorial/test_request_form_models/test_tutorial002_pv1_an.py [new file with mode: 0644]
tests/test_tutorial/test_request_form_models/test_tutorial002_pv1_an_p39.py [new file with mode: 0644]

index 8bb1ffb1fc2cb2173a2ca2d8de494e959f3d0dc8..a317ee14d097bdf694aede76b8066b957e7ab7ec 100644 (file)
@@ -1,6 +1,6 @@
 # Form Models
 
-You can use Pydantic models to declare form fields in FastAPI.
+You can use **Pydantic models** to declare **form fields** in FastAPI.
 
 /// info
 
@@ -22,7 +22,7 @@ This is supported since FastAPI version `0.113.0`. 🤓
 
 ## Pydantic Models for Forms
 
-You just need to declare a Pydantic model with the fields you want to receive as form fields, and then declare the parameter as `Form`:
+You just need to declare a **Pydantic model** with the fields you want to receive as **form fields**, and then declare the parameter as `Form`:
 
 //// tab | Python 3.9+
 
@@ -54,7 +54,7 @@ Prefer to use the `Annotated` version if possible.
 
 ////
 
-FastAPI will extract the data for each field from the form data in the request and give you the Pydantic model you defined.
+**FastAPI** will **extract** the data for **each field** from the **form data** in the request and give you the Pydantic model you defined.
 
 ## Check the Docs
 
@@ -63,3 +63,72 @@ You can verify it in the docs UI at `/docs`:
 <div class="screenshot">
 <img src="/img/tutorial/request-form-models/image01.png">
 </div>
+
+## Restrict Extra Form Fields
+
+In some special use cases (probably not very common), you might want to **restrict** the form fields to only those declared in the Pydantic model. And **forbid** any **extra** fields.
+
+/// note
+
+This is supported since FastAPI version `0.114.0`. 🤓
+
+///
+
+You can use Pydantic's model configuration to `forbid` any `extra` fields:
+
+//// tab | Python 3.9+
+
+```Python hl_lines="12"
+{!> ../../../docs_src/request_form_models/tutorial002_an_py39.py!}
+```
+
+////
+
+//// tab | Python 3.8+
+
+```Python hl_lines="11"
+{!> ../../../docs_src/request_form_models/tutorial002_an.py!}
+```
+
+////
+
+//// tab | Python 3.8+ non-Annotated
+
+/// tip
+
+Prefer to use the `Annotated` version if possible.
+
+///
+
+```Python hl_lines="10"
+{!> ../../../docs_src/request_form_models/tutorial002.py!}
+```
+
+////
+
+If a client tries to send some extra data, they will receive an **error** response.
+
+For example, if the client tries to send the form fields:
+
+* `username`: `Rick`
+* `password`: `Portal Gun`
+* `extra`: `Mr. Poopybutthole`
+
+They will receive an error response telling them that the field `extra` is not allowed:
+
+```json
+{
+    "detail": [
+        {
+            "type": "extra_forbidden",
+            "loc": ["body", "extra"],
+            "msg": "Extra inputs are not permitted",
+            "input": "Mr. Poopybutthole"
+        }
+    ]
+}
+```
+
+## Summary
+
+You can use Pydantic models to declare form fields in FastAPI. 😎
diff --git a/docs_src/request_form_models/tutorial002.py b/docs_src/request_form_models/tutorial002.py
new file mode 100644 (file)
index 0000000..59b329e
--- /dev/null
@@ -0,0 +1,15 @@
+from fastapi import FastAPI, Form
+from pydantic import BaseModel
+
+app = FastAPI()
+
+
+class FormData(BaseModel):
+    username: str
+    password: str
+    model_config = {"extra": "forbid"}
+
+
+@app.post("/login/")
+async def login(data: FormData = Form()):
+    return data
diff --git a/docs_src/request_form_models/tutorial002_an.py b/docs_src/request_form_models/tutorial002_an.py
new file mode 100644 (file)
index 0000000..bcb0227
--- /dev/null
@@ -0,0 +1,16 @@
+from fastapi import FastAPI, Form
+from pydantic import BaseModel
+from typing_extensions import Annotated
+
+app = FastAPI()
+
+
+class FormData(BaseModel):
+    username: str
+    password: str
+    model_config = {"extra": "forbid"}
+
+
+@app.post("/login/")
+async def login(data: Annotated[FormData, Form()]):
+    return data
diff --git a/docs_src/request_form_models/tutorial002_an_py39.py b/docs_src/request_form_models/tutorial002_an_py39.py
new file mode 100644 (file)
index 0000000..3004e08
--- /dev/null
@@ -0,0 +1,17 @@
+from typing import Annotated
+
+from fastapi import FastAPI, Form
+from pydantic import BaseModel
+
+app = FastAPI()
+
+
+class FormData(BaseModel):
+    username: str
+    password: str
+    model_config = {"extra": "forbid"}
+
+
+@app.post("/login/")
+async def login(data: Annotated[FormData, Form()]):
+    return data
diff --git a/docs_src/request_form_models/tutorial002_pv1.py b/docs_src/request_form_models/tutorial002_pv1.py
new file mode 100644 (file)
index 0000000..d5f7db2
--- /dev/null
@@ -0,0 +1,17 @@
+from fastapi import FastAPI, Form
+from pydantic import BaseModel
+
+app = FastAPI()
+
+
+class FormData(BaseModel):
+    username: str
+    password: str
+
+    class Config:
+        extra = "forbid"
+
+
+@app.post("/login/")
+async def login(data: FormData = Form()):
+    return data
diff --git a/docs_src/request_form_models/tutorial002_pv1_an.py b/docs_src/request_form_models/tutorial002_pv1_an.py
new file mode 100644 (file)
index 0000000..fe9dbc3
--- /dev/null
@@ -0,0 +1,18 @@
+from fastapi import FastAPI, Form
+from pydantic import BaseModel
+from typing_extensions import Annotated
+
+app = FastAPI()
+
+
+class FormData(BaseModel):
+    username: str
+    password: str
+
+    class Config:
+        extra = "forbid"
+
+
+@app.post("/login/")
+async def login(data: Annotated[FormData, Form()]):
+    return data
diff --git a/docs_src/request_form_models/tutorial002_pv1_an_py39.py b/docs_src/request_form_models/tutorial002_pv1_an_py39.py
new file mode 100644 (file)
index 0000000..942d5d4
--- /dev/null
@@ -0,0 +1,19 @@
+from typing import Annotated
+
+from fastapi import FastAPI, Form
+from pydantic import BaseModel
+
+app = FastAPI()
+
+
+class FormData(BaseModel):
+    username: str
+    password: str
+
+    class Config:
+        extra = "forbid"
+
+
+@app.post("/login/")
+async def login(data: Annotated[FormData, Form()]):
+    return data
index 98ce17b55d9bab800b2c7163d6533b96576da4a1..6083b73195fc5d80d15234d769047f0283c30808 100644 (file)
@@ -789,6 +789,9 @@ async def _extract_form_body(
             value = serialize_sequence_value(field=field, value=results)
         if value is not None:
             values[field.name] = value
+    for key, value in received_body.items():
+        if key not in values:
+            values[key] = value
     return values
 
 
diff --git a/tests/test_tutorial/test_request_form_models/test_tutorial002.py b/tests/test_tutorial/test_request_form_models/test_tutorial002.py
new file mode 100644 (file)
index 0000000..76f4800
--- /dev/null
@@ -0,0 +1,196 @@
+import pytest
+from fastapi.testclient import TestClient
+
+from tests.utils import needs_pydanticv2
+
+
+@pytest.fixture(name="client")
+def get_client():
+    from docs_src.request_form_models.tutorial002 import app
+
+    client = TestClient(app)
+    return client
+
+
+@needs_pydanticv2
+def test_post_body_form(client: TestClient):
+    response = client.post("/login/", data={"username": "Foo", "password": "secret"})
+    assert response.status_code == 200
+    assert response.json() == {"username": "Foo", "password": "secret"}
+
+
+@needs_pydanticv2
+def test_post_body_extra_form(client: TestClient):
+    response = client.post(
+        "/login/", data={"username": "Foo", "password": "secret", "extra": "extra"}
+    )
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "extra_forbidden",
+                "loc": ["body", "extra"],
+                "msg": "Extra inputs are not permitted",
+                "input": "extra",
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+def test_post_body_form_no_password(client: TestClient):
+    response = client.post("/login/", data={"username": "Foo"})
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "password"],
+                "msg": "Field required",
+                "input": {"username": "Foo"},
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+def test_post_body_form_no_username(client: TestClient):
+    response = client.post("/login/", data={"password": "secret"})
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "username"],
+                "msg": "Field required",
+                "input": {"password": "secret"},
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+def test_post_body_form_no_data(client: TestClient):
+    response = client.post("/login/")
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "username"],
+                "msg": "Field required",
+                "input": {},
+            },
+            {
+                "type": "missing",
+                "loc": ["body", "password"],
+                "msg": "Field required",
+                "input": {},
+            },
+        ]
+    }
+
+
+@needs_pydanticv2
+def test_post_body_json(client: TestClient):
+    response = client.post("/login/", json={"username": "Foo", "password": "secret"})
+    assert response.status_code == 422, response.text
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "username"],
+                "msg": "Field required",
+                "input": {},
+            },
+            {
+                "type": "missing",
+                "loc": ["body", "password"],
+                "msg": "Field required",
+                "input": {},
+            },
+        ]
+    }
+
+
+@needs_pydanticv2
+def test_openapi_schema(client: TestClient):
+    response = client.get("/openapi.json")
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "openapi": "3.1.0",
+        "info": {"title": "FastAPI", "version": "0.1.0"},
+        "paths": {
+            "/login/": {
+                "post": {
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {"application/json": {"schema": {}}},
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                    "summary": "Login",
+                    "operationId": "login_login__post",
+                    "requestBody": {
+                        "content": {
+                            "application/x-www-form-urlencoded": {
+                                "schema": {"$ref": "#/components/schemas/FormData"}
+                            }
+                        },
+                        "required": True,
+                    },
+                }
+            }
+        },
+        "components": {
+            "schemas": {
+                "FormData": {
+                    "properties": {
+                        "username": {"type": "string", "title": "Username"},
+                        "password": {"type": "string", "title": "Password"},
+                    },
+                    "additionalProperties": False,
+                    "type": "object",
+                    "required": ["username", "password"],
+                    "title": "FormData",
+                },
+                "ValidationError": {
+                    "title": "ValidationError",
+                    "required": ["loc", "msg", "type"],
+                    "type": "object",
+                    "properties": {
+                        "loc": {
+                            "title": "Location",
+                            "type": "array",
+                            "items": {
+                                "anyOf": [{"type": "string"}, {"type": "integer"}]
+                            },
+                        },
+                        "msg": {"title": "Message", "type": "string"},
+                        "type": {"title": "Error Type", "type": "string"},
+                    },
+                },
+                "HTTPValidationError": {
+                    "title": "HTTPValidationError",
+                    "type": "object",
+                    "properties": {
+                        "detail": {
+                            "title": "Detail",
+                            "type": "array",
+                            "items": {"$ref": "#/components/schemas/ValidationError"},
+                        }
+                    },
+                },
+            }
+        },
+    }
diff --git a/tests/test_tutorial/test_request_form_models/test_tutorial002_an.py b/tests/test_tutorial/test_request_form_models/test_tutorial002_an.py
new file mode 100644 (file)
index 0000000..179b297
--- /dev/null
@@ -0,0 +1,196 @@
+import pytest
+from fastapi.testclient import TestClient
+
+from tests.utils import needs_pydanticv2
+
+
+@pytest.fixture(name="client")
+def get_client():
+    from docs_src.request_form_models.tutorial002_an import app
+
+    client = TestClient(app)
+    return client
+
+
+@needs_pydanticv2
+def test_post_body_form(client: TestClient):
+    response = client.post("/login/", data={"username": "Foo", "password": "secret"})
+    assert response.status_code == 200
+    assert response.json() == {"username": "Foo", "password": "secret"}
+
+
+@needs_pydanticv2
+def test_post_body_extra_form(client: TestClient):
+    response = client.post(
+        "/login/", data={"username": "Foo", "password": "secret", "extra": "extra"}
+    )
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "extra_forbidden",
+                "loc": ["body", "extra"],
+                "msg": "Extra inputs are not permitted",
+                "input": "extra",
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+def test_post_body_form_no_password(client: TestClient):
+    response = client.post("/login/", data={"username": "Foo"})
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "password"],
+                "msg": "Field required",
+                "input": {"username": "Foo"},
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+def test_post_body_form_no_username(client: TestClient):
+    response = client.post("/login/", data={"password": "secret"})
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "username"],
+                "msg": "Field required",
+                "input": {"password": "secret"},
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+def test_post_body_form_no_data(client: TestClient):
+    response = client.post("/login/")
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "username"],
+                "msg": "Field required",
+                "input": {},
+            },
+            {
+                "type": "missing",
+                "loc": ["body", "password"],
+                "msg": "Field required",
+                "input": {},
+            },
+        ]
+    }
+
+
+@needs_pydanticv2
+def test_post_body_json(client: TestClient):
+    response = client.post("/login/", json={"username": "Foo", "password": "secret"})
+    assert response.status_code == 422, response.text
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "username"],
+                "msg": "Field required",
+                "input": {},
+            },
+            {
+                "type": "missing",
+                "loc": ["body", "password"],
+                "msg": "Field required",
+                "input": {},
+            },
+        ]
+    }
+
+
+@needs_pydanticv2
+def test_openapi_schema(client: TestClient):
+    response = client.get("/openapi.json")
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "openapi": "3.1.0",
+        "info": {"title": "FastAPI", "version": "0.1.0"},
+        "paths": {
+            "/login/": {
+                "post": {
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {"application/json": {"schema": {}}},
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                    "summary": "Login",
+                    "operationId": "login_login__post",
+                    "requestBody": {
+                        "content": {
+                            "application/x-www-form-urlencoded": {
+                                "schema": {"$ref": "#/components/schemas/FormData"}
+                            }
+                        },
+                        "required": True,
+                    },
+                }
+            }
+        },
+        "components": {
+            "schemas": {
+                "FormData": {
+                    "properties": {
+                        "username": {"type": "string", "title": "Username"},
+                        "password": {"type": "string", "title": "Password"},
+                    },
+                    "additionalProperties": False,
+                    "type": "object",
+                    "required": ["username", "password"],
+                    "title": "FormData",
+                },
+                "ValidationError": {
+                    "title": "ValidationError",
+                    "required": ["loc", "msg", "type"],
+                    "type": "object",
+                    "properties": {
+                        "loc": {
+                            "title": "Location",
+                            "type": "array",
+                            "items": {
+                                "anyOf": [{"type": "string"}, {"type": "integer"}]
+                            },
+                        },
+                        "msg": {"title": "Message", "type": "string"},
+                        "type": {"title": "Error Type", "type": "string"},
+                    },
+                },
+                "HTTPValidationError": {
+                    "title": "HTTPValidationError",
+                    "type": "object",
+                    "properties": {
+                        "detail": {
+                            "title": "Detail",
+                            "type": "array",
+                            "items": {"$ref": "#/components/schemas/ValidationError"},
+                        }
+                    },
+                },
+            }
+        },
+    }
diff --git a/tests/test_tutorial/test_request_form_models/test_tutorial002_an_py39.py b/tests/test_tutorial/test_request_form_models/test_tutorial002_an_py39.py
new file mode 100644 (file)
index 0000000..510ad9d
--- /dev/null
@@ -0,0 +1,203 @@
+import pytest
+from fastapi.testclient import TestClient
+
+from tests.utils import needs_py39, needs_pydanticv2
+
+
+@pytest.fixture(name="client")
+def get_client():
+    from docs_src.request_form_models.tutorial002_an_py39 import app
+
+    client = TestClient(app)
+    return client
+
+
+@needs_pydanticv2
+@needs_py39
+def test_post_body_form(client: TestClient):
+    response = client.post("/login/", data={"username": "Foo", "password": "secret"})
+    assert response.status_code == 200
+    assert response.json() == {"username": "Foo", "password": "secret"}
+
+
+@needs_pydanticv2
+@needs_py39
+def test_post_body_extra_form(client: TestClient):
+    response = client.post(
+        "/login/", data={"username": "Foo", "password": "secret", "extra": "extra"}
+    )
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "extra_forbidden",
+                "loc": ["body", "extra"],
+                "msg": "Extra inputs are not permitted",
+                "input": "extra",
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@needs_py39
+def test_post_body_form_no_password(client: TestClient):
+    response = client.post("/login/", data={"username": "Foo"})
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "password"],
+                "msg": "Field required",
+                "input": {"username": "Foo"},
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@needs_py39
+def test_post_body_form_no_username(client: TestClient):
+    response = client.post("/login/", data={"password": "secret"})
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "username"],
+                "msg": "Field required",
+                "input": {"password": "secret"},
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@needs_py39
+def test_post_body_form_no_data(client: TestClient):
+    response = client.post("/login/")
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "username"],
+                "msg": "Field required",
+                "input": {},
+            },
+            {
+                "type": "missing",
+                "loc": ["body", "password"],
+                "msg": "Field required",
+                "input": {},
+            },
+        ]
+    }
+
+
+@needs_pydanticv2
+@needs_py39
+def test_post_body_json(client: TestClient):
+    response = client.post("/login/", json={"username": "Foo", "password": "secret"})
+    assert response.status_code == 422, response.text
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "username"],
+                "msg": "Field required",
+                "input": {},
+            },
+            {
+                "type": "missing",
+                "loc": ["body", "password"],
+                "msg": "Field required",
+                "input": {},
+            },
+        ]
+    }
+
+
+@needs_pydanticv2
+@needs_py39
+def test_openapi_schema(client: TestClient):
+    response = client.get("/openapi.json")
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "openapi": "3.1.0",
+        "info": {"title": "FastAPI", "version": "0.1.0"},
+        "paths": {
+            "/login/": {
+                "post": {
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {"application/json": {"schema": {}}},
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                    "summary": "Login",
+                    "operationId": "login_login__post",
+                    "requestBody": {
+                        "content": {
+                            "application/x-www-form-urlencoded": {
+                                "schema": {"$ref": "#/components/schemas/FormData"}
+                            }
+                        },
+                        "required": True,
+                    },
+                }
+            }
+        },
+        "components": {
+            "schemas": {
+                "FormData": {
+                    "properties": {
+                        "username": {"type": "string", "title": "Username"},
+                        "password": {"type": "string", "title": "Password"},
+                    },
+                    "additionalProperties": False,
+                    "type": "object",
+                    "required": ["username", "password"],
+                    "title": "FormData",
+                },
+                "ValidationError": {
+                    "title": "ValidationError",
+                    "required": ["loc", "msg", "type"],
+                    "type": "object",
+                    "properties": {
+                        "loc": {
+                            "title": "Location",
+                            "type": "array",
+                            "items": {
+                                "anyOf": [{"type": "string"}, {"type": "integer"}]
+                            },
+                        },
+                        "msg": {"title": "Message", "type": "string"},
+                        "type": {"title": "Error Type", "type": "string"},
+                    },
+                },
+                "HTTPValidationError": {
+                    "title": "HTTPValidationError",
+                    "type": "object",
+                    "properties": {
+                        "detail": {
+                            "title": "Detail",
+                            "type": "array",
+                            "items": {"$ref": "#/components/schemas/ValidationError"},
+                        }
+                    },
+                },
+            }
+        },
+    }
diff --git a/tests/test_tutorial/test_request_form_models/test_tutorial002_pv1.py b/tests/test_tutorial/test_request_form_models/test_tutorial002_pv1.py
new file mode 100644 (file)
index 0000000..249b937
--- /dev/null
@@ -0,0 +1,189 @@
+import pytest
+from fastapi.testclient import TestClient
+
+from tests.utils import needs_pydanticv1
+
+
+@pytest.fixture(name="client")
+def get_client():
+    from docs_src.request_form_models.tutorial002_pv1 import app
+
+    client = TestClient(app)
+    return client
+
+
+@needs_pydanticv1
+def test_post_body_form(client: TestClient):
+    response = client.post("/login/", data={"username": "Foo", "password": "secret"})
+    assert response.status_code == 200
+    assert response.json() == {"username": "Foo", "password": "secret"}
+
+
+@needs_pydanticv1
+def test_post_body_extra_form(client: TestClient):
+    response = client.post(
+        "/login/", data={"username": "Foo", "password": "secret", "extra": "extra"}
+    )
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "value_error.extra",
+                "loc": ["body", "extra"],
+                "msg": "extra fields not permitted",
+            }
+        ]
+    }
+
+
+@needs_pydanticv1
+def test_post_body_form_no_password(client: TestClient):
+    response = client.post("/login/", data={"username": "Foo"})
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "value_error.missing",
+                "loc": ["body", "password"],
+                "msg": "field required",
+            }
+        ]
+    }
+
+
+@needs_pydanticv1
+def test_post_body_form_no_username(client: TestClient):
+    response = client.post("/login/", data={"password": "secret"})
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "value_error.missing",
+                "loc": ["body", "username"],
+                "msg": "field required",
+            }
+        ]
+    }
+
+
+@needs_pydanticv1
+def test_post_body_form_no_data(client: TestClient):
+    response = client.post("/login/")
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "value_error.missing",
+                "loc": ["body", "username"],
+                "msg": "field required",
+            },
+            {
+                "type": "value_error.missing",
+                "loc": ["body", "password"],
+                "msg": "field required",
+            },
+        ]
+    }
+
+
+@needs_pydanticv1
+def test_post_body_json(client: TestClient):
+    response = client.post("/login/", json={"username": "Foo", "password": "secret"})
+    assert response.status_code == 422, response.text
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "value_error.missing",
+                "loc": ["body", "username"],
+                "msg": "field required",
+            },
+            {
+                "type": "value_error.missing",
+                "loc": ["body", "password"],
+                "msg": "field required",
+            },
+        ]
+    }
+
+
+@needs_pydanticv1
+def test_openapi_schema(client: TestClient):
+    response = client.get("/openapi.json")
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "openapi": "3.1.0",
+        "info": {"title": "FastAPI", "version": "0.1.0"},
+        "paths": {
+            "/login/": {
+                "post": {
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {"application/json": {"schema": {}}},
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                    "summary": "Login",
+                    "operationId": "login_login__post",
+                    "requestBody": {
+                        "content": {
+                            "application/x-www-form-urlencoded": {
+                                "schema": {"$ref": "#/components/schemas/FormData"}
+                            }
+                        },
+                        "required": True,
+                    },
+                }
+            }
+        },
+        "components": {
+            "schemas": {
+                "FormData": {
+                    "properties": {
+                        "username": {"type": "string", "title": "Username"},
+                        "password": {"type": "string", "title": "Password"},
+                    },
+                    "additionalProperties": False,
+                    "type": "object",
+                    "required": ["username", "password"],
+                    "title": "FormData",
+                },
+                "ValidationError": {
+                    "title": "ValidationError",
+                    "required": ["loc", "msg", "type"],
+                    "type": "object",
+                    "properties": {
+                        "loc": {
+                            "title": "Location",
+                            "type": "array",
+                            "items": {
+                                "anyOf": [{"type": "string"}, {"type": "integer"}]
+                            },
+                        },
+                        "msg": {"title": "Message", "type": "string"},
+                        "type": {"title": "Error Type", "type": "string"},
+                    },
+                },
+                "HTTPValidationError": {
+                    "title": "HTTPValidationError",
+                    "type": "object",
+                    "properties": {
+                        "detail": {
+                            "title": "Detail",
+                            "type": "array",
+                            "items": {"$ref": "#/components/schemas/ValidationError"},
+                        }
+                    },
+                },
+            }
+        },
+    }
diff --git a/tests/test_tutorial/test_request_form_models/test_tutorial002_pv1_an.py b/tests/test_tutorial/test_request_form_models/test_tutorial002_pv1_an.py
new file mode 100644 (file)
index 0000000..44cb3c3
--- /dev/null
@@ -0,0 +1,196 @@
+import pytest
+from fastapi.testclient import TestClient
+
+from tests.utils import needs_pydanticv1
+
+
+@pytest.fixture(name="client")
+def get_client():
+    from docs_src.request_form_models.tutorial002_pv1_an import app
+
+    client = TestClient(app)
+    return client
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+def test_post_body_form(client: TestClient):
+    response = client.post("/login/", data={"username": "Foo", "password": "secret"})
+    assert response.status_code == 200
+    assert response.json() == {"username": "Foo", "password": "secret"}
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+def test_post_body_extra_form(client: TestClient):
+    response = client.post(
+        "/login/", data={"username": "Foo", "password": "secret", "extra": "extra"}
+    )
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "value_error.extra",
+                "loc": ["body", "extra"],
+                "msg": "extra fields not permitted",
+            }
+        ]
+    }
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+def test_post_body_form_no_password(client: TestClient):
+    response = client.post("/login/", data={"username": "Foo"})
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "value_error.missing",
+                "loc": ["body", "password"],
+                "msg": "field required",
+            }
+        ]
+    }
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+def test_post_body_form_no_username(client: TestClient):
+    response = client.post("/login/", data={"password": "secret"})
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "value_error.missing",
+                "loc": ["body", "username"],
+                "msg": "field required",
+            }
+        ]
+    }
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+def test_post_body_form_no_data(client: TestClient):
+    response = client.post("/login/")
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "value_error.missing",
+                "loc": ["body", "username"],
+                "msg": "field required",
+            },
+            {
+                "type": "value_error.missing",
+                "loc": ["body", "password"],
+                "msg": "field required",
+            },
+        ]
+    }
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+def test_post_body_json(client: TestClient):
+    response = client.post("/login/", json={"username": "Foo", "password": "secret"})
+    assert response.status_code == 422, response.text
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "value_error.missing",
+                "loc": ["body", "username"],
+                "msg": "field required",
+            },
+            {
+                "type": "value_error.missing",
+                "loc": ["body", "password"],
+                "msg": "field required",
+            },
+        ]
+    }
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+def test_openapi_schema(client: TestClient):
+    response = client.get("/openapi.json")
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "openapi": "3.1.0",
+        "info": {"title": "FastAPI", "version": "0.1.0"},
+        "paths": {
+            "/login/": {
+                "post": {
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {"application/json": {"schema": {}}},
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                    "summary": "Login",
+                    "operationId": "login_login__post",
+                    "requestBody": {
+                        "content": {
+                            "application/x-www-form-urlencoded": {
+                                "schema": {"$ref": "#/components/schemas/FormData"}
+                            }
+                        },
+                        "required": True,
+                    },
+                }
+            }
+        },
+        "components": {
+            "schemas": {
+                "FormData": {
+                    "properties": {
+                        "username": {"type": "string", "title": "Username"},
+                        "password": {"type": "string", "title": "Password"},
+                    },
+                    "additionalProperties": False,
+                    "type": "object",
+                    "required": ["username", "password"],
+                    "title": "FormData",
+                },
+                "ValidationError": {
+                    "title": "ValidationError",
+                    "required": ["loc", "msg", "type"],
+                    "type": "object",
+                    "properties": {
+                        "loc": {
+                            "title": "Location",
+                            "type": "array",
+                            "items": {
+                                "anyOf": [{"type": "string"}, {"type": "integer"}]
+                            },
+                        },
+                        "msg": {"title": "Message", "type": "string"},
+                        "type": {"title": "Error Type", "type": "string"},
+                    },
+                },
+                "HTTPValidationError": {
+                    "title": "HTTPValidationError",
+                    "type": "object",
+                    "properties": {
+                        "detail": {
+                            "title": "Detail",
+                            "type": "array",
+                            "items": {"$ref": "#/components/schemas/ValidationError"},
+                        }
+                    },
+                },
+            }
+        },
+    }
diff --git a/tests/test_tutorial/test_request_form_models/test_tutorial002_pv1_an_p39.py b/tests/test_tutorial/test_request_form_models/test_tutorial002_pv1_an_p39.py
new file mode 100644 (file)
index 0000000..899549e
--- /dev/null
@@ -0,0 +1,203 @@
+import pytest
+from fastapi.testclient import TestClient
+
+from tests.utils import needs_py39, needs_pydanticv1
+
+
+@pytest.fixture(name="client")
+def get_client():
+    from docs_src.request_form_models.tutorial002_pv1_an_py39 import app
+
+    client = TestClient(app)
+    return client
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+@needs_py39
+def test_post_body_form(client: TestClient):
+    response = client.post("/login/", data={"username": "Foo", "password": "secret"})
+    assert response.status_code == 200
+    assert response.json() == {"username": "Foo", "password": "secret"}
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+@needs_py39
+def test_post_body_extra_form(client: TestClient):
+    response = client.post(
+        "/login/", data={"username": "Foo", "password": "secret", "extra": "extra"}
+    )
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "value_error.extra",
+                "loc": ["body", "extra"],
+                "msg": "extra fields not permitted",
+            }
+        ]
+    }
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+@needs_py39
+def test_post_body_form_no_password(client: TestClient):
+    response = client.post("/login/", data={"username": "Foo"})
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "value_error.missing",
+                "loc": ["body", "password"],
+                "msg": "field required",
+            }
+        ]
+    }
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+@needs_py39
+def test_post_body_form_no_username(client: TestClient):
+    response = client.post("/login/", data={"password": "secret"})
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "value_error.missing",
+                "loc": ["body", "username"],
+                "msg": "field required",
+            }
+        ]
+    }
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+@needs_py39
+def test_post_body_form_no_data(client: TestClient):
+    response = client.post("/login/")
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "value_error.missing",
+                "loc": ["body", "username"],
+                "msg": "field required",
+            },
+            {
+                "type": "value_error.missing",
+                "loc": ["body", "password"],
+                "msg": "field required",
+            },
+        ]
+    }
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+@needs_py39
+def test_post_body_json(client: TestClient):
+    response = client.post("/login/", json={"username": "Foo", "password": "secret"})
+    assert response.status_code == 422, response.text
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "value_error.missing",
+                "loc": ["body", "username"],
+                "msg": "field required",
+            },
+            {
+                "type": "value_error.missing",
+                "loc": ["body", "password"],
+                "msg": "field required",
+            },
+        ]
+    }
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+@needs_py39
+def test_openapi_schema(client: TestClient):
+    response = client.get("/openapi.json")
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "openapi": "3.1.0",
+        "info": {"title": "FastAPI", "version": "0.1.0"},
+        "paths": {
+            "/login/": {
+                "post": {
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {"application/json": {"schema": {}}},
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                    "summary": "Login",
+                    "operationId": "login_login__post",
+                    "requestBody": {
+                        "content": {
+                            "application/x-www-form-urlencoded": {
+                                "schema": {"$ref": "#/components/schemas/FormData"}
+                            }
+                        },
+                        "required": True,
+                    },
+                }
+            }
+        },
+        "components": {
+            "schemas": {
+                "FormData": {
+                    "properties": {
+                        "username": {"type": "string", "title": "Username"},
+                        "password": {"type": "string", "title": "Password"},
+                    },
+                    "additionalProperties": False,
+                    "type": "object",
+                    "required": ["username", "password"],
+                    "title": "FormData",
+                },
+                "ValidationError": {
+                    "title": "ValidationError",
+                    "required": ["loc", "msg", "type"],
+                    "type": "object",
+                    "properties": {
+                        "loc": {
+                            "title": "Location",
+                            "type": "array",
+                            "items": {
+                                "anyOf": [{"type": "string"}, {"type": "integer"}]
+                            },
+                        },
+                        "msg": {"title": "Message", "type": "string"},
+                        "type": {"title": "Error Type", "type": "string"},
+                    },
+                },
+                "HTTPValidationError": {
+                    "title": "HTTPValidationError",
+                    "type": "object",
+                    "properties": {
+                        "detail": {
+                            "title": "Detail",
+                            "type": "array",
+                            "items": {"$ref": "#/components/schemas/ValidationError"},
+                        }
+                    },
+                },
+            }
+        },
+    }