83

I am trying to upload both a file and JSON data, as shown in the example below, but it is not working. 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.

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(...)):
    # read requested id and text columns from csv file
    pass
2
  • 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. Commented Jan 1, 2021 at 5:57
  • Does this answer your question? fastapi form data with pydantic model Commented Jan 1, 2021 at 13:07

8 Answers 8

137

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, as 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.

If you are looking for how to upload both files and a list of dictionaries/JSON data, please have a look at this answer, as well as this answer and this answer for working examples.

I would also recommend taking a look at this answer, which provides a much faster approach to uploading files (optionally, together with Form or JSON data), as using FastAPI/Starlette's UploadFile (as in the examples provided below) might be quite slow; especially, when uploading rather large files.

Method 1

As described here, one can define files and form fileds at the same time using File and Form. 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 declare multiple Form fields, using either a dependency class or Pydantic model. Also, for more details on Jinja2Templates, please have a look at the relevant documentation, as well as the first section of this answer, regarding a small change on TemplateResponse recently by Starlette.

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)
async def main(request: Request):
    return templates.TemplateResponse("index.html", {"request": request})

You could 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 instead return a simple HTMLResponse. See this answer as well, if you are looking for a JavaScript Fetch API solution.

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="files">Choose file(s) to upload</label>
         <input type="file" id="files" name="files" multiple>
         <input type="submit" value="submit">
      </form>
   </body>
</html>

You could also test this example using the interactive OpenAPI/Swagger UI autodocs at /docs, e.g., http://127.0.0.1:8000/docs, or using 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'))]
data = {"name": "foo", "point": 0.13, "is_accepted": False}
resp = requests.post(url=url, data=data, files=files) 
print(resp.json())

Method 2

One could also use Pydantic models, along with Dependencies, to inform the /submit endpoint (in the example below) that the parameterized variable base depends on the Base class. Please note that this method expects the base data as query (not body) parameters, which are then validated against and converted into the Pydantic model (in this case, the Base model). Also, note that one should never pass sensitive data through the query string, as this poses serious security risks—please have a look at this answer for more details.

When returning a Pydantic model instance (e.g, base) from a FastAPI endpoint (e.g., /submit), it would automatically be converted into a JSON string, behind the scenes, using the jsonable_encoder, as explained in detail in this answer. However, if you would like to have the model converted into a JSON string on your own within the endpoint, you could use Pydantic's model_dump_json() (in Pydantic V2), e.g., base.model_dump_json(), and return a custom Response directly, as explained in the linked answer earlier; thus, avoiding the use of jsonable_encoder. Otherwise, in order to convert the model into a dict on your own, you could use Pydantic's model_dump() (in Pydantic V2), e.g., base.model_dump(), or simply dict(base) (Note that returning a dict object from an endpoint, FastAPI would still use the jsonable_encoder, behind the scenes, as explained in the linked answer above). You may also have a look at this answer for the relevant Pydantic methods and documentation.

Apart from using a Pydantic model for the query parameters, one could also define query parameters directly in the endpoint, as demonstrated in this answer, as well as this answer and this answer.

Besides the base query parameters, the following /submit endpoint also expects Files encoded as multipart/form-data in the request body.

app.py

from fastapi import Form, File, UploadFile, Request, FastAPI, Depends
from typing import List, Optional
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
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,
        "Filenames": [file.filename for file in files],
    }


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

The template below now uses JavaScript to modify the action attribute of the form element, in order to pass the form data as query params to the URL instead of form-data in the request body.

templates/index.html

<!DOCTYPE html>
<html>
   <body>
      <form method="post" id="myForm" onsubmit="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="files">Choose file(s) 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>

If, instead, you would like to perform a JavaScript fetch() request, you could use the following template (see related answer on submiting HTML form as well):

