根据FastAPI文档所述:
在路径操作中,您可以声明多个“Form”参数,但是您不能同时声明希望以“JSON”形式接收的“Body”字段,因为请求将使用“application/x-www-form-urlencoded”而不是“application/json”对请求体进行编码(当表单包含文件时,它将被编码为“multipart/form-data”)。
这不是FastAPI的限制,而是HTTP协议的一部分。
请注意,您首先需要安装“python-multipart”,因为上传的文件将作为“form data”发送。例如:
pip install python-multipart
需要注意的是,在下面的示例中,端点是使用普通的def
定义的,但您也可以根据需要使用async def
。有关def
与async def
在FastAPI中的更多详细信息,请参阅this answer。
方法1
如此处所述,可以同时使用File
和Form
定义文件和表单字段。以下是一个工作示例。如果您有大量参数,并且希望将它们与端点分开定义,请参阅this answer,了解如何创建自定义依赖类。
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})
你可以通过访问下面的
模板来测试上面的示例,网址为
http://127.0.0.1:8000
。如果你的模板不包含任何Jinja代码,你也可以返回一个简单的
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>
你还可以使用交互式
OpenAPI/Swagger UI autodocs来测试这个示例,地址是
/docs
,例如:
http://127.0.0.1:8000/docs
,或者使用Python的requests库,如下所示:
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())
方法二
一个也可以使用Pydantic模型,连同
Dependencies一起,来通知
/submit
端点(在下面的示例中),参数化变量
base
依赖于
Base
类。请注意,此方法期望将
base
数据作为
query
(而
不是body
)参数,然后对其进行验证并转换为Pydantic模型(在本例中即
Base
模型)。从FastAPI端点返回一个Pydantic模型实例(在本例中即
base
)将自动转换为等效的字典/JSON对象,使用
jsonable_encoder
在后台进行处理,详细说明请参见
this answer。然而,如果您希望在端点内部自行完成此操作,可以使用Pydantic的
model_dump()
方法,例如
base.model_dump()
,或者简单地使用
dict(base)
,详细说明请参见
this answer。除了
data
之外,以下示例还期望将
Files
作为
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")
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)
def main(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
再次,您可以使用下面的模板进行测试,这次使用JavaScript来修改
form
元素的
action
属性,以便将
form
数据作为
query
参数传递到URL,而不是
form-data
。
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>
如前所述,为了测试API,您也可以使用Swagger UI或Python requests,如下面的示例所示。请注意,数据现在应该传递给
params
(而不是
data
)参数,这是
requests.post()
方法的参数,因为数据现在作为
query
参数发送,而不是在请求体中作为
form-data
,这是之前的
方法1的情况。
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())
方法三
另一个选择是将请求体数据作为一个参数(类型为Form
)以JSON字符串的形式传递。为此,您需要在服务器端创建一个dependency函数。
依赖是一个函数,它可以接受与路径操作函数(也称为端点)相同的所有参数。你可以将其视为没有装饰器的路径操作函数。因此,你需要以与端点参数相同的方式声明依赖(即,依赖中的参数名称和类型应与FastAPI在客户端发送HTTP请求到该端点时期望的参数名称和类型相同,例如,data: str = Form(...))。然后,在你的端点中创建一个新的参数(例如,base),使用Depends()并将依赖函数作为参数传递给它(注意:不要直接调用它,也就是说,不要在函数名称的末尾添加括号,而是使用Depends(checker),其中checker是你的依赖函数的名称)。每当有新的请求到达时,FastAPI会负责调用你的依赖,获取结果并将该结果分配给你的端点中的参数(例如,base)。有关依赖的更多详细信息,请查看本节中提供的链接。
在这种情况下,应使用依赖函数来解析(JSON字符串)
data
,使用
parse_raw
方法(注意:在Pydantic V2中,
parse_raw
已被弃用,并被
model_validate_json
替代),同时验证
data
与相应的Pydantic模型。如果引发了
ValidationError
,则应将
HTTP_422_UNPROCESSABLE_ENTITY
错误发送回客户端,包括错误消息;否则,将该模型的实例(即,在这种情况下是
Base
模型)分配给端点中的参数,可以根据需要使用。以下是示例:
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)
def main(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
如果你有多个模型,并且不想为每个模型创建一个检查函数,你可以创建一个检查器类,如
文档中所述,并拥有一个模型字典,你可以用它来查找要解析的模型。例如:
models = {"base": Base, "other": SomeOtherModel}
class DataChecker:
def __init__(self, name: str):
self.name = name
def __call__(self, data: str = Form(...)):
try:
return models[self.name].model_validate_json(data)
except ValidationError as e:
raise HTTPException(
detail=jsonable_encoder(e.errors()),
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
)
base_checker = DataChecker("base")
other_checker = DataChecker("other")
@app.post("/submit")
def submit(base: Base = Depends(base_checker), files: List[UploadFile] = File(...)):
pass
如果对于特定的Pydantic模型的检查对你来说不重要,而是希望接收任意的JSON数据,并且只需检查端点是否接收到有效的JSON字符串,你可以使用以下方法:
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(d: dict = Depends(checker), files: List[UploadFile] = File(...)):
pass
使用Python requests进行测试
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" 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);
})
.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);
})
.catch(error => {
console.error(error);
});
} else {
resp.innerHTML = "Please choose some file(s)...";
resp.style.color = "red";
}
}
</script>
</body>
</html>
方法四
另一种方法来自于github的讨论这里,它使用了一个自定义类custom class,其中包含一个classmethod,用于将给定的JSON
字符串转换为Python字典,然后用于对Pydantic模型进行验证(注意,与上述github链接中给出的示例相比,下面的示例使用了@model_validator(mode='before')
,因为Pydantic V2的引入)。
类似于上面的“方法3”,输入数据应以单个“Form”参数的形式传递,以“JSON”字符串的形式(请注意,在下面的示例中,使用“Body”或“Form”定义“data”参数都可以,因为{{link1:
Form
是直接继承自
Body
的类}}。也就是说,FastAPI仍然期望将JSON字符串作为“form”数据,而不是“application/json”,在这种情况下,请求的主体将使用“multipart/form-data”进行编码)。因此,与上面的“方法3”一样,可以使用相同的“test.py”示例和“index.html”模板来测试下面的示例。
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)
def main(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
方法5
另一种解决方案是将文件字节转换为base64
格式的字符串,并将其添加到JSON对象中,以及您可能想要发送到服务器的其他数据。我不强烈推荐使用这种方法;然而,出于完整性的考虑,它已被添加到这个答案中作为另一种选择。
我不建议使用它的原因是使用base64
编码文件会增加文件的大小,从而增加带宽利用率,以及上传文件所需的时间和资源(例如,CPU使用率)(特别是当API将被多个用户同时使用时),因为base64编码和解码将分别在客户端和服务器端进行(这种方法只对非常小的图像有用)。根据MDN的文档:
每个Base64数字表示6位数据。因此,输入字符串/二进制文件的三个8位字节(3×8位=24位)可以由四个6位Base64数字(4×6=24位)表示。
这意味着字符串或文件的Base64版本至少会比其源大小增加133%(约33%增加)。如果编码数据很小,增加可能更大。例如,长度为1的字符串"a"被编码为长度为4的"YQ==",增加了300%。
使用这种方法,再次强烈不建议,原因如上所述,您需要确保使用普通的def
来定义端点,因为base64.b64decode()
执行的是一个阻塞操作,会阻塞事件循环,从而阻塞整个服务器。请参考this answer了解更多细节。否则,要使用async def
端点,您应该在外部的ThreadPool
或ProcessPool
中执行解码函数(同样,请参考this answer了解如何执行),并使用aiofiles
将文件写入磁盘(还请参考this answer)。
下面的示例提供了Python requests
和JavaScript的客户端测试示例。
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})
使用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())
使用 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>
DataConfiguration
类链接。 - Gino Mempin