API Resources
- Introduction
- Defining a Resource
- Writing Resources
- Resource Collections
- Data Wrapping
- Top-Level Metadata
- Response Headers
- Binding Resources to Models
- JSON:API Resources
Introduction
API resources provide a transformation layer between your models and the JSON responses returned to API clients. Instead of returning models directly, resources let you control the exact shape of the response — including which fields are exposed, how relationships are nested, and what metadata is included.
Defining a Resource
Extend Resource<T> and implement toArray():
import { Resource } from '@wrsouza/orion';
class UserResource extends Resource<User> {
toArray(): Record<string, unknown> {
return {
id: this.resource.id,
name: this.resource.name,
email: this.resource.email,
created_at: this.resource.created_at,
};
}
}Resolve a single resource:
const user = await User.findOrFail(1);
new UserResource(user).toResponse();
// { data: { id: 1, name: 'Alice', email: 'alice@example.com', created_at: '...' } }
new UserResource(user).resolve();
// { id: 1, name: 'Alice', email: '...', created_at: '...' } — no data: wrapperWriting Resources
Conditional Attributes
Include a field only when a condition is true:
class UserResource extends Resource<User> {
toArray(): Record<string, unknown> {
return {
id: this.resource.id,
name: this.resource.name,
// Only include email if the user is an admin
email: this.when(this.resource.is_admin, this.resource.email),
// Lazy — closure is only called when condition is true
token: this.when(this.resource.is_admin, () => this.resource.generateToken()),
};
}
}whenNotNull — include only when the value is not null:
bio: this.whenNotNull(this.resource.bio),
// or with a different value to include:
bio: this.whenNotNull(this.resource.bio, this.resource.bio.toUpperCase()),whenHas — include if the attribute exists on the model:
score: this.whenHas('score'),
// Useful for optional columns that may not be selected in all queriesConditional Relationships
Include a relationship only when it has been eager-loaded (prevents N+1 when the relation is not loaded):
class UserResource extends Resource<User> {
toArray(): Record<string, unknown> {
return {
id: this.resource.id,
name: this.resource.name,
posts: this.whenLoaded('posts', () =>
PostResource.collection(this.resource.getRelation('posts'))
),
profile: this.whenLoaded('profile', () =>
new ProfileResource(this.resource.getRelation('profile'))
),
};
}
}If posts was not eager-loaded, the posts key is omitted entirely from the output.
Conditional Aggregates
Include aggregate columns only when they were loaded via withCount, withSum, etc.:
class UserResource extends Resource<User> {
toArray(): Record<string, unknown> {
return {
id: this.resource.id,
posts_count: this.whenCounted('posts'),
orders_sum: this.whenAggregated('orders', 'amount', 'sum'),
orders_avg: this.whenAggregated('orders', 'amount', 'avg'),
};
}
}Conditional Pivot Data
Include pivot data only when the model was loaded through a pivot relationship:
class RoleResource extends Resource<Role> {
toArray(): Record<string, unknown> {
return {
id: this.resource.id,
name: this.resource.name,
// pivot data available when loaded via belongsToMany
membership: this.whenPivotLoaded('role_user', () => ({
approved: this.resource.getRelation<any>('pivot').get('approved'),
assigned_at: this.resource.getRelation<any>('pivot').get('assigned_at'),
})),
// using a custom pivot alias set with .as('membership')
subscription: this.whenPivotLoadedAs('subscription', 'role_user', () => ({
status: this.resource.getRelation<any>('subscription').get('status'),
})),
};
}
}Merging Conditional Data
Merge a set of key/value pairs conditionally:
class UserResource extends Resource<User> {
toArray(): Record<string, unknown> {
return {
id: this.resource.id,
name: this.resource.name,
...this.mergeWhen(this.resource.is_admin, {
admin_notes: this.resource._attributes.admin_notes,
last_login_ip: this.resource._attributes.last_login_ip,
}),
};
}
}Resource Collections
Static collection() method
const users = await User.all();
UserResource.collection(users).toResponse();
// { data: [ { id: 1, ... }, { id: 2, ... } ] }Custom ResourceCollection
Extend ResourceCollection for more control:
import { ResourceCollection } from '@wrsouza/orion';
class UserCollection extends ResourceCollection<User> {
$collects = UserResource; // the resource class for each item
}
new UserCollection(users).toResponse();Pagination Metadata
const page = await User.paginate(15);
UserResource.collection(page.data)
.additional({ meta: { total: page.total, last_page: page.lastPage } })
.toResponse();Or override paginationInformation in a custom collection:
class UserCollection extends ResourceCollection<User> {
$collects = UserResource;
paginationInformation(paginated: any): Record<string, unknown> {
return {
links: {
first: `/users?page=1`,
last: `/users?page=${paginated.lastPage}`,
next: paginated.hasMorePages ? `/users?page=${paginated.currentPage + 1}` : null,
},
meta: {
total: paginated.total,
per_page: paginated.perPage,
current_page: paginated.currentPage,
last_page: paginated.lastPage,
},
};
}
}Data Wrapping
All resources are wrapped in a data key by default:
new UserResource(user).toResponse();
// { data: { id: 1, ... } }Disable wrapping globally:
Resource.withoutWrapping();
// All resources now return unwrapped objects: { id: 1, ... }Top-Level Metadata
additional() — runtime
Merge additional top-level keys into the response:
new UserResource(user)
.additional({ meta: { api_version: 2, server: 'us-east-1' } })
.toResponse();
// { data: { id: 1, ... }, meta: { api_version: 2, server: 'us-east-1' } }with() — declarative in subclass
Override in the resource class for static top-level metadata:
class UserResource extends Resource<User> {
with(): Record<string, unknown> {
return { api_version: 2 };
}
toArray(): Record<string, unknown> {
return { id: this.resource.id, name: this.resource.name };
}
}
new UserResource(user).toResponse();
// { data: { id: 1, name: 'Alice' }, api_version: 2 }Response Headers
Attach custom HTTP headers to a resource response:
const response = new UserResource(user)
.withResponseHeaders({ 'X-User-Version': '2', 'Cache-Control': 'no-store' })
.response();
// response.data — the resource payload
// response.headers — { 'X-User-Version': '2', ... }Binding Resources to Models
Bind a resource class to a model so it can auto-resolve itself:
import { UseResource, UseResourceCollection } from '@wrsouza/orion';
@UseResource(UserResource)
@UseResourceCollection(UserCollection)
@table('users')
class User extends Model {}
// On a model instance
const user = await User.findOrFail(1);
user.toResource(); // returns new UserResource(user)
user.toResource().toResponse(); // { data: { ... } }
// On a ModelCollection
const users = await User.all();
users.toResourceCollection().toResponse(); // { data: [ ... ] }JSON:API Resources
JsonApiResource<T> produces JSON:API 1.1 compliant documents with correct type, id, attributes, and relationships structure.
Basic Usage
import { JsonApiResource } from '@wrsouza/orion';
class UserResource extends JsonApiResource<User> {
$type = 'users';
$attributes = ['name', 'email', 'created_at'];
$relationships = ['posts'];
}
new UserResource(user).toResponse();
// {
// data: {
// type: 'users',
// id: '1',
// attributes: { name: 'Alice', email: 'alice@example.com', created_at: '...' },
// relationships: { posts: { data: [{ type: 'posts', id: '1' }] } }
// }
// }Defining Attributes and Relationships
Override toAttributes() and toRelationships() for full control:
class UserResource extends JsonApiResource<User> {
$type = 'users';
toType(): string {
return 'users';
}
toId(): string {
return String(this.resource.id);
}
toAttributes(): Record<string, unknown> {
return {
name: this.resource.name,
email: this.resource.email,
created_at: this.resource.created_at,
};
}
toRelationships(): Record<string, unknown> {
return {
posts: PostResource.jsonApiCollection(
this.resource.getRelation<any>('posts') ?? []
),
};
}
}Sparse Fieldsets and Includes
Pass a request context to enable JSON:API sparse fieldsets (?fields[users]=name,email) and includes (?include=posts):
const ctx = {
fields: { users: ['name', 'email'] },
include: ['posts'],
};
new UserResource(user, ctx).toResponse();
// Only 'name' and 'email' returned in attributes; posts relationship includedEnable query-string parsing automatically (opt-in per resource class):
class UserResource extends JsonApiResource<User> {
$type = 'users';
$attributes = ['name', 'email'];
constructor(resource: User, ctx?: any) {
super(resource, ctx);
this.includePreviouslyLoadedRelationships();
}
}Links and Meta
class UserResource extends JsonApiResource<User> {
$type = 'users';
$attributes = ['name'];
toLinks(): Record<string, string> {
return { self: `/api/users/${this.resource.id}` };
}
toMeta(): Record<string, unknown> {
return { version: 1, last_updated: this.resource.updated_at };
}
}Collection Resources
import { JsonApiCollectionResource } from '@wrsouza/orion';
const users = await User.with('posts').get();
JsonApiCollectionResource.make(UserResource, users).toResponse();
// {
// data: [
// { type: 'users', id: '1', attributes: { ... }, relationships: { ... } },
// ...
// ]
// }Global depth limit:
JsonApiResource.maxRelationshipDepth(2); // default: 3