pythonjsonfile-uploadfastapipydantic

How to POST both Files and a List of JSON data in FastAPI?


I am creating a registration API in FastAPI using Pydantic models. During registration, users need to input their files, user information, and school information. Since users should be able to input multiple schools, I am considering passing the school information as a list. Based on my research, it seems that uploading a file and sending data in JSON format are not supported in the same API. Therefore, I found out that I need to send the data in form_data instead of JSON format(How to add both file and JSON body in a FastAPI POST request?). I was able to convert and send user information as form data, but I'm not sure how to pass the list of school information. What I've tried so far is as follows.

I used a class method to receive User information as form data.

class User(BaseModel):
    name: str 
    password:  str 
    email: str

    @classmethod
    def as_form(cls, name: str = Form(...), password:  str= Form(...),
                email:  str= Form(...)) -> 'User':
        return cls(name=first_name,  password=password, email=email)

This is the model I want to receive as a list.

class School(BaseModel):
    school: str 
    country: str

and This is api.

@router.post("/signup", status_code=status.HTTP_201_CREATED)
async def signup(hospitals: List[School] = Form(...),
                 user: User = Depends(UserIn.as_form),
                 file: UploadFile = File(),session: Session = Depends(db.session)):

I tried schools: Annotated[Json[School], Form]

I also tried creating an as_form class method for the School model, just like I did for the User model, in order to pass it as a list like this. However, it didn't work for either. Additionally, it seems that passing form data as a list is not a commonly used approach.

schools: List[School] = Depends(School.as_form)

I'm not sure how to approach this. I considered receiving data in JSON format and parsing it on the server, but in that case, how should I define the Pydantic models? I would like to know the most ideal approach in my current situation. Can you help me?


Solution

  • One way to solve this would be to have two separate List parameters of Form type, one for schools and another one for countries. For instance:

    @router.post("/signup")
    async def signup(schools: List[str] = Form(...), countries: List[str] = Form(...))
        pass
    

    However, since such a solution would require extra work in the backend to combine the two lists into one etc.,—as well as things would become more complicated, if more lists needed to be added—the following solutions are also given below.

    Solutions 1 and 2 provided below are heavily based on Methods 3 and 4 of this answer, respectively. Hence, please refer to that answer for further details and explanation. Both the solutions below demonstrate how to upload both File(s) and a List of JSON data in FastAPI using a single POST request. Client examples in Python requests and JavaScript Fetch API are also provided below. Related answers can also be found here and here.

    Solution 1

    app.py

    from fastapi import FastAPI, status, Form, UploadFile, File, Depends, Request
    from pydantic import BaseModel, ValidationError
    from fastapi.exceptions import HTTPException
    from fastapi.encoders import jsonable_encoder
    from typing import Optional, List
    from fastapi.templating import Jinja2Templates
    from fastapi.responses import HTMLResponse
    
    app = FastAPI()
    templates = Jinja2Templates(directory="templates")
    
    
    class Base(BaseModel):
        name: str
        point: Optional[float] = None
        is_accepted: Optional[bool] = False
    
    
    def checker(data: List[str] = Form(...)):
        models = []
        try:
            for d in data:
                models.append(Base.model_validate_json(d))
        except ValidationError as e:
            raise HTTPException(
                detail=jsonable_encoder(e.errors()),
                status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            )
    
        return models
    
    
    @app.post("/submit")
    def submit(models: List[Base] = Depends(checker), files: List[UploadFile] = File(...)):
        return {"JSON Payload ": models, "Filenames": [file.filename for file in files]}
    
    
    @app.get("/", response_class=HTMLResponse)
    def main(request: Request):
        return templates.TemplateResponse("index.html", {"request": request})
    

    Solution 2

    app.py

    from fastapi import FastAPI, File, Body, UploadFile, Request
    from pydantic import BaseModel, model_validator
    from typing import Optional, List
    from fastapi.templating import Jinja2Templates
    from fastapi.responses import HTMLResponse
    import json
    
    app = FastAPI()
    templates = Jinja2Templates(directory="templates")
    
    
    class Base(BaseModel):
        name: str
        point: Optional[float] = None
        is_accepted: Optional[bool] = False
    
        @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(data: List[Base] = Body(...), files: List[UploadFile] = File(...)):
        return {"JSON Payload ": data, "Filenames": [file.filename for file in files]}
    
    
    @app.get("/", response_class=HTMLResponse)
    def main(request: Request):
        return templates.TemplateResponse("index.html", {"request": request})
    

    Test Solutions 1 and 2 above

    Using Python requests

    test.py

    import requests
    
    url = 'http://127.0.0.1:8000/submit'
    files = [('files', open('test_files/a.txt', 'rb')), ('files', open('test_files/b.txt', 'rb'))]
    items = ['{"name": "foo", "point": 0.11, "is_accepted": false}', '{"name": "bar", "point": 0.42, "is_accepted": true}']
    data = {'data': items}
    resp = requests.post(url=url, data=data, files=files) 
    print(resp.json())
    

    Using JavaScript Fetch API

    templates/index.html

    <!DOCTYPE html>
    <html>
       <body>
          <input type="file" id="fileInput" name="file" onchange="reset()" multiple><br>
          <input type="button" value="Submit using fetch" onclick="submitUsingFetch()">
          <p id="resp"></p>
          <script>
             function reset() {
                var resp = document.getElementById("resp");
                resp.innerHTML = "";
                resp.style.color = "black";
             }
             
             function submitUsingFetch() {
                var resp = document.getElementById("resp");
                var fileInput = document.getElementById('fileInput');
                if (fileInput.files[0]) {
                   var formData = new FormData();
                   var items = [];
             
                   items[0] = JSON.stringify({"name": "foo", "point": 0.11, "is_accepted": false});
                   items[1] = JSON.stringify({"name": "bar", "point": 0.42, "is_accepted": true});  
             
                   for (const i of items)
                      formData.append("data", i);
             
                   for (const file of fileInput.files)
                      formData.append('files', file);
             
                   fetch('/submit', {
                         method: 'POST',
                         body: formData,
                      })
                      .then(response => response.json())
                      .then(data => {
                         resp.innerHTML = JSON.stringify(data); // data is a JSON object
                      })
                      .catch(error => {
                         console.error(error);
                      });
                } else {
                   resp.innerHTML = "Please choose some file(s)...";
                   resp.style.color = "red";
                }
             }
          </script>
       </body>
    </html>