| name | frourio-framework |
| description | frourio (Fastify + aspida + zod ベースのTypeScriptフルスタックフレームワーク) の使い方。 ファイルベースルーティング規約、defineController/defineValidators/defineHooks パターン、 動的セグメント、自動生成ファイル ($server.ts, $relay.ts, $api.ts)、aspida型連携、 hooks 経由の JWT decode を controller で取得するパターン、 frourio-framework-prisma-generators による Prisma Model クラス生成と toDto 返却ルール、 フロントエンドでの useFrourioSWR (useEffect 回避) によるデータ取得規約、 新規ルート追加手順を網羅。最新テンプレートは frourio 本体を内蔵 (@frouvel/frourio)。 Triggers: "frourio", "ルート追加", "新しいAPI", "endpoint 追加", "controller 作る", "$server.ts", "$relay.ts", "aspida", "DefineMethods", "defineController", "frourio-framework-prisma-generators", "ModelDto", "toDto", "useFrourioSWR", "useAspidaSWR", "@frouvel/frourio".
|
| allowed-tools | ["Read","Grep","Glob","Bash","Edit","Write"] |
frourio Framework
frourio = Fastify + aspida + zod ベース TypeScript フルスタックフレームワーク。
ファイルベースルーティング (Next.js風)。frourio CLI で $server.ts / $relay.ts 自動生成。
最新の frourio-framework テンプレートは frourio 本体を内蔵 (backend-api/@frouvel/frourio/、CLI + src 同梱) → 外部 frourio パッケージへの依存なし。テンプレート: https://github.com/InterfaceX-co-jp/frourio-framework-template。
1. ディレクトリ規約
ルート単位 = api/<path>/ ディレクトリ。各ディレクトリに以下ファイル配置。
| ファイル | 役割 | 必須 | 自動生成 |
|---|
index.ts | aspida DefineMethods でHTTPメソッド型定義 | ✅ | ❌ |
controller.ts | defineController で handler 実装 | ✅ | ❌ |
validators.ts | defineValidators で zod 検証 | 動的セグメント時必須 | ❌ |
hooks.ts | defineHooks で Fastify hooks (auth等) | 任意 | ❌ |
$relay.ts | defineController/defineHooks/defineValidators 関数 export | ✅ | ✅ frourio生成 |
ルート全集計: プロジェクトルートの $server.ts (frourio生成、編集禁止)。
aspida クライアント型: api/$api.ts (生成)。
2. ルートパスマッピング
ディレクトリ構造 = URL パス。
api/users/ → /users
api/users/_id@string/ → /users/:id (params.id: string)
api/posts/_postId@number/comments/ → /posts/:postId/comments (params.postId: number)
動的セグメント: _<name>@<type> ディレクトリ名。型は string / number。
3. index.ts — aspida 型定義
import type { DefineMethods } from 'aspida';
export type Methods = DefineMethods<{
get: {
query: {
page: number;
limit: number;
search?: string;
};
resBody: { data: UserDto[]; total: number };
};
post: {
reqBody: { email: string; password: string };
resBody: { token: string };
};
}>;
定義可能キー: query / reqBody / reqHeaders / reqFormat / resBody / resHeaders / status。
4. controller.ts — defineController
import { defineController } from './$relay';
export default defineController(() => ({
get: ({ query }) => ({
status: 200,
body: { data: [...], total: 0 },
}),
post: async ({ body }) => ({
status: 200,
body: { token: '...' },
}),
}));
handler 引数: { query, params, body, headers } (index.ts で定義したものだけ存在)。
handler 戻り値: { status, body, headers } シェイプ (status は HTTP コード、body は resBody 型)。
非同期可: handler を async または Promise 返却に。
DI overload (velona depend 経由):
export default defineController({ userService }, ({ userService }, fastify) => ({
get: async ({ params }) => ({ status: 200, body: await userService.find(params.id) }),
}));
4.1 hooks 経由で注入した request プロパティを controller で取得 (JWT 等)
hooks.ts で JWT decode 等を行い request オブジェクトに property 注入 → controller の handler 引数からそのまま取得可能。handler 第一引数は Fastify の FastifyRequest と同じ参照 → hooks で追加した値がそのまま見える。
1. Fastify request 型拡張 (型補完用、例: @fastify/jwt):
import 'fastify';
type JwtPayload = { id: string; email: string; scope: string[] };
declare module 'fastify' {
interface FastifyRequest {
user: JwtPayload;
}
}
2. hooks で decode・注入:
import { defineHooks } from './$relay';
export default defineHooks(() => ({
onRequest: async (req, reply) => {
try {
req.user = await req.jwtVerify();
} catch (err) {
reply.code(401).send({ error: 'unauthorized' });
}
},
}));
3. controller で取得 (handler 引数を destructure or 直接参照):
import { defineController } from './$relay';
export default defineController(() => ({
get: (req) => ({ status: 200, body: { id: req.user.id } }),
post: ({ body, user }) => ({
status: 200,
body: { ownerId: user.id, ...body },
}),
}));
階層継承: 親ディレクトリ hooks.ts で req.user を注入すれば、子孫ルート全 controller で取得可能 (ルート単位で hooks.ts を再宣言する必要はない)。
5. validators.ts — defineValidators (動的ルート必須)
動的セグメント _id@string 等含むディレクトリは zod 検証必須。
import { z } from 'zod';
import { defineValidators } from './$relay';
export default defineValidators(() => ({
params: z.object({ id: z.string() }),
}));
$relay.ts の defineValidators 型は params の名前/型に対応 (frourio が動的に生成)。
6. hooks.ts — defineHooks (Fastify hooks)
import { defineHooks } from './$relay';
import type { FastifyRequest, FastifyReply } from 'fastify';
const authMiddleware = async (req: FastifyRequest, reply: FastifyReply) => {
};
export default defineHooks(() => ({
onRequest: [authMiddleware],
}));
階層継承: 親ディレクトリ hooks.ts は子孫ルート全てに適用される。
6.1 共通 hooks ロジックは middleware/ に切り出し (重複禁止)
複数ルートで同じ認証 / rate limit / logging 等を使うときは、ロジックを hooks.ts 内にインラインで書かず、backend-api/middleware/<name>.ts に切り出して各 hooks.ts から import する。hooks.ts は薄ラッパに留める。
❌ アンチパターン — 兄弟 hooks.ts に同じロジックをコピペ:
✅ 推奨パターン — middleware 切り出し + 階層継承活用:
import type { FastifyReply, FastifyRequest } from 'fastify';
export const authAdminMiddleware = async (req: FastifyRequest, reply: FastifyReply) => {
const payload = await req.server.jwt.verify(token);
if (!hasAdminScope(payload)) {
reply.code(403).send({ error: 'forbidden' });
return reply;
}
};
import { defineHooks } from './$relay';
import { authAdminMiddleware } from '$/middleware/authAdminMiddleware';
export default defineHooks(() => ({
onRequest: authAdminMiddleware,
}));
ポイント:
- middleware は
(req: FastifyRequest, reply: FastifyReply) => Promise<void> のシンプル形式に統一 (defineHooks のクロージャ引数 fastify は不要)
- Fastify インスタンスが必要なら
req.server で取得 (req.server.jwt.verify(token) 等)
- 認証必須範囲の 共通祖先ディレクトリ に
hooks.ts を 1つだけ置けば、子孫全ルートに適用される — 子孫ごとに個別 hooks.ts を作る必要なし
- 認証範囲が異なる兄弟ディレクトリ (例:
auth/login は token 発行 endpoint なので認証掛けたくない) がある場合は、共通祖先より下に分けて配置 (frourio hooks は階層継承で 重ねがけ、override 不可)
7. $relay.ts — 自動生成 (編集禁止)
frourio が各ルートディレクトリに生成。以下を export:
defineController (DI付き overload あり)
defineHooks
defineValidators (動的ルート時のみ、params 型と整合)
multipartFileValidator() (zodで MultipartFile 検証)
8. 自動生成コマンド
frourio CLI 直接実行:
frourio --watch
frourio
最新 frourio-framework-template (frourio 内蔵版) では @frouvel/frourio/cli.ts を tsx で直接呼ぶ:
tsx @frouvel/frourio/cli.ts --watch
tsx @frouvel/frourio/cli.ts --build
テンプレート標準 npm scripts:
dev:frourio — tsx @frouvel/frourio/cli.ts --watch (ウォッチ生成)
generate — npm run cli -- generate (kaname CLI が aspida + frourio + prisma + config + openapi を統合実行)
generate:frourio — npm run cli -- generate:frourio
generate:db — npx prisma generate --schema=database/prisma/schema.prisma
$server.ts, $relay.ts, $api.ts 編集禁止 → 次回生成で消える。
9. 起動フロー
import Fastify from 'fastify';
import server from './$server';
const fastify = Fastify();
server(fastify, { basePath: '/api' });
fastify.listen({ port: 3000 });
$server.ts の default export (fastify, options) => void で全ルート登録。
options.basePath で URL prefix、options.multipart で @fastify/multipart 設定。
10. 新規ルート追加手順
api/<path>/index.ts 作成 — Methods 型 (DefineMethods)
api/<path>/controller.ts 作成 — defineController で handler
- 動的セグメント時:
_<name>@<type>/ ディレクトリ + validators.ts
- auth 必要時:
hooks.ts で defineHooks({ onRequest: [authMiddleware] })
frourio --watch 実行中なら自動再生成、停止時は frourio を一度実行
- tsc で
$server.ts の import 整合性確認
11. フロントエンド: useFrourioSWR
frontend からの API 呼び出しは useFrourioSWR を最優先。useAspidaSWR は legacy fallback。useEffect でのデータ取得は避ける。
useFrourioSWR 実体: backend-api/@frouvel/kaname/http/client/browser/useFrourioSWR.ts (フロント側で import 利用)。useAspidaSWR 互換 API + useAspidaSWR で解決できなかった enable / 条件付き fetch に対応 (継続メンテ)。
11.1 基本
import { useFrourioSWR } from '<path>/useFrourioSWR';
import { adminApiClient } from '<aspida client>';
const { data, error, isLoading } = useFrourioSWR(adminApiClient.admin.users);
戻り値型: aspida endpoint の $get() 戻り値から自動 infer → *ModelDto までそのまま型補完。
11.2 query 付き
const { data } = useFrourioSWR(adminApiClient.hq.projects, {
query: { status: 'IN_PROGRESS' },
});
11.3 動的セグメント
aspida client のメソッド呼び出しで params 注入:
const { data } = useFrourioSWR(adminApiClient.admin.tenants._tenantId(tenantId));
11.4 条件付き fetch (enable 相当)
null / undefined / false を endpoint に渡せば SWR が無効化 → useEffect + state 不要。
const { data } = useFrourioSWR(
id ? adminApiClient.admin.tenants._tenantId(id) : null,
);
const { data } = useFrourioSWR(isAuthenticated && adminApiClient.admin.me);
11.5 SWR config
useFrourioSWR(adminApiClient.admin.users, { refreshInterval: 5000 });
useFrourioSWR(adminApiClient.admin.users, undefined, { refreshInterval: 5000 });
useFrourioSWR(
adminApiClient.hq.projects,
{ query: { status: 'IN_PROGRESS' } },
{ refreshInterval: 5000 },
);
11.6 useEffect を避ける指針
- データ取得:
useFrourioSWR (条件付き含む) → useEffect + fetch 禁止
- 依存値で再取得:
useFrourioSWR の query / params 経由 (キャッシュキー変化で自動再取得)
- mutation 後の再取得:
mutate() (SWR 標準) 経由
useEffect は副作用専用 (subscription、外部 system 同期等)。データ取得には使わない
11.7 useAspidaSWR からの移行
const { data } = useAspidaSWR(adminApiClient.admin.users);
const { data } = useAspidaSWR(adminApiClient.hq.projects, { query: {...} });
const { data } = useFrourioSWR(adminApiClient.admin.users);
const { data } = useFrourioSWR(adminApiClient.hq.projects, { query: {...} });
差分: 条件付き fetch (null/undefined/false 渡し) が型安全に書ける + 継続メンテ。
12. Prisma Model 生成 (frourio-framework-prisma-generators)
公式リポジトリ: https://github.com/InterfaceX-co-jp/frourio-framework-prisma-generators
各 Prisma model からイミュータブルな Domain Model クラスと DTO 型を自動生成 →
API 応答は 必ず生成 Model クラスの toDto() 経由で返却。Prisma 生型を直接返さない。
11.1 要件 / インストール
prisma と @prisma/client は v7.2.0 以上、かつ同一バージョン
- インストール:
npm install -D frourio-framework-prisma-generators
11.2 生成器設定
schema.prisma にジェネレーターブロック追加:
generator frourio_framework_prisma_model_generator {
provider = "frourio-framework-prisma-model-generator"
output = "__generated__/models"
additionalTypePath = "./@additionalType/index" // @json アノテーション使用時のみ必須
}
オプション:
provider: 固定値
output: 生成先 (schema からの相対パス)
additionalTypePath: @json で参照する独自型の import 先
11.3 生成構造 — 1モデルあたり
例 schema:
model Post {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
title String
content String?
author User? @relation(fields: [authorId], references: [id])
authorId Int?
}
生成される 5 種類:
A. {Model}ModelDto — toDto() 戻り値型。DateTime → string (ISO 8601)。
FK は relation field 存在時に自動除外 (例: authorId は relation author があるため除外)。
export type PostModelDto = {
id: number;
createdAt: string;
title: string;
content?: string | null;
author?: UserWithIncludes | null;
};
B. {Model}ModelConstructorArgs — Date のまま保持。
C. {Model}ModelFromPrismaValueArgs — self: Prisma{Model} + 関連を個別キーで。
export type PostModelFromPrismaValueArgs = {
self: PrismaPost;
author?: UserWithIncludes;
};
D. {Model}Model class — private readonly + getter + static fromPrismaValue + toDto + static builder()。
E. {Related}WithIncludes — Prisma.{Model}GetPayload<typeof include> ベースの関連込み型。
11.4 型変換ルール
| Prisma 型 | Constructor / Getter | DTO (toDto()) | 変換 |
|---|
String / Int / Float / Boolean | 同名 | 同名 | なし |
DateTime | Date | string | .toISOString() |
Decimal | number | number | fromPrismaValue 内で .toNumber() |
BigInt | bigint | string | .toString() |
Bytes | Buffer | string | Buffer.from().toString('base64') |
Json | Prisma.JsonValue | Prisma.JsonValue | なし (@json で上書き可) |
| Enum | Prisma{EnumName} | Prisma{EnumName} | なし |
| Relation | {Type}WithIncludes | {Type}WithIncludes (@dto(nested) で {Type}ModelDto 化) | なし |
nullable は ? + | null、toDto() で ?. + ?? null 経由の安全変換。
11.5 アノテーション (/// コメント)
@json(type: [TypeName]) — Json フィールドにカスタム型
model JsonField {
id Int @id @default(autoincrement())
rawJson Json
jsonObject Json /// @json(type: [JsonObject])
}
additionalTypePath 先で型 export:
export type JsonObject = { foo: string; bar: number };
fromPrismaValue 内では as unknown as JsonObject で cast。
@dto(hidden: true) — DTO から除外
model User {
password String /// @dto(hidden: true)
}
{Model}ModelDto 型・toDto() 出力から除外
ConstructorArgs / fromPrismaValue / private field / getter は 保持 (内部利用可)
@dto(nested: true) — 関連を {Related}ModelDto にネスト変換
model User {
posts Post[] /// @dto(nested: true)
books Book[]
}
export type UserModelDto = {
posts: PostModelDto[];
books: BookWithIncludes[];
};
@dto.profile(name: X, pick: [...] | omit: [...]) — 用途別 DTO
モデル直上に /// コメントで宣言:
/// @dto.profile(name: Public, pick: [id, email, name])
/// @dto.profile(name: Admin, omit: [password])
model User {
id Int @id
email String @unique
name String?
password String /// @dto(hidden: true)
}
生成: UserPublicDto / UserAdminDto 型 + toPublicDto() / toAdminDto() メソッド。
ルール:
pick と omit は排他 (両方指定 → 当該プロファイル無視)
- profile は
@dto(hidden) を 上書き — pick で hidden field を明示指定すれば含まれる
- 存在しない field 名は warning 出力でスキップ
- 同名 profile 重複は最初のもののみ採用
複合: 同一 field に複数アノテーション可
settings Json /// @json(type: [SettingsObject]) @dto(hidden: true)
11.6 Builder パターン
fromPrismaValue は全リレーション必須。Builder は柔軟版 (リレーションスキップ可、テスト fixture 向け、サブクラス拡張可)。
const user = UserModel.fromPrismaValue({ self, posts, books, notifications });
const user = UserModel.builder()
.fromPrisma(prismaUser)
.posts(loadedPosts)
.build();
API:
fromPrisma(value: Prisma{Model}) — スカラー一括 set (型変換込み)
<scalarField>(value) — 個別 setter (テスト fixture で Faker と組み合わせ向け)
<relationField>(value) — 関連 setter
protected buildArgs() — サブクラスから利用可
build(): {Model}Model — 必須 field 未設定時は Error
buildArgs() デフォルト値:
- 必須 scalar 未設定 → throw
- optional scalar →
null
- list relation →
[]
- optional single relation →
undefined
- 必須 single relation 未設定 → throw
サブクラス拡張 (カスタム field 追加):
class AppUser extends UserModel {
constructor(args: UserModelConstructorArgs & { fullName: string }) {
super(args);
this._fullName = args.fullName;
}
override toDto() { return { ...super.toDto(), fullName: this._fullName }; }
}
class AppUserBuilder extends UserModelBuilder {
fullName(v: string): this { this._fullName = v; return this; }
override build(): AppUser {
return new AppUser({ ...this.buildArgs(), fullName: this._fullName });
}
}
11.7 Repository Generator (Beta)
別ジェネレーターブロックで有効化:
generator repository {
provider = "frourio-framework-prisma-repository-generator"
output = "__generated__/repository"
modelPath = "__generated__/models" // model generator の output と一致
}
生成物:
BaseRepository — CRUD + ページネーション抽象クラス
{Model}Repository — 各モデル具象クラス (自動 findBy* + paginate)
自動生成メソッド (schema メタデータから):
| ソース | メソッド | 例 |
|---|
@id field | findBy{Field}(value) | findById(id) |
@unique field | findBy{Field}(value) | findByEmail(email) |
@@unique([x, y]) | findBy{X}And{Y}(x, y) | findByBookIdAndPostId(...) |
| 全モデル | paginate(args?) | typed where/orderBy/page/perPage |
継承メソッド (BaseRepository): findMany / findFirst / count / exists / create / createMany / update / updateMany / upsert / delete / deleteMany / aggregate / cursorPaginate / withTransaction(tx)。
利用例:
const userRepo = new UserRepository(prisma.user);
const user = await userRepo.findById(1);
const page = await userRepo.paginate({
page: 1, perPage: 20,
where: { name: { contains: 'alice', mode: 'insensitive' } },
orderBy: { field: 'id', direction: 'desc' },
});
カスタムメソッド追加は継承で:
import { UserRepository as Generated } from './__generated__/repository/User.repository';
export class UserRepository extends Generated {
async findActive() { return this.findMany({ where: { active: true } }); }
}
11.8 共有型エクスポート (推奨)
frontend と DTO 型を単一ソース共有するため、共通 types エントリで re-export:
export type { UserModelDto, UserPublicDto, UserAdminDto } from '<output>/User.model';
export type { PostModelDto } from '<output>/Post.model';
11.9 使用ルール (必須)
A. index.ts の resBody は生成 DTO 型を使用 (自前再定義禁止)
import type { UserModelDto } from '<shared-types>';
export type Methods = DefineMethods<{ get: { resBody: UserModelDto } }>;
B. controller は .toDto() 経由で返却
get: async ({ params }) => {
const user = await userRepo.findById(params.id);
return { status: 200, body: user.toDto() };
},
UseCase / repository が Prisma 値取得 → <Model>.fromPrismaValue({ self, ...includes }) または <Model>.builder().fromPrisma(...).build() で Model 化 → controller が .toDto()。
C. Prisma 生値・Model インスタンス直接返却禁止
return { status: 200, body: await prisma.user.findUnique({ where: { id } }) };
return { status: 200, body: userModel };
return { status: 200, body: userModel.toDto() };
D. 用途別 DTO は profile 使用 — 同一 model から複数 DTO 形が必要なら @dto.profile で toPublicDto() 等を生成し、index.ts の resBody はそれぞれ UserPublicDto / UserAdminDto を使用。
E. センシティブフィールドは @dto(hidden: true) — password 等は schema で hidden 指定 → 標準 toDto() から自動除外。
11.10 生成コマンド
prisma generate
schema.prisma 変更後は必ず実行。
生成ファイル編集禁止 (__generated__/ 配下は次回生成で消える)。
チェックリスト (新規ルート追加時)