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/$fetchをapp/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()などが芋づる式に無効化されます。 computedをwatchより優先します。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:generate(openapi-typescript で app/types/api.d.ts を生成)→ openapi:zod(openapi-zod-client で app/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 三点同期)。
- リポジトリルート
shared/mock-data/— 正本の JSON データ - モジュールの mock repository(
TagRepositoryインターフェースを実装するフラットなファイル) - MSW ハンドラ(
frontend/test/mocks/handlers/+server.ts)
API のレスポンス形が変わったときは、この3箇所がずれていないか確認してください。ずれると「テストは通るのに実際のAPIとは形が違う」状態になります。