diff --git a/docs/en/docs/tutorial/request-files.md b/docs/en/docs/tutorial/request-files.md index b7257c7eb..ed2c8b6af 100644 --- a/docs/en/docs/tutorial/request-files.md +++ b/docs/en/docs/tutorial/request-files.md @@ -17,7 +17,7 @@ Import `File` and `UploadFile` from `fastapi`: {!../../../docs_src/request_files/tutorial001.py!} ``` -## Define `File` parameters +## Define `File` Parameters Create file parameters the same way you would for `Body` or `Form`: @@ -41,7 +41,7 @@ Have in mind that this means that the whole contents will be stored in memory. T But there are several cases in which you might benefit from using `UploadFile`. -## `File` parameters with `UploadFile` +## `File` Parameters with `UploadFile` Define a `File` parameter with a type of `UploadFile`: @@ -51,6 +51,7 @@ Define a `File` parameter with a type of `UploadFile`: Using `UploadFile` has several advantages over `bytes`: +* You don't have to use `File()` in the default value. * It uses a "spooled" file: * A file stored in memory up to a maximum size limit, and after passing this limit it will be stored in disk. * This means that it will work well for large files like images, videos, large binaries, etc. without consuming all the memory. @@ -113,7 +114,31 @@ The way HTML forms (`
`) sends the data to the server normally uses This is not a limitation of **FastAPI**, it's part of the HTTP protocol. -## Multiple file uploads +## Optional File Upload + +You can make a file optional by using standard type annotations: + +=== "Python 3.6 and above" + + ```Python hl_lines="9 17" + {!> ../../../docs_src/request_files/tutorial001_02.py!} + ``` + +=== "Python 3.9 and above" + + ```Python hl_lines="7 14" + {!> ../../../docs_src/request_files/tutorial001_02_py310.py!} + ``` + +## `UploadFile` with Additional Metadata + +You can also use `File()` with `UploadFile` to set additional parameters in `File()`, for example additional metadata: + +```Python hl_lines="13" +{!../../../docs_src/request_files/tutorial001_03.py!} +``` + +## Multiple File Uploads It's possible to upload several files at the same time. @@ -140,6 +165,22 @@ You will receive, as declared, a `list` of `bytes` or `UploadFile`s. **FastAPI** provides the same `starlette.responses` as `fastapi.responses` just as a convenience for you, the developer. But most of the available responses come directly from Starlette. +### Multiple File Uploads with Additional Metadata + +And the same way as before, you can use `File()` to set additional parameters, even for `UploadFile`: + +=== "Python 3.6 and above" + + ```Python hl_lines="18" + {!> ../../../docs_src/request_files/tutorial003.py!} + ``` + +=== "Python 3.9 and above" + + ```Python hl_lines="16" + {!> ../../../docs_src/request_files/tutorial003_py39.py!} + ``` + ## Recap Use `File` to declare files to be uploaded as input parameters (as form data). diff --git a/docs_src/request_files/tutorial001.py b/docs_src/request_files/tutorial001.py index fffb56af8..0fb1dd571 100644 --- a/docs_src/request_files/tutorial001.py +++ b/docs_src/request_files/tutorial001.py @@ -9,5 +9,5 @@ async def create_file(file: bytes = File(...)): @app.post("/uploadfile/") -async def create_upload_file(file: UploadFile = File(...)): +async def create_upload_file(file: UploadFile): return {"filename": file.filename} diff --git a/docs_src/request_files/tutorial001_02.py b/docs_src/request_files/tutorial001_02.py new file mode 100644 index 000000000..26a4c9cbf --- /dev/null +++ b/docs_src/request_files/tutorial001_02.py @@ -0,0 +1,21 @@ +from typing import Optional + +from fastapi import FastAPI, File, UploadFile + +app = FastAPI() + + +@app.post("/files/") +async def create_file(file: Optional[bytes] = File(None)): + if not file: + return {"message": "No file sent"} + else: + return {"file_size": len(file)} + + +@app.post("/uploadfile/") +async def create_upload_file(file: Optional[UploadFile] = None): + if not file: + return {"message": "No upload file sent"} + else: + return {"filename": file.filename} diff --git a/docs_src/request_files/tutorial001_02_py310.py b/docs_src/request_files/tutorial001_02_py310.py new file mode 100644 index 000000000..0e576251b --- /dev/null +++ b/docs_src/request_files/tutorial001_02_py310.py @@ -0,0 +1,19 @@ +from fastapi import FastAPI, File, UploadFile + +app = FastAPI() + + +@app.post("/files/") +async def create_file(file: bytes | None = File(None)): + if not file: + return {"message": "No file sent"} + else: + return {"file_size": len(file)} + + +@app.post("/uploadfile/") +async def create_upload_file(file: UploadFile | None = None): + if not file: + return {"message": "No upload file sent"} + else: + return {"filename": file.filename} diff --git a/docs_src/request_files/tutorial001_03.py b/docs_src/request_files/tutorial001_03.py new file mode 100644 index 000000000..abcac9e4c --- /dev/null +++ b/docs_src/request_files/tutorial001_03.py @@ -0,0 +1,15 @@ +from fastapi import FastAPI, File, UploadFile + +app = FastAPI() + + +@app.post("/files/") +async def create_file(file: bytes = File(..., description="A file read as bytes")): + return {"file_size": len(file)} + + +@app.post("/uploadfile/") +async def create_upload_file( + file: UploadFile = File(..., description="A file read as UploadFile") +): + return {"filename": file.filename} diff --git a/docs_src/request_files/tutorial002.py b/docs_src/request_files/tutorial002.py index 6fdf16a75..94abb7c6c 100644 --- a/docs_src/request_files/tutorial002.py +++ b/docs_src/request_files/tutorial002.py @@ -12,7 +12,7 @@ async def create_files(files: List[bytes] = File(...)): @app.post("/uploadfiles/") -async def create_upload_files(files: List[UploadFile] = File(...)): +async def create_upload_files(files: List[UploadFile]): return {"filenames": [file.filename for file in files]} diff --git a/docs_src/request_files/tutorial002_py39.py b/docs_src/request_files/tutorial002_py39.py index 26cd56769..2779618bd 100644 --- a/docs_src/request_files/tutorial002_py39.py +++ b/docs_src/request_files/tutorial002_py39.py @@ -10,7 +10,7 @@ async def create_files(files: list[bytes] = File(...)): @app.post("/uploadfiles/") -async def create_upload_files(files: list[UploadFile] = File(...)): +async def create_upload_files(files: list[UploadFile]): return {"filenames": [file.filename for file in files]} diff --git a/docs_src/request_files/tutorial003.py b/docs_src/request_files/tutorial003.py new file mode 100644 index 000000000..4a91b7a8b --- /dev/null +++ b/docs_src/request_files/tutorial003.py @@ -0,0 +1,37 @@ +from typing import List + +from fastapi import FastAPI, File, UploadFile +from fastapi.responses import HTMLResponse + +app = FastAPI() + + +@app.post("/files/") +async def create_files( + files: List[bytes] = File(..., description="Multiple files as bytes") +): + return {"file_sizes": [len(file) for file in files]} + + +@app.post("/uploadfiles/") +async def create_upload_files( + files: List[UploadFile] = File(..., description="Multiple files as UploadFile") +): + return {"filenames": [file.filename for file in files]} + + +@app.get("/") +async def main(): + content = """ + + + + + """ + return HTMLResponse(content=content) diff --git a/docs_src/request_files/tutorial003_py39.py b/docs_src/request_files/tutorial003_py39.py new file mode 100644 index 000000000..d853f48d1 --- /dev/null +++ b/docs_src/request_files/tutorial003_py39.py @@ -0,0 +1,35 @@ +from fastapi import FastAPI, File, UploadFile +from fastapi.responses import HTMLResponse + +app = FastAPI() + + +@app.post("/files/") +async def create_files( + files: list[bytes] = File(..., description="Multiple files as bytes") +): + return {"file_sizes": [len(file) for file in files]} + + +@app.post("/uploadfiles/") +async def create_upload_files( + files: list[UploadFile] = File(..., description="Multiple files as UploadFile") +): + return {"filenames": [file.filename for file in files]} + + +@app.get("/") +async def main(): + content = """ + + + + + """ + return HTMLResponse(content=content) diff --git a/fastapi/datastructures.py b/fastapi/datastructures.py index b13171287..b20a25ab6 100644 --- a/fastapi/datastructures.py +++ b/fastapi/datastructures.py @@ -1,4 +1,4 @@ -from typing import Any, Callable, Iterable, Type, TypeVar +from typing import Any, Callable, Dict, Iterable, Type, TypeVar from starlette.datastructures import URL as URL # noqa: F401 from starlette.datastructures import Address as Address # noqa: F401 @@ -20,6 +20,10 @@ 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: """ diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 35ba44aab..d4028d067 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -390,6 +390,8 @@ def get_param_field( field.required = required if not had_schema and not is_scalar_field(field=field): field.field_info = params.Body(field_info.default) + if not had_schema and lenient_issubclass(field.type_, UploadFile): + field.field_info = params.File(field_info.default) return field @@ -701,25 +703,6 @@ def get_missing_field_error(loc: Tuple[str, ...]) -> ErrorWrapper: return missing_field_error -def get_schema_compatible_field(*, field: ModelField) -> ModelField: - out_field = field - if lenient_issubclass(field.type_, UploadFile): - use_type: type = bytes - if field.shape in sequence_shapes: - use_type = List[bytes] - out_field = create_response_field( - name=field.name, - type_=use_type, - class_validators=field.class_validators, - model_config=field.model_config, - default=field.default, - required=field.required, - alias=field.alias, - field_info=field.field_info, - ) - return out_field - - def get_body_field(*, dependant: Dependant, name: str) -> Optional[ModelField]: flat_dependant = get_flat_dependant(dependant) if not flat_dependant.body_params: @@ -729,9 +712,8 @@ def get_body_field(*, dependant: Dependant, name: str) -> Optional[ModelField]: embed = getattr(field_info, "embed", None) body_param_names_set = {param.name for param in flat_dependant.body_params} if len(body_param_names_set) == 1 and not embed: - final_field = get_schema_compatible_field(field=first_param) - check_file_field(final_field) - return final_field + check_file_field(first_param) + return first_param # If one field requires to embed, all have to be embedded # in case a sub-dependency is evaluated with a single unique body field # That is combined (embedded) with other body fields @@ -740,7 +722,7 @@ def get_body_field(*, dependant: Dependant, name: str) -> Optional[ModelField]: model_name = "Body_" + name BodyModel: Type[BaseModel] = create_model(model_name) for f in flat_dependant.body_params: - BodyModel.__fields__[f.name] = get_schema_compatible_field(field=f) + BodyModel.__fields__[f.name] = f required = any(True for f in flat_dependant.body_params if f.required) BodyFieldInfo_kwargs: Dict[str, Any] = dict(default=None) diff --git a/tests/test_tutorial/test_request_files/test_tutorial001_02.py b/tests/test_tutorial/test_request_files/test_tutorial001_02.py new file mode 100644 index 000000000..e852a1b31 --- /dev/null +++ b/tests/test_tutorial/test_request_files/test_tutorial001_02.py @@ -0,0 +1,157 @@ +from fastapi.testclient import TestClient + +from docs_src.request_files.tutorial001_02 import app + +client = TestClient(app) + +openapi_schema = { + "openapi": "3.0.2", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/files/": { + "post": { + "summary": "Create File", + "operationId": "create_file_files__post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_create_file_files__post" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + "/uploadfile/": { + "post": { + "summary": "Create Upload File", + "operationId": "create_upload_file_uploadfile__post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_create_upload_file_uploadfile__post" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + }, + "components": { + "schemas": { + "Body_create_file_files__post": { + "title": "Body_create_file_files__post", + "type": "object", + "properties": { + "file": {"title": "File", "type": "string", "format": "binary"} + }, + }, + "Body_create_upload_file_uploadfile__post": { + "title": "Body_create_upload_file_uploadfile__post", + "type": "object", + "properties": { + "file": {"title": "File", "type": "string", "format": "binary"} + }, + }, + "HTTPValidationError": { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "detail": { + "title": "Detail", + "type": "array", + "items": {"$ref": "#/components/schemas/ValidationError"}, + } + }, + }, + "ValidationError": { + "title": "ValidationError", + "required": ["loc", "msg", "type"], + "type": "object", + "properties": { + "loc": { + "title": "Location", + "type": "array", + "items": {"type": "string"}, + }, + "msg": {"title": "Message", "type": "string"}, + "type": {"title": "Error Type", "type": "string"}, + }, + }, + } + }, +} + + +def test_openapi_schema(): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == openapi_schema + + +def test_post_form_no_body(): + response = client.post("/files/") + assert response.status_code == 200, response.text + assert response.json() == {"message": "No file sent"} + + +def test_post_uploadfile_no_body(): + response = client.post("/uploadfile/") + assert response.status_code == 200, response.text + assert response.json() == {"message": "No upload file sent"} + + +def test_post_file(tmp_path): + path = tmp_path / "test.txt" + path.write_bytes(b"