pythonjsonfastapimultipartform-datapydantic

How to upload both Files and a List of dictionaries using Pydantic's BaseModel in FastAPI?


I have the following code example:

from fastapi import File, UploadFile, Request, FastAPI, Depends
from typing import List
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
from typing import Optional

app = FastAPI()

class BaseBox(BaseModel):
    l: float=Field(...)
    t: float=Field(...)
    r: float=Field(...)
    b: float=Field(...)

class BaseInput(BaseModel):
    boxes: List[BaseBox] = Field(...)
    words: List[str] = Field(...)
    width: Optional[float] = Field(...)
    height: Optional[float] = Field(...)

@app.post("/submit")
def submit(
    base_input: BaseInput = Depends(),
    file: UploadFile = File(...),  # Add this line to accept a file
):

    return {
        "JSON Payload": base_input,
        "Filename": file.filename,
    }

@app.get("/")
def main(request: Request):
    return {"status":"alive"}

but some how I can not make it works. I use the interactive API docs, but I always get an error. Do you think I have to send 2 files instead? I did also try with

curl -X 'POST' \
  'http://localhost:8007/submit?width=10&height=10' \
  -H 'accept: application/json' \
  -H 'Content-Type: multipart/form-data' \
  -F 'file=@test.png;type=image/png' \
  -F 'boxes={
  "l": 0,
  "t": 0,
  "r": 0,
  "b": 0
}' \
  -F 'words=test,test2,tes3,test'

but I always get the error "POST /submit?width=10&height=10 HTTP/1.1" 422 Unprocessable Entity.


