Как добавить и файл, и тело JSON в POST-запрос FastAPI?

#python #http #http-post #fastapi #pydantic

#python #http #http-post #fastapi #pydantic

Вопрос:

В частности, я хочу, чтобы приведенный ниже пример работал:

 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
 

Если это неправильный способ для POST-запроса, пожалуйста, посоветуйте мне, как выбрать необходимые столбцы из загруженного CSV-файла в FastAPI.

Ответ №1:

Согласно документации FastAPI,

Вы можете объявить несколько Form параметров в операции path , но вы также не можете объявлять Body поля, которые вы ожидаете получить как JSON , поскольку тело запроса будет кодироваться с помощью application/x-www-form-urlencoded вместо application/json (когда форма включает файлы, она кодируется как multipart/form-data ).

Это не ограничение FastAPI, это часть HTTP протокола.

Способ 1

Как описано здесь, можно одновременно определять файлы и формировать данные, используя File Form поля и . Ниже приведен рабочий пример:

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

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

Вы можете протестировать его, обратившись к шаблону ниже по адресу http://127.0.0.1:8000 .

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>
 

Вы также можете протестировать его с помощью интерактивных документов OpenAPI (предоставляемых Swagger UI) по адресу http://127.0.0.1:8000/docs , или запросы Python, как показано ниже:

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

Способ 2

Можно использовать модели Pydantic вместе с зависимостями, чтобы сообщить маршруту «отправки» (в приведенном ниже примере), что параметризованная переменная base зависит от Base класса. Пожалуйста, обратите внимание, что этот метод ожидает base данные как query (не body ) параметры (которые затем преобразуются в эквивалентную JSON полезную нагрузку с помощью .dict() метода) и Files as multipart/form-data в теле.

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")
async 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})
 

Опять же, вы можете протестировать его, используя приведенный ниже шаблон, который на этот раз использует Javascript для изменения action атрибута the form , чтобы передать form данные в качестве query параметров в 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>
 

Как упоминалось ранее, вы также можете использовать пользовательский интерфейс Swagger или запросы Python, как показано в примере ниже. На этот раз params=payload используется, поскольку параметры являются query параметрами, а не form-data параметрами (body), как в предыдущем методе.

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

Способ 3

Другим вариантом было бы передать данные тела в виде одного параметра (типа Form ) в виде JSON строки. На стороне сервера вы можете создать функцию зависимости, в которой вы анализируете данные с помощью parse_raw метода и проверяете данные на соответствие соответствующей модели. Если ValidationError возникает, HTTP_422_UNPROCESSABLE_ENTITY ошибка отправляется обратно клиенту, включая сообщение об ошибке. Пример приведен ниже:

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

async 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")
async 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})
 

test.py

Обратите внимание, что в JSON , логические значения представлены с использованием true false литералов или в нижнем регистре, тогда как в Python они должны быть заглавными True буквами или 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())
 

Или, если вы предпочитаете:

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

Тест с использованием Fetch API или 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>
 

Способ 4

Вероятно, предпочтительный метод вытекает из обсуждения здесь и включает пользовательский класс с classmethod, используемым для преобразования заданной JSON строки в словарь Python, который затем используется для проверки на соответствие модели Pydantic. Аналогично методу 3 выше, входные данные должны передаваться как один Form параметр в виде JSON строки. Таким образом, то же самое test.py файл(ы) и index.html шаблон из предыдущего метода можно использовать для тестирования приведенного ниже.

app.py

 from fastapi import FastAPI, File, Form, 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 = Form(...), 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})
 

Ответ №2:

Вы не можете смешивать данные формы с json.

Согласно документации FastAPI:

Предупреждение: вы можете объявить несколько File Form параметров и в операции path , но вы также не можете объявлять Body поля, которые вы ожидаете получить как JSON , так как тело запроса будет закодировано с использованием multipart/form-data вместо application/json . Это не ограничение FastAPI, это часть протокола HTTP.

Однако вы можете использовать Form(...) в качестве обходного пути для присоединения дополнительной строки как 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
 

Ответ №3:

Я выбрал очень элегантный Метод3 от @Chris (первоначально предложенный @M.Winkwns). Тем не менее, я немного изменил его для работы с любой моделью Pydantic:

 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)

    
 

Когда вы используете его в конечной точке, вы можете затем использовать functools.partial для привязки конкретной модели Pydantic:

 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
 

Ответ №4:

Как указано @Chris (и просто для полноты картины):

Согласно документации FastAPI,

Вы можете объявить несколько параметров формы в операции path, но вы также не можете объявлять поля тела, которые вы ожидаете получить как JSON, поскольку тело запроса будет закодировано с использованием application/x-www-form-urlencoded вместо application/ json. (Но когда форма включает файлы, она кодируется как составная часть / form-data)

Это не ограничение FastAPI, это часть протокола HTTP.

Поскольку его Метод1 не был вариантом, а Метод2 не может работать для глубоко вложенных типов данных, я придумал другое решение:

Просто преобразуйте свой тип данных в string / json и вызовите parse_raw функцию pydantics

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