openapi-typescriptでMultipartFileの型を生成してopenapi-fetchで共通化したAPIクライアントでmultipart/form-dataのリクエストをするのに苦労した話

こちらは、Mavs Advent Calendar 2024 11日目の記事です🐺!
🌲🌲🌲
はじめに
タイトルを読んで「なんのこっちゃねん」となった方へ、すみません。
今回はかなり限定的なエラー解消記事ですが、どこかの誰かの役に立つと信じて書いていきます。
一応使われている技術についても大まかに解説するので、こんな技術もあるんだ、こんなやり方もあるんだ〜くらいのテンション感でご覧いただけますと幸いです。
無理!!めっちゃ詰まってるから解決策を早く!!という方は、前半部分を読み飛ばしてください。
環境はNext.js (App Router)とJava (Spring boot)です。
それでは参ります。
登場人物の紹介
タイトルがよくわからないことになってるので今回起きた事象を整理していきます。
まずは登場人物の紹介です。ざっくり紹介するので興味のある方はじっくり調べてみてください。
サーバーコンポーネント
Next.js (App Rounter)で登場するコンポーネントの仕組みです。
クライアントコンポーネントとサーバーコンポーネントがあり、扱えるデータも異なります。
openapi-typescript
yamlファイルやJSONファイルで定義されたAPI情報から型情報を生成してくれるライブラリです。
リクエストやレスポンスの型を1から定義する必要がないので大変便利です。今回はバックエンド側で生成されたyamlを参照し型を生成していました。
MultipartFile
バックエンドで定義されたリクエストボディの型です。今回はファイル形式のデータを送付する必要があったためこの指定がされています。後述しますがこの子がキーポイントです。
openapi-fetch
API通信を便利にしてくれるライブラリです。Next.jsだとfetchでAPI通信ができますが、都度headerなどを渡してあげる必要があります。そうしたものをひとまとめにできる、簡単に言えばfetchカスタマイズ機能的な感じです。
multipart/form-data
フォームデータをサーバーに送信する際のコンテンツタイプの一つです。似たものに’application/json’があります。今回はFileデータを扱いたいので’multipart/form-data’も一つの鍵になります。
やりたいこと
超省いていうと、「フロントエンド環境からバックエンドにFile形式のデータを送信する」ことが今回の目標です。
詰まったのは下記の5点。同時複合的に起きたのでエラーの解析に時間がかかりましたが、紐解いていくとなんて事はないエラーでした。
- そもそもFile型をサーバーコンポーネントが受け付けていない
- OpenAPIで型変換するときの問題 File型 → String型
- formで送るためにはbodySerializerを使用する必要がある
- ただのmultipart/form-dataだとエラーになる
- apiClientの共通処理を見直す
まずは知る事、それが大事、そう思い知らされました。
一つずつ解説していきます。
そもそもFile型をサーバーコンポーネントが受け付けていない
こちらは超単純です。読んで字の如くFile型をサーバーコンポーネントが受け付けていません。
TSエラーなどで気づくことができず、関数実行して初めてその事実に気付かされます。
下記のような関数を呼び出すとエラーになりそもそもバックエンドまで処理が到達しません。なので今回は関数呼び出し前にFile型をFormData型に変更して渡すようにすることで、一応エラーなく処理することができました。
もしかするともっといい方法があるかもしれません。
'use server'
export const apiFunction = async (
file: File,
) => {...}
// FormData
'use server'
export const apiFunction = async (
file: FormData,
) => {...}
// 呼び出し元でFormData型に変換
const Formdata = new FormData()
Formdata.append('file', fileData)
const result = await apiFunction(Formdata)
openapi-typescriptで型変換するときの問題 File型 → String型
続いての問題はこちら。
事象としては、yamlなどで定義されたMultipartFile型などのファイル形式にまつわる記述が、openapi-typescriptで型変換するときにstring型になってしまうというものです。
こちらは公式も把握している事象のようで、解決の記事が出てきたので参考にさせていただきました。
参考記事はこちらです。
実際に生成されるコードはこちら。
content: {
"multipart/form-data": {
/**
* Format: binary
* @description CSVファイル
*/
file: string; //stringになっている
};
};
解決方法としては、型生成する際に特定の型を上書きするという方法です。
import fs from 'node:fs'
import path from 'node:path'
import openapiTS, { astToString } from 'openapi-typescript'
import { factory } from 'typescript'
const FormData = factory.createTypeReferenceNode(
factory.createIdentifier('FormData'),
)
async function generateTypes() {
const ast = await openapiTS(
// OpenAPI スキーマファイルのパスを指定
new URL('src/schema.yaml', import.meta.url),
{
// @ts-ignore
transform(schemaObject) {
// binary format の場合、FormData型に変換
if (schemaObject.format === 'binary') {
return FormData
}
},
},
)
const contents = astToString(ast)
// 生成したい場所にファイルを出力
fs.writeFileSync(
path.resolve(process.cwd(), './src/schema.ts'),
contents,
)
}
generateTypes()
参考の記事にもありますが、generate用のtsファイルを作成し、そのファイルを実行することで型を上書きします。package.jsonなどでschema作成のコマンドを指定している場合はそちらも書き直す必要があります。
openapi-fetchでFormDataを送るためにはbodySerializerを使用する必要がある
こちらはopenapi-fetchを使用していたために起きたエラーです。
上記で参考にさせていただいた記事に同じくコード共に解説が載っていたので、詳しい話は割愛しますが、bodyの形式をそのまま渡してしまうと、リクエストヘッダーで’multipart/form-data’を指定しても、bodyの内容が’JSON.stringify’になってしまいます。
こちらはbodySerializerを指定し、bodyの内容を再定義する必要があります。
また、解説の記事ではBlob型をFormData型に変更しているのですが、私の場合はもともとFormData型で指定しているのにも関わらず、エラーが出ていました。
下記のようにbodySerializerの中でbodyに渡したいデータを返してあげることで解決することができました。
export const apiFunction = async (
formData: FormData
) => {
const { data, error ,response} = await apiClient.POST('localhost:8080', {
body:{
formData // FormDataとして渡しているはずなのに...
},
bodySerializer() {
// formDataとしてシリアライズを返却
return formData;
},
})
}
ただのmultipart/form-dataだとエラーになる
こちらは直接今回のエラー解消には問題ないのですが、個人的つまづきポイントだったので一応紹介します。
今回様々なエラーメッセージを見たのですがその中でも多かったのが、「Content-Type ‘application/json’ is not supported」でした。
後述するapiClientの共通設定で、デフォルトのヘッダーを’application/json’にしていたため起きていたエラーですが、単純脳の私は「じゃあ’multipart/form-data’に書き換えればいいじゃん!」と考えてしまい沼にハマりました。
ここでは’multipart/form-data’の特性が鍵となります。
1つのリクエストボディ内で複数の異なるデータ(テキストフィールド、ファイルなど)を送信します。これらのデータは 1つのリクエストに含まれる複数のパート に分けられて送信されます。それぞれのパートがどこで終わるのか、どこから始まるのかを示すために、boundaryが必要です。
そしてboundaryは通常、リクエストと同時に一意の値になるように自動生成されます。つまり手動で’multipart/form-data’とだけリクエストヘッダーを指定しまうと、コンピュータ的には中身のデータがどこで分かれているかわからなくなってしまいます。
要約すると‘multipart/form-data’は基本手動設定しないのが基本となります
apiClientの共通処理を見直す
今回はプロジェクト全体でapiの使用頻度が高いため以下のようなapiClientを設定し、各apiの定義ファイルで呼び出して使用していました。
基本的に呼び出し元でリクエストヘッダーの指定はせず、apiClient.tsファイル内で’application/json’を指定していました。
なので、リクエストヘッダーが既に存在する場合はそのリクエストヘッダーを保持するように条件文を追記することで、apiClientに大きな変更を加えずに処理を分けることができました。
ちなみに、FormData型を送信する際は前述のboundaryの関係もあり、リクエストヘッダーが自動で設定されます。
// apiClient.ts
import type { paths } from '@/types'
import createClient, { type Middleware } from 'openapi-fetch'
import { API_HOST } from '../constants/baseUrl'
// リクエスト/レスポンスの前処理
const apiInterceptor: Middleware = {
async onRequest({ request }) {
const requestCookies = getRequestCookies()
// 改修前
request.headers.set('Content-Type', 'application/json')
// 改修後
if((request.headers.get('content-type') === null) ){
request.headers.set('Content-Type', 'application/json')
}
return request
},
}
export const apiClient = createClient<paths>({
baseUrl: API_HOST,
})
apiClient.use(apiInterceptor)
// 呼び出し元
const { data, error } = await apiClient.POST('localhost:8080', {...})
まとめ
今回のエラーは、使っている技術が絡み合い、対面した時は複雑に見えましたが、一つ一つ紐解いていけばそこまで複雑なものではないと感じました。
また事前知識があればそこまで時間をかけずに理解して解決できたなぁと感じました。(どんなエラーもそうですが)
あとは改めてコンソールログの大切さを痛感しました。いつもありがとう。
今回の記事は対象読者がかなり限定的ですが、こんなこともあるのかと頭に入れておくといざという時の引き出しが増えるかもしれません。
以上、遠藤でした。
それでは〜!