API Design
Stacks.js provides tools and conventions for building well-designed, consistent, and developer-friendly APIs. This guide covers RESTful patterns, versioning, pagination, rate limiting, and API documentation.
Protocol context — This guide covers Stacks.js API resources and routing in relation to the draft API representation contract. The APIs here are implementation-specific.
RESTful Conventions
Resource Naming
// routes/api.ts
import { Router } from '@stacksjs/router'
const router = Router.prefix('/api')
// Resources use plural nouns
router.resource('users', UserController)
router.resource('posts', PostController)
router.resource('comments', CommentController)
// Nested resources
router.resource('posts.comments', PostCommentController)
// Creates: /api/posts/:postId/comments
// Custom actions
router.post('posts/:id/publish', PostController.publish)
router.post('posts/:id/archive', PostController.archive)
HTTP Methods
// GET - Retrieve resources
router.get('/users', UserController.index) // List users
router.get('/users/:id', UserController.show) // Get single user
// POST - Create resources
router.post('/users', UserController.store) // Create user
// PUT/PATCH - Update resources
router.put('/users/:id', UserController.update) // Full update
router.patch('/users/:id', UserController.patch) // Partial update
// DELETE - Remove resources
router.delete('/users/:id', UserController.destroy) // Delete user
Response Status Codes
// app/Controllers/Api/UserController.ts
export default class UserController extends Controller {
// GET /users - 200 OK
async index() {
const users = await User.paginate()
return response(users, 200)
}
// POST /users - 201 Created
async store(request: Request) {
const user = await User.create(request.validated())
return response(user, 201)
}
// GET /users/:id - 200 OK or 404 Not Found
async show(request: Request) {
const user = await User.findOrFail(request.params.id)
return response(user, 200)
}
// PUT /users/:id - 200 OK
async update(request: Request) {
const user = await User.findOrFail(request.params.id)
await user.update(request.validated())
return response(user, 200)
}
// DELETE /users/:id - 204 No Content
async destroy(request: Request) {
const user = await User.findOrFail(request.params.id)
await user.delete()
return response(null, 204)
}
}
Response Format
// Consistent response structure
// app/Support/ApiResponse.ts
export class ApiResponse {
static success(data: any, meta?: object) {
return {
success: true,
data,
meta,
}
}
static error(message: string, errors?: object, code?: string) {
return {
success: false,
error: {
message,
code,
errors,
},
}
}
static paginated(data: any, pagination: object) {
return {
success: true,
data,
meta: {
pagination,
},
}
}
}
// Usage
return ApiResponse.success(user)
// {
// "success": true,
// "data": { "id": 1, "name": "John" }
// }
return ApiResponse.error('Validation failed', errors, 'VALIDATION_ERROR')
// {
// "success": false,
// "error": {
// "message": "Validation failed",
// "code": "VALIDATION_ERROR",
// "errors": { "email": ["Invalid email"] }
// }
// }
API Versioning
URL Versioning
// routes/api.ts
const v1 = Router.prefix('/api/v1')
const v2 = Router.prefix('/api/v2')
// V1 routes
v1.resource('users', V1.UserController)
// V2 routes with updated behavior
v2.resource('users', V2.UserController)
// Version detection middleware
router.use(ApiVersionMiddleware)
Header Versioning
// middleware/ApiVersion.ts
export class ApiVersionMiddleware extends Middleware {
handle(request: Request, next: Function) {
const version = request.header('API-Version') || 'v1'
request.apiVersion = version
return next()
}
}
// Controller using version
export default class UserController extends Controller {
async show(request: Request) {
const user = await User.find(request.params.id)
if (request.apiVersion === 'v2') {
return this.transformV2(user)
}
return this.transformV1(user)
}
}
Version Deprecation
// middleware/DeprecatedVersion.ts
export class DeprecatedVersionMiddleware extends Middleware {
handle(request: Request, next: Function) {
const response = next()
// Add deprecation headers
response.headers.set('Deprecation', 'true')
response.headers.set('Sunset', 'Sat, 01 Jan 2025 00:00:00 GMT')
response.headers.set('Link', '</api/v2/users>; rel="successor-version"')
return response
}
}
// Apply to deprecated routes
v1.use(DeprecatedVersionMiddleware)
Pagination
Offset Pagination
// Controller
async index(request: Request) {
const page = request.query('page', 1)
const perPage = request.query('per_page', 20)
const users = await User.paginate(page, perPage)
return response({
data: users.data,
meta: {
pagination: {
total: users.total,
count: users.data.length,
per_page: perPage,
current_page: page,
total_pages: users.lastPage,
links: {
previous: users.previousPageUrl,
next: users.nextPageUrl,
},
},
},
})
}
Cursor Pagination
// Better for large datasets
async index(request: Request) {
const cursor = request.query('cursor')
const limit = request.query('limit', 20)
const users = await User
.orderBy('id')
.cursorPaginate(limit, cursor)
return response({
data: users.data,
meta: {
cursor: {
next: users.nextCursor,
previous: users.previousCursor,
has_more: users.hasMore,
},
},
})
}
Pagination Links
// Include HATEOAS links
{
"data": [...],
"links": {
"self": "/api/users?page=2",
"first": "/api/users?page=1",
"prev": "/api/users?page=1",
"next": "/api/users?page=3",
"last": "/api/users?page=10"
},
"meta": {
"current_page": 2,
"from": 21,
"to": 40,
"per_page": 20,
"total": 200,
"last_page": 10
}
}
Filtering & Sorting
Query Parameters
// GET /api/users?status=active&role=admin&sort=-created_at
async index(request: Request) {
let query = User.query()
// Filtering
if (request.has('status')) {
query = query.where('status', request.query('status'))
}
if (request.has('role')) {
query = query.where('role', request.query('role'))
}
if (request.has('search')) {
query = query.where('name', 'like', `%${request.query('search')}%`)
}
// Sorting (- prefix for descending)
const sort = request.query('sort', 'created_at')
const direction = sort.startsWith('-') ? 'desc' : 'asc'
const field = sort.replace(/^-/, '')
query = query.orderBy(field, direction)
return response(await query.paginate())
}
Filter Builder
// app/Support/QueryFilter.ts
export class QueryFilter {
constructor(
private query: any,
private request: Request,
private allowedFilters: string[],
) {}
apply() {
for (const filter of this.allowedFilters) {
if (this.request.has(filter)) {
this.query = this.query.where(filter, this.request.query(filter))
}
}
return this.query
}
}
// Controller
async index(request: Request) {
const query = new QueryFilter(User.query(), request, [
'status',
'role',
'department_id',
]).apply()
return response(await query.paginate())
}
Advanced Filtering
// GET /api/users?filter[status]=active&filter[created_at][gte]=2024-01-01
async index(request: Request) {
let query = User.query()
const filters = request.query('filter', {})
for (const [field, value] of Object.entries(filters)) {
if (typeof value === 'object') {
// Operator filters: { gte: '2024-01-01' }
for (const [operator, operand] of Object.entries(value)) {
query = this.applyOperator(query, field, operator, operand)
}
} else {
// Simple equality
query = query.where(field, value)
}
}
return response(await query.paginate())
}
applyOperator(query: any, field: string, operator: string, value: any) {
const operators = {
eq: '=',
ne: '!=',
gt: '>',
gte: '>=',
lt: '<',
lte: '<=',
like: 'like',
in: 'in',
}
if (operator === 'in') {
return query.whereIn(field, value.split(','))
}
return query.where(field, operators[operator], value)
}
Rate Limiting
API Rate Limits
// config/api.ts
export default {
rateLimit: {
// Default limits
default: {
requests: 60,
window: 60, // seconds
},
// Per-tier limits
tiers: {
free: { requests: 60, window: 60 },
pro: { requests: 600, window: 60 },
enterprise: { requests: 6000, window: 60 },
},
},
}
// middleware/ApiRateLimit.ts
export class ApiRateLimitMiddleware extends Middleware {
async handle(request: Request, next: Function) {
const user = request.user
const tier = user?.subscription?.tier || 'free'
const limits = config(`api.rateLimit.tiers.${tier}`)
const key = user ? `api:${user.id}` : `api:${request.ip}`
const current = await RateLimiter.get(key)
if (current >= limits.requests) {
return response({
error: {
message: 'Rate limit exceeded',
code: 'RATE_LIMIT_EXCEEDED',
retry_after: await RateLimiter.retryAfter(key),
},
}, 429)
}
await RateLimiter.increment(key, limits.window)
const response = await next()
// Add rate limit headers
response.headers.set('X-RateLimit-Limit', limits.requests)
response.headers.set('X-RateLimit-Remaining', limits.requests - current - 1)
response.headers.set('X-RateLimit-Reset', await RateLimiter.resetTime(key))
return response
}
}
Endpoint-Specific Limits
// routes/api.ts
router.post('/api/auth/login', [
RateLimiter.perMinute(5).by((req) => req.ip),
], AuthController.login)
router.post('/api/emails/send', [
RateLimiter.perHour(100).by((req) => req.user.id),
], EmailController.send)
router.get('/api/search', [
RateLimiter.perMinute(30),
], SearchController.index)
Authentication
API Key Authentication
// middleware/ApiKeyAuth.ts
export class ApiKeyAuthMiddleware extends Middleware {
async handle(request: Request, next: Function) {
const apiKey = request.header('X-API-Key')
if (!apiKey) {
return response({
error: { message: 'API key required', code: 'MISSING_API_KEY' },
}, 401)
}
const key = await ApiKey.where('key', Hash.make(apiKey)).first()
if (!key || key.isExpired()) {
return response({
error: { message: 'Invalid API key', code: 'INVALID_API_KEY' },
}, 401)
}
request.apiKey = key
request.user = await key.user
// Track usage
await key.increment('requests_count')
return next()
}
}
JWT Authentication
// middleware/JwtAuth.ts
export class JwtAuthMiddleware extends Middleware {
async handle(request: Request, next: Function) {
const token = request.bearerToken()
if (!token) {
return response({
error: { message: 'Token required', code: 'MISSING_TOKEN' },
}, 401)
}
try {
const payload = await JWT.verify(token)
request.user = await User.find(payload.sub)
return next()
} catch (error) {
return response({
error: { message: 'Invalid token', code: 'INVALID_TOKEN' },
}, 401)
}
}
}
OAuth2 Scopes
// Define scopes
const scopes = {
'read:users': 'Read user information',
'write:users': 'Create and update users',
'delete:users': 'Delete users',
'read:orders': 'Read orders',
'write:orders': 'Create and update orders',
}
// Require scopes on routes
router.get('/api/users', [
'auth:api',
'scope:read:users',
], UserController.index)
router.post('/api/users', [
'auth:api',
'scope:write:users',
], UserController.store)
router.delete('/api/users/:id', [
'auth:api',
'scope:delete:users',
], UserController.destroy)
Error Handling
API Error Responses
// app/Exceptions/ApiHandler.ts
export default class ApiHandler extends ExceptionHandler {
render(error: Error, request: Request) {
// Validation errors
if (error instanceof ValidationException) {
return response({
error: {
message: 'Validation failed',
code: 'VALIDATION_ERROR',
errors: error.errors(),
},
}, 422)
}
// Not found
if (error instanceof ModelNotFoundException) {
return response({
error: {
message: `${error.model} not found`,
code: 'NOT_FOUND',
},
}, 404)
}
// Authentication
if (error instanceof AuthenticationException) {
return response({
error: {
message: 'Unauthenticated',
code: 'UNAUTHENTICATED',
},
}, 401)
}
// Authorization
if (error instanceof AuthorizationException) {
return response({
error: {
message: 'Forbidden',
code: 'FORBIDDEN',
},
}, 403)
}
// Generic server error
return response({
error: {
message: 'Internal server error',
code: 'SERVER_ERROR',
...(config('app.debug') && { trace: error.stack }),
},
}, 500)
}
}
Error Codes
// app/Support/ErrorCodes.ts
export const ErrorCodes = {
// Authentication
MISSING_TOKEN: { status: 401, message: 'Authentication token required' },
INVALID_TOKEN: { status: 401, message: 'Invalid authentication token' },
TOKEN_EXPIRED: { status: 401, message: 'Authentication token expired' },
// Authorization
FORBIDDEN: { status: 403, message: 'You do not have permission' },
INSUFFICIENT_SCOPE: { status: 403, message: 'Insufficient scope' },
// Validation
VALIDATION_ERROR: { status: 422, message: 'Validation failed' },
INVALID_INPUT: { status: 400, message: 'Invalid input' },
// Resources
NOT_FOUND: { status: 404, message: 'Resource not found' },
CONFLICT: { status: 409, message: 'Resource conflict' },
// Rate limiting
RATE_LIMIT_EXCEEDED: { status: 429, message: 'Rate limit exceeded' },
// Server
SERVER_ERROR: { status: 500, message: 'Internal server error' },
SERVICE_UNAVAILABLE: { status: 503, message: 'Service unavailable' },
}
API Documentation
OpenAPI/Swagger
An OpenAPI 3.0 spec is generated automatically from your routes and served at /__openapi.json — no manual annotation required. The options below customize that output.
// Generate OpenAPI spec from routes
// buddy api:docs
// config/api.ts
export default {
docs: {
enabled: true,
path: '/api/docs',
title: 'My API',
version: '1.0.0',
description: 'API documentation',
servers: [
{ url: 'https://api.example.com', description: 'Production' },
{ url: 'https://staging-api.example.com', description: 'Staging' },
],
},
}
Route Documentation
// app/Controllers/Api/UserController.ts
import { ApiDoc } from '@stacksjs/api'
export default class UserController extends Controller {
@ApiDoc({
summary: 'List all users',
description: 'Returns a paginated list of users',
tags: ['Users'],
parameters: [
{ name: 'page', in: 'query', type: 'integer', description: 'Page number' },
{ name: 'per_page', in: 'query', type: 'integer', description: 'Items per page' },
{ name: 'status', in: 'query', type: 'string', enum: ['active', 'inactive'] },
],
responses: {
200: { description: 'Successful response', schema: UserListResponse },
401: { description: 'Unauthorized' },
},
})
async index(request: Request) {
return User.paginate()
}
@ApiDoc({
summary: 'Create a user',
tags: ['Users'],
requestBody: { schema: CreateUserRequest },
responses: {
201: { description: 'User created', schema: UserResponse },
422: { description: 'Validation error' },
},
})
async store(request: CreateUserRequest) {
return User.create(request.validated())
}
}
Response Schemas
// app/Schemas/UserSchema.ts
import { Schema } from '@stacksjs/api'
export const UserSchema = Schema.object({
id: Schema.integer(),
name: Schema.string(),
email: Schema.string().format('email'),
role: Schema.enum(['user', 'admin']),
created_at: Schema.string().format('date-time'),
updated_at: Schema.string().format('date-time'),
})
export const UserListResponse = Schema.object({
data: Schema.array(UserSchema),
meta: Schema.object({
pagination: PaginationSchema,
}),
})
API Resources
Resource Transformers
// app/Resources/UserResource.ts
import { Resource } from '@stacksjs/api'
export default class UserResource extends Resource {
toArray() {
return {
id: this.id,
name: this.name,
email: this.email,
avatar_url: this.avatarUrl,
created_at: this.createdAt.toISOString(),
// Conditional fields
...(this.when(this.isAdmin, {
admin_notes: this.adminNotes,
})),
// Relationships
...(this.whenLoaded('posts', {
posts: PostResource.collection(this.posts),
})),
// Links
links: {
self: `/api/users/${this.id}`,
posts: `/api/users/${this.id}/posts`,
},
}
}
}
// Usage
async show(request: Request) {
const user = await User.with('posts').find(request.params.id)
return new UserResource(user)
}
async index() {
const users = await User.paginate()
return UserResource.collection(users)
}
Collection Resources
// Paginated collection
return UserResource.collection(users).additional({
meta: {
total: users.total,
per_page: users.perPage,
},
})
// With wrapper
return {
data: UserResource.collection(users.data),
links: users.links,
meta: users.meta,
}
Webhooks
Sending Webhooks
// app/Services/WebhookService.ts
import { Webhook } from '@stacksjs/api'
export class WebhookService {
async send(event: string, payload: object) {
const subscriptions = await WebhookSubscription
.where('event', event)
.where('active', true)
.get()
for (const subscription of subscriptions) {
await Webhook.send({
url: subscription.url,
event,
payload,
secret: subscription.secret,
retries: 3,
})
}
}
}
// Send webhook
await WebhookService.send('order.created', {
order_id: order.id,
total: order.total,
customer: order.customer,
})
Webhook Signature
// Signing webhooks
const signature = Webhook.sign(payload, secret)
// X-Webhook-Signature: sha256=abc123...
// Verifying webhooks (receiving)
router.post('/webhooks/stripe', async (request) => {
const signature = request.header('Stripe-Signature')
if (!Webhook.verify(request.body, signature, secret)) {
return response({ error: 'Invalid signature' }, 401)
}
// Process webhook
await processStripeWebhook(request.body)
return response({ received: true })
})
Testing APIs
import { apiTest } from '@stacksjs/testing'
describe('User API', () => {
it('lists users', async () => {
const users = await User.factory().count(3).create()
const response = await apiTest()
.withToken(adminToken)
.get('/api/users')
expect(response.status).toBe(200)
expect(response.body.data).toHaveLength(3)
})
it('creates a user', async () => {
const response = await apiTest()
.withToken(adminToken)
.post('/api/users', {
name: 'John Doe',
email: 'john@example.com',
})
expect(response.status).toBe(201)
expect(response.body.data.name).toBe('John Doe')
})
it('validates input', async () => {
const response = await apiTest()
.withToken(adminToken)
.post('/api/users', { name: '' })
expect(response.status).toBe(422)
expect(response.body.error.errors.name).toBeDefined()
})
it('requires authentication', async () => {
const response = await apiTest().get('/api/users')
expect(response.status).toBe(401)
})
})