35

Specifically, I want the below example to work:

from typing import List
from pydantic import BaseModel
from fastapi import FastAPI, UploadFile, File


app = FastAPI()


class DataConfiguration(BaseModel):
    textColumnNames: List[str]
    idColumn: str


@app.post("/data")
async def data(dataConfiguration: DataConfiguration,
               csvFile: UploadFile = File(...)):
    pass
    # read requested id and text columns from csvFile

If this is not the proper way for a POST request, please let me know how to select the required columns from an uploaded CSV file in FastAPI.

Chris
  • 18,724
  • 6
  • 46
  • 80
Abdullah
  • 535
  • 1
  • 5
  • 10
  • Please [edit] to show the expected format of the CSV file. It's not clear what "*read requested ID and text columns*" means, and how it's supposed to link with the `DataConfiguration` class. – Gino Mempin Jan 01 '21 at 05:57
  • Does this answer your question? [fastapi form data with pydantic model](https://stackoverflow.com/questions/60127234/fastapi-form-data-with-pydantic-model) – alex_noname Jan 01 '21 at 13:07

6 Answers6

60

As per FastAPI documentation:

You can declare multiple 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 application/x-www-form-urlencoded instead of application/json (when the form includes files, it is encoded as multipart/form-data).

This is not a limitation of FastAPI, it's part of the HTTP protocol.

Note that you need to have python-multipart installed first—if you haven't already—since uploaded files are sent as "form data". For instance:

pip install python-multipart

It should also be noted that in the examples below, the endpoints are defined with normal def, but you could also use async def (depending on your needs). Please have a look at this answer for more details on def vs async def in FastAPI.

Method 1

As described here, one can define files and form fileds at the same time using File and Form. Below is a working example. In case you had a large number of parameters and would like to define them separately from the endpoint, please have a look at this answer on how to create a custom dependency class instead.

app.py

from fastapi import Form, File, UploadFile, Request, FastAPI
from typing import List
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates

app = FastAPI()
templates = Jinja2Templates(directory="templates")


@app.post("/submit")
def submit(
    name: str = Form(...),
    point: float = Form(...),
    is_accepted: bool = Form(...),
    files: List[UploadFile] = File(...),
):
    return {
        "JSON Payload ": {"name": name, "point": point, "is_accepted": is_accepted},
        "Filenames": [file.filename for file in files],
    }


@app.get("/", response_class=HTMLResponse)
def main(request: Request):
    return templates.TemplateResponse("index.html", {"request": request})

You can test the above example by accessing the template below at http://127.0.0.1:8000. If your template does not include any Jinja code, you could alternatively return a simple HTMLResponse.

templates/index.html

<!DOCTYPE html>
<html>
   <body>
      <form method="post" action="http://127.0.0.1:8000/submit"  enctype="multipart/form-data">
         name : <input type="text" name="name" value="foo"><br>
         point : <input type="text" name="point" value=0.134><br>
         is_accepted : <input type="text" name="is_accepted" value=True><br>    
         <label for="file">Choose files to upload</label>
         <input type="file" id="files" name="files" multiple>
         <input type="submit" value="submit">
      </form>
   </body>
</html>

You can also test it using the interactive OpenAPI docs (provided by Swagger UI) at http://127.0.0.1:8000/docs, or Python requests, as shown below:

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'))]
payload ={"name": "foo", "point": 0.13, "is_accepted": False}
resp = requests.post(url=url, data=payload, files=files) 
print(resp.json())

Method 2

One can use Pydantic models, along with Dependencies to inform the "submit" route (in the case below) that the parameterised variable base depends on the Base class. Please note, this method expects the base data as query (not body) parameters (which are then converted into an equivalent JSON payload using .dict() method) and the Files as multipart/form-data in the body.

app.py

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

app = FastAPI()
templates = Jinja2Templates(directory="templates")


class Base(BaseModel):
    name: str
    point: Optional[float] = None
    is_accepted: Optional[bool] = False


@app.post("/submit")
def submit(base: Base = Depends(), files: List[UploadFile] = File(...)):
    return {
        "JSON Payload ": base.dict(),
        "Filenames": [file.filename for file in files],
    }


@app.get("/", response_class=HTMLResponse)
def main(request: Request):
    return templates.TemplateResponse("index.html", {"request": request})

Again, you can test it using the template below, which, this time, uses Javascript to modify the action attribute of the form, in order to pass the form data as query params to the URL.

templates/index.html

<!DOCTYPE html>
<html>
   <body>
      <form method="post" id="myForm" onclick="transformFormData();" enctype="multipart/form-data">
         name : <input type="text" name="name" value="foo"><br>
         point : <input type="text" name="point" value=0.134><br>
         is_accepted : <input type="text" name="is_accepted" value=True><br>    
         <label for="file">Choose files to upload</label>
         <input type="file" id="files" name="files" multiple>
         <input type="submit" value="submit">
      </form>
      <script>
         function transformFormData(){
            var myForm = document.getElementById('myForm');
            var qs = new URLSearchParams(new FormData(myForm)).toString();
            myForm.action = 'http://127.0.0.1:8000/submit?' + qs;
         }
      </script>
   </body>
</html>

As mentioned earlier you can also use Swagger UI, or Python requests, as shown in the example below. Note that this time, the payload is passed to the params parameter of requests.post(), as you submit query parameters, not form-data (body) params, which was the case in the previous method.

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'))]
payload ={"name": "foo", "point": 0.13, "is_accepted": False}
resp = requests.post(url=url, params=payload, files=files)
print(resp.json())

Method 3

Another option would be to pass the body data as a single parameter (of type Form) in the form of a JSON string. On server side, you can create a dependency function, where you parse the data using parse_raw method and validate the data against the corresponding model. If ValidationError is raised, an HTTP_422_UNPROCESSABLE_ENTITY error is sent back to the client, including the error message. Example is given below:

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: str = Form(...)):
    try:
        model = Base.parse_raw(data)
    except ValidationError as e:
        raise HTTPException(
            detail=jsonable_encoder(e.errors()),
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
        )

    return model


@app.post("/submit")
def submit(model: Base = Depends(checker), files: List[UploadFile] = File(...)):
    return {"JSON Payload ": model, "Filenames": [file.filename for file in files]}


@app.get("/", response_class=HTMLResponse)
def main(request: Request):
    return templates.TemplateResponse("index.html", {"request": request})

In case you had multiple models and would like to avoid creating a checker function for each model, you could instead create a checker class, as described in the documentation, and have a dictionary of your models that you can use to look up for a model to parse. Example:

# ...
models = {"base": Base, "other": SomeOtherModel}

class DataChecker:
    def __init__(self, name: str):
        self.name = name

    def __call__(self, data: str = Form(...)):
        try:
            model = models[self.name].parse_raw(data)
        except ValidationError as e:
            raise HTTPException(
                detail=jsonable_encoder(e.errors()),
                status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            )
        return model

base_checker = DataChecker("base")
other_checker = DataChecker("other")

@app.post("/submit")
def submit(model: Base = Depends(base_checker), files: List[UploadFile] = File(...)):
    # ...

test.py

Note that in JSON, boolean values are represented using the true or false literals in lower case, whereas in Python they must be capitalised as either True or False.

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'))]
data = {'data': '{"name": "foo", "point": 0.13, "is_accepted": false}'}
resp = requests.post(url=url, data=data, files=files) 
print(resp.json())

Or, if you prefer:

import requests
import json

url = 'http://127.0.0.1:8000/submit'
files = [('files', open('test_files/a.txt', 'rb')), ('files', open('test_files/b.txt', 'rb'))]
data = {'data': json.dumps({"name": "foo", "point": 0.13, "is_accepted": False})}
resp = requests.post(url=url, data=data, files=files) 
print(resp.json())

Test using Fetch API or Axios

templates/index.html

<!DOCTYPE html>
<html>
   <head>
      <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.27.2/axios.min.js"></script>
   </head>
   <body>
      <input type="file" id="fileInput" name="file" multiple><br>
      <input type="button" value="Submit using fetch" onclick="submitUsingFetch()">
      <input type="button" value="Submit using axios" onclick="submitUsingAxios()">
      <script>
         function submitUsingFetch() {
             var fileInput = document.getElementById('fileInput');
             if (fileInput.files[0]) {
                var formData = new FormData();
                formData.append("data", JSON.stringify({"name": "foo", "point": 0.13, "is_accepted": false}));
                for (const file of fileInput.files)
                    formData.append('files', file);
                    
                 fetch('/submit', {
                       method: 'POST',
                       body: formData,
                     })
                     .then(response => {
                       console.log(response);
                     })
                     .catch(error => {
                       console.error(error);
                     });
             }
         }
         
         function submitUsingAxios() {
             var fileInput = document.getElementById('fileInput');
             if (fileInput.files[0]) {
                var formData = new FormData();
                formData.append("data", JSON.stringify({"name": "foo", "point": 0.13, "is_accepted": false}));
                for (const file of fileInput.files)
                    formData.append('files', file);
                    
                 axios({
                         method: 'POST',
                         url: '/submit',
                         data: formData,
                     })
                     .then(response => {
                       console.log(response);
                     })
                     .catch(error => {
                       console.error(error);
                     });
             }
         }
      </script>
   </body>
</html>

Method 4

