「保存」ボタンを押したのに、画面が読み込み中のまま止まっている。見た目は崩れていないので、HTMLやCSSの問題ではなさそうです。

このとき、ブラウザの裏側では、画面からサーバーへデータを送る通信が失敗しているかもしれません。Webアプリでは、画面を表示するコードだけでなく、データを受け渡すAPIが動いています。

APIという言葉は広い意味で使われますが、この記事ではWebアプリでよく使うHTTP APIを扱います。自分でAPIを設計できるようになることより、エージェントが作った通信をたどり、失敗した場所を説明できることが目標です。

APIは画面と処理の受付窓口

記事を投稿するアプリを考えてみます。ブラウザへ表示されているフォームだけでは、投稿をデータベースへ保存できません。入力内容をサーバーへ送り、サーバー側で確認してから保存します。

  1. ブラウザの投稿フォーム
  2. リクエストとして POST /api/articles を送る
  3. 入力内容を確認する
  4. データベースへ保存する
  5. レスポンスとして作成した記事をブラウザへ返す

この/api/articlesが、記事作成を受け付けるAPIの入口です。APIの個々の入口をendpointと呼びます。

シリーズ内のAI駆動開発で困らないためのデプロイでは、ブラウザから自分のAPIを通って外部のLLM APIへ届く流れを説明しました。ここでは、その通信の中身をもう少し細かく見ていきます。

リクエストとレスポンス

ブラウザなどがAPIへ送るものをリクエスト、APIが返すものをレスポンスと呼びます。

API通信をrequest側のmethod、URL、headers、bodyと、response側のstatus code、headers、JSONに分けた図

リクエストには、主に次の情報があります。

  • どこへ送るかを表すURL
  • 何をしたいかを表すHTTPメソッド
  • 追加情報を持つheaders
  • 送信するデータを持つbody

レスポンスには、処理結果を表すstatus code、headers、返すデータが含まれます。

Request
  POST /api/articles
  Content-Type: application/json
  { "title": "はじめての記事" }

Response
  201 Created
  { "id": "article-123", "title": "はじめての記事" }

画面で「保存できませんでした」と表示されているだけでは、どの段階で失敗したか分かりません。ブラウザのNetworkパネルを見ると、実際のリクエストとレスポンスを確認できます。

URLを分けて読む

次のURLを例にします。

https://example.com/api/articles/123?preview=true

大まかには、次のように分けられます。

  • 接続先は https://example.com
  • pathは /api/articles/123
  • query parameterは ?preview=true

123の部分をpath parameterとして扱い、記事IDを表すことがあります。preview=trueはquery parameterで、表示方法や検索条件など、追加の指定に使われます。

機密情報をquery parameterへ入れるのは避けます。URLはブラウザ履歴、アクセスログ、共有されたスクリーンショットなどへ残ることがあるからです。

HTTPメソッドは操作の種類を表す

よく使うHTTPメソッドは次のとおりです。

メソッド よくある用途
GET データを取得する
POST 新しいデータを作る、処理を開始する
PUT データ全体を置き換える
PATCH データの一部を変更する
DELETE データを削除する

これは絶対的な命名規則ではありませんが、APIを読む手掛かりになります。

GET    /api/articles       記事一覧を取得
GET    /api/articles/123   記事を1件取得
POST   /api/articles       記事を作成
PATCH  /api/articles/123   記事を変更
DELETE /api/articles/123   記事を削除

特に注意したいのは、GETを開いただけでデータが削除されるような設計です。リンクの先読みや検索エンジンのアクセスでもGETが実行されることがあるため、データを変更する操作には適切なメソッドと権限確認が必要です。

JSONはデータを渡す形式

Web APIでは、JSONという形式でデータを渡すことがよくあります。

{
  "id": "article-123",
  "title": "はじめての記事",
  "tags": ["AI", "Next.js"],
  "published": false
}

