stacksjs

stacks-api

Use when building, modifying, or debugging API endpoints in a Stacks application - defining routes, handling requests, API middleware, working with the API server, HTTP client (fetcher), API resources, or OpenAPI generation. Covers both @stacksjs/api utilities and the stacks-api server implementation.

stacksjs 636 20 Updated 1w ago
GitHub

Install

npx skillscat add stacksjs/stacks/stacks-api

Install via the SkillsCat registry.

SKILL.md

Stacks API

The Stacks API system comprises two packages: the @stacksjs/api utilities package (HTTP client, API resources, OpenAPI generation) and the stacks-api server (Bun HTTP server with hot-reload). Routing is delegated to @stacksjs/router which wraps @stacksjs/bun-router.

Key Paths

  • Core utilities (package): storage/framework/core/api/src/
  • API server: storage/framework/api/
  • Router package: storage/framework/core/router/src/
  • Route definitions: routes/
  • Route registry: app/Routes.ts (re-exports storage/framework/defaults/app/Routes.ts)
  • Port configuration: config/ports.ts
  • Generated OpenAPI spec: storage/framework/api/openapi.json
  • Generated API types: storage/framework/api/api-types.ts
  • Actions directory: app/Actions/
  • Controllers directory: app/Controllers/
  • Middleware directory: app/Middleware/
  • Middleware alias map: app/Middleware.ts
  • Package: @stacksjs/api

Source Files

core/api/src/
├── index.ts             # Re-exports fetcher, generate-openapi, resource
├── fetcher.ts           # Fetcher HTTP client class (fluent API)
├── generate-openapi.ts  # OpenAPI 3.0 spec generator from route definitions
├── resource.ts          # JsonResource, ResourceCollection, PaginatedResourceCollection
└── types.ts             # FetcherResponse, QueryParams, BodyData, FileAttachment

framework/api/
├── dev.ts               # API server entry point (Bun --hot, file watchers)
├── build.ts             # Bun.build config for production
├── api-types.ts         # Auto-generated TypeScript types from OpenAPI spec
├── openapi.json         # Generated OpenAPI 3.0 specification
├── package.json         # Dependencies: @stacksjs/router, queue, storage, logging
└── tsconfig.json

core/router/src/
├── index.ts             # Re-exports bun-router + stacks-specific additions
├── stacks-router.ts     # Route object, serve(), url(), action/controller resolution
├── response.ts          # Response factory (json, text, html, redirect, etc.)
├── middleware.ts         # Middleware class definition
├── request-context.ts   # AsyncLocalStorage-based request context (request proxy)
├── route-loader.ts      # Loads route files from registry with auto-prefixing
├── error-handler.ts     # Ignition-style error pages, query tracking
└── action-paths.ts      # StacksActionPath type definitions

routes/
├── api.ts               # Main API routes (auto-prefixed with /api; see #1835)
├── v1.ts                # Versioned routes (loaded at /v1/*)
├── buddy.ts             # Buddy CLI routes (loaded at /buddy/*)
└── users.ts             # Example user routes (commented out)

API Server (dev.ts)

The API server runs on Bun's HTTP server with hot-reload (bun --hot run dev.ts).

import { serve } from '@stacksjs/router'

serve({
  port: ports.api || 3008,  // Configurable via config/ports.ts or PORT_API env
  timezone: app.timezone || 'UTC',
})

Hot Reload Watchers

