]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
✨ Add support for declaring `UploadFile` parameters without explicit `File()` (#4469)
authorSebastián Ramírez <tiangolo@gmail.com>
Sun, 23 Jan 2022 19:14:13 +0000 (20:14 +0100)
committerGitHub <noreply@github.com>
Sun, 23 Jan 2022 19:14:13 +0000 (19:14 +0000)
16 files changed:
docs/en/docs/tutorial/request-files.md
docs_src/request_files/tutorial001.py
docs_src/request_files/tutorial001_02.py [new file with mode: 0644]
docs_src/request_files/tutorial001_02_py310.py [new file with mode: 0644]
docs_src/request_files/tutorial001_03.py [new file with mode: 0644]
docs_src/request_files/tutorial002.py
docs_src/request_files/tutorial002_py39.py
docs_src/request_files/tutorial003.py [new file with mode: 0644]
docs_src/request_files/tutorial003_py39.py [new file with mode: 0644]
fastapi/datastructures.py
fastapi/dependencies/utils.py
tests/test_tutorial/test_request_files/test_tutorial001_02.py [new file with mode: 0644]
tests/test_tutorial/test_request_files/test_tutorial001_02_py310.py [new file with mode: 0644]
tests/test_tutorial/test_request_files/test_tutorial001_03.py [new file with mode: 0644]
tests/test_tutorial/test_request_files/test_tutorial003.py [new file with mode: 0644]
tests/test_tutorial/test_request_files/test_tutorial003_py39.py [new file with mode: 0644]

index b7257c7eb686602e59fbf8774893b7b1f1ea31a7..ed2c8b6af5ac968f23804293cc442d3d484b2da9 100644 (file)
@@ -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 (`<form></form>`) 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).
index fffb56af8630fa0c8d9ad51102c5f4e8d01b3702..0fb1dd571b1b021130d8118a10cd81aabfc3bf96 100644 (file)
@@ -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 (file)
index 0000000..26a4c9c
--- /dev/null
@@ -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 (file)
index 0000000..0e57625
--- /dev/null
@@ -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 (file)
index 0000000..abcac9e
--- /dev/null
@@ -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}
index 6fdf16a751213a6ffa861b0eaaf758fb8c01684b..94abb7c6c0492eca74598e7b7d8c508c9e4636b7 100644 (file)
@@ -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]}
 
 
index 26cd5676905e4dd1da48718c3bcdc926fb8a3393..2779618bde83f93e958fabfd09aa27b923399605 100644 (file)
@@ -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 (file)
index 0000000..4a91b7a
--- /dev/null
@@ -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 = """
+<body>
+<form action="/files/" enctype="multipart/form-data" method="post">
+<input name="files" type="file" multiple>
+<input type="submit">
+</form>
+<form action="/uploadfiles/" enctype="multipart/form-data" method="post">
+<input name="files" type="file" multiple>
+<input type="submit">
+</form>
+</body>
+    """
+    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 (file)
index 0000000..d853f48
--- /dev/null
@@ -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 = """
+<body>
+<form action="/files/" enctype="multipart/form-data" method="post">
+<input name="files" type="file" multiple>
+<input type="submit">
+</form>
+<form action="/uploadfiles/" enctype="multipart/form-data" method="post">
+<input name="files" type="file" multiple>
+<input type="submit">
+</form>
+</body>
+    """
+    return HTMLResponse(content=content)
index b1317128707b6f0cfd85fb86f2413e3fc8842c4b..b20a25ab6ed090cdee112830b5510659425d9a2f 100644 (file)
@@ -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 @@ class UploadFile(StarletteUploadFile):
             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:
     """
index 35ba44aabfd932566db258b93e2a2b74c787627a..d4028d067b882bd35a45babc2014c1e55b73d718 100644 (file)
@@ -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 (file)
index 0000000..e852a1b
--- /dev/null
@@ -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"<file content>")
+
+    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"<file content>")
+
+    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 (file)
index 0000000..62e9f98
--- /dev/null
@@ -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"<file content>")
+
+    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"<file content>")
+
+    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 (file)
index 0000000..ec7509e
--- /dev/null
@@ -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"<file content>")
+
+    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"<file content>")
+
+    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 (file)
index 0000000..943b235
--- /dev/null
@@ -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"<file content>")
+    path2 = tmp_path / "test2.txt"
+    path2.write_bytes(b"<file content2>")
+
+    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"<file content>")
+    path2 = tmp_path / "test2.txt"
+    path2.write_bytes(b"<file content2>")
+
+    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"<form" in response.content
diff --git a/tests/test_tutorial/test_request_files/test_tutorial003_py39.py b/tests/test_tutorial/test_request_files/test_tutorial003_py39.py
new file mode 100644 (file)
index 0000000..d5fbd78
--- /dev/null
@@ -0,0 +1,223 @@
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from ...utils import needs_py39
+
+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"},
+                },
+            },
+        }
+    },
+}
+
+
+@pytest.fixture(name="app")
+def get_app():
+    from docs_src.request_files.tutorial003_py39 import app
+
+    return app
+
+
+@pytest.fixture(name="client")
+def get_client(app: FastAPI):
+
+    client = TestClient(app)
+    return client
+
+
+@needs_py39
+def test_openapi_schema(client: TestClient):
+    response = client.get("/openapi.json")
+    assert response.status_code == 200, response.text
+    assert response.json() == openapi_schema
+
+
+file_required = {
+    "detail": [
+        {
+            "loc": ["body", "files"],
+            "msg": "field required",
+            "type": "value_error.missing",
+        }
+    ]
+}
+
+
+@needs_py39
+def test_post_files(tmp_path, app: FastAPI):
+    path = tmp_path / "test.txt"
+    path.write_bytes(b"<file content>")
+    path2 = tmp_path / "test2.txt"
+    path2.write_bytes(b"<file content2>")
+
+    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"<file content>")
+    path2 = tmp_path / "test2.txt"
+    path2.write_bytes(b"<file content2>")
+
+    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"<form" in response.content