Question
I would like to create an endpoint in FastAPI that might receive (multipart)
Form
data or JSON
body. Is there a way I can make such an endpoint accept
either, or detect which type of data is receiving?
Answer
Option 1
You could do that by having a
dependency function,
where you check the value of the Content-Type
request header and parse the
body using Starlette's methods,
accordingly. Note that just because a request's Content-Type
header says,
for instance, application/json
, application/x-www-form-urlencoded
or
multipart/form-data
,
doesn't always mean that this is true, or that the incoming data is a valid
JSON, or File(s) and/or form-data. Hence, you should use a try-except
block
to catch any potential errors when parsing the body. Also, you may want to
implement various checks to ensure that you get the correct type of data and
all the fields that you expect to be required. For JSON body, you could create
a BaseModel
and use Pydantic's [parse_obj
](https://pydantic-
docs.helpmanual.io/usage/models/#helper-functions) function to validate the
received dictionary (similar to Method 3 of this
answer).
Regarding File/Form-data, you can use Starlette's Request
object
directly, and
more specifically, the
request.form()
method to parse the body, which will return a
FormData
object that is an immutable multidict (i.e.,
ImmutableMultiDict
)
containing both file uploads and text input. When you send a list
of
values for some form
input, or a list of files
, you can use the
multidict's
getlist()
method to retrieve the list
. In the case of files, this would return a
list
of [UploadFile
](https://fastapi.tiangolo.com/tutorial/request-
files/#uploadfile) objects, which you can use in the same way as this
answer and this
answer to loop through the
files and retrieve their content. Instead of using request.form()
, you could
also read the request body directly from the stream
and parse it using the
streaming-form-data
library, as demonstrated in this
answer.
Working Example
from fastapi import FastAPI, Depends, Request, HTTPException
from starlette.datastructures import FormData
from json import JSONDecodeError
app = FastAPI()
async def get_body(request: Request):
content_type = request.headers.get('Content-Type')
if content_type is None:
raise HTTPException(status_code=400, detail='No Content-Type provided!')
elif content_type == 'application/json':
try:
return await request.json()
except JSONDecodeError:
raise HTTPException(status_code=400, detail='Invalid JSON data')
elif (content_type == 'application/x-www-form-urlencoded' or
content_type.startswith('multipart/form-data')):
try:
return await request.form()
except Exception:
raise HTTPException(status_code=400, detail='Invalid Form data')
else:
raise HTTPException(status_code=400, detail='Content-Type not supported!')
@app.post('/')
def main(body = Depends(get_body)):
if isinstance(body, dict): # if JSON data received
return body
elif isinstance(body, FormData): # if Form/File data received
msg = body.get('msg')
items = body.getlist('items')
files = body.getlist('files') # returns a list of UploadFile objects
if files:
print(files[0].file.read(10))
return msg
Option 2
Another option would be to have a single endpoint, and have your File(s)
and/or Form data parameters defined as Optional
(have a look at this
answer and this
answer for all the available
ways on how to do that). Once a client's request enters the endpoint, you
could check whether the defined parameters have any values passed to them,
meaning that they were included in the request body by the client and this was
a request having as Content-Type
either application/x-www-form-urlencoded
or multipart/form-data
(Note that if you expected to receive arbitrary
file(s) or form-data, you should rather use Option 1 above ). Otherwise,
if every defined parameter was still None
(meaning that the client did not
include any of them in the request body), then this was likely a JSON request,
and hence, proceed with confirming that by attempting to parse the request
body as JSON.
Working Example
from fastapi import FastAPI, UploadFile, File, Form, Request, HTTPException
from typing import Optional, List
from json import JSONDecodeError
app = FastAPI()
@app.post('/')
async def submit(request: Request, items: Optional[List[str]] = Form(None),
files: Optional[List[UploadFile]] = File(None)):
# if File(s) and/or form-data were received
if items or files:
filenames = None
if files:
filenames = [f.filename for f in files]
return {'File(s)/form-data': {'items': items, 'filenames': filenames}}
else: # check if JSON data were received
try:
data = await request.json()
return {'JSON': data}
except JSONDecodeError:
raise HTTPException(status_code=400, detail='Invalid JSON data')
Option 3
Another option would be to define two separate endpoints; one to handle JSON
requests and the other for handling File/Form-data requests. Using a
middleware, you could
check whether the incoming request is pointing to the route you wish users to
send either JSON or File/Form data (in the example below that is /
route),
and if so, check the Content-Type
similar to the previous option and reroute
the request to either /submitJSON
or /submitForm
endpoint, accordingly
(you can do that by modifying the path
property in request.scope
). The
advantage of this approach is that it allows you to define your endpoints as
usual, without worrying about handling errors if required fields were missing
from the request, or the received data were not in the expected format.
Working Example
from fastapi import FastAPI, Request, Form, File, UploadFile
from fastapi.responses import JSONResponse
from typing import List, Optional
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
items: List[str]
msg: str
@app.middleware("http")
async def some_middleware(request: Request, call_next):
if request.url.path == '/':
content_type = request.headers.get('Content-Type')
if content_type is None:
return JSONResponse(
content={'detail': 'No Content-Type provided!'}, status_code=400)
elif content_type == 'application/json':
request.scope['path'] = '/submitJSON'
elif (content_type == 'application/x-www-form-urlencoded' or
content_type.startswith('multipart/form-data')):
request.scope['path'] = '/submitForm'
else:
return JSONResponse(
content={'detail': 'Content-Type not supported!'}, status_code=400)
return await call_next(request)
@app.post('/')
def main():
return
@app.post('/submitJSON')
def submit_json(item: Item):
return item
@app.post('/submitForm')
def submit_form(msg: str = Form(...), items: List[str] = Form(...),
files: Optional[List[UploadFile]] = File(None)):
return msg
Option 4
I would also suggest you have a look at this
answer, which provides
solutions on how to send both JSON body and Files/Form-data together in the
same request, which might give you a different perspective on the problem you
are trying to solve. For instance, declaring the various endpoint's parameters
as Optional
and checking which ones have been received and which haven't
from a client's request—as well as using Pydantic's model_validate_json()
method to parse a JSON string passed in a Form
parameter—might be another
approach to solving the problem. Please see the linked answer above for more
details and examples.
Testing Options 1, 2 & 3 using Python requests
test.py
import requests
url = 'http://127.0.0.1:8000/'
files = [('files', open('a.txt', 'rb')), ('files', open('b.txt', 'rb'))]
payload ={'items': ['foo', 'bar'], 'msg': 'Hello!'}
# Send Form data and files
r = requests.post(url, data=payload, files=files)
print(r.text)
# Send Form data only
r = requests.post(url, data=payload)
print(r.text)
# Send JSON data
r = requests.post(url, json=payload)
print(r.text)