Core Building Blocks / Command

HTTP Exposure

Expose commands as REST endpoints with path parameters, query strings, and OpenAPI documentation.

Commands are not HTTP endpoints by default. You expose them explicitly using exposeAsHttpEndpoint. The command builder defines the contract; the HTTP server builds the router and OpenAPI definition from it.

PURISTA uses Hono under the hood for HTTP handling, but you do not write Hono routes directly. Instead, you declare the HTTP contract on the command builder, and the HTTP server adapter auto-generates the route.

Basic exposure

import { z } from 'zod'

const signUpCommandBuilder = userServiceV1ServiceBuilder
  .getCommandBuilder('signUp', 'Register a new user')
  .addPayloadSchema(z.object({ email: z.string().email(), password: z.string().min(8) }))
  .addParameterSchema(z.object({ referralCode: z.string().optional() }))
  .addOutputSchema(z.object({ userId: z.string() }))
  .exposeAsHttpEndpoint('POST', 'api/v1/users')
  .setCommandFunction(async function (context, payload, parameter) {
    const user = await context.resources.db.insert('users', payload)
    return { userId: user.id }
  })

The exposeAsHttpEndpoint method takes:

ArgumentPurpose
methodHTTP method: GET, POST, PUT, PATCH, DELETE
pathURL path without leading slash, can include parameters like :id
contentTypeRequest(optional) Request content type
contentEncodingRequest(optional) Request encoding
contentTypeResponse(optional) Response content type
contentEncodingResponse(optional) Response encoding
options(optional) { mode?: 'sync' | 'async' }

Path and query parameters

Path parameters in the URL are extracted and passed to the command’s parameter schema:

const getUserCommandBuilder = userServiceV1ServiceBuilder
  .getCommandBuilder('getUser', 'Get a user by ID')
  .addParameterSchema(z.object({ id: z.string().uuid() }))
  .addOutputSchema(z.object({ id: z.string(), email: z.string() }))
  .exposeAsHttpEndpoint('GET', 'api/v1/users/:id')
  .setCommandFunction(async function (context, payload, parameter) {
    // parameter.id is extracted from the URL path
    const user = await context.resources.db.findById('users', parameter.id)
    if (!user) {
      throw new HandledError(StatusCode.NotFound, 'User not found')
    }
    return user
  })

Query parameters are also passed through the parameter schema:

const listUsersCommandBuilder = userServiceV1ServiceBuilder
  .getCommandBuilder('listUsers', 'List users with pagination')
  .addParameterSchema(z.object({
    page: z.string().transform(Number).default('1'),
    limit: z.string().transform(Number).default('20'),
    search: z.string().optional(),
  }))
  .addOutputSchema(z.object({
    items: z.array(z.object({ id: z.string(), email: z.string() })),
    total: z.number(),
  }))
  .exposeAsHttpEndpoint('GET', 'api/v1/users')
  .setCommandFunction(async function (context, payload, parameter) {
    const users = await context.resources.db.find('users', {
      limit: parameter.limit,
      offset: (parameter.page - 1) * parameter.limit,
      search: parameter.search,
    })
    return { items: users, total: users.length }
  })

Async HTTP exposure

For long-running operations, use { mode: 'async' }. The HTTP adapter returns 202 Accepted immediately and enqueues the work:

const generateReportCommandBuilder = userServiceV1ServiceBuilder
  .getCommandBuilder('generateReport', 'Generate a usage report')
  .addPayloadSchema(z.object({ startDate: z.string(), endDate: z.string() }))
  .addOutputSchema(z.object({ jobId: z.string(), statusUrl: z.string() }))
  .canEnqueue('reportJob', z.object({ startDate: z.string(), endDate: z.string() }), z.object({}))
  .exposeAsHttpEndpoint('POST', 'api/v1/reports', undefined, undefined, undefined, undefined, { mode: 'async' })
  .setCommandFunction(async function (context, payload, parameter) {
    const job = await context.queue.enqueue.reportJob(payload, {})
    return {
      jobId: job.jobId,
      statusUrl: `/api/v1/reports/status/${job.jobId}`,
    }
  })

When mode: 'async' is set, the builder automatically adds StatusCode.Accepted to the OpenAPI error status codes so the documentation correctly reflects the 202 response.

OpenAPI metadata

Control the generated OpenAPI documentation with fluent methods:

signUpCommandBuilder
  .setOpenApiSummary('Register a new user account')
  .setOpenApiOperationId('signUp')
  .addOpenApiTags('Authentication', 'Users')
  .addOpenApiErrorStatusCodes(StatusCode.Conflict, StatusCode.TooManyRequests)
  .makeEndpointPublic() // disables default auth requirement
MethodPurpose
setOpenApiSummary(text)Short description in OpenAPI
setOpenApiOperationId(id)Unique operation identifier
addOpenApiTags(...tags)Grouping tags
addOpenApiErrorStatusCodes(...codes)Document possible error responses
makeEndpointPublic()Disables auth for this endpoint
enableHttpSecurity(enabled)Toggle security (default: true)
addQueryParameters(...params)Declare query parameter metadata

Security

By default, exposed endpoints require authentication. Use makeEndpointPublic() for public endpoints like health checks or sign-up:

const healthCommandBuilder = userServiceV1ServiceBuilder
  .getCommandBuilder('health', 'Health check endpoint')
  .addOutputSchema(z.object({ status: z.string() }))
  .exposeAsHttpEndpoint('GET', 'health')
  .makeEndpointPublic()
  .setCommandFunction(async function () {
    return { status: 'ok' }
  })

Content types

Override content types for non-JSON payloads:

const uploadCommandBuilder = userServiceV1ServiceBuilder
  .getCommandBuilder('upload', 'Upload a file')
  .addPayloadSchema(z.instanceof(Buffer))
  .addParameterSchema(z.object({ filename: z.string() }))
  .exposeAsHttpEndpoint('POST', 'api/v1/uploads', 'application/octet-stream')
  .setCommandFunction(async function (context, payload, parameter) {
    await context.resources.storage.put(parameter.filename, payload)
    return { uploaded: true }
  })