メインコンテンツへ

API アクセスの一本道

Astar Management のフロントエンドで backend の API を叩く経路は、常に同じ形をしています。

Component → composable (TanStack Query) → repository (useApi() + apiRequest())

Component が repository や useApi() を直接呼ぶことは禁止です。 必ず composable を経由します。この一本道を崩さないことが、キャッシュ管理・エラーハンドリング・テナント分離を一貫させる前提になっています。

repository 層 — useApi() + apiRequest()

repository は API 呼び出しそのものをラップする層です。tag モジュールの実装:

// app/modules/tag/repositories/TagRepository.ts
export const useTagRepository = (): TagRepository => {
  const api = useApi()

  const listTags = async (): Promise<TagResponse[]> => {
    return apiRequest(
      () => api.GET('/api/v1/tags'),
      { context: 'TagRepository.listTags' }
    )
  }

  const deleteTag = async (id: string): Promise<void> => {
    await apiRequestVoid(
      () => api.DELETE('/api/v1/tags/{id}', { params: { path: { id } } }),
      { context: 'TagRepository.deleteTag', logData: { id } }
    )
  }

  // ...
  return { listTags, deleteTag, /* ... */ }
}
  • useApi()app/shared/api/composables/useApi.ts)が型付きの API クライアントを返します。生の fetch/$fetchapp/modules/** で使うのは lint エラーです。相対 URL の fetch はフロントエンドのオリジンを叩いてしまい、apiBaseUrl / Authorization / X-Tenant-Id を素通りします(これが原因で本番の公開共有バイナリが壊れた実例があります)。
  • apiRequest() / apiRequestVoid() がエラーハンドリングを統一します。戻り値のある呼び出しは apiRequest()void を返す呼び出しは apiRequestVoid() を使います。
  • apiRequest() が投げる ApiError.status直接 持ちます(.response.status のような入れ子はありません)。ステータスコードを見るときは getErrorStatus(err)app/shared/api/utils/getErrorStatus.ts)を使ってください。.response?.status は常に undefined を返し、409/503/413 のような分岐を静かに握りつぶします。
  • context オプションはエラーログにどの呼び出しで失敗したかを残すためのものです。省略しないでください。

composable 層 — TanStack Query + readonly()

composable が実際の状態管理を担当します。tag モジュールの useTagManagement:

// app/modules/tag/composables/useTagManagement.ts
export function useTagManagement() {
  const repository = useTagRepository()
  const queryClient = useQueryClient()

  const { data: tagsData, isPending } = useQuery({
    queryKey: computed(() => queryKeys.tags.list()),
    queryFn: () => repository.listTags(),
  })

  const tags = computed(() => tagsData.value ?? [])

  const createTag = async (request: CreateTagRequest) => {
    const tag = await repository.createTag(request)
    await queryClient.invalidateQueries({ queryKey: queryKeys.tags.all() })
    return tag
  }

  return {
    tags: readonly(tags),   // 呼び出し側から書き換えられないようにする
    isLoadingTags: isPending,
    createTag,
  }
}

要点:

  • queryKey は必ず queryKeys ファクトリ経由app/shared/api/query-keys.ts)。手書きの配列(['tags', id])は禁止です。queryKeys の全キーは先頭に getTenantId() を持ち、これがマルチテナントのキャッシュ分離を担保しています。
  • 参照系の戻り値は readonly() で返します。 composable の外から状態を直接書き換えられると、TanStack Query のキャッシュと実際の表示がずれるからです。
  • mutation の後はキャッシュを無効化します。 queryKeys.tags.all() のような上位階層を invalidate すると、そこから派生する list() / roots() などが芋づる式に無効化されます。
  • computedwatch より優先します。watch を使うのは API トリガー・DOM 操作・ステータス変化時のトースト通知など、副作用が本当に必要な場合だけです。

component 層

component は composable だけを呼びます。TagPicker.vue の例:

<script setup lang="ts">
const { tags, createTag, deleteTag } = useTagManagement()

const tagOptions = computed<MultiComboboxOption[]>(() =>
  tags.value.map(tag => ({ value: tag.id!, label: tag.name! }))
)

const handleCreate = async (query: string) => {
  try {
    const newTag = await createTag({ name: query })
    // ...
  } catch {
    tagNotifications.notifyTagCreateError()
  }
}
</script>

component が useTagRepository()useApi() を直接 import することはありません。この一本道のおかげで、component は「どの composable を呼ぶか」だけを気にすればよく、API のエラーハンドリングやキャッシュ管理をコンポーネントごとに再発明せずに済みます。

OpenAPI 型生成

api.d.ts / openapi.json / zod-client.ts手で編集しないファイル です。backend が公開する OpenAPI スキーマから自動生成されます。

bun run openapi:all   # 独立した backend に対してのみ実行すること

openapi:all は次を連続実行します: openapi:fetch(backend の /v3/api-docs から openapi.json を取得)→ openapi:generateopenapi-typescriptapp/types/api.d.ts を生成)→ openapi:zodopenapi-zod-clientapp/shared/api/zod-client.ts を生成、schemas.* として Zod スキーマも書き出す)→ 各種プロパティ型/SSEストリーム型の再生成。

オプションを付けずに bun run openapi:all を叩くと、デフォルトで :8080 の backend を見に行きます。並行して worktree を使っている場合、他のエージェントの未コミット WIP がそのまま frontend の型として焼き付いてしまいます。worktree で作業しているときは、自分の worktree の独立した backend に対して実行してください。

repository の型(Tier 1)は必ず components['schemas'][...](= api.d.ts 由来)から取ります。手書きの interface で代替すると OpenAPI と乖離します。

Zod による実行時検証

api.d.ts の型はコンパイル時のみのチェックです。次のような 信頼境界 では、生成済みの schemas.*zod-client.ts 由来)を使った実行時検証が必須です。

  • 金銭・課金に関わるデータ
  • 認証トークン
  • JSON.parse(localStorage) の結果(readStoredJson を使う)
  • SSE / postMessage / ドラッグ&ドロップのペイロード
  • 生の $fetch の戻り値
  • まだ api.d.ts に載っていないエンドポイント
apiRequest(() => api.GET('/api/v1/tags'), { schema: schemas.TagResponseList })

それ以外の場所では、単純な as XResponse のキャストで問題ありません。ただし (api as any) / (api as unknown) は lint エラーです。

mock / MSW との関係

テストと /__dev プレビューは実 backend を呼ばず、モックデータで動きます。3箇所を常に同期させる必要があります(Mock 三点同期)。

  1. リポジトリルート shared/mock-data/ — 正本の JSON データ
  2. モジュールの mock repository(TagRepository インターフェースを実装するフラットなファイル)
  3. MSW ハンドラ(frontend/test/mocks/handlers/ + server.ts

API のレスポンス形が変わったときは、この3箇所がずれていないか確認してください。ずれると「テストは通るのに実際のAPIとは形が違う」状態になります。

次の一歩

  • i18n — API から取得したデータの表示テキストをどう組み立てるか
  • デザインシステム — component 層の見た目のルール