見た目はJavaScriptのオブジェクトに似ていますが、JSONには独自の決まりがあります。キーと文字列はダブルクォートで囲み、コメントや関数は書けません。

APIの返答に期待している項目がなかったり、publishedが真偽値ではなく文字列"false"になっていたりすると、画面側で不具合が起きます。TypeScriptの型を書いただけでは、実際に届いたJSONを検証したことにはなりません。

画面とAPIは、データの形について同じ約束を守る

記事を保存するAPIの修正をエージェントに頼んだあと、記事一覧のタイトルが空欄で表示されるようになりました。調べると、APIが返す項目名がtitleからnameに変わっていました。画面のほうは、変わらずtitleを読んでいます。型チェックもビルドも通っていました。

画面とAPIは、やり取りするデータの形について、同じ約束を守る必要があります。この約束をAPIの契約と呼ぶことがあります。どちらか片方だけが変わると、エラーにならないまま表示が崩れます。

// 画面側のコード
type Article = {
  id: string
  title: string
}

const response = await fetch("/api/articles/123")
const article: Article = await response.json()

articleArticle型を付けていますが、これは画面側の思い込みを書いただけです。response.json()が返す値を、TypeScriptは実際には検査しません。APIがnameを返していても、型チェックは通ります。TypeScriptの型がいつ働くかは、シリーズ内のAI駆動開発で困らないためのTypeScriptで説明しています。

形を揃える方法は、大きく3つに分かれます。

方法 形を決める場所 食い違いに気づけるか
別々に書く 画面とAPIのそれぞれ 気づきにくい
型を1か所に置いて共有する 共通のファイル 型チェックで気づける
仕様書や定義ファイルから作る 仕様書・定義ファイル 生成し直したときに気づける

データの形を決める場所を、別々に書く場合と共有する場合とスキーマから生成する場合で比べた図

Next.jsで画面とAPIを同じリポジトリに書いている場合は、型を1か所に置いて両方からimportできます。

// src/types/article.ts
export type Article = {
  id: string
  title: string
  published: boolean
}
// src/app/api/articles/[id]/route.ts
import type { Article } from "@/types/article"
// src/app/articles/[id]/page.tsx
import type { Article } from "@/types/article"

titlenameに変えるときは、この型を変えます。すると、古いtitleを使っている画面とAPIの両方で型エラーが出るので、直し漏れに気づけます。

型は実行時に消えるので、受け口で検証する

型を共有しても、実際に届いたJSONを確かめたことにはなりません。型はコードを書くときとビルドするときに使われ、動いている最中には残らないからです。

実際に届いた値を確かめるには、実行中に検査する仕組みが必要です。よく使われるライブラリにZodがあります。

// src/types/article.ts
import { z } from "zod"

export const ArticleSchema = z.object({
  id: z.string(),
  title: z.string().min(1),
  published: z.boolean(),
})

export type Article = z.infer<typeof ArticleSchema>

ArticleSchemaは、データの形を実行中に確かめるための定義です。z.inferを使うと、そこからTypeScriptの型も作れます。形を書く場所が1か所になるので、型と検査の内容がずれません。

APIで入力を受け取るときに使うと、次のようになります。

const ArticleInputSchema = ArticleSchema.omit({ id: true })

export async function POST(request: Request) {
  const result = ArticleInputSchema.safeParse(await request.json())

  if (!result.success) {
    return Response.json({ error: "入力が正しくありません" }, { status: 400 })
  }

  // result.data は形が確かめられた値
}
  • omit({ id: true })は、idを除いた形を作ります。IDはサーバー側で決めるので、入力には含めません
  • safeParseは、形が合わなくても例外を投げず、成功か失敗かを結果として返します
  • 形が合わなければ、処理を進めずに400を返します

外部のAPIから届くデータを画面で使う場合も、同じように検査できます。相手のサービスが返す形を変えたとき、表示が崩れる前に失敗として気づけます。

型が守るのは開発中までで、実行時には消えるため受け口に検査が要ることを示す対比図

仕様書と、仕組みごと型を共有する方法

APIを別のチームやモバイルアプリ、外部の利用者が使う場合は、コードとは別に仕様書を用意することがあります。HTTP APIの仕様書でよく使われる形式がOpenAPIです。

openapi: 3.1.0
info:
  title: Articles API
  version: 1.0.0
paths:
  /api/articles/{id}:
    get:
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 記事を1件返す
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Article"
components:
  schemas:
    Article:
      type: object
      required: [id, title, published]
      properties:
        id:
          type: string
        title:
          type: string
        published:
          type: boolean

大まかには、次のように読みます。

  • pathsに、endpointとHTTPメソッドが並ぶ
  • parametersに、URLなどから受け取る値が書かれる
  • responsesに、status codeごとの返答が書かれる
  • components.schemasに、データの形がまとめて定義される

OpenAPIの仕様書からTypeScriptの型を生成するツールや、逆にコードから仕様書を生成するツールもあります。気を付けたいのは、どちらを正とするかです。仕様書を手で書き、コードも手で書いていると、片方だけ更新されてずれていきます。エージェントが古い仕様書を読むと、存在しないendpointを呼ぶコードを書くことがあります。

APIの形を共有する仕組みには、ほかにもいくつかあります。名前を見かけたときに、何のための仕組みか分かれば十分です。

仕組み 特徴 向いている状況
OpenAPI HTTP APIの仕様をYAMLやJSONで書く。多くの言語向けに型を生成できる 外部や別チームにAPIを公開する
tRPC サーバーと画面の両方がTypeScriptのとき、関数を呼ぶ形で型を共有する。生成の手順が要らない 画面とサーバーを同じリポジトリで作っている
GraphQL スキーマを書き、画面が必要な項目を指定して取得する 画面ごとに必要なデータが大きく違う
gRPC(Protocol Buffers) 定義ファイルから複数の言語のコードを生成する 言語の違うサーバーやアプリが通信する

エージェントに「型を安全にしたい」と頼むと、これらの仕組みを新しく入れようとすることがあります。途中から切り替えるのは大きな変更になるので、まず既存のやり方で足りないかを確認します。

仕様書や定義ファイルから型を生成している場合、生成されたファイルを手で直してはいけません。次に生成したときに上書きされます。generated/のようなフォルダにある、ファイル名に.gen.tsが付いている、先頭に編集しない旨のコメントがある、といった特徴で見分けられます。形を変えたいときは、元の仕様書や定義ファイルを直してから生成し直します。

互換性を壊す変更を知っておく

APIの形を変えると、古い画面やアプリが動かなくなることがあります。これを互換性を壊す変更、英語でbreaking changeと呼びます。

変更 古い画面やアプリへの影響
項目を追加する 基本的には影響しない
任意だった項目を必須にする 古い画面がその項目を送らず、エラーになる
項目名を変える 古い画面が値を読めない
項目を削除する 古い画面が値を読めない
値の型を変える(数値から文字列など) 古い画面の計算や表示が崩れる
選択肢の値を追加する 古い画面が知らない値を受け取り、表示が崩れることがある

同じリポジトリで画面とAPIを一緒に更新していても、影響が出ることがあります。

  • デプロイの途中で、新しいAPIと古い画面が同時に動く時間がある
  • 利用者が開きっぱなしにしているタブは、古い画面のコードのまま新しいAPIを呼ぶ
  • スマートフォンアプリは、利用者が更新するまで古いまま使われる

形を変える必要があるときは、次のような進め方があります。

  • 新しい項目を追加し、古い項目もしばらく残す
  • 画面が新しい項目を使うようになってから、古い項目を削除する
  • 大きく変える場合は、/api/v2/articlesのように別のバージョンとして用意する

status codeで結果を知る

HTTPのstatus codeは、リクエストがどうなったかを三桁の数字で表します。

範囲 大まかな意味
200番台 成功 200 OK201 Created204 No Content
300番台 別の場所への案内など 301 Moved Permanently302 Found
400番台 リクエスト側の問題 400 Bad Request401 Unauthorized403 Forbidden404 Not Found
500番台 サーバー側の問題 500 Internal Server Error503 Service Unavailable

初心者が混乱しやすいのは401403です。

  • 401:ログイン情報がない、期限切れなど、本人確認ができていない
  • 403:本人は分かっているが、その操作をする権限がない

APIが失敗したのに200を返し、JSONの中だけに{"success": false}と書く設計もあります。しかし、ブラウザや監視ツールが成功と失敗を判断しにくくなります。エージェントには、処理結果に合うstatus codeを使っているか確認します。

Next.jsのRoute Handlerを読む

App Routerを使うNext.jsでは、route.tsにAPIの処理が書かれることがあります。

  • src/
    • app/
      • api/
        • articles/
          • route.ts
import { NextResponse } from "next/server"

export async function POST(request: Request) {
  const body = await request.json()

  return NextResponse.json(
    { id: "article-123", title: body.title },
    { status: 201 },
  )
}

このPOST関数が、POST /api/articlesを処理します。GET関数を書けば、同じpathへのGETを処理できます。

読むときは、次の順に追います。

  1. どのメソッドを受け付けるか
  2. URLやbodyから何を受け取るか
  3. 入力を検証しているか
  4. 誰が実行できるかを確認しているか
  5. データベースや外部APIのどの処理を呼ぶか
  6. どのstatus codeとデータを返すか

ファイルの場所や細かな書き方は、Next.jsのバージョンやプロジェクト構成によって変わることがあります。

ブラウザからfetchする

画面側では、fetchを使ってAPIへ通信することがあります。

const response = await fetch("/api/articles", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ title: "はじめての記事" }),
})

if (!response.ok) {
  throw new Error(`保存に失敗しました: ${response.status}`)
}

const article = await response.json()

fetchは、サーバーが404500を返しても、それだけで必ず例外になるわけではありません。そのため、response.okresponse.statusを確認します。

また、ボタンを連打して同じPOSTが複数回送られることがあります。二重登録を画面側で防ぐだけでなく、API側でも同じ処理が重複しない設計が必要になる場合があります。

headersには通信の追加情報が入る

headersには、データ形式や認証情報などが入ります。

Content-Type: application/json
Authorization: Bearer ...

Content-Typeはbodyの形式を伝えます。Authorizationは認証情報を送るために使われることがあります。

ブラウザで動くコードへAPIキーを直接書いてはいけません。シリーズ内のデプロイの記事で説明したとおり、ブラウザへ届いたJavaScriptや通信内容は利用者が確認できます。外部サービスの秘密鍵を使う通信は、サーバー側で行います。

CORSは権限管理ではない

ブラウザは、開いているWebサイトとは異なるoriginへ通信するとき、CORSのルールを確認します。originは、基本的にscheme、host、portの組み合わせです。

https://app.example.com
https://api.example.com

この二つはhostが違うため、別originです。API側が許可するoriginやメソッドをレスポンスヘッダーで示していないと、ブラウザが返答を画面のJavaScriptへ渡さないことがあります。

CORSエラーが出たからといって、何でも*で許可すればよいわけではありません。また、CORSはAPIの認証や認可の代わりではありません。ブラウザ以外のプログラムはCORSに従わず直接通信できるため、API側で本人確認と権限確認を行います。

timeout、retry、同じ処理の重複

外部APIは、必ずすぐ返るとは限りません。通信が遅い、接続が切れる、相手側が一時的に止まることがあります。

そのため、次を決めます。

  • 何秒待ったらtimeoutにするか
  • どの失敗ならretryするか
  • 何回までretryするか
  • 同じ処理が二重に実行されても安全か

すべての失敗を自動で再試行すると、注文、課金、メール送信などが重複する危険があります。同じリクエストを繰り返しても結果が重複しない性質をidempotencyと呼びます。重要な作成処理では、idempotency keyや処理済み記録を使うことがあります。

相手が他社のサービスである場合は、失敗の扱いに加えて、呼ぶたびにかかる費用と、短時間に呼べる回数の上限も決めることになります。これらは外部APIを使うときの料金と上限(現在は公開されていません)で扱っています。

Webhookは相手から届くAPI通信

通常のAPI通信では、自分のアプリから外部サービスへ問い合わせます。Webhookでは、外部サービス側から自分のAPIへ通知が届きます。

  1. 決済サービスで支払いが完了する
  2. Webhookとして自分の /api/webhooks/payment へ通知が届く
  3. 注文を支払い済みに更新する

WebhookのURLは外部からアクセスできるため、送信元を署名で確認します。画面から同じURLを開けないようにするだけでは不十分です。通知が重複した場合や、到着順が前後した場合も考えます。

Networkパネルで通信を見る

Chromeなどの開発者ツールでNetworkパネルを開き、問題の操作をもう一度行います。

確認する項目は次のとおりです。

  • リクエストが実際に送られているか
  • URLとHTTPメソッドは正しいか
  • status codeは何か
  • Request Payloadに想定したデータがあるか
  • Responseにどんなエラーが返っているか
  • 同じリクエストが複数回送られていないか
  • 処理に何秒かかっているか

Cookie、Authorization header、個人情報、APIの返答には秘密情報が含まれる場合があります。スクリーンショットやログをエージェントへ渡す前に確認してください。

エージェントが作ったAPIを確認する

  • endpointとHTTPメソッドは何か
  • ブラウザ、Next.js、外部APIのどこで処理しているか
  • path、query、headers、bodyのどこから値を受け取るか
  • 入力値をサーバー側で検証しているか
  • データの形を決めている場所はどこか(別々、共有の型、仕様書)
  • 項目名や型を変えたとき、画面とAPIの両方を直したか
  • response.json()の結果に型を付けただけで、検証したことにしていないか
  • 仕様書がある場合、コードの変更と一緒に更新したか
  • 生成されたファイルを手で直していないか
  • 項目の削除や名前の変更など、互換性を壊す変更が含まれていないか
  • 認証と権限確認をどこで行うか
  • 成功と失敗に適切なstatus codeを返すか
  • エラーの詳細を利用者へ出しすぎていないか
  • timeoutとretryの方針があるか
  • 二重送信されても安全か
  • 外部から届くWebhookの署名を確認しているか
  • Networkパネルとサーバーログの両方で動作を確認したか

エージェントには、次のように頼めます。

この機能で発生するAPI通信を、ブラウザから順番に図にしてください。 各通信のURL、HTTPメソッド、入力、status code、返答を示してください。

このAPIで、入力検証、認証、権限確認を行っている場所を分けて説明してください。 CORSの設定を権限確認として扱っていないことも確認してください。

この変更で、APIがやり取りするデータの項目を変更前と変更後で表にしてください。 項目の追加、削除、名前の変更、型の変更を分けて示してください。

外部から受け取るデータを、実行時に検証している場所を示してください。 検証していない入口があれば、Zodなどで検証を追加する案を出してください。

失敗時のtimeout、retry、二重実行の扱いを確認してください。 注文やメール送信など、再試行で重複する処理があれば実行前に止めてください。

APIを読むときは、まずリクエストとレスポンスを一組として見ます。どのURLへ何を送り、何が返ったのかが分かれば、画面、サーバー、データベース、外部サービスのどこで止まったのかを切り分けられます。そのうえで、やり取りするデータの形をどこで決め、どこで確かめているかまで押さえておくと、エラーにならずに表示だけが崩れる不具合も追えるようになります。