API Resources
API Resources provide a transformation layer between your database models and the JSON responses your API returns. They decouple your database schema from your API contract, letting you control exactly which fields are exposed, how they're named, and under what conditions they appear.
Basic Usage
Derive Macro
The simplest way to create a resource is with #[derive(ApiResource)]:
#![allow(unused)] fn main() { use ferro::ApiResource; #[derive(ApiResource)] pub struct UserResource { pub id: i32, pub name: String, pub email: String, } }
This generates a Resource trait implementation that serializes id, name, and email into a JSON object.
Model Conversion
Link a resource to a database model with the model attribute to generate a From<Model> implementation:
#![allow(unused)] fn main() { use ferro::ApiResource; #[derive(ApiResource)] #[resource(model = "crate::models::entities::users::Model")] pub struct UserResource { pub id: i32, pub name: String, pub email: String, #[resource(skip)] pub password: String, #[resource(skip)] pub remember_token: Option<String>, #[resource(skip)] pub created_at: String, #[resource(skip)] pub updated_at: String, } }
The struct must include all fields from the model. Use #[resource(skip)] to exclude fields from JSON output while keeping them available programmatically.
Field Attributes
| Attribute | Effect | Example |
|---|---|---|
#[resource(skip)] | Exclude field from JSON output | Passwords, internal tokens |
#[resource(rename = "display_name")] | Use a different key in JSON | API naming conventions |
#![allow(unused)] fn main() { use ferro::ApiResource; #[derive(ApiResource)] pub struct ProfileResource { pub id: i32, #[resource(rename = "display_name")] pub name: String, pub email: String, #[resource(skip)] pub password_hash: String, } }
Output: {"id": 1, "display_name": "Alice", "email": "alice@example.com"}
ResourceMap Builder
For conditional fields or complex logic, implement the Resource trait manually using ResourceMap:
#![allow(unused)] fn main() { use ferro::{Resource, ResourceMap, Request}; use serde_json::json; struct UserResource { id: i32, name: String, email: String, is_admin: bool, internal_notes: Option<String>, } impl Resource for UserResource { fn to_resource(&self, _req: &Request) -> serde_json::Value { ResourceMap::new() .field("id", json!(self.id)) .field("name", json!(self.name)) .when("email", self.is_admin, || json!(self.email)) .when_some("notes", &self.internal_notes) .merge_when(self.is_admin, || vec![ ("role", json!("admin")), ("permissions", json!(["read", "write", "delete"])), ]) .build() } } }
Builder Methods
| Method | Description |
|---|---|
field(key, value) | Always include this field |
when(key, condition, value_fn) | Include only when condition is true |
unless(key, condition, value_fn) | Include only when condition is false |
when_some(key, option) | Include only when Option is Some |
merge_when(condition, fields_fn) | Conditionally include multiple fields |
All methods preserve insertion order in the output.
Response Helpers
The Resource trait provides response methods for common patterns:
#![allow(unused)] fn main() { use ferro::Resource; // Direct JSON response let response = resource.to_response(&req); // Output: {"id": 1, "name": "Alice", "email": "alice@example.com"} // Wrapped in data envelope let response = resource.to_wrapped_response(&req); // Output: {"data": {"id": 1, "name": "Alice", "email": "alice@example.com"}} // Wrapped with additional top-level fields let response = resource.to_response_with(&req, json!({"meta": {"version": "v1"}})); // Output: {"data": {"id": 1, ...}, "meta": {"version": "v1"}} }
| Method | Output Shape |
|---|---|
to_response(&req) | {fields...} |
to_wrapped_response(&req) | {"data": {fields...}} |
to_response_with(&req, extra) | {"data": {fields...}, ...extra} |
Handler Integration
Use resources in handlers by converting models and calling response helpers:
#![allow(unused)] fn main() { use ferro::{handler, Auth, HttpResponse, Request, Resource, Response}; use crate::resources::UserResource; use crate::models::users; #[handler] pub async fn profile(req: Request) -> Response { let user = Auth::user_as::<users::Model>() .await? .ok_or_else(|| HttpResponse::json( serde_json::json!({"message": "Unauthenticated."}) ).status(401))?; let resource = UserResource::from(user); Ok(resource.to_wrapped_response(&req)) } }
The From<Model> implementation (generated by the model attribute) handles the conversion. The to_wrapped_response method produces a {"data": {...}} envelope.
CLI Scaffolding
Generate a new resource with the CLI:
# Basic resource
ferro make:resource UserResource
# With model attribute for From<Model> generation
ferro make:resource UserResource --model entities::users::Model
# Name without "Resource" suffix is auto-appended
ferro make:resource User
# Creates UserResource in src/resources/user_resource.rs
The generated file includes the derive macro template with commented examples for rename and skip attributes.
When to Use
Derive macro (#[derive(ApiResource)]):
- Simple field selection from a model
- Static field renaming
- Excluding sensitive fields (passwords, tokens)
Manual ResourceMap:
- Conditional fields based on user role or request context
- Computed fields not present in the model
- Merging data from multiple sources
- Dynamic field inclusion logic
Resource Collections
ResourceCollection wraps a Vec<T: Resource> and produces a standard JSON envelope. Use it for any endpoint returning a list of resources.
Simple Collection
#![allow(unused)] fn main() { use ferro::{handler, Request, Resource, ResourceCollection, Response}; use crate::resources::UserResource; #[handler] pub async fn index(req: Request) -> Response { let db = req.db(); let users = User::find().all(db).await?; let resources: Vec<UserResource> = users.into_iter() .map(UserResource::from) .collect(); let collection = ResourceCollection::new(resources); Ok(collection.to_response(&req)) } }
Output:
{
"data": [
{"id": 1, "name": "Alice", "email": "alice@example.com"},
{"id": 2, "name": "Bob", "email": "bob@example.com"}
]
}
Additional Metadata
Add extra top-level fields alongside data with .additional():
#![allow(unused)] fn main() { let collection = ResourceCollection::new(resources) .additional(json!({"meta": {"version": "v1"}})); Ok(collection.to_response(&req)) // Output: {"data": [...], "meta": {"version": "v1"}} }
Collection Mapping Shortcut
Resource::collection() maps a slice of resources to their JSON representations without constructing a full ResourceCollection:
#![allow(unused)] fn main() { let users: Vec<UserResource> = /* ... */; let json_values = UserResource::collection(&users, &req); // Returns: Vec<serde_json::Value> }
| Constructor | Output |
|---|---|
ResourceCollection::new(items) | {"data": [...]} |
ResourceCollection::paginated(items, meta) | {"data": [...], "meta": {...}, "links": {...}} |
.additional(json!({...})) | Merges fields at top level |
Pagination
PaginationMeta computes page metadata and ResourceCollection::paginated() produces the standard paginated envelope. Integrates with SeaORM's PaginatorTrait.
PaginationMeta
#![allow(unused)] fn main() { use ferro::PaginationMeta; let meta = PaginationMeta::new(page, per_page, total); }
PaginationMeta::new() accepts a 1-indexed page number (the value from API query parameters). It computes last_page, from, and to automatically. SeaORM's fetch_page() is 0-indexed -- pass page - 1 to SeaORM and the raw page to PaginationMeta.
Paginated Handler
#![allow(unused)] fn main() { use ferro::{handler, PaginationMeta, Request, Resource, ResourceCollection, Response}; use sea_orm::PaginatorTrait; use crate::resources::UserResource; #[handler] pub async fn index(req: Request) -> Response { let db = req.db(); let page: u64 = req.query("page").unwrap_or(1); let per_page: u64 = req.query("per_page").unwrap_or(15); let paginator = User::find() .order_by_desc(users::Column::Id) .paginate(db, per_page); let items = paginator.fetch_page(page - 1).await?; // 0-indexed let total = paginator.num_items().await?; let resources: Vec<UserResource> = items.into_iter() .map(UserResource::from) .collect(); let meta = PaginationMeta::new(page, per_page, total); // 1-indexed Ok(ResourceCollection::paginated(resources, meta).to_response(&req)) } }
JSON Output Format
{
"data": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}],
"meta": {
"current_page": 1,
"per_page": 15,
"total": 42,
"last_page": 3,
"from": 1,
"to": 15
},
"links": {
"first": "/users?page=1",
"last": "/users?page=3",
"prev": null,
"next": "/users?page=2"
}
}
Pagination links are relative URLs. Existing query parameters (e.g., sort=name) are preserved in links.
Relationship Inclusion
Ferro uses explicit batch-loading for relationships. All related data is loaded before resource construction -- never inside to_resource(). This prevents N+1 queries by design.
when_loaded (belongs_to / has_one)
when_loaded() looks up a key in a HashMap. If the key exists, the field is included in the output. If absent, the field is omitted.
#![allow(unused)] fn main() { use ferro::{Resource, ResourceMap, Request}; use std::collections::HashMap; use serde_json::{json, Value}; struct PostResource { post: posts::Model, authors: HashMap<i32, users::Model>, } impl Resource for PostResource { fn to_resource(&self, req: &Request) -> Value { ResourceMap::new() .field("id", json!(self.post.id)) .field("title", json!(self.post.title)) .when_loaded("author", &self.post.author_id, &self.authors, |user| { json!({"id": user.id, "name": &user.name}) }) .build() } } }
when_loaded_many (has_many)
when_loaded_many() operates on HashMap<K, Vec<M>>. An empty vec is still included (loaded but empty); a missing key means the field is omitted entirely.
#![allow(unused)] fn main() { struct UserResource { user: users::Model, posts: HashMap<i32, Vec<posts::Model>>, } impl Resource for UserResource { fn to_resource(&self, req: &Request) -> Value { ResourceMap::new() .field("id", json!(self.user.id)) .field("name", json!(self.user.name)) .when_loaded_many("posts", &self.user.id, &self.posts, |items| { json!(items.iter().map(|p| { json!({"id": p.id, "title": &p.title}) }).collect::<Vec<_>>()) }) .build() } } }
Complete Paginated Handler with Relationships
#![allow(unused)] fn main() { use ferro::{handler, PaginationMeta, Request, Resource, ResourceCollection, ResourceMap, Response}; use sea_orm::PaginatorTrait; use std::collections::HashMap; use serde_json::{json, Value}; struct UserWithPostsResource { user: users::Model, posts_map: HashMap<i32, Vec<posts::Model>>, } impl Resource for UserWithPostsResource { fn to_resource(&self, _req: &Request) -> Value { ResourceMap::new() .field("id", json!(self.user.id)) .field("name", json!(self.user.name)) .when_loaded_many("posts", &self.user.id, &self.posts_map, |items| { json!(items.iter().map(|p| { json!({"id": p.id, "title": &p.title}) }).collect::<Vec<_>>()) }) .build() } } #[handler] pub async fn index(req: Request) -> Response { let db = req.db(); let page: u64 = req.query("page").unwrap_or(1); let per_page: u64 = 15; // 1. Paginate parent entity let paginator = User::find() .order_by_asc(users::Column::Id) .paginate(db, per_page); let users = paginator.fetch_page(page - 1).await?; let total = paginator.num_items().await?; // 2. Batch load relations for this page let user_ids: Vec<i32> = users.iter().map(|u| u.id).collect(); let posts_map: HashMap<i32, Vec<posts::Model>> = Post::find() .filter(posts::Column::UserId.is_in(user_ids)) .all(db) .await? .into_iter() .fold(HashMap::new(), |mut map, post| { map.entry(post.user_id).or_default().push(post); map }); // 3. Map to resources with relations let resources: Vec<UserWithPostsResource> = users.into_iter() .map(|u| UserWithPostsResource { user: u, posts_map: posts_map.clone(), }) .collect(); // 4. Return paginated collection let meta = PaginationMeta::new(page, per_page, total); Ok(ResourceCollection::paginated(resources, meta).to_response(&req)) } }
Anti-Patterns
N+1 inside to_resource(): Never call database queries inside to_resource(). All data must be loaded before resource construction.
#![allow(unused)] fn main() { // BAD: queries the database for every resource impl Resource for UserResource { fn to_resource(&self, req: &Request) -> Value { let posts = Post::find() // N+1 query! .filter(posts::Column::UserId.eq(self.user.id)) .all(db).await; // ... } } }
Paginating joined queries: SeaORM's find_with_related() (SelectTwoMany) does not support .paginate(). Always paginate the parent entity first, then batch-load relations for the fetched page.
#![allow(unused)] fn main() { // BAD: won't compile let results = User::find() .find_with_related(Post) .paginate(db, 15); // SelectTwoMany has no PaginatorTrait // GOOD: paginate parent, then batch load let users = User::find().paginate(db, 15).fetch_page(0).await?; // Then load posts for these users in a second query }