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 我認為是最好的解決方案。