The dev server watches these directories for changes and auto-reloads:

  • storage/framework/core/*/src/ -- rebuilds core packages on change
  • routes/ -- detects route file changes
  • app/Actions/ -- invalidates module cache, hot-reloads actions
  • app/Controllers/ -- invalidates module cache, hot-reloads controllers
  • app/Middleware/ -- invalidates module cache, hot-reloads middleware
  • app/Models/ (user models) -- regenerates the model artifacts (there is no
    generate:model-files command; the dev server does this itself, and
    buddy generate:types is the manual equivalent)

Production build: bun build.ts bundles dev.ts to dist/dev.js.

Route Definitions

Route Registry (app/Routes.ts)

Routes are organized in routes/*.ts files and registered in app/Routes.ts:

export interface RouteDefinition {
  path: string           // Route file path relative to routes/
  prefix?: string        // URL prefix (overrides key-based prefix)
  middleware?: string | string[]  // Middleware for all routes in file
}

export type RouteRegistry = Record<string, string | RouteDefinition>

export default {
  'api': 'api',                           // Auto-prefixed with /api (see #1835)
  'v1': { path: 'v1', prefix: 'v1' },    // routes at /v1/*
  // 'admin': { path: 'admin', middleware: ['auth'] },
} satisfies RouteRegistry

'web' is the only key that loads at root / with no prefix; all other keys (including 'api') auto-prefix with /<key>. The 'api' auto-prefix lines up with the rpx proxy forward path so a user route registered as route.get('/cart/add', ...) in routes/api.ts resolves at https://<domain>/api/cart/add through the dev proxy.

Route Methods

import { route, response } from '@stacksjs/router'

// Inline handler
route.get('/hello', () => response.json({ message: 'Hello' }))
route.post('/users', () => response.created({ id: 1 }))

// String-based handler (Action)
route.get('/subscribers', 'Actions/SubscriberIndexOrmAction')
route.post('/login', 'Actions/Auth/LoginAction')

// String-based handler (Controller@method)
route.get('/coming-soon', 'Controllers/ComingSoonController@index')
route.get('/stats', 'Controllers/QueryController@getStats')

// Route with path parameters
route.get('/foo/bar/{id}', () => response.text('hello'))
route.get('/users/{id}/posts/{postId}', handler)

// Route chaining: middleware + naming
route.get('/tokens', 'Actions/Auth/ListTokensAction').middleware('auth')
route.post('/email/subscribe', 'Actions/SubscriberEmailAction').name('email.subscribe')
route.delete('/tokens/{id}', 'Actions/Auth/RevokeTokenAction').middleware('auth').name('token.revoke')

// Route groups (prefix + middleware)
route.group({ prefix: '/auth' }, () => {
  route.post('/refresh', 'Actions/Auth/RefreshTokenAction')
  route.get('/tokens', 'Actions/Auth/ListTokensAction').middleware('auth')
})

route.group({ middleware: 'auth' }, () => {
  route.get('/me', 'Actions/Auth/AuthUserAction')
  route.post('/logout', 'Actions/Auth/LogoutAction')
})

// Health check (adds GET /health)
route.health()

Named Routes & URL Generation

import { url } from '@stacksjs/router'

// Define named routes
route.get('/api/email/unsubscribe', 'Actions/UnsubscribeAction').name('email.unsubscribe')
route.get('/users/{id}/posts/{postId}', handler).name('user.post')

// Generate URLs
url('email.unsubscribe', { token: 'abc-123' })
// => https://stacksjs.com/api/email/unsubscribe?token=abc-123

url('user.post', { id: 42, postId: 7 })
// => https://stacksjs.com/users/42/posts/7

Path parameters ({id}) are substituted; remaining params become query string. Uses APP_URL env var as base (defaults to https://localhost).

Action Resolution

String-based route handlers are resolved at runtime:

  1. Actions ('Actions/MyAction'): checks app/Actions/MyAction.ts first, falls back to storage/framework/defaults/app/Actions/MyAction.ts
  2. ORM Actions ('Actions/UserIndexOrmAction'): loaded from storage/framework/actions/src/
  3. Controllers ('Controllers/MyController@method'): checks app/Controllers/ first, falls back to defaults

Action modules must export a default object with a handle(req: EnhancedRequest) method:

// app/Actions/MyAction.ts
export default {
  handle(request: EnhancedRequest): Response {
    const name = request.get('name', 'world')
    return Response.json({ message: `Hello ${name}` })
  }
}

Actions can also define validations for automatic input validation:

export default {
  validations: {
    email: {
      rule: { validate: (value) => ({ valid: typeof value === 'string' && value.includes('@') }) },
      message: 'A valid email is required',
    },
  },
  handle(request: EnhancedRequest): Response {
    // Request is validated before reaching handle()
    return Response.json({ success: true })
  },
}

Validation failures return a 422 response:

{ "error": "Validation failed", "errors": { "email": ["A valid email is required"] } }

Response Factory

import { response } from '@stacksjs/router'

// JSON (200)
response.json({ message: 'OK' })
response.json(data, { status: 200, headers: { 'X-Custom': 'value' }, pretty: true })

// Status shortcuts
response.created({ id: 1 })              // 201
response.noContent()                      // 204
response.badRequest({ error: 'Bad' })     // 400
response.unauthorized()                   // 401 (default: { error: 'Unauthorized' })
response.forbidden()                      // 403 (default: { error: 'Forbidden' })
response.notFound()                       // 404 (default: { error: 'Not Found' })
response.error()                          // 500 (default: { error: 'Internal Server Error' })

// Other content types
response.text('Hello', { status: 200 })
response.html('<h1>Hello</h1>')
response.redirect('/new-url', 302)        // 301 | 302 | 303 | 307 | 308

All methods return a standard Response object.

Enhanced Request (EnhancedRequest)

Route handlers receive an EnhancedRequest (extends Request) with Laravel-style helper methods:

// Input access (merges query params + JSON body + form body + route params)
request.get<T>(key: string, defaultValue?: T): T
request.input<T>(key: string, defaultValue?: T): T
request.all(): Record<string, unknown>
request.only<T>(keys: string[]): T
request.except<T>(keys: string[]): T

// Presence checks
request.has(key: string | string[]): boolean
request.hasAny(keys: string[]): boolean
request.filled(key: string | string[]): boolean
request.missing(key: string | string[]): boolean

// Typed getters
request.string(key: string, defaultValue?: string): string
request.integer(key: string, defaultValue?: number): number
request.float(key: string, defaultValue?: number): number
request.boolean(key: string, defaultValue?: boolean): boolean
request.array<T>(key: string): T[]

// File handling (returns UploadedFile with store/storeAs methods)
request.file(key: string): UploadedFile | null
request.getFiles(key: string): UploadedFile[]
request.hasFile(key: string): boolean
request.allFiles(): Record<string, UploadedFile | UploadedFile[]>

// Authentication (set by auth middleware)
await request.user(): Promise<any>
await request.userToken(): Promise<any>
await request.tokenCan(ability: string): Promise<boolean>
await request.tokenCant(ability: string): Promise<boolean>
request.bearerToken(): string | null

Request Context (AsyncLocalStorage)

Access the current request from anywhere using the request proxy:

import { request, getCurrentRequest, runWithRequest, setCurrentRequest } from '@stacksjs/router'

// The `request` export is a Proxy that reads from AsyncLocalStorage
const token = request.bearerToken()
const user = await request.user()

// Manually run code in a request context
runWithRequest(enhancedReq, () => {
  // request proxy works here
})

Middleware

Defining Middleware

// app/Middleware/Auth.ts
import { Middleware } from '@stacksjs/router'

export default new Middleware({
  name: 'Auth',
  priority: 1,        // Lower numbers run first (default: 10)
  async handle(request) {
    const token = request.bearerToken()
    if (!token) {
      const error = new Error('Unauthorized') as Error & { statusCode: number }
      error.statusCode = 401
      throw error
    }
    // Set authenticated user on request
    ;(request as any)._authenticatedUser = user
  },
})

Middleware Alias Map

Define shorthand names in app/Middleware.ts:

export default {
  auth: 'Auth',                     // maps to app/Middleware/Auth.ts
  verified: 'EnsureEmailIsVerified',
  abilities: 'CheckAbilities',      // supports params: 'abilities:read,write'
}

Middleware Resolution Order

  1. Parse alias via app/Middleware.ts (e.g., 'auth' -> 'Auth')
  2. Load from app/Middleware/Auth.ts (user override)
  3. Fall back to storage/framework/defaults/app/Middleware/Auth.ts

Middleware with Parameters

route.get('/admin', handler).middleware('abilities:admin,write')
// Params accessible in middleware via: (request as any)._middlewareParams.abilities === 'admin,write'

Which client to reach for

Stacks has three, and they answer different questions. Pick by who is calling.

Caller Client Why
TypeScript in this repo or a workspace package Typed client (below) Full input/output inference with no generation step. Change a route, the call site stops compiling.
Anything that is not TypeScript here - native iOS/Android via the Craft bridge, third-party integrators, Swagger UI Generated REST client (buddy generate:openapi) Needs a real spec. That pipeline is unchanged and permanent.
One-off calls to any URL Fetcher (below) No route awareness, and none intended.

The first two are both permanent. This is not a migration off REST.

Typed client (zero generation)

Register routes with createTypedRouter() and the compiler can see both ends —
no buddy generate:openapi, no committed file to go stale.

// routes/api.ts
import IndexAction from '../app/Actions/Project/IndexAction'
import StoreAction from '../app/Actions/Project/StoreAction'
import { createTypedRouter } from '@stacksjs/router'

export const api = createTypedRouter()
  .get('/v1/projects', IndexAction)
  .get('/v1/projects/{id}', ShowAction)
  .post('/v1/projects', StoreAction, { middleware: 'auth' })

export type AppRoutes = typeof api
// any TypeScript consumer
import type { AppRoutes } from '../routes/api'
import { createTypedClient } from '@stacksjs/router'

const client = createTypedClient<AppRoutes>({ baseUrl: 'https://api.example.com' })

const projects = await client.get('/v1/projects')
const one = await client.get('/v1/projects/{id}', { params: { id: '42' } })
const created = await client.post('/v1/projects', { name: 'apollo', budget: 1200 })

An unknown path is a compile error. A body that does not match the action's
validations is a compile error. The result is the action's own return type.

How it infers. Input comes from the action's validations — the same object
the validator runs, so the two cannot drift. Output comes from handle's return
type. An action returning a Response or a stream is typed unknown, honestly:
it took over the wire format.

Per-route settings are an argument, not a chained call — chaining
.middleware() would return the route and lose the accumulated type:

.post('/v1/projects', StoreAction, {
  middleware: ['auth', 'can:create,project'],
  name: 'projects.store',
  skipCsrf: true,
  rateLimit: { max: 10, window: 'minute' },
})

No .group(). A prefix applied only at runtime makes every accumulated path
type wrong; one applied only in the type is a second place for the URL to live.
Write the full path.

The string form is untouched. route.get('/x', 'Actions/Foo') stays exactly
as it is — lazy import, hot-reload friendly — and simply produces no inferred
types. A route wanting inference opts in by using the builder instead. Routes
registered either way still land in the generated OpenAPI document.

Generated CRUD. A useApi model's endpoints are describable too, without
touching them:

import type { ApiRoutesFor } from '@stacksjs/orm'
import type Product from '../app/Models/Product'

type AppRoutes = RoutesOf<typeof api> & ApiRoutesFor<typeof Product>
await client.get('/api/products')  // typed listing envelope

The builder, the client and the route-map contract all live in
@stacksjs/bun-router and are re-exported by @stacksjs/router. Import
createTypedClient from @stacksjs/bun-router directly in a browser bundle.

Fetcher (HTTP Client)

A fluent HTTP client for ad hoc requests, with no route awareness:

import { fetcher } from '@stacksjs/api'

// GET request
const res = await fetcher.get<UserResponse>('/api/users')
res.data       // UserResponse
res.status     // 200
res.isOk       // true
res.ok()       // true (status === 200)
res.notFound() // false

// POST with JSON body
const res = await fetcher
  .withBody({ name: 'John', email: 'john@example.com' })
  .post<User>('/api/users')

// PUT/PATCH/DELETE
await fetcher.withBody(data).put<User>('/api/users/1')
await fetcher.withBody(data).patch<User>('/api/users/1')
await fetcher.delete('/api/users/1')

// Query parameters
await fetcher.withQueryParams({ page: 1, limit: 25 }).get('/api/users')

// Custom headers
await fetcher.withHeaders({ 'X-Custom': 'value' }).get('/api/users')

// Authentication
await fetcher.withToken('bearer-token-here').get('/api/me')
await fetcher.withBasicAuth('user', 'pass').get('/api/protected')
await fetcher.withDigestAuth('user', 'pass').get('/api/digest-protected')

// Form data
await fetcher.asForm().withBody({ email: 'test@test.com' }).post('/subscribe')

// File uploads (multipart/form-data)
await fetcher
  .attach('avatar', file, 'photo.jpg')
  .withBody({ name: 'John' })
  .post('/api/upload')

// Accept header
await fetcher.acceptJson().get('/api/data')
await fetcher.accept('text/xml').get('/api/feed')

FetcherResponse

interface FetcherResponse<T = any> {
  data: T
  status: number
  headers: Headers
  isOk: boolean

  // Status checkers
  ok(): boolean             // 200
  created(): boolean        // 201
  accepted(): boolean       // 202
  noContent(): boolean      // 204
  movedPermanently(): boolean  // 301
  found(): boolean          // 302
  badRequest(): boolean     // 400
  unauthorized(): boolean   // 401
  paymentRequired(): boolean   // 402
  forbidden(): boolean      // 403
  notFound(): boolean       // 404
  requestTimeout(): boolean    // 408
  conflict(): boolean       // 409
  unprocessableEntity(): boolean // 422
  tooManyRequests(): boolean   // 429
  serverError(): boolean    // 500
}

Fetcher Types

type QueryParams = Record<string, string | number | boolean | null | undefined>
type BodyData = Record<string, any>

interface FileAttachment {
  name: string
  content: Blob
  filename?: string
  headers?: Record<string, string>
}

API Resources (Laravel-style)

JsonResource

Transform models into JSON responses with conditional field inclusion:

import { JsonResource } from '@stacksjs/api'

class UserResource extends JsonResource<User> {
  toArray() {
    return {
      id: this.resource.id,
      name: this.resource.name,
      email: this.resource.email,
      // Conditional inclusion
      secret: this.when(isAdmin, this.resource.secret),
      avatar: this.whenNotNull(this.resource.avatar),
      // Relationship loading
      posts: this.whenLoaded('posts', () => PostResource.collection(this.resource.posts)),
      posts_count: this.whenCounted('posts'),
      // Conditional merge
      ...this.mergeWhen(isAdmin, { admin_notes: this.resource.adminNotes }),
    }
  }
}

// Single resource
const response = new UserResource(user).toResponse()
// => { data: { id: 1, name: 'John', email: 'john@...' } }

// With additional data
new UserResource(user).withAdditional({ meta: { version: 1 } }).toResponse()

// JSON string
new UserResource(user).toJson()

JsonResource Methods

abstract class JsonResource<T> {
  resource: T
  additional: Record<string, any>
  static wrap: string | null = 'data'  // Wrap key, null to disable

  abstract toArray(request?: Request): Record<string, any>

  // Instance methods
  withRequest(request: Request): this
  withAdditional(data: Record<string, any>): this
  resolve(request?: Request): Record<string, any>
  toResponse(request?: Request): Record<string, any>
  toJson(request?: Request): string

  // Static methods
  static collection<T, R>(resources: T[]): ResourceCollection<T, R>
  static withoutWrapping(): void     // Set wrap = null for this class
  static wrapWith(key: string): void // Set custom wrap key

  // Conditional helpers (protected, use inside toArray)
  protected when<V>(condition: boolean | (() => boolean), value: V | (() => V), defaultValue?: any): V | MissingValue
  protected whenNotNull<V>(value: V | null | undefined, transform?: (v: V) => any): any | MissingValue
  protected whenLoaded<V>(relationship: string, value?: V | (() => V), defaultValue?: any): V | any[] | MissingValue
  protected whenCounted(relationship: string, defaultValue?: number): number | MissingValue
  protected merge(data: Record<string, any>): MergeValue
  protected mergeWhen(condition: boolean | (() => boolean), data: Record<string, any> | (() => Record<string, any>)): MergeValue | MissingValue
}

ResourceCollection

class ResourceCollection<T, R extends JsonResource<T>> {
  resources: T[]
  static wrap: string | null = 'data'

  withRequest(request: Request): this
  withAdditional(data: Record<string, any>): this
  resolve(request?: Request): Record<string, any>[]
  toResponse(request?: Request): Record<string, any>
  toJson(request?: Request): string
  count(): number
  isEmpty(): boolean
  isNotEmpty(): boolean
}

// Usage
const collection = UserResource.collection(users)
collection.toResponse()
// => { data: [{ id: 1, ... }, { id: 2, ... }] }

PaginatedResourceCollection

class PaginatedResourceCollection<T, R extends JsonResource<T>> extends ResourceCollection<T, R> {
  meta: PaginationMeta
  links: PaginationLinks

  // Create from raw pagination data
  static fromPagination<T, R>(
    data: T[],
    ResourceClass: new (resource: T) => R,
    options: { currentPage: number, perPage: number, total: number, baseUrl?: string },
  ): PaginatedResourceCollection<T, R>

  toResponse(): Record<string, any>
  // => { data: [...], meta: { current_page, from, last_page, per_page, to, total }, links: { first, last, prev, next } }
}

interface PaginationMeta {
  current_page: number
  from: number | null
  last_page: number
  per_page: number
  to: number | null
  total: number
}

interface PaginationLinks {
  first: string | null
  last: string | null
  prev: string | null
  next: string | null
}

Anonymous Resources

Quick inline transformations without defining a class:

import { resource, collection } from '@stacksjs/api'

// Single resource
resource(user, (u) => ({ id: u.id, name: u.name })).toResponse()
// => { data: { id: 1, name: 'John' } }

// Collection
collection(users, (u) => ({ id: u.id, name: u.name })).toResponse()
// => { data: [{ id: 1, name: 'John' }, ...] }

Conditional Value Types

class MissingValue {
  static instance: MissingValue
  isMissing(): boolean
}

class MergeValue {
  constructor(data: Record<string, any>)
}

class ConditionalValue {
  constructor(condition: boolean | (() => boolean), value: any, defaultValue?: any)
  resolve(): any
}

MissingValue entries are stripped from the final output by filterAndResolve() (max depth: 20).

OpenAPI Generation

import { generateOpenApi } from '@stacksjs/api'

await generateOpenApi()
// Reads all registered routes via `route.routes()`
// Writes OpenAPI 3.0 spec to storage/framework/api/openapi.json

The generated spec includes:

  • Path definitions from route URLs
  • HTTP methods from route registrations
  • Path parameters extracted from {param} placeholders
  • Response/request schemas if defined on routes (route.responseSchema, route.requestSchema)
  • Operation IDs from route callbacks (action paths)

TypeScript types can be generated from the spec:

# Defined in package.json scripts
bun run generate-types
# Runs: open-api ./../../api/openapi.json --output ./../../api/api-types.ts

Error Handling

Error Response Structure

interface ErrorResponseBody {
  error: string
  message: string
  status: number
  timestamp: string
  details?: Record<string, unknown>
}

Development vs Production

  • Development: Ignition-style HTML error pages with full stack traces, database queries, and request context
  • Production: Simple JSON or HTML error responses (no stack traces)
  • Detection: APP_ENV !== 'production' && NODE_ENV !== 'production'
  • API requests (Accept: application/json): always get JSON error responses

Error Functions

import {
  createErrorResponse,
  createMiddlewareErrorResponse,
  createValidationErrorResponse,
  createNotFoundResponse,
  trackQuery,
  clearTrackedQueries,
} from '@stacksjs/router'

// Track queries for error context (circular buffer, max 50)
trackQuery('SELECT * FROM users', 12.5, 'sqlite')

// Create error responses
await createErrorResponse(error, request, { status: 500, handlerPath: 'Actions/MyAction' })
await createMiddlewareErrorResponse(error, request)  // Uses error.statusCode
createValidationErrorResponse({ email: ['Required'] }, request)  // 422
await createNotFoundResponse('/missing-path', request)  // 404

Sensitive Data Sanitization

Error pages automatically redact fields matching these patterns: password, secret, token, api_key, authorization, credential, cookie, session_id, and others.

Port Configuration (config/ports.ts)

{
  frontend: env.PORT ?? 3000,
  backend: env.PORT_BACKEND ?? 3001,
  admin: env.PORT_ADMIN ?? 3002,
  api: env.PORT_API ?? 3008,
  // ... other services
}

Route Groups in routes/api.ts

The main routes/api.ts file defines route groups for major feature areas:

Prefix Feature Actions Prefix
/auth Authentication & tokens Actions/Auth/
/password Password reset flow Actions/Password/
/payments Stripe payment integration Actions/Payment/
/commerce E-commerce (products, orders, customers, coupons, gift cards, tax rates, reviews, receipts, POS, waitlists) Actions/Commerce/
/shipping Shipping methods, rates, zones, delivery routes, drivers, digital delivery, license keys Actions/Commerce/Shipping/
/cms Blog posts, authors, categories, tags, comments, pages Actions/Cms/
/blog Public blog endpoints (read-only) Actions/Cms/
/monitoring Error tracking & resolution Actions/Monitoring/
/dashboard Dashboard stats & health Actions/Dashboard/
/queries Query log dashboard Controllers/QueryController@
/queues Queue management Actions/Queue/
/realtime WebSocket management Actions/Realtime/
/voide Voice AI code assistant Actions/Buddy/

ORM-Generated CRUD Routes

Models with traits.useApi automatically get CRUD routes via ORM actions:

Method Path Handler Status
GET /api/{model} Generated index 200
POST /api/{model} Generated store 201
GET /api/{model}/{id} Generated show 200
PUT/PATCH /api/{model}/{id} Generated update 200
DELETE /api/{model}/{id} Generated destroy 204

Which useApi models get these routes is security.api.models in
config/security.ts ('all' by default, 'own', 'none', or a list of model
names that may include 'own'), overridden by STACKS_MODEL_APIS. 'all'
includes the ~80 framework models, so an app that does not use commerce or
CMS should set 'own'. The OpenAPI schemas follow the same selection.

Generated store and update handlers accept declared fillable attributes and
the foreign-key attributes derived from belongsTo. For example,
belongsTo: ['Product', 'Customer'] accepts productId / product_id and
customerId / customer_id. Other undeclared fields remain guarded.

CLI Commands

  • buddy dev -- starts the API server with hot-reload
  • buddy generate:types -- regenerates the API TypeScript types and server
    declarations (there is no generate:api-types)
  • buddy generate:openapi-spec -- regenerates the OpenAPI spec those types come from

Gotchas

  • The fetcher is a singleton instance -- state (headers, body, query params) is reset after each request via resetState(), but calling chain methods without executing a request will accumulate state
  • fetcher.withDigestAuth() uses SHA-256 internally despite the method name generateMD5 -- this is a misnomer in the source code
  • fetcher always parses responses as JSON (response.json()) -- it will throw if the response is not valid JSON
  • Only the 'web' route file key loads at root / with no prefix; all other keys (including 'api') auto-prefix with /<key>. The 'api' auto-prefix was added in stacksjs/stacks#1835 so user routes line up with the rpx proxy forward path
  • The route object is a global singleton from stacks-router.ts -- all route definitions across files share the same router instance
  • String-based handlers ('Actions/MyAction') are resolved lazily at request time, not at registration time -- import errors surface only when the route is hit
  • Actions must export default with a handle() method; Controllers must export default as a class with the specified method (defaults to index)
  • The request proxy from @stacksjs/router returns safe defaults (null, undefined, empty) when accessed outside of a request context -- it warns in non-production
  • JsonResource.filterAndResolve() has a max recursion depth of 20 to prevent stack overflow on circular references
  • ResourceCollection.toResponse() and JsonResource.toResponse() both wrap data in { data: ... } by default -- call MyResource.withoutWrapping() to disable
  • The error handler tracks up to 50 recent queries in a circular buffer -- these are cleared after each successful response
  • Middleware runs in registration order (not by priority) within a single route -- the priority field on Middleware is for documentation/ordering within files
  • Middleware parameters (e.g., 'abilities:read,write') are stored on request._middlewareParams -- middleware must parse the param string itself
  • The dev server's file watchers use Node's fs.watch (not chokidar) -- on macOS this uses FSEvents, on Linux it uses inotify
  • openapi.json starts empty ({}) and is only populated when generateOpenApi() is explicitly called
  • The api-types.ts file uses operations keyed by action path (e.g., 'Actions/UserIndexOrmAction'), not by route name
  • CORS headers (Access-Control-Allow-Origin: http://localhost:5173) are only added in development mode for error responses
  • Body parsing happens once per request (tracked via _bodyParsed flag) -- JSON, URL-encoded, and multipart/form-data are all supported
  • response.json() catches circular reference errors and returns { error: 'Response data could not be serialized' } instead of throwing

Sharing an authorized operation across transports

defineApiOperation({ authorize, handle }) from @stacksjs/api/operation returns
an immutable operation with execute(context). Every execution awaits authorize
before invoking handle. Authenticate the context at each transport boundary;
keep scope and resource-ownership checks in the operation and its domain helpers.
This lets HTTP routes, native MCP tools and jobs share behavior without an HTTP
loopback or a second implementation of the same business rules.