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/→/usersapi/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):
// types/fastify.d.ts
import 'fastify';
type JwtPayload = { id: string; email: string; scope: string[] };
declare module 'fastify' {
interface FastifyRequest {
user: JwtPayload;
}
}
2. hooks で decode・注入:
// hooks.ts
import { defineHooks } from './$relay';
export default defineHooks(() => ({
onRequest: async (req, reply) => {
try {
req.user = await req.jwtVerify(); // @fastify/jwt が verify + decode
} catch (err) {
reply.code(401).send({ error: 'unauthorized' });
}
},
}));
3. controller で取得 (handler 引数を destructure or 直接参照):
import { defineController } from './$relay';
export default defineController(() => ({
// 引数全体を受けて user 参照
get: (req) => ({ status: 200, body: { id: req.user.id } }),
// destructure (body, query, params 等と並列で user 取り出せる)
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() }),
// query, body も同様に定義可能
}));
$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) => {
// JWT 検証等
};
export default defineHooks(() => ({
onRequest: [authMiddleware],
// preParsing, preValidation, preHandler 利用可
}));
階層継承: 親ディレクトリ hooks.ts は子孫ルート全てに適用される。
6.1 共通 hooks ロジックは middleware/ に切り出し (重複禁止)
複数ルートで同じ認証 / rate limit / logging 等を使うときは、ロジックを hooks.ts 内にインラインで書かず、backend-api/middleware/<name>.ts に切り出して各 hooks.ts から import する。hooks.ts は薄ラッパに留める。
❌ アンチパターン — 兄弟 hooks.ts に同じロジックをコピペ:
// api/admin/clients/.../tiktok/hooks.ts と api/admin/report/hooks.ts に同一の認証コード 60 行...
✅ 推奨パターン — middleware 切り出し + 階層継承活用:
// middleware/authAdminMiddleware.ts — 1箇所に集約
import type { FastifyReply, FastifyRequest } from 'fastify';
export const authAdminMiddleware = async (req: FastifyRequest, reply: FastifyReply) => {
// token 抽出 → JWT verify → scope check
const payload = await req.server.jwt.verify(token);
if (!hasAdminScope(payload)) {
reply.code(403).send({ error: 'forbidden' });
return reply;
}
};
// api/admin/clients/hooks.ts — 子孫 (_id@string/, tiktok/* 等) 全部に継承される
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 あり)defineHooksdefineValidators(動的ルート時のみ、params 型と整合)multipartFileValidator()(zodで MultipartFile 検証)
8. 自動生成コマンド
frourio CLI 直接実行:
frourio --watch # 外部 frourio パッケージ使用時
frourio # 一回のみ
最新 frourio-framework-template (frourio 内蔵版) では @frouvel/frourio/cli.ts を tsx で直接呼ぶ:
tsx @frouvel/frourio/cli.ts --watch # dev (= npm run dev:frourio)
tsx @frouvel/frourio/cli.ts --build # build (= npm run 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:frouriogenerate: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>';
// シンプル GET — キャッシュキー = $path() 自動生成
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' },
});
// キャッシュキーに query 含まれる → query 変更で自動再フェッチ
11.3 動的セグメント
aspida client のメソッド呼び出しで params 注入:
const { data } = useFrourioSWR(adminApiClient.admin.tenants._tenantId(tenantId));
11.4 条件付き fetch (enable 相当)
null / undefined / false を endpoint に渡せば SWR が無効化 → useEffect + state 不要。
// id 確定時のみ fetch
const { data } = useFrourioSWR(
id ? adminApiClient.admin.tenants._tenantId(id) : null,
);
// 認証済みのときだけ fetch
const { data } = useFrourioSWR(isAuthenticated && adminApiClient.admin.me);
11.5 SWR config
// 第2引数に config (query 不要時)
useFrourioSWR(adminApiClient.admin.users, { refreshInterval: 5000 });
// useAspidaSWR 互換: 第2引数 undefined + 第3引数に config
useFrourioSWR(adminApiClient.admin.users, undefined, { refreshInterval: 5000 });
// query + config
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 からの移行
// before (useAspidaSWR)
const { data } = useAspidaSWR(adminApiClient.admin.users);
const { data } = useAspidaSWR(adminApiClient.hq.projects, { query: {...} });
// after (useFrourioSWR — シグネチャ互換)
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;
// authorId は出力されない
};
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:
// prisma/@additionalType/index.ts
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[]; // nested → DTO
books: BookWithIncludes[]; // 注釈なし → 生 Prisma 型
};
// toDto() 内で自動変換: posts.map(p => PostModel.builder().fromPrisma(p).build().toDto())
@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 向け、サブクラス拡張可)。
// fromPrismaValue (厳格)
const user = UserModel.fromPrismaValue({ self, posts, books, notifications }); // 全関連必須
// builder (柔軟)
const user = UserModel.builder()
.fromPrisma(prismaUser) // スカラー全 set
.posts(loadedPosts) // 必要な関連だけ
.build(); // 未設定: list → [], optional → undefined
API:
fromPrisma(value: Prisma{Model})— スカラー一括 set (型変換込み)<scalarField>(value)— 個別 setter (テスト fixture で Faker と組み合わせ向け)<relationField>(value)— 関連 setterprotected 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:
// shared-types/models.ts
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); // UserModel インスタンス
return { status: 200, body: user.toDto() };
},
UseCase / repository が Prisma 値取得 → <Model>.fromPrismaValue({ self, ...includes }) または <Model>.builder().fromPrisma(...).build() で Model 化 → controller が .toDto()。
C. Prisma 生値・Model インスタンス直接返却禁止
// ❌ Prisma 生値 (Date 型残留)
return { status: 200, body: await prisma.user.findUnique({ where: { id } }) };
// ❌ Model インスタンス (private field 露出, Date 残留)
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 # 全 generator (model + repository) を同時起動
schema.prisma 変更後は必ず実行。
生成ファイル編集禁止 (__generated__/ 配下は次回生成で消える)。
チェックリスト (新規ルート追加時)
-
index.tsのMethods型は実装と一致 - resBody は生成
<Model>ModelDto型を使用 — 自前で型再定義しない - 動的セグメントは
_name@type形式 (@stringor@number)、validators.tsで zod 検証 - handler は
defineController経由 (素の関数 export 禁止) - API 応答は Domain Model
.toDto()(or.to<Profile>Dto()) 経由 — Prisma 生値・Model インスタンス直接返却禁止 - Prisma → Model 変換は
<Model>Model.fromPrismaValue({ self, ...includes })または<Model>Model.builder().fromPrisma(...).<rel>(...).build()で行う - センシティブ field (
password等) は schema で/// @dto(hidden: true)指定済み - 用途別 DTO が必要な場合は
/// @dto.profile(name: X, pick/omit: [...])で生成し、自前型再定義しない -
Jsonフィールドの独自型は/// @json(type: [TypeName])+additionalTypePath経由 - auth 要否 →
hooks.tsで middleware 注入 (親 hooks.ts 継承確認) - hooks ロジックの重複コピペ禁止 — 共通認証/middleware は
backend-api/middleware/に切り出し、各hooks.tsはdefineHooks(() => ({ onRequest: <imported fn> }))薄ラッパで import 再利用 - 認証必須範囲が複数ルートに渡るときは、共通祖先ディレクトリに
hooks.tsを 1つだけ置いて階層継承で適用 (子孫ごとの個別hooks.ts不要) -
$server.ts/$relay.ts/ Prisma 生成__generated__/を手書き編集していない - schema.prisma 変更時は
prisma generate実行 (model + repository 同時生成) -
frourio実行 → 型エラー無し -
prismaと@prisma/clientのバージョンが一致 (>=7.2.0) - 新規 Model 追加時は共有 types エントリに
*ModelDto(+ profile DTO) を re-export 追加 - frontend 側で
aspidaクライアント経由呼び出し時に型補完が効く - frontend のデータ取得は
useFrourioSWRを使用 —useAspidaSWRは legacy fallback、useEffect+fetch禁止 - 条件付き fetch は
useFrourioSWRの endpoint 引数にnull/undefined/falseを渡す (enable 相当) - 依存値変化での再取得は
useFrourioSWRの query / params 経由 (キャッシュキー自動更新)