APIリソース
APIリソースは、モデルとAPIレスポンス間の変換レイヤーを提供します。データがJSONにシリアライズされる方法を細かく制御できます。
APIリソース
APIリソースは、モデルとAPIレスポンス間の変換レイヤーを提供します。データがJSONにシリアライズされる方法を細かく制御できます。
基本的な使い方
Resourceクラスを継承してリソースを作成します:
import { Resource } from '@guren/core'
import type { User } from '../Models/User'
export class UserResource extends Resource<User> {
toArray() {
return {
id: this.resource.id,
name: this.resource.name,
email: this.resource.email,
createdAt: this.resource.createdAt?.toISOString(),
}
}
}
コントローラーでの使用
import { Controller } from '@guren/core'
import { UserResource } from '../Resources/UserResource'
export default class UserController extends Controller {
async show(id: number) {
const user = await User.find(id)
return this.json({
data: new UserResource(user).toJSON(),
})
}
async index() {
const users = await User.all()
return this.json({
data: UserResource.collection(users),
})
}
}
条件付きフィールド
リソースは条件付きでフィールドを含めるためのヘルパーメソッドを提供します。
when()
条件がtrueの場合のみフィールドを含めます。
export class UserResource extends Resource<User> {
toArray() {
return {
id: this.resource.id,
name: this.resource.name,
// 認証済みユーザーのみメールを含める
email: this.when(this.resource.verified, this.resource.email),
// 計算値にはコールバックを使用
role: this.when(this.resource.isAdmin, () => 'admin'),
}
}
}
whenLoaded()
リレーションがロードされている場合のみフィールドを含めます。
export class PostResource extends Resource<Post> {
toArray() {
return {
id: this.resource.id,
title: this.resource.title,
// authorリレーションがロードされている場合のみ含める
author: this.whenLoaded('author', () => ({
id: this.resource.author?.id,
name: this.resource.author?.name,
})),
// ロード時にネストされたリソースを含める
comments: this.whenLoaded('comments', () =>
CommentResource.collection(this.resource.comments)
),
}
}
}
whenNotNull()
nullでない場合のみフィールドを含めます。
export class ProfileResource extends Resource<Profile> {
toArray() {
return {
id: this.resource.id,
bio: this.whenNotNull(this.resource.bio),
avatarUrl: this.whenNotNull(this.resource.avatarUrl),
}
}
}
whenOr()
デフォルト値付きでフィールドを含めます。
export class SettingsResource extends Resource<Settings> {
toArray() {
return {
theme: this.whenOr(
this.resource.theme !== undefined,
this.resource.theme,
'light' // デフォルト値
),
}
}
}
追加データ
リソースレスポンスに追加データを加えます。
const resource = new UserResource(user)
.additional({
permissions: ['read', 'write'],
meta: { version: '1.0' },
})
return this.json({ data: resource.toJSON() })
// { id: 1, name: 'John', ..., permissions: [...], meta: {...} }
リソースコレクション
モデルの配列をリソースに変換します。
// 静的メソッド
const users = await User.all()
const data = UserResource.collection(users)
// またはcollectヘルパーを使用
import { collect } from '@guren/core'
const data = collect(users, UserResource)
ページネーション
Guren は2つのページネーション戦略を提供します。
オフセットベースページネーション
ページ番号を使用した従来のページネーションです。
import { paginate, Paginator } from '@guren/core'
export default class UserController extends Controller {
async index() {
const page = Number(this.request.query('page') ?? 1)
const perPage = Number(this.request.query('per_page') ?? 15)
const result = await User.paginate({ page, perPage })
const paginator = paginate(result, {
path: '/api/users',
query: { per_page: String(result.meta.perPage) },
})
return this.json(paginator.toResource(UserResource))
}
}
レスポンス形式:
{
"data": [
{ "id": 1, "name": "John" },
{ "id": 2, "name": "Jane" }
],
"meta": {
"currentPage": 1,
"lastPage": 5,
"perPage": 15,
"total": 75,
"from": 1,
"to": 15
},
"links": {
"first": "/api/users?page=1&per_page=15",
"last": "/api/users?page=5&per_page=15",
"prev": null,
"next": "/api/users?page=2&per_page=15",
"pages": [
{ "page": 1, "url": "/api/users?page=1&per_page=15", "active": true },
{ "page": 2, "url": "/api/users?page=2&per_page=15", "active": false }
]
}
}
カーソルベースページネーション
無限スクロールやリアルタイムデータに最適です。
import { cursorPaginate, CursorPaginator } from '@guren/core'
export default class PostController extends Controller {
async index() {
const cursor = this.request.query('cursor')
const perPage = Number(this.request.query('per_page') ?? 20)
const posts = await Post.query()
.where('id', '>', decodeCursor(cursor) ?? 0)
.orderBy('id', 'asc')
.limit(perPage + 1)
.all()
const hasMore = posts.length > perPage
const items = hasMore ? posts.slice(0, perPage) : posts
const paginator = CursorPaginator.fromArray(items, cursor, perPage)
return this.json(paginator.toResource(PostResource))
}
}
レスポンス形式:
{
"data": [
{ "id": 101, "title": "Post 1" },
{ "id": 102, "title": "Post 2" }
],
"meta": {
"perPage": 20,
"nextCursor": "MTAy",
"prevCursor": null,
"hasMore": true
}
}
Paginatorメソッド
オフセットPaginator
| メソッド | 説明 |
|---|---|
items() |
ページネートされたアイテムを取得 |
total() |
総アイテム数を取得 |
perPage() |
ページあたりのアイテム数を取得 |
currentPage() |
現在のページ番号を取得 |
lastPage() |
最終ページ番号を取得 |
hasMorePages() |
次のページが存在するか確認 |
onFirstPage() |
最初のページか確認 |
onLastPage() |
最後のページか確認 |
firstItem() |
最初のアイテムインデックス(1ベース) |
lastItem() |
最後のアイテムインデックス(1ベース) |
meta() |
ページネーションメタデータを取得 |
links() |
ページネーションリンクを取得 |
withPath(path) |
ベースURLパスを設定 |
withQuery(query) |
クエリパラメータを追加 |
toResource(Class) |
リソースクラスで変換 |
toJSON() |
生のページネートレスポンスを取得 |
カーソルPaginator
| メソッド | 説明 |
|---|---|
items() |
ページネートされたアイテムを取得 |
perPage() |
ページあたりのアイテム数を取得 |
currentCursor() |
現在のカーソルを取得 |
nextCursor() |
次ページのカーソルを取得 |
prevCursor() |
前ページのカーソルを取得 |
hasMorePages() |
次のページが存在するか確認 |
meta() |
カーソルページネーションメタデータを取得 |
toResource(Class) |
リソースクラスで変換 |
JsonResource
カスタムクラスなしで簡単に変換できます。
import { JsonResource } from '@guren/core'
const user = { id: 1, name: 'John', password: 'secret' }
const resource = new JsonResource(user)
// 戻り値: { id: 1, name: 'John', password: 'secret' }
リソースの生成
CLIを使用して新しいリソースを生成します。
bunx guren make:resource User
# 作成: app/Http/Resources/UserResource.ts
Resource から API レスポンスを型付けする
guren codegen は各 Resource の形を .guren/data.gen.ts に抽出します(Data.Post、Data.User など)。Resource で応答するルートは、ルートコントラクトで Resource を指名するだけで、その形をレスポンス型として宣言できます。Zod スキーマも、フィールドの再記述も不要です:
router.query('/posts/search', {
name: 'posts.search',
body: PostSearchSchema,
resource: { data: [PostResource] },
}, [PostController, 'search'])
生成された API クライアントは、このルートの json() を { data: Data.Post[] } として型付けします。ヒントの書き方は Resource レスポンスヒントを参照してください。
コード生成が読む形を宣言する
抽出はソースコードレベルで行われるため、Resource は自分のファイル内にペイロードの型を明記する必要があります。注釈のない toArray() からオブジェクトリテラルを返すのは TypeScript としては正しいものの、コード生成はその形を読み取れません。make:resource が生成するとおり、クラス名にちなんだ interface を宣言して toArray() に注釈を付けてください:
export interface UserResourceData {
id: number
name: string
}
export class UserResource extends Resource<User, UserResourceData> {
toArray(): UserResourceData {
return { id: this.resource.id, name: this.resource.name }
}
}
2 つ目の型引数はペイロードの型で、これを渡すと toJSON() も同じ型を返します。省略した場合は Record<string, unknown> になるので、toJSON() の戻り値をそのままページや API クライアントに渡すなら指定してください。
interface は Resource 自身のファイルで宣言してください。共通の型モジュールから import したものは読み取られません。型を抽出できなかった Resource は黙って捨てられるのではなく guren codegen の警告で名指しされるので、Data.* が生成されない理由は必ず表示されます。
ペイロード型はプレーンな interface である必要はありません。本体をコピーできない形のエクスポート済みエイリアス、たとえば Zod スキーマ由来の型や交差型、宣言マージされた interface は、宣言そのものへの参照として出力されます。これにより、1 つのスキーマをランタイムのコントラクトとペイロード型の両方の単一の情報源にできます:
export const UserResourceSchema = z.object({ id: z.number(), name: z.string() })
export type UserResourceData = z.infer<typeof UserResourceSchema>
export class UserResource extends Resource<User> {
toArray(): UserResourceData {
return UserResourceSchema.parse(this.resource)
}
}
data.gen.ts は Resource のモジュール越しに宣言を名前で参照するため、宣言はエクスポートされている必要があります。また、参照には渡す型引数がないため、ジェネリック型はどちらの方式でも対象外です。
モジュール内の Resource
コード生成はプロジェクトルートの app/Http/Resources に加えて、各 modules/<name>/ の中も走査します。モジュールの Resource はモジュール名を冠した名前で出力され、modules/billing/app/Http/Resources/InvoiceResource.ts は Data.BillingInvoice になります。この修飾は衝突したときだけでなく常に付きます。そうすることで型名は「クラスがどこにあるか」だけで決まり、別の場所に2つ目の InvoiceResource を追加してもフロントエンドが既にインポートしている型名が変わることはありません。
レスポンスヒントが持つ情報は Resource のクラス名だけなので、2つのアプリルートが同じ InvoiceResource を宣言しているとヒントは解決できません。この場合はコード生成が両方のファイル名を挙げて警告し、どちらのモジュールのペイロードなのかを推測せず、そのルートのレスポンスを型無しのままにします。解消するにはどちらかのクラス名を変更してください。
ベストプラクティス
- リソースを焦点化 - モデル変換ごとに1つのリソース
- リレーションにはwhenLoadedを使用 - ロードされたリレーションのみ含めてN+1問題を防止
- 日付を一貫して変換 - 日付フィールドには
.toISOString()を使用 - 機密データを隠す - パスワード、トークン、内部IDを公開しない
- 大規模データセットにはカーソルページネーション - オフセットより高パフォーマンス