FastAPI 無法解析 PHP cURL 多檔上傳的原因與解法
FastAPI 本身對多檔案上傳有清楚且合理的設計,但當 API 的呼叫端改成使用 PHP cURL 時,卻很容易遇到「看起來寫法正確,實際卻無法解析」的情況。這類問題通常不是程式碼錯誤,而是不同語言對 multipart/form-data 欄位結構的期待不一致。
本文將整理我實際嘗試過的寫法與分析過程,並在最後提供兩個可正常運作的解決方案。
FastAPI 的多檔案上傳定義方式
在 FastAPI 中,多檔案上傳的 API 定義相當直覺,常見寫法如下:
@router.post("/upload")
async def upload_files(
request: Request,
files: Annotated[List[UploadFile], File(...)],
):
pass
這樣的定義本身沒有任何問題,也完全符合 FastAPI 官方文件的建議做法。在這個前提下,FastAPI 的行為是:
- files 是單一參數名稱
- API 期望在 multipart/form-data 中,收到多個同名欄位 files。
- FastAPI 會將這些同名欄位整理成 List[UploadFile]
以下所有討論,都假設 FastAPI 端維持上述定義方式不變。
使用 PHP cURL 上傳檔案
API 定義完成後,接下來就是 PHP 端透過 cURL 傳送檔案。
嘗試一:使用 files[0]、files[1] 的寫法
許多開發者在 PHP 中,第一時間會寫出像下面這樣的程式碼:
curl_setopt($curl, CURLOPT_POSTFIELDS, [
'files[0]' => new CURLFile($filePath1, mime_content_type($filePath1), basename($filePath1)),
'files[1]' => new CURLFile($filePath2, mime_content_type($filePath2), basename($filePath2)),
'files[2]' => new CURLFile($filePath3, mime_content_type($filePath3), basename($filePath3)),
]);
這是一種非常直覺的寫法,看起來也相當合理,因為在 PHP 的角度,這正是一個陣列結構。然而,問題在於:
- FastAPI 並不會將 files[0]、files[1] 視為同一個欄位
- 對 FastAPI 而言,這些其實是三個完全不同的 key
- 與 files: List[UploadFile] 的定義無法對應
最終結果就是 FastAPI 回傳「找不到 files 欄位」,或直接解析不到任何上傳的檔案。
嘗試二:直接用陣列包住 files
另一個常見嘗試方式,是直接把多個 CURLFile 放進陣列中:
curl_setopt($curl, CURLOPT_POSTFIELDS, [
'files' => [
new CURLFile($filePath1, mime_content_type($filePath1), basename($filePath1)),
new CURLFile($filePath2, mime_content_type($filePath2), basename($filePath2)),
new CURLFile($filePath3, mime_content_type($filePath3), basename($filePath3)),
],
]);
但這種寫法在 PHP 中會直接產生錯誤。
- CURLOPT_POSTFIELDS 在 multipart 模式下不支援巢狀 array 搭配 CURLFile
- PHP cURL 無法自動將這種資料結構轉換成合法的 multipart/form-data
- PHP cURL 本身並沒有提供一個語法,可以直接表達「多個相同欄位名稱的檔案」。
推薦作法:自行組裝 multipart Payload
綜合上述兩個嘗試可以發現,FastAPI 的 API 定義本身並沒有問題,PHP 的 cURL 行為也符合其設計預期,真正的落差在於兩者對 multipart/form-data 結構的表達方式不同。 因此若要穩定解決此問題,最可靠的做法是在 PHP 端自行組裝 multipart 的 Payload 字串。
定義組裝 Payload 的 Function
// 組裝 Payload 的 Function
function build_multipart_body($fields, $files, $boundary) {
$body = '';
// 處理一般欄位 (model_name)
foreach ($fields as $key => $value) {
$body .= "--$boundary\r\n";
$body .= "Content-Disposition: form-data; name=\"$key\"\r\n\r\n";
$body .= "$value\r\n";
}
// 處理檔案欄位 (files)
// 這裡我們允許重複的 Key (例如 'files')
foreach ($files as $file) {
$fieldName = $file['field'];
$filename = $file['filename'];
$mimetype = $file['mimetype'];
$content = file_get_contents($file['path']); // 注意:大檔案建議改用 stream 方式
$body .= "--$boundary\r\n";
$body .= "Content-Disposition: form-data; name=\"$fieldName\"; filename=\"$filename\"\r\n";
$body .= "Content-Type: $mimetype\r\n\r\n";
$body .= $content . "\r\n";
}
// 結尾
$body .= "--$boundary--\r\n";
return $body;
}
使用方法
// boundary
$boundary = '--------------------------' . microtime(true);
// 一般欄位
$formFields = [
'foo' => 'bar'
];
// 檔案欄位 (注意:這裡的 field 都設為 'files',這是 FastAPI 要的)
$filesData = [
[
'field' => 'files',
'path' => $filePath1,
'filename' => basename($filePath1),
'mimetype' => mime_content_type($filePath1),
],
[
'field' => 'files',
'path' => $filePath2,
'filename' => basename($filePath2),
'mimetype' => mime_content_type($filePath2),
],
[
'field' => 'files',
'path' => $filePath3,
'filename' => basename($filePath3),
'mimetype' => mime_content_type($filePath3),
]
];
// 建立 Body
$payload = build_multipart_body($formFields, $filesData, $boundary);
// 傳入組裝好的 String
curl_setopt($curl, CURLOPT_POSTFIELDS, $payload);
// 手動設定 Content-Type 和 Boundary
curl_setopt($curl, CURLOPT_HTTPHEADER, [
'Accept: application/json',
"Content-Type: multipart/form-data; boundary=$boundary" // 這裡必須明確指定
]);
這樣 FastAPI 就能正確從 files 取得上傳的檔案了。
不推薦作法:調整 FastAPI 的取值方式
如果您覺得自行組裝 multipart Payload 字串太麻煩,程式碼太過冗長,希望維持 PHP 端 files[0]、files[1] 的寫法,另一種作法是改變 FastAPI 的取值方式來配合 PHP。
@router.post("/upload")
async def upload_files(request: Request):
form = await request.form()
# 手動取得所有開頭是 files 的欄位
files = [val for key, val in form.items() if key.startswith("files")]
為什麼不建議?
- 後端不再清楚定義自己到底期待什麼結構,而是「來什麼我就猜什麼」。
- API 應該是規格驅動,而不是被 PHP cURL 的怪癖牽著走。
- FastAPI 本來就是強型別、結構明確的框架,這種寫法等於繞過它最重要的優點。
結論
這個問題不是 FastAPI 或 PHP 本身有錯誤,而是 multipart/form-data 在不同生態系中「被表達的方式不同」,而各自的框架對它的期待也不同。
如果你在意的是穩定性及可維護性,那麼在使用 PHP cURL 和 FastAPI 進行多檔案上傳時,手動建立 multipart 我認為是最好的解決方案。