A further method comes from the discussion here, and incorporates a custom class with a classmethod used to transform a given JSON string into a Python dictionary, which is then used for validation against the Pydantic model. Similar to Method 3 above, the input data should be passed as a single Form parameter in the form of JSON string (defining the parameter with Body type would also work and still expect the JSON string as form data, as in this case the data comes encoded as multipart/form-data). Thus, the same test.py file(s) and index.html template from the previous method can be used for testing the below.

app.py

from fastapi import FastAPI, File, Body, UploadFile, Request
from pydantic import BaseModel
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

    @classmethod
    def __get_validators__(cls):
        yield cls.validate_to_json

    @classmethod
    def validate_to_json(cls, value):
        if isinstance(value, str):
            return cls(**json.loads(value))
        return value


@app.post("/submit")
def submit(data: 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})

Method 5

Another solution would be to convert the file bytes into a base64-format string and add it to the JSON object, along with other data that you might want to send over to the server. I would not strongly recommend using this approach; however, it has been added to this answer as a further option for the sake of completeness.

The reason I would not suggest using it is because encoding file(s) using base64 would essentially increase the size of the file, and hence, increase bandwidth utilisation, as well as the time and resources (e.g., CPU usage) required to upload the file (especially, when the API is going to be used by multiple users at the same time), as base64 encoding and decoding would need to take place on client and server side, respectively (could only be useful for very tiny images). As per MDN's documentation:

Each Base64 digit represents exactly 6 bits of data. So, three 8-bits bytes of the input string/binary file (3×8 bits = 24 bits) can be represented by four 6-bit Base64 digits (4×6 = 24 bits).

This means that the Base64 version of a string or file will be at least 133% the size of its source (a ~33% increase). The increase may be larger if the encoded data is small. For example, the string "a" with length === 1 gets encoded to "YQ==" with length === 4 — a 300% increase.

Using this approach, which again I would not recommend for the reasons explained above, you need to make sure to define the endpoint with normal def, as base64.b64decode() performs a blocking operation that would block the event loop. Otherwise, to use async def endpoint, you could execute the decoding function in an external ThreadPool or ProcessPool (see this answer), as well as use aiofiles to write the file to disk (see this answer).

The example below provides test client code in Python requests and JavaScript as well.

app.py

from fastapi import FastAPI, Request, HTTPException
from typing import List
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from fastapi.templating import Jinja2Templates
import base64
import binascii

app = FastAPI()
templates = Jinja2Templates(directory='templates')


class Bas64File(BaseModel):
    filename: str
    owner: str
    bas64_str: str


@app.post('/submit')
def submit(files: List[Bas64File]):
    for file in files:
        try:
            contents = base64.b64decode(file.bas64_str.encode('utf-8'))
            with open(file.filename, 'wb') as f:
                f.write(contents)
        except base64.binascii.Error as e:
            raise HTTPException(
                400, detail='There was an error decoding the base64 string'
            )
        except Exception:
            raise HTTPException(
                500, detail='There was an error uploading the file(s)'
            )

    return {'Filenames': [file.filename for file in files]}


@app.get('/', response_class=HTMLResponse)
def main(request: Request):
    return templates.TemplateResponse('index.html', {'request': request})

test.py

import requests
import os
import glob
import base64

url = 'http://127.0.0.1:8000/submit'
paths = glob.glob('files/*', recursive=True)
data = []

for p in paths:
    with open(p, 'rb') as f:
        bas64_str = base64.b64encode(f.read()).decode('utf-8')
    data.append({'filename': os.path.basename(p), 'owner': 'me', 'bas64_str': bas64_str})
 

resp = requests.post(url=url, json=data)
print(resp.json())

templates/index.html

<input type="file" id="fileInput" onchange="base64Handler()" multiple><br>
<script>
   async function base64Handler() {
      var fileInput = document.getElementById('fileInput');
      var data = [];
      for (const file of fileInput.files) {
         var dict = {};
         dict.filename = file.name;
         dict.owner = 'me';
         base64String = await this.toBase64(file);
         dict.bas64_str = base64String.replace("data:", "").replace(/^.+,/, "");
         data.push(dict);
      }
   
      uploadFiles(data);
   }


   function toBase64(file) {
      return new Promise((resolve, reject) => {
         const reader = new FileReader();
         reader.readAsDataURL(file);
         reader.onload = () => resolve(reader.result);
         reader.onerror = error => reject(error);
      });
   };


   function uploadFiles(data) {
      fetch('/submit', {
            method: 'POST',
            headers: {
               'Content-Type': 'application/json'
            },
            body: JSON.stringify(data)
         })
         .then(response => {
            console.log(response);
         })
         .catch(error => {
            console.error(error);
         });
   }
</script>
Chris
  • 18,724
  • 6
  • 46
  • 80
  • method is not working – Lalliantluanga Hauhnar Jun 27 '22 at 06:43
  • 1
    Method 4 worked fine for me. But I would implement the validation differently to allow the other validators to still function: ``` @classmethod def __get_validators__(cls): yield cls._validate_from_json_string @classmethod def _validate_from_json_string(cls, value): if isinstance(value, str): return cls.validate(json.loads(value.encode())) return cls.validate(value) ``` – derphelix Jun 28 '22 at 10:25
  • 1
    method 2 works like a charm! Thank You! – Joe Aug 02 '23 at 21:21
13

You can't mix form-data with json.

Per FastAPI documentation:

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. This is not a limitation of FastAPI, it's part of the HTTP protocol.

You can, however, use Form(...) as a workaround to attach extra string as form-data:

from typing import List
from fastapi import FastAPI, UploadFile, File, Form


app = FastAPI()


@app.post("/data")
async def data(textColumnNames: List[str] = Form(...),
               idColumn: str = Form(...),
               csvFile: UploadFile = File(...)):
    pass
Eric L
  • 592
  • 1
  • 6
  • 20
4

I went with the very elegant Method3 from @Chris (originally proposed from @M.Winkwns). However, I modified it slightly to work with any Pydantic model:

from typing import Type, TypeVar

from pydantic import BaseModel, ValidationError
from fastapi import Form

Serialized = TypeVar("Serialized", bound=BaseModel)


def form_json_deserializer(schema: Type[Serialized], data: str = Form(...)) -> Serialized:
    """
    Helper to serialize request data not automatically included in an application/json body but
    within somewhere else like a form parameter. This makes an assumption that the form parameter with JSON data is called 'data'

    :param schema: Pydantic model to serialize into
    :param data: raw str data representing the Pydantic model
    :raises ValidationError: if there are errors parsing the given 'data' into the given 'schema'
    """
    try:
        return schema.parse_raw(data)
    except ValidationError as e 
        raise HTTPException(detail=jsonable_encoder(e.errors()), status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)

    

When you use it in an endpoint you can then use functools.partial to bind the specific Pydantic model:

import functools

from pydantic import BaseModel
from fastapi import Form, File, UploadFile, FastAPI

class OtherStuff(BaseModel):
    stuff: str

class Base(BaseModel):
    name: str
    stuff: OtherStuff

@app.post("/upload")
async def upload(
    data: Base = Depends(functools.partial(form_json_deserializer, Base)),
    files: Sequence[UploadFile] = File(...)
) -> Base:
    return data
phillipuniverse
  • 2,025
  • 1
  • 14
  • 15
2

As stated by @Chris (and just for completeness):

As per FastAPI documentation,

You can declare multiple 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 application/x-www-form-urlencoded instead of application/json. (But when the form includes files, it is encoded as multipart/form-data)

This is not a limitation of FastAPI, it's part of the HTTP protocol.

Since his Method1 wasn't an option and Method2 can't work for deeply nested datatypes I came up with a different solution:

Simply convert your datatype to a string/json and call pydantics parse_raw function

from pydantic import BaseModel
from fastapi import Form, File, UploadFile, FastAPI

class OtherStuff(BaseModel):
    stuff: str

class Base(BaseModel):
    name: str
    stuff: OtherStuff

@app.post("/submit")
async def submit(base: str = Form(...), files: List[UploadFile] = File(...)):
    try:
        model = Base.parse_raw(base)
    except pydantic.ValidationError as e:
        raise HTTPException(
            detail=jsonable_encoder(e.errors()),
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY
        ) from e

    return {"JSON Payload ": received_data, "Uploaded Filenames": [file.filename for file in files]}
M.Winkens
  • 140
  • 2
  • 11
0

Example using pythantic models for cleaner documentation. The file is encoded to base64 any other logic can be applied.

class BaseTestUser(BaseModel):
    name: str
    image_1920: str
            
            
class UpdateUserEncodeFile(BaseTestUser):
            
    def __init__(self, name: str = Form(...), image_1920: UploadFile = File(...)):
        super().__init__(name=name, image_1920=base64.b64encode(image_1920.file.read()))

#routers

@router.put("/users/{id}/encoded", status_code=status.HTTP_200_OK)
def user_update_encode(id: int, user:UpdateUserEncodeFile=Depends()):
    return user
ocobacho
  • 511
  • 3
  • 7
0

If you are using pydantic v2:

import json

@app.post(/endpoint)
async def endpoint(file: UploadFile, payload: A)

class A(BaseModel):
    attr: str

    @model_validator(mode="before")
    @classmethod
    def to_py_dict(cls, data):
        return json.loads(data)

Your request shall be a multipart/form-data, the payload key's value will be a string in JSON's format, and when it reaches the model's serialization stage, the @model_validator will execute before that, and then you can transform the value into a python's dict and return it to the serialization.