]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
Add support for UploadFile class annotations (#63)
authorSebastián Ramírez <tiangolo@gmail.com>
Sun, 3 Mar 2019 16:52:37 +0000 (20:52 +0400)
committerGitHub <noreply@github.com>
Sun, 3 Mar 2019 16:52:37 +0000 (20:52 +0400)
* :sparkles: Add support for UploadFile annotations

* :memo: Update File upload docs with FileUpload class

* :white_check_mark: Add tests for UploadFile support

* :memo: Update UploadFile docs

docs/src/request_files/tutorial001.py
docs/src/request_forms_and_files/tutorial001.py
docs/tutorial/request-files.md
docs/tutorial/request-forms-and-files.md
fastapi/__init__.py
fastapi/datastructures.py [new file with mode: 0644]
fastapi/dependencies/utils.py
fastapi/routing.py
tests/test_datastructures.py [new file with mode: 0644]
tests/test_tutorial/test_request_files/test_tutorial001.py
tests/test_tutorial/test_request_forms_and_files/test_tutorial001.py

index 3e99fcdde799d25f2d35590ae02105ff87991bfb..fffb56af8630fa0c8d9ad51102c5f4e8d01b3702 100644 (file)
@@ -1,8 +1,13 @@
-from fastapi import FastAPI, File
+from fastapi import FastAPI, File, UploadFile
 
 app = FastAPI()
 
 
 @app.post("/files/")
-async def create_file(*, file: bytes = File(...)):
+async def create_file(file: bytes = File(...)):
     return {"file_size": len(file)}
+
+
+@app.post("/uploadfile/")
+async def create_upload_file(file: UploadFile = File(...)):
+    return {"filename": file.filename}
index 1882a6397a9542ee181e31119b3c55f33de3237c..5bf3a5bc0530ee41a46cd503b4c7ffcea6c00c43 100644 (file)
@@ -1,8 +1,14 @@
-from fastapi import FastAPI, File, Form
+from fastapi import FastAPI, File, Form, UploadFile
 
 app = FastAPI()
 
 
 @app.post("/files/")
-async def create_file(*, file: bytes = File(...), token: str = Form(...)):
-    return {"file_size": len(file), "token": token}
+async def create_file(
+    file: bytes = File(...), fileb: UploadFile = File(...), token: str = Form(...)
+):
+    return {
+        "file_size": len(file),
+        "token": token,
+        "fileb_content_type": fileb.content_type,
+    }
index e97fa9556026cc33dcf2239663f1f4c038514222..ee5c9b7d4d127527dbdd7a8d4610b9c35870ffc8 100644 (file)
@@ -2,7 +2,7 @@ You can define files to be uploaded by the client using `File`.
 
 ## Import `File`
 
-Import `File` from `fastapi`:
+Import `File` and `UploadFile` from `fastapi`:
 
 ```Python hl_lines="1"
 {!./src/request_files/tutorial001.py!}
@@ -16,14 +16,78 @@ Create file parameters the same way you would for `Body` or `Form`:
 {!./src/request_files/tutorial001.py!}
 ```
 
-The files will be uploaded as form data and you will receive the contents as `bytes`.
-
 !!! info
     `File` is a class that inherits directly from `Form`.
 
 !!! info
     To declare File bodies, you need to use `File`, because otherwise the parameters would be interpreted as query parameters or body (JSON) parameters.
 
+The files will be uploaded as "form data".
+
+If you declare the type of your *path operation function* parameter as `bytes`, **FastAPI** will read the file for you and you will receive the contents as `bytes`.
+
+Have in mind that this means that the whole contents will be stored in memory. This will work well for small files.
+
+But there are several cases in where you might benefit from using `UploadFile`.
+
+
+## `File` parameters with `UploadFile`
+
+Define a `File` parameter with a type of `UploadFile`:
+
+```Python hl_lines="12"
+{!./src/request_files/tutorial001.py!}
+```
+
+Using `UploadFile` has several advantages over `bytes`:
+
+* 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. All without consuming all the memory.
+* You can get metadata from the uploaded file.
+* It has a <a href="https://docs.python.org/3/glossary.html#term-file-like-object" target="_blank">file-like</a> `async` interface.
+* It exposes an actual Python <a href="https://docs.python.org/3/library/tempfile.html#tempfile.SpooledTemporaryFile" target="_blank">`SpooledTemporaryFile`</a> object that you can pass directly to other libraries that expect a file-like object.
+
+
+### `UploadFile`
+
+`UploadFile` has the following attributes:
+
+* `filename`: A `str` with the original file name that was uploaded (e.g. `myimage.jpg`).
+* `content_type`: A `str` with the content type (MIME type / media type) (e.g. `image/jpeg`).
+* `file`: A <a href="https://docs.python.org/3/library/tempfile.html#tempfile.SpooledTemporaryFile" target="_blank">`SpooledTemporaryFile`</a> (a <a href="https://docs.python.org/3/glossary.html#term-file-like-object" target="_blank">file-like</a> object). This is the actual Python file that you can pass directly to other functions or libraries that expect a "file-like" object.
+
+
+`UploadFile` has the following `async` methods. They all call the corresponding file methods underneath (using the internal `SpooledTemporaryFile`).
+
+* `write(data)`: Writes `data` (`str` or `bytes`) to the file.
+* `read(size)`: Reads `size` (`int`) bytes/characters of the file.
+* `seek(offset)`: Goes to the byte position `offset` (`int`) in the file.
+    * E.g., `myfile.seek(0)` would go to the start of the file.
+    * This is especially useful if you run `myfile.read()` once and then need to read the contents again.
+* `close()`: Closes the file.
+
+As all these methods are `async` methods, you need to "await" them.
+
+For example, inside of an `async` *path operation function* you can get the contents with:
+
+```Python
+contents = await myfile.read()
+```
+
+If you are inside of a normal `def` *path operation function*, you can access the `UploadFile.file` directly, for example:
+
+```Python
+contents = myfile.file.read()
+```
+
+!!! note "`async` Technical Details"
+    When you use the `async` methods, **FastAPI** runs the file methods in a threadpool and awaits for them.
+
+
+!!! note "Starlette Technical Details"
+    **FastAPI**'s `UploadFile` inherits directly from **Starlette**'s `UploadFile`, but adds some necessary parts to make it compatible with **Pydantic** and the other parts of FastAPI.
+
 ## "Form Data"? 
 
 The way HTML forms (`<form></form>`) sends the data to the server normally uses a "special" encoding for that data, it's different from JSON.
index 00bbead32875c6e494cdebcdbf86448ef6b2f728..eb1f9967d5e425dfebe15b66f0e63a6118893651 100644 (file)
@@ -10,12 +10,14 @@ You can define files and form fields at the same time using `File` and `Form`.
 
 Create file and form parameters the same way you would for `Body` or `Query`:
 
-```Python hl_lines="7"
+```Python hl_lines="8"
 {!./src/request_forms_and_files/tutorial001.py!}
 ```
 
 The files and form fields will be uploaded as form data and you will receive the files and form fields.
 
+And you can declare some of the files as `bytes` and some as `UploadFile`.
+
 !!! warning
     You can declare multiple `File` and `Form` parameters in a path operation, but you can't also declare `Body` fields that you expect to receive as JSON, as the request will have the body encoded using `multipart/form-data` instead of `application/json`.
 
index 0f5eadabfdc51c26cbd8d0829a0088114e7cb84f..4152eed036251ea630e9fc8e8a693900fdc14c1b 100644 (file)
@@ -6,3 +6,4 @@ from .applications import FastAPI
 from .routing import APIRouter
 from .params import Body, Path, Query, Header, Cookie, Form, File, Security, Depends
 from .exceptions import HTTPException
+from .datastructures import UploadFile
diff --git a/fastapi/datastructures.py b/fastapi/datastructures.py
new file mode 100644 (file)
index 0000000..1ee9900
--- /dev/null
@@ -0,0 +1,15 @@
+from typing import Any, Callable, Iterable, Type
+
+from starlette.datastructures import UploadFile as StarletteUploadFile
+
+
+class UploadFile(StarletteUploadFile):
+    @classmethod
+    def __get_validators__(cls: Type["UploadFile"]) -> Iterable[Callable]:
+        yield cls.validate
+
+    @classmethod
+    def validate(cls: Type["UploadFile"], v: Any) -> Any:
+        if not isinstance(v, StarletteUploadFile):
+            raise ValueError(f"Expected UploadFile, received: {type(v)}")
+        return v
index 2dce12bf1f94bea61ee99c0508574e87d409fd16..5c1f42632afc23d2a731bbb8c690facebb647258 100644 (file)
@@ -17,6 +17,7 @@ from pydantic.fields import Field, Required, Shape
 from pydantic.schema import get_annotation_from_schema
 from pydantic.utils import lenient_issubclass
 from starlette.concurrency import run_in_threadpool
+from starlette.datastructures import UploadFile
 from starlette.requests import Headers, QueryParams, Request
 
 param_supported_types = (
@@ -323,6 +324,12 @@ async def request_body_to_args(
                 else:
                     values[field.name] = deepcopy(field.default)
                 continue
+            if (
+                isinstance(field.schema, params.File)
+                and lenient_issubclass(field.type_, bytes)
+                and isinstance(value, UploadFile)
+            ):
+                value = await value.read()
             v_, errors_ = field.validate(value, values, loc=("body", field.alias))
             if isinstance(errors_, ErrorWrapper):
                 errors.append(errors_)
@@ -333,6 +340,21 @@ async def request_body_to_args(
     return values, errors
 
 
+def get_schema_compatible_field(*, field: Field) -> Field:
+    if lenient_issubclass(field.type_, UploadFile):
+        return Field(
+            name=field.name,
+            type_=bytes,
+            class_validators=field.class_validators,
+            model_config=field.model_config,
+            default=field.default,
+            required=field.required,
+            alias=field.alias,
+            schema=field.schema,
+        )
+    return field
+
+
 def get_body_field(*, dependant: Dependant, name: str) -> Field:
     flat_dependant = get_flat_dependant(dependant)
     if not flat_dependant.body_params:
@@ -340,11 +362,11 @@ def get_body_field(*, dependant: Dependant, name: str) -> Field:
     first_param = flat_dependant.body_params[0]
     embed = getattr(first_param.schema, "embed", None)
     if len(flat_dependant.body_params) == 1 and not embed:
-        return first_param
+        return get_schema_compatible_field(field=first_param)
     model_name = "Body_" + name
     BodyModel = create_model(model_name)
     for f in flat_dependant.body_params:
-        BodyModel.__fields__[f.name] = f
+        BodyModel.__fields__[f.name] = get_schema_compatible_field(field=f)
     required = any(True for f in flat_dependant.body_params if f.required)
     if any(isinstance(f.schema, params.File) for f in flat_dependant.body_params):
         BodySchema: Type[params.Body] = params.File
index 2c8d262e0c9852d78e58f5859d0cf4835f8bb72a..b14d7b99601a31e3b3644682d81b7bc733d3df0c 100644 (file)
@@ -15,7 +15,6 @@ from pydantic.utils import lenient_issubclass
 from starlette import routing
 from starlette.concurrency import run_in_threadpool
 from starlette.exceptions import HTTPException
-from starlette.formparsers import UploadFile
 from starlette.requests import Request
 from starlette.responses import JSONResponse, Response
 from starlette.routing import compile_path, get_name, request_response
@@ -57,10 +56,7 @@ def get_app(
                     raw_body = await request.form()
                     form_fields = {}
                     for field, value in raw_body.items():
-                        if isinstance(value, UploadFile):
-                            form_fields[field] = await value.read()
-                        else:
-                            form_fields[field] = value
+                        form_fields[field] = value
                     if form_fields:
                         body = form_fields
                 else:
diff --git a/tests/test_datastructures.py b/tests/test_datastructures.py
new file mode 100644 (file)
index 0000000..27c6d30
--- /dev/null
@@ -0,0 +1,7 @@
+import pytest
+from fastapi import UploadFile
+
+
+def test_upload_file_invalid():
+    with pytest.raises(ValueError):
+        UploadFile.validate("not a Starlette UploadFile")
index 84de46e0ad806208abf9cf464ea06d75ec79391d..66a2c137399f18b1a2c4d50365484a37a9c4bc22 100644 (file)
@@ -39,7 +39,39 @@ openapi_schema = {
                     "required": True,
                 },
             }
-        }
+        },
+        "/uploadfile/": {
+            "post": {
+                "responses": {
+                    "200": {
+                        "description": "Successful Response",
+                        "content": {"application/json": {"schema": {}}},
+                    },
+                    "422": {
+                        "description": "Validation Error",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/HTTPValidationError"
+                                }
+                            }
+                        },
+                    },
+                },
+                "summary": "Create Upload File Post",
+                "operationId": "create_upload_file_uploadfile__post",
+                "requestBody": {
+                    "content": {
+                        "multipart/form-data": {
+                            "schema": {
+                                "$ref": "#/components/schemas/Body_create_upload_file"
+                            }
+                        }
+                    },
+                    "required": True,
+                },
+            }
+        },
     },
     "components": {
         "schemas": {
@@ -51,6 +83,14 @@ openapi_schema = {
                     "file": {"title": "File", "type": "string", "format": "binary"}
                 },
             },
+            "Body_create_upload_file": {
+                "title": "Body_create_upload_file",
+                "required": ["file"],
+                "type": "object",
+                "properties": {
+                    "file": {"title": "File", "type": "string", "format": "binary"}
+                },
+            },
             "ValidationError": {
                 "title": "ValidationError",
                 "required": ["loc", "msg", "type"],
@@ -131,3 +171,14 @@ def test_post_large_file(tmpdir):
     response = client.post("/files/", files={"file": open(path, "rb")})
     assert response.status_code == 200
     assert response.json() == {"file_size": default_pydantic_max_size + 1}
+
+
+def test_post_upload_file(tmpdir):
+    path = os.path.join(tmpdir, "test.txt")
+    with open(path, "wb") as file:
+        file.write(b"<file content>")
+
+    client = TestClient(app)
+    response = client.post("/uploadfile/", files={"file": open(path, "rb")})
+    assert response.status_code == 200
+    assert response.json() == {"filename": "test.txt"}
index 5e344482c8db6b45299509bb044be022c19a6714..d444e0fc007b41fd2fe4f42680d43848908da7e3 100644 (file)
@@ -1,4 +1,5 @@
 import os
+from pathlib import Path
 
 from starlette.testclient import TestClient
 
@@ -45,10 +46,11 @@ openapi_schema = {
         "schemas": {
             "Body_create_file": {
                 "title": "Body_create_file",
-                "required": ["file", "token"],
+                "required": ["file", "fileb", "token"],
                 "type": "object",
                 "properties": {
                     "file": {"title": "File", "type": "string", "format": "binary"},
+                    "fileb": {"title": "Fileb", "type": "string", "format": "binary"},
                     "token": {"title": "Token", "type": "string"},
                 },
             },
@@ -94,20 +96,32 @@ file_required = {
             "loc": ["body", "file"],
             "msg": "field required",
             "type": "value_error.missing",
-        }
+        },
+        {
+            "loc": ["body", "fileb"],
+            "msg": "field required",
+            "type": "value_error.missing",
+        },
     ]
 }
 
 token_required = {
     "detail": [
+        {
+            "loc": ["body", "fileb"],
+            "msg": "field required",
+            "type": "value_error.missing",
+        },
         {
             "loc": ["body", "token"],
             "msg": "field required",
             "type": "value_error.missing",
-        }
+        },
     ]
 }
 
+# {'detail': [, {'loc': ['body', 'token'], 'msg': 'field required', 'type': 'value_error.missing'}]}
+
 file_and_token_required = {
     "detail": [
         {
@@ -115,6 +129,11 @@ file_and_token_required = {
             "msg": "field required",
             "type": "value_error.missing",
         },
+        {
+            "loc": ["body", "fileb"],
+            "msg": "field required",
+            "type": "value_error.missing",
+        },
         {
             "loc": ["body", "token"],
             "msg": "field required",
@@ -153,14 +172,24 @@ def test_post_file_no_token(tmpdir):
     assert response.json() == token_required
 
 
-def test_post_file_and_token(tmpdir):
-    path = os.path.join(tmpdir, "test.txt")
-    with open(path, "wb") as file:
-        file.write(b"<file content>")
+def test_post_files_and_token(tmpdir):
+    patha = Path(tmpdir) / "test.txt"
+    pathb = Path(tmpdir) / "testb.txt"
+    patha.write_text("<file content>")
+    pathb.write_text("<file b content>")
 
     client = TestClient(app)
     response = client.post(
-        "/files/", data={"token": "foo"}, files={"file": open(path, "rb")}
+        "/files/",
+        data={"token": "foo"},
+        files={
+            "file": patha.open("rb"),
+            "fileb": ("testb.txt", pathb.open("rb"), "text/plain"),
+        },
     )
     assert response.status_code == 200
-    assert response.json() == {"file_size": 14, "token": "foo"}
+    assert response.json() == {
+        "file_size": 14,
+        "token": "foo",
+        "fileb_content_type": "text/plain",
+    }