<!DOCTYPE html>
<html>
   <body>
      <form id="myForm" >
         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>    
      </form>
      <label for="fileInput">Choose file(s) to upload</label>
      <input type="file" id="fileInput" onchange="reset()" multiple><br>
      <input type="button" value="Submit" 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();
               for (const file of fileInput.files)
                  formData.append('files', file);
               var myForm = document.getElementById('myForm');
               var qs = new URLSearchParams(new FormData(myForm)).toString();
               fetch('/submit?' + qs, {
                     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>

As mentioned earlier, you could also use Swagger UI or Python requests for testing. Note that the data should now be passed to the params(not the data) argument of requests.post(), as the data are now sent as query parameters, not form-data, which was the case in Method 1 earlier.

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

Method 3

Another option would be to pass the body data as a JSON string in a single Form parameter. To that end, you would need to create a dependency function on server side.

A dependency is "just a function that can take all the same parameters that a path operation function (also known as endpoint) can take. You can think of it as a path operation function without the decorator". Hence, you would need to declare the dependency in the same way you would do with your endpoint (i.e., the parameters' names and types in the dependency should be the ones expected by FastAPI, when a client sends an HTTP request to that endpoint, e.g., data: str = Form(...)). Then, create a new parameter (e.g., base) in your endpoint, using Depends() and pass the dependency function as a parameter to it (Note: Don't call it directly, meaning don't add the parentheses at the end of your function's name, but instead use Depends(checker), where checker is the name of your dependency function). Whenever a new request arrives, FastAPI will take care of calling your dependency, getting the result and assigning that result to the parameter (e.g., base) in your endpoint. For more details on dependencies, please have a look at the links provided earlier.

In this case, the dependency should be used to parse the (JSON string) data using the parse_raw method (Note: In Pydantic V2 parse_raw has been deprecated and replaced by model_validate_json), as well as validate the data against the corresponding Pydantic model. If ValidationError is raised, an HTTP_422_UNPROCESSABLE_ENTITY error should be sent back to the client, including the error message; otherwise, an instance of that model (e.g., Base) is assigned to the parameter in the endpoint.

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:
        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: Base = Depends(checker), files: List[UploadFile] = File(...)):
    return {"JSON Payload": base, "Filenames": [file.filename for file in files]}


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

In case you had multiple models and would like to avoid creating a checker function for each model, you could instead create a generic Checker dependency class, as described in the docs (see this answer as well), and use it for every different model in your API. Example:

# ...  rest of the code is the same as above

class Other(BaseModel):
    msg: str
    details: Base
 
    
class Checker:
    def __init__(self, model: BaseModel):
        self.model = model

    def __call__(self, data: str = Form(...)):
        try:
            return self.model.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: Base = Depends(Checker(Base)), files: List[UploadFile] = File(...)):
    pass


@app.post("/submit_other")
def submit_other(other: Other = Depends(Checker(Other)), files: List[UploadFile] = File(...)):
    pass
Arbitrary JSON data

In case validating the input data against a Pydantic model wasn't important to you, but, instead, you would like to receive arbitrary JSON data and simply check whether or not a valid JSON string was sent by the client, use the below (orjson, as shown here, could be used instead of the standard json module):

# ...
from json import JSONDecodeError
import json

def checker(data: str = Form(...)):
    try:
       return json.loads(data)
    except JSONDecodeError:
        raise HTTPException(status_code=400, detail='Invalid JSON data')


@app.post("/submit")
def submit(payload: dict = Depends(checker), files: List[UploadFile] = File(...)):
    pass

Alternatively, you could simply use the Json type from Pydantic:

from pydantic import Json

@app.post("/submit")
def submit(data: Json = Form(), files: List[UploadFile] = File(...)):
    pass

Test using Python requests

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 capitalized 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())

P.S. To test the /submit_other endpoint (described in the generic Checker class earlier) using Python requests, replace the data attribute in the example above with the one below:

import requests
import json

url = 'http://127.0.0.1:8000/submit_other'
data = {'data': json.dumps({"msg": "Hi", "details": {"name": "bar", "point": 0.11, "is_accepted": True}})}
# ... rest of the code is the same as above

