API Resources
API Resources provide a transformation layer between your models and API responses. They give you fine-grained control over how data is serialized to JSON.
API Resources
API Resources provide a transformation layer between your models and API responses. They give you fine-grained control over how data is serialized to JSON.
Basic Usage
Create a resource by extending the Resource class:
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(),
}
}
}
Using Resources in Controllers
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),
})
}
}
Conditional Fields
Resources provide helper methods for conditionally including fields.
when()
Include a field only when a condition is true:
export class UserResource extends Resource<User> {
toArray() {
return {
id: this.resource.id,
name: this.resource.name,
// Only include email for verified users
email: this.when(this.resource.verified, this.resource.email),
// Use a callback for computed values
role: this.when(this.resource.isAdmin, () => 'admin'),
}
}
}
whenLoaded()
Include a field only when a relation is loaded:
export class PostResource extends Resource<Post> {
toArray() {
return {
id: this.resource.id,
title: this.resource.title,
// Only include if author relation is loaded
author: this.whenLoaded('author', () => ({
id: this.resource.author?.id,
name: this.resource.author?.name,
})),
// Include nested resource when loaded
comments: this.whenLoaded('comments', () =>
CommentResource.collection(this.resource.comments)
),
}
}
}
whenNotNull()
Include a field only if it's not 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()
Include a field with a default value:
export class SettingsResource extends Resource<Settings> {
toArray() {
return {
theme: this.whenOr(
this.resource.theme !== undefined,
this.resource.theme,
'light' // default value
),
}
}
}
Additional Data
Add extra data to the resource response:
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: {...} }
Resource Collections
Transform an array of models into resources:
// Static method
const users = await User.all()
const data = UserResource.collection(users)
// Or use the collect helper
import { collect } from '@guren/core'
const data = collect(users, UserResource)
Pagination
Guren provides two pagination strategies.
Offset-Based Pagination
Traditional pagination with page numbers:
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))
}
}
Response format:
{
"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 }
]
}
}
Cursor-Based Pagination
Ideal for infinite scroll and real-time data:
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))
}
}
Response format:
{
"data": [
{ "id": 101, "title": "Post 1" },
{ "id": 102, "title": "Post 2" }
],
"meta": {
"perPage": 20,
"nextCursor": "MTAy",
"prevCursor": null,
"hasMore": true
}
}
Paginator Methods
Offset Paginator
| Method | Description |
|---|---|
items() |
Get paginated items |
total() |
Get total item count |
perPage() |
Get items per page |
currentPage() |
Get current page number |
lastPage() |
Get last page number |
hasMorePages() |
Check if more pages exist |
onFirstPage() |
Check if on first page |
onLastPage() |
Check if on last page |
firstItem() |
Get first item index (1-based) |
lastItem() |
Get last item index (1-based) |
meta() |
Get pagination metadata |
links() |
Get pagination links |
withPath(path) |
Set base URL path |
withQuery(query) |
Add query parameters |
toResource(Class) |
Transform with resource class |
toJSON() |
Get raw paginated response |
Cursor Paginator
| Method | Description |
|---|---|
items() |
Get paginated items |
perPage() |
Get items per page |
currentCursor() |
Get current cursor |
nextCursor() |
Get next page cursor |
prevCursor() |
Get previous page cursor |
hasMorePages() |
Check if more pages exist |
meta() |
Get cursor pagination metadata |
toResource(Class) |
Transform with resource class |
JsonResource
For simple transformations without a custom class:
import { JsonResource } from '@guren/core'
const user = { id: 1, name: 'John', password: 'secret' }
const resource = new JsonResource(user)
// Returns: { id: 1, name: 'John', password: 'secret' }
Generating Resources
Use the CLI to generate a new resource:
bunx guren make:resource User
# Creates: app/Http/Resources/UserResource.ts
Typing API Responses from Resources
guren codegen extracts each Resource's shape into .guren/data.gen.ts (as Data.Post, Data.User, …). A route that answers with a Resource can declare that shape as its response type — no Zod schema, no restating the fields — by naming the Resource in its route contract:
router.query('/posts/search', {
name: 'posts.search',
body: PostSearchSchema,
resource: { data: [PostResource] },
}, [PostController, 'search'])
The generated API client then types json() for that route as { data: Data.Post[] }. See Resource Response Hints for the hint syntax.
Declaring the shape codegen reads
Extraction is source-level, so a Resource has to state its payload type in its own file — an object literal returned from an unannotated toArray() is correct TypeScript that codegen cannot read. Declare an interface named after the class and annotate toArray() with it, which is what make:resource scaffolds:
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 }
}
}
The second type argument is the payload type, and passing it makes toJSON() report the same type. It defaults to Record<string, unknown>, so declare it whenever the result of toJSON() is handed straight to a page or an API client.
The interface must be declared in the resource's own file — one imported from a shared types module is not read. A Resource codegen cannot extract a type from is named in a guren codegen warning rather than dropped in silence, so a missing Data.* member always says why.
The payload type does not have to be a plain interface. An exported alias whose shape codegen cannot copy — one derived from a Zod schema, an intersection, a merged interface — is emitted as a reference to the declaration itself, so one schema can be the single source of truth for the runtime contract and the payload type alike:
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)
}
}
The declaration must be exported — data.gen.ts names it through the resource's module — and a generic type stays unsupported either way, since a reference has no type arguments to pass it.
Resources inside modules
Codegen scans app/Http/Resources at the project root and inside every modules/<name>/ directory. A module's Resource is emitted under a name qualified with its module, so modules/billing/app/Http/Resources/InvoiceResource.ts becomes Data.BillingInvoice. The qualifier is always applied, never only on collision — that way a type's name depends solely on where its class lives, and adding a second InvoiceResource elsewhere cannot rename one the frontend already imports.
A response hint carries only the Resource's class name, so two app roots that both declare an InvoiceResource make the hint unresolvable: codegen warns, naming both files, and leaves that route's response untyped rather than guessing which module's payload the route returns. Rename one of the classes to resolve it.
Best Practices
- Keep resources focused - One resource per model transformation
- Use whenLoaded for relations - Prevents N+1 issues by only including loaded relations
- Transform dates consistently - Use
.toISOString()for date fields - Hide sensitive data - Never expose passwords, tokens, or internal IDs
- Use cursor pagination for large datasets - Better performance than offset pagination