Solution

  • As can be seen in the code you provided, you already had a look at this answer, and that answer should help you find the solution you are looking for in the end. Related answers can also be found here and here.

    But, first, please let me explain what is wrong with the code in your example. You are submitting both Files and query data, or, at least, that is what seems you have been trying to achieve. Having a query parameter defined, for instance, as str or int, in an endpoint—or having a Pydantic BaseModel along with using Depends() on a parameter in the endpoint to indicate that the fileds defined in the model are expected as query parameters—in either case, that should work just fine. However, when you define a query parameter as a List, e.g., List[int] or List[str], either directly in the endpoint or in a BaseModel, you should define it explicitly with Query, as explained and demonstrated here and here. While Pydantic models did not allow the use of Query fields in the past, and one had to implement the query parameter-parsing in a separate dependency class, as shown in this answer and this answer, this has recently been changed, and hence, one could instead wrap the Query() in a Field() using a BaseModel class, as demonstrated in this answer.

    Working Example 1

    from fastapi import FastAPI, Query, Depends
    from pydantic import BaseModel, Field
    from typing import Optional, List
    
    app = FastAPI()
    
    
    class Base(BaseModel):
        width: Optional[float] = Field (...)
        height: Optional[float] = Field (...)
        words: List[str] = Field (Query(...))  #  wrap the `Query()` in a `Field()` for `List` params
        
    
    @app.get('/')
    async def main(base: Base = Depends()):
        return base
    

    In your example, you also seem to have a List query parameter that expects as a value a list of dictionary/JSON objects. That, however, cannot be achieved using query parameters. If you attempted defining such a parameter (e.g., boxes: List[BaseBox] = Field (Query(...))) in the working example above, you would come accross the following error when trying to run the FastAPI application:

    AssertionError: Param: boxes can only be a request body, using Body()

    If you instead defined the parameter as boxes: List[BaseBox] = Field (...), along with file: UploadFile = File(...) defined in the endpoint, as you have already been doing in your code, even though the application would start running as usual, when attempting to submit a request to that endpoint (through Swagger UI autodocs at /docs, for instance), you would receive a 422 Unprocessable Entity error, with a message that has a similar meaning, saying that Input should be a valid dictionary or object to extract fields from.

    That is because, in the first case, you can't have a query parameter expecting dictionary data (unless you followed the approach described here and here for arbitrary query data, which you would need to parse on your own, and which I would not recommend doing), while, in the second case, the request body is sent encoded as multipart/form-data due to the file: UploadFile = File(...) field defined in the endpoint; however, sending both Form and JSON data together is not supported by the HTTP protocol (again, see this answer).

    If, however, you removed the UploadFile parameter from the endpoint, the request should go through successfully, as the request body would then be endoced as application/json (have a look at the Content-Type request header in Swagger UI when submitting the request). In that case, however, you should use a POST request—and hence, define your endpoint with @app.post('/'), for instance—not a GET one, as a request with GET/HEAD method shouldn't have a body (Requests using GET method should only be used to request data; they shouldn't include data).

    Working Example 2

    from fastapi import FastAPI, Query, Depends
    from pydantic import BaseModel, Field
    from typing import Optional, List
    
    app = FastAPI()
    
    class BaseBox(BaseModel):
        l: float = Field(...)
        t: float = Field(...)
        
    class Base(BaseModel):
        width: Optional[float] = Field (...)
        height: Optional[float] = Field (...)
        words: List[str] = Field (Query(...))  #  wrap the `Query()` in a `Field()` for `List` params
        boxes: List[BaseBox] = Field (...)
    
    
    @app.post('/')
    async def main(base: Base = Depends()):
        return base
    

    Posting both File(s) and JSON body (including List of dictionaries)

    If you still had to add both File(s) and JSON body in a FastAPI POST request, I would highly suggest having a look at Methods 3 and 4 of this answer. The examples provided below are based on those two approaches in the linked answer, and demonstrate how to post Files together with JSON data that also include lists of dictionaries, just like in your example. Please have a look at the linked answer for further details and examples in Python and JavaScript on how to test the approaches. In your case, you would need to separate query parameters from body fields, and have query parameters defined either in the endpoint (as explained in the linked answers provided earlier), or in separate Pydantic models, as demonstrated below.

    Working Example 3 (based on Method 3 of this answer)

    In Swagger UI /docs, since data is a Form parameter and represented as a single field, you would need to pass the data for Base in that field as a dictionary, which will be submitted as str in the data Form parameter. Test example:

    {"boxes": [{"l": 0,"t": 0,"r": 0,"b": 0}], "comments": ["foo", "bar"], "code": 0}
    

    For further information on how to test this, see the linked answer above.

    app.py

    from fastapi import FastAPI, status, Form, UploadFile, File, Depends, Query
    from pydantic import BaseModel, Field, ValidationError
    from fastapi.exceptions import HTTPException
    from fastapi.encoders import jsonable_encoder
    from typing import Optional, List
    
    
    app = FastAPI()
    
    
    class BaseParams(BaseModel):
        width: Optional[float] = Field (...)
        height: Optional[float] = Field (...)
        words: List[str] = Field (Query(...))  #  wrap the `Query()` in a `Field()` for `List` params
    
    
    class BaseBox(BaseModel):
        l: float=Field(...)
        t: float=Field(...)
        r: float=Field(...)
        b: float=Field(...)
    
    
    class Base(BaseModel):
        boxes: List[BaseBox] = Field (...)
        comments: List[str] = Field (...)
        code: int = Field (...)
        
    
    def checker(data: str = Form(...)):
        try:
            return Base.model_validate_json(data)
        except ValidationError as e:
            raise HTTPException(
                detail=jsonable_encoder(e.errors()),
                status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            )
    
    
    @app.post("/submit")
    def submit(
        base_params: BaseParams = Depends(),
        base: Base = Depends(checker),
        files: List[UploadFile] = File(...),
    ):
        return {
            "Params": base_params,
            "JSON Payload": base,
            "Filenames": [file.filename for file in files],
        }
    

    Working Example 4 (based on Method 4 of this answer)

    This approach has the advantage of requiring less code to achieve the expected outcome, as well as the Base model is represented (with an automatically generated input example) in the request body section of Swagger UI /docs, resulting in a more clear view of the data and easier way of posting the data. Again, please have a look at the linked answer above for more details on this approach.

    app.py

    from fastapi import FastAPI, Body, UploadFile, File, Depends, Query
    from pydantic import BaseModel, Field, model_validator
    from typing import Optional, List
    import json
    
    
    app = FastAPI()
    
    
    class BaseParams(BaseModel):
        width: Optional[float] = Field (...)
        height: Optional[float] = Field (...)
        words: List[str] = Field (Query(...))  #  wrap the `Query()` in a `Field()` for `List` params
    
        
    class BaseBox(BaseModel):
        l: float=Field(...)
        t: float=Field(...)
        r: float=Field(...)
        b: float=Field(...)
    
    
    class Base(BaseModel):
        boxes: List[BaseBox] = Field (...)
        comments: List[str] = Field (...)
        code: int = Field (...)
    
        @model_validator(mode="before")
        @classmethod
        def validate_to_json(cls, value):
            if isinstance(value, str):
                return cls(**json.loads(value))
            return value
    
    
    @app.post("/submit")
    def submit(
        base_params: BaseParams = Depends(),
        base: Base = Body(...),
        files: List[UploadFile] = File(...),
    ):
        return {
            "Params": base_params,
            "JSON Payload": base,
            "Filenames": [file.filename for file in files],
        }