Test using Fetch API or Axios

You might find this answer helpful as well, if you are looking for how to convert entries from HTML <form> into a JSON string.

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" onchange="reset()" multiple><br>
      <input type="button" value="Submit using fetch" onclick="submitUsingFetch()">
      <input type="button" value="Submit using axios" onclick="submitUsingAxios()">
      <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();
               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 => 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";
            }
         }
         
         function submitUsingAxios() {
            var resp = document.getElementById("resp");
            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 => {
                     resp.innerHTML = JSON.stringify(response.data); // response.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>

Method 4

A further method comes from the github 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 (Note that, compared to the example given in the aforementioned github link, the example below uses @model_validator(mode='before'), since the introduction of Pydantic V2).

Similar to Method 3 above, the input data should be passed as a single Form parameter in the form of JSON string (Note that defining the data parameter in the example below with either Body or Form would work regardless—Form is a class that inherits directly from Body. That is, FastAPI would still expect the JSON string as form-data, not application/json, as in this case the request will have the body encoded using multipart/form-data). Thus, the exact same test.py examples and index.html template provided in Method 3 above could be used for testing the application example below as well.

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: Base = Body(...), files: List[UploadFile] = File(...)):
    return {"JSON Payload": data, "Filenames": [file.filename for file in files]}


@app.get("/", response_class=HTMLResponse)
async 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. However, I would not highly recommend using this approach for the reasons explained below—it has been added, though, to this answer as an alternative 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 utilization, 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 (this approach 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 discussed above, you would need to make sure to define the endpoint with normal def, as base64.b64decode() performs a blocking operation that would block the event loop, and hence the entire server—have a look at this answer for more details. Otherwise, to use async def endpoint, you should execute the decoding function in an external ThreadPool or ProcessPool (again, see this answer on how to do that), as well as use aiofiles to write the file to disk (see this answer as well).

The example below provides client test examples 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)
async def main(request: Request):
    return templates.TemplateResponse('index.html', {'request': request})

Test using Python requests

test.py

import requests
import os
import glob
import base64

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

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

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

Test using Fetch API

templates/index.html

<input type="file" id="fileInput" onchange="base64Handler()" multiple><br>
<script>
   async function base64Handler() {
      var fileInput = document.getElementById('fileInput');
      var payload = [];
      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(/^.+,/, "");
         payload.push(dict);
      }
   
      uploadFiles(payload);
   }


   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(payload) {
      fetch('/submit', {
            method: 'POST',
            headers: {
               'Content-Type': 'application/json'
            },
            body: JSON.stringify(payload)
         })
         .then(response => {
            console.log(response);
         })
         .catch(error => {
            console.error(error);
         });
   }
</script>
Sign up to request clarification or add additional context in comments.

