简体   繁体   中英

How to add both file and JSON body in a FastAPI POST request?

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 a proper way for a POST request, please advise me how to select required columns from an uploaded CSV file in FastAPI.

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

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.

Method 1

So, as described here , one can define files and form fields at the same time using File and Form . Below is a working example:

app.py

import uvicorn
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")
async 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}, "Uploaded Filenames": [file.filename for file in files]}


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

if __name__ == '__main__':
    uvicorn.run(app, host='127.0.0.1', port=8000, debug=True)

You can test it by accessing the template below at http://127.0.0.1:8000

templates/ index.html

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

You can also test it using Swagger at http://127.0.0.1:8000/docs or Python requests, as 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() ) and the Files as multipart/form-data in the body.

app.py

import uvicorn
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")
async def submit(base: Base = Depends(), files: List[UploadFile] = File(...)):
    received_data= base.dict()
    return {"JSON Payload ": received_data, "Uploaded Filenames": [file.filename for file in files]}
 

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

if __name__ == '__main__':
    uvicorn.run(app, host='127.0.0.1', port=8000, debug=True)

Again, you can test it with the template below (which uses JQuery this time to modify the "action" attribute in the form, in order to pass the form-data as query params).

templates/ index.html

<html>
<head>
    <script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
    <script type="text/javascript">
    /* Create the action attribute and append the form data as query parameters to it */
    function on_click(){
        document.getElementById('my_form').action = 'http://127.0.0.1:8000/submit?'+$('#my_form').serialize();
    }
    </script>
</head>
<body>
<form method="post" id="my_form" onclick="on_click();" 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>

As mentioned earlier you can also use Swagger, or the Python requests example below. Note, this time "params=payload" is used, as the parameters are query params, and not body (data) params.

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

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

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

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM