/**
* フロントから自アプリの API ルートを叩くための唯一の入口。
*
* ★fetch を画面に直書きせず、必ずここを通すこと★
*
* 1箇所に寄せる理由:
* - **認証方式が未確定(cookie 予定)。** 決まった際に付与処理を入れる場所がここになる。
* 画面ごとに fetch が散っていると、認証の追加が全画面の修正になる。
* なお同一オリジンの fetch は既定で cookie を送るため、現時点で追加設定は要らない。
* - **エラー形式 `{ error: { code, message, details? } }` と一覧の
* `meta { total, page, per_page }` は CLAUDE.md §9.3 が全API共通と定めている。**
* 共通の形が決まっているものは、共通の場所で解釈するのが筋。
* - `docs/spec.md` にあと9画面が控えており、呼び出しが数十箇所へ散る前に寄せる。
*
* 外部URLは扱わない。D-046 の通りビューアーは自アプリの API ルート経由でしかデータを読まない。
*/
import type { ApiErrorDetails, ErrorCode } from "@/lib/types";
/**
* API 呼び出しの失敗。
*
* `serverMessage` を独立させているのは、画面が
* 「サーバーが返した文言を出す」か「画面側の既定文言を出す」かを
* 判別できるようにするため。ここを `message` に混ぜると、
* HTML のエラーページが返ったときに `"<!DOCTYPE html>..."` のような
* 文字列が画面に出てしまう。
*/
export class ApiFetchError extends Error {
/** HTTP ステータス。ネットワーク到達不能などレスポンスが無い場合は 0 */
readonly status: number;
/** `ApiError` として解釈できた場合のみ。それ以外は null */
readonly code: ErrorCode | null;
/** `ApiError` として解釈できた場合のみ。それ以外は null */
readonly serverMessage: string | null;
/**
* `ApiError.error.details` をそのまま持つ。
*
* ★★`Record<string, string[]>` に狭めないこと★★(指示書AL 段階2 で直した)—
* ★`GET /reports/kpi` の 422 は `{ customer_ids: [1, 2] }`(★数値の配列★)を返す★
* ので、狭い型のままだと ★「文字列の配列」として扱うコードが型検査を通ってしまう★
* (`String(id)` が `"1"` ではなく数値の文字列化として偶然動くぶん、気づきにくい)。
* ★`ApiErrorDetails` は3つの形の union なので、読む側が実行時に絞ること★
* (`lib/reports/block-visibility.ts` の `customerIdCandidates()` が例)。
*/
readonly details: ApiErrorDetails;
constructor(params: {
status: number;
code: ErrorCode | null;
serverMessage: string | null;
details?: ApiErrorDetails;
cause?: unknown;
}) {
super(params.serverMessage ?? `API request failed (status ${params.status})`, {
cause: params.cause,
});
this.name = "ApiFetchError";
this.status = params.status;
this.code = params.code;
this.serverMessage = params.serverMessage;
this.details = params.details ?? null;
}
}
/**
* `{ error: { code, message } }` の形を読む。形が違えば null 扱い。
*
* 判定の要は `message` が文字列かどうか。`code` が欠けていても `message` は採用する。
* CLAUDE.md §9.3 は両方揃うと定めているが、ここを厳しくすると
* 規約違反のレスポンスが来たときに画面の文言が既定文へ変わってしまう
* (移行前の `err.error?.message ?? 既定文` と挙動を揃える)。
*/
function parseApiError(body: unknown): {
code: ErrorCode | null;
message: string;
details: ApiErrorDetails;
} | null {
if (typeof body !== "object" || body === null) return null;
const error = (body as { error?: unknown }).error;
if (typeof error !== "object" || error === null) return null;
const { code, message, details } = error as {
code?: unknown;
message?: unknown;
details?: unknown;
};
if (typeof message !== "string") return null;
return {
code: typeof code === "string" ? (code as ErrorCode) : null,
message,
// JSON なので実際の形は保証できない。★ここでは union のまま通し、
// 使う側が実行時に絞る★(`customerIdCandidates()` 参照)
details: (details as ApiErrorDetails | undefined) ?? null,
};
}
/**
* 自アプリの API を叩き、成功時はパース済みの本文を返す。
* 失敗時(HTTPエラー / JSON不正 / ネットワーク到達不能)は `ApiFetchError` を投げる。
*
* @param path `/api/...` 形式の相対パス。絶対URLは受け付けない
*/
export async function apiFetch<T>(
path: string,
init?: RequestInit
): Promise<T> {
if (/^([a-z][a-z0-9+.-]*:)?\/\//i.test(path)) {
throw new TypeError(
`apiFetch は自アプリの API ルート専用です(相対パスで指定してください): ${path}`
);
}
let response: Response;
try {
response = await fetch(path, init);
} catch (cause) {
// レスポンスが得られていないので、サーバー由来の文言は無い
throw new ApiFetchError({
status: 0,
code: null,
serverMessage: null,
cause,
});
}
// 失敗時も本文を読む。ApiError が入っていれば文言を画面へ渡せる
let body: unknown;
let parsed = true;
try {
body = await response.json();
} catch {
parsed = false;
}
if (!response.ok) {
const error = parsed ? parseApiError(body) : null;
throw new ApiFetchError({
status: response.status,
code: error ? error.code : null,
serverMessage: error?.message ?? null,
details: error?.details ?? null,
});
}
if (!parsed) {
throw new ApiFetchError({
status: response.status,
code: null,
serverMessage: null,
});
}
return body as T;
}