デジタル道具屋
デジタル道具屋
記事一覧へ戻る
更新: 読了 約 5 分

cURL から fetch / axios への移行で詰まる 7 つのポイント

#development

執筆: ありがっさまりょうた

デジタル道具屋を個人で開発・運用しています。記事は実装時の検証結果と公式仕様を照合して執筆しています。

本記事の執筆方針と編集ポリシーについてはAbout ページをご覧ください。

導入:ドキュメントの cURL は動くのに、JavaScript から呼ぶと 401 になる理由

API ドキュメントに掲載されている cURL コマンドはそのまま実行すると成功するのに、同じ URL とヘッダーを fetch に書き換えると 401 や 403 が返る、という現象は珍しくありません。 原因の多くは、cURL と fetch のデフォルト挙動の違いにあります。

cURL の各オプションには暗黙の副作用(メソッドの自動変更、ヘッダーの自動付与、Cookie の自動送信など)があり、これらを fetch 側で明示しないと「同じリクエストのつもりで別のリクエストを送っている」状態になります。 本記事では、移行時に差が出やすい 7 つのポイントを順に解説します。

落とし穴 1:-d は「自動的に POST + form 形式」

cURL の -d オプションは 2つの暗黙の振る舞いがあります。

  • HTTP メソッドを 自動的に POST に変える
  • Content-Type: application/x-www-form-urlencoded を 自動付与する

fetch ではこれを明示する必要があります:

# cURL
curl -d 'name=alice&age=20' https://api.example.com/users

// 等価な fetch
fetch('https://api.example.com/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({ name: 'alice', age: '20' }),
})

JSON を送る場合は、Content-Type: application/json と JSON.stringify(...) をセットで指定します。 cURL 側で -H 'Content-Type: application/json' を明示している場合、-d によるフォーム形式の自動付与は上書きされるため、fetch でも同じヘッダーを明示する必要があります。

# cURL(JSON を POST)
curl -X POST \
  -H 'Content-Type: application/json' \
  -d '{"name":"alice","age":20}' \
  https://api.example.com/users

// 等価な fetch
fetch('https://api.example.com/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'alice', age: 20 }),
})

なお、-u user:pass(Basic 認証)は fetch では Authorization: Basic ヘッダーに置き換えます。 値は user:pass を Base64 エンコードした文字列で、ブラウザでは btoa('user:pass')、Node.js では Buffer.from('user:pass').toString('base64') で生成できます。

落とし穴 2:Cookie の自動送信

cURL は同一セッション内では Cookie を自動で扱います。fetch は明示的に credentials: 'include' を指定しないと Cookie を送りません。 認証 Cookie を使う API では、これを忘れると 401 / 403 になります。

fetch('https://api.example.com/me', {
  credentials: 'include', // または 'same-origin'
})

さらにブラウザ環境では、サーバー側で Access-Control-Allow-Credentials: true と Access-Control-Allow-Origin に具体的なオリジン(* ではダメ)を返さないと、Cookie 送信が拒否されます。

落とし穴 3:CORS は cURL では起きない

「cURL では成功するのに、ブラウザの fetch では失敗する」という場合、CORS(Cross-Origin Resource Sharing)が原因である可能性が高いです。 cURL はブラウザのセキュリティ機構を経由しないため、CORS の制約を受けません。 一方、ブラウザからクロスオリジンの API を呼ぶ場合、サーバーが適切な Access-Control-* ヘッダーを返さないとレスポンスがブロックされます。

ブラウザ環境でのみ発生する問題は主に次の 2 つです。

  • CORS:プリフライト(OPTIONS)リクエストを含め、サーバー側の許可ヘッダーが必要
  • Cookie の SameSite 属性:クロスサイトのリクエストでは SameSite=None; Secure でないと Cookie が送信されない

対策は基本的にサーバー側で適切な Access-Control-* ヘッダーを返すこと。 自分が API を握っていない場合は、Next.js の API ルートやプロキシを挟んで自サーバー経由で呼ぶ手もあります。

落とし穴 4:リダイレクトの扱い

cURL で -L を付けないとリダイレクトを追わないのに対し、fetch はデフォルトで redirect: 'follow'(自動追従)です。 逆に「最初のリクエストだけ送ってリダイレクトの Location ヘッダーを取りたい」場合は、fetch 側で redirect: 'manual' を指定します。

落とし穴 5:HTTPメソッドと body の組み合わせ

GET リクエストに body を付けると、fetch は TypeError になります。 cURL は -X GET -d '...' でも黙って受け付けてしまうため、API ドキュメントが間違って GET + body で書かれているケースがあります。 その場合はクエリパラメータに変更するか、サーバー実装を確認しましょう。

落とし穴 6:レスポンスの読み方

fetch のレスポンスは 1度しか読めません。response.json() を呼んだ後に response.text() を呼ぶとエラーです。 生のテキストとパース後の両方が欲しい場合は、const text = await response.text(); const data = JSON.parse(text); と段階を分けます。

// 失敗するパターン
const data = await response.json();
const text = await response.text(); // → エラー

// 正しいパターン
const text = await response.text();
const data = JSON.parse(text);

落とし穴 7:ステータスコードはエラーにならない

cURL は終了コードで成功/失敗を判別しますが、fetch は HTTP レベルで通信できれば 4xx / 5xx でも resolve します。try/catch だけではサーバーエラーを捕捉できないので、response.ok を必ずチェックしましょう。

const res = await fetch(url);
if (!res.ok) {
  // 4xx / 5xx の処理
  throw new Error(`API error: ${res.status}`);
}
const data = await res.json();

axios はこの点で挙動が異なり、デフォルトで 4xx / 5xx のレスポンスを例外(reject)として扱います(validateStatus オプションで変更可能)。 fetch と axios を併用するコードベースでは、fetch 側にも上記のような response.ok によるガードを入れて、エラー処理の挙動を揃えておくと保守しやすくなります。

変換作業を機械化する

オプションが多い cURL コマンドを手作業で fetch / axios に書き換えるのは事故のもとです。 当サイトのcURL → Fetch 変換ツールでは、 cURL コマンドを貼り付けるだけで JavaScript の fetch / axios コードに変換できます。 生成されたコードに、本記事で解説した credentials・response.ok チェック・エラーハンドリングを足して仕上げてください。

関連: HTTP ステータス検索、JSON 整形(API レスポンスのデバッグに)、URL エンコード/デコード。

よくある失敗パターン

AbortController を渡していないと、画面遷移後もバックグラウンドで fetch が完了まで走り続け、 アンマウント済みのコンポーネントに対して setState が呼ばれる警告や、不要な処理の残留につながります。 React では useEffect のクリーンアップ関数で controller.abort() を呼ぶのが基本的な対策です。 React Query や SWR などのデータ取得ライブラリはこのキャンセル処理を内部で扱うため、複数の fetch を扱う場合は導入を検討する価値があります。

useEffect(() => {
  const controller = new AbortController();
  fetch(url, { signal: controller.signal })
    .then((res) => res.json())
    .then(setData)
    .catch((err) => {
      if (err.name !== 'AbortError') throw err;
    });
  return () => controller.abort();
}, [url]);

参考にした一次情報

本記事の内容は、以下の公式仕様や一次情報を参照して執筆しています。

この記事の内容を実際に試す

解説した内容は、以下のツールでブラウザ上から無料で試せます。