6 Comments

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) ```
@Chris Just a note to say thanks for putting so much time into these answers. I've only been doing python for a couple months now and these are immensely helpful for understanding the nuances of FastAPI and the underlying apps that support it. Really appreciate the effort you made here. Much love sensai Chris.
I went with the alternative mentioned in method 3. Using Json from Pydantic. Worked like a charm. Thanks much.
Method 4 works fine to me, and I think a better way is use return cls.model_validate_json(value) instead of return cls(**json.loads(value)) as I guess pydantic-core is written in rust so it might be faster than python standard json
Method 3 with data: Json worked for me
|
20

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

4 Comments

It's sad that browsers don't support encode files as json fields then POST.
How would such a post request look like in the requests library? The documentation is unclear
Http has multipart/mixed where each part is its own request.
Can I pass a pydantic with these fields as body: MyFieldsRequest?
7

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.

Comments

5

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

Comments

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]}

Comments

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

Comments

0

Not an alternative, but an addendum to Chris's generous response above https://stackoverflow.com/a/70640522/20479, that helped me with my code, but only after some debugging because I'd changed some variable names.

So the gotcha is that the mapping to parameter names and form field names is key.

Take Chris's excellent method 3, which I ended up using. The last example of plain json exemplifies what confused me:

In the FastAPI service, we have:

def checker(data: str = Form(...)):
...

@app.post("/submit")
def submit(data: dict = Depends(checker), files: List[UploadFile] = File(...)):
    pass

and in the client code we see:

files = [('files', open('test_files/a.txt', 'rb')), ('files', ...
data = {'data': '{"name":...}
resp = requests.post(url=url, data=data, files=files) 

Note that the terms data and files appear no fewer than 6 times each in the code I excerpted. We also see That's what tripped me up with hard-to-resolve errors including info like [{'loc': ('body', 'data'), 'msg': 'field required', 'type': 'value_error.missing'}] (which I've learnt to read as "there's a missing form field named 'data' expected in the 'body' of this request)

So the catch is that the first data in submit is the function parameter and it's name doesn't have to match anything other than references to it below in the function (which are left to the imagination). This parameter is arbitrary and could be foo here.

The one in def checker(data:..., however, is crucial. It can have any name, but must be used in the request, specifically as the dict key in the form. (Read that again).

i.e. it matches the second 'data' in this line:

data = {'data': '{"name":...}

This is because checker is a FastAPI dependency function, so its parameters are used in place of parameters in path operation function. (That's the whole point of dependencies: reusing parameter sets instead of repeating them).

See the details here: https://fastapi.tiangolo.com/tutorial/dependencies/. The phrase that helped me was down the page where it says:

And it has the same shape and structure that all your path operation functions have.

You can think of it as a path operation function without the "decorator" (without the @app.get("/some-path")).

(Note that def submit... is an example of a "path operation function")

Meanwhile, the first data in the client line

resp = requests.post(url=url, data=data, files=files) 

is required by the requests post method (so if you change that one, you'll find out soon).

Similarly the only values of files that have to match are the ones in the dict created in the client and the parameter name in the function. The rest are either required parameters for the requests post function, or arbitrary choices.

Don't get me wrong - calling the arbitrary variable by the same name as the parameter to which you assign it, is very pythonic - just that it tripped me up in understanding Chris's answer.

To make it clearer, I'm transcribing my excerpt below, replacing the word "data" where I can. (And adding an assertion and a write..)

Service:

def checker(foo: str = Form(...)):
   return "dingo"
...

@app.post("/submit")
def submit(quux: dict = Depends(checker), bananas: List[UploadFile] = File(...)):
  assert quux == "dingo"  # quux assigned to return value of checker
  # write bananas to local files:

and in the client:

apples = [('bananas', open('test_files/a.txt', 'rb')), ('bananas', ...
baz = {'foo': { 'name': '...'} ... }
resp = requests.post(url=url, data=baz, files=apples) 

Now there's only one 'data' and its required by requests (and by the same method in httpx, which is what I'm using)

The biggest two gotchas here are:

  1. the dependency function param checker(foo must be provided in the form data {'foo': {...

  2. the client must provide names matching the form fields as keys in the dict / json of the request body. Look carefully at the 2 appearances of 'foo' and the 4 of 'bananas' in my code.

Comments

0

In Pydantic>2, the Json type annotated with Form() can be used to read a multipart form body as json.

from typing import Annotated

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

app = FastAPI()

@app.post("/myendpoint")
async def myendpoint(
    file: Annotated[UploadFile, File()],
    metadata: Annotated[Json[dict[str, str]] | None, Form()] = None,
):
    print(metadata) # it's a dict

It can be used from requests via:

import requests, json

requests.post(
    "http://localhost:8000/myendpoint",
    data={"metadata": json.dumps({"data": "value", "foo": "bar"})},
    files={"file": ("data.txt", open("data.txt"), "text/plain")},
)

1 Comment

This option has already been provided under Method 3 (please take a look at Arbitrary JSON data section) of this answer

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.