Skip to content

Controllers

defineController<TService>(options)

Generic factory that returns a ControllerFactory<TService>. The handler receives a destructured context object with body, query, params, headers, cookies, user, service, req, res — each typed by the middleware that populates it (no manual casts). Return whatever you want; the framework JSON-encodes it and picks the success status from responses (defaults to 200).

typescript
// src/modules/users/user.controllers.ts
import {
  s, defineController,
  validateBody, validateQuery, validateHeaders,
  requireAuth, requireRoles,
} from '@supersec-ai/superman';
import {
  ListUsersQuery,
  TenancyHeaders,
  CreateUserBody,
  UserResponse,
} from './user.schemas';

const createUserController = defineController<IMyService>({
  middlewares: [
    requireAuth('bearerAuth'),                // ➡️ security + auto 401
    requireRoles('admin'),                    // ➡️ scopes + auto 403
    validateHeaders(TenancyHeaders),          // ➡️ header params + auto 400
    validateBody(CreateUserBody),             // ➡️ requestBody + auto 400
  ],
  responses: {
    201: { schema: UserResponse },
  },
  errors: [
    { status: 409, description: 'Email already in use.' },
    {
      status: 422,
      description: 'Validation failed.',
      metadataSchema: s.object({
        field:  s.string(),
        reason: s.string(),
      }),
    },
  ],
  throttleConfig: 'STANDARD',
  operationId: 'createUser',
  summary: 'Create a user',
  handler: async ({ body, service }) => service.create(body),
  //                ^^^^ CreateUserDto (inferred from validateBody)
  // ➡️ framework writes 201 Created with the returned object as the JSON body
});

The middleware list is the single source of truth: each framework middleware validates at runtime and brands its context slot so the handler argument is fully typed. No as CreateUserDto, no res.status(...).json(...), no parallel request: declaration.

Handler shapes

The framework accepts two handler forms, distinguished by arity:

ShapeDetected byWhat you can do
async ({ body, query, ..., service }) => resultarity ≤ 1Return a value (or reply()); the framework writes the response. Returning undefined after writing to res works for streaming.
async (req, res, service) => voidarity 3Legacy positional form. Write to res yourself. Useful when you need full control.

Flat-context shorthand. Leaf properties from body / query / params / headers / cookies are spread at the context root, so you can destructure values directly:

typescript
handler: async ({ id, name, email, user, service }) =>
  service.update(id, { name, email }, user.id)
  //                ↑ from params      ↑ from body          ↑ from requireAuth

Both ctx.params.id and ctx.id reference the same value. Precedence on key collision (higher wins): params > body > query > headers > cookies. Reserved structural keys (req, res, service, body, query, params, headers, cookies, user) are never overwritten by a flattened source — ctx.service always refers to the injected service, even if a body schema declared a field named service. The user principal stays structural only — its inner fields aren't spread to avoid colliding with path params (user.id vs params.id).

typescript
// Context form — recommended
handler: async ({ params, service }) => service.findById(params.id),

// reply() for status / header / mediaType overrides
import { reply } from '@supersec-ai/superman';
handler: async ({ body, service }) => {
  const order = await service.checkout(body);
  return reply(order, { status: 202, headers: { Location: `/orders/${order.id}` } });
},

// XML / non-JSON via reply({ mediaType })
handler: async ({ params, service }) => {
  const user = await service.findById(params.id);
  return reply(toXml(user), { mediaType: 'application/xml' });
},

// Legacy form — full control over res
handler: async (req, res, service) => {
  res.setHeader('Content-Type', 'text/event-stream');
  for await (const event of service.stream()) res.write(`data: ${JSON.stringify(event)}\n\n`);
  res.end();
},

Success status rules

When the context handler returns a value, the framework picks the response status by this rule:

  1. reply(data, { status }) overrides everything.
  2. Else if responses has exactly one 2xx key, use that key.
  3. Else default to 200.

responses is never obligatory — omit it and successful returns get 200. The OpenAPI document still shows the framework's auto-injected 429/500/default envelopes for the route.

Options

OptionTypeRequiredDescription
handler({ body, query, ..., service }) => unknown or (req, res, service) => voidyesContext-form (recommended) or legacy positional form. See Handler shapes.
throttleConfigThrottlePreset | { limit, ttl }noRate limit config. Defaults to 'STANDARD'
middlewaresRequestHandler[]noFramework-provided middlewares self-document (see table below). User-defined middlewares run normally but don't appear in the spec.
responsesRecord<number, { schema?; contentType?; content?; description?; headers? }>noSuccess / non-framework responses by status code. Optional headers map documents response headers.
errorsArray<{ status; description; metadataSchema? }>noFramework-envelope errors this route may emit beyond the middleware auto-errors. Each entry is rendered as allOf [FrameworkError, { metadata: <your-schema> }].
operationIdstringnoStable identifier surfaced as operation.operationId — used by OpenAPI codegen tools
summarystringnoShort summary; overrides route.description as the OpenAPI summary
deprecatedbooleannoMarks the operation as deprecated in the generated spec
securityArray<Record<scheme, scopes[]>>noPer-operation security requirement override. Usually unneeded — requireAuth(...) middlewares set this automatically.

Middlewares and what they auto-document

MiddlewareValidates at runtimeAuto-emits in OpenAPI
validateBody(schema | {mediaType: schema})req.bodyrequestBody (single or multi-media-type), auto 400
validateQuery(schema)req.query (string ➡️ typed)parameters[in: 'query'], auto 400
validateHeaders(schema)req.headers (string ➡️ typed)parameters[in: 'header'], auto 400
validateCookies(schema)req.cookiesparameters[in: 'cookie'], auto 400
validatePathParams(schema)req.params (string ➡️ typed)refines auto-generated path-param schemas, auto 400
validateContentType(...types)Content-Type headerrequestBody.content keys, auto 415
requireAuth(scheme | opts)runs verifier; populates req.usersecurity: [{ scheme: [] }], auto 401
requireRoles(...roles) / authorize({ roles, scopes })checks req.user.roles / scopesscopes merge onto preceding scheme, auto 403

The framework also auto-documents on every operation, no controller code needed:

  • 429 (rate limit) and Retry-After response header
  • 500 (uncaught error)
  • default catch-all referencing FrameworkError
  • X-RateLimit-Remaining response header (every response)

Declare statuses beyond the auto-injected ones via errors; a declaration with the same status overrides the auto-injected one.

Example — Post creation (auth-aware writes + custom message)

A post is created by an authenticated user; the authorId is taken from the Principal populated by requireAuth. The validation middleware uses a friendlier custom error message:

typescript
export const createPostController = defineController<IPostsService>({
  middlewares: [
    requireAuth('bearerAuth'),
    requireRoles('author', 'admin'),
    validateBody(CreatePostBody, { message: 'Please supply a valid post payload.' }),
  ],
  responses: { 201: { schema: PostResponse } },
  operationId: 'createPost',
  handler: async ({ body, user, service }) =>          // body=CreatePostDto, user=Principal
    service.create({ ...body, authorId: user.id }),
});

Example — Comments on a post (nested path params + flat destructure)

Comments live under a post (GET /posts/:postId/comments). Path params flatten to the context root, so postId is destructurable directly:

typescript
export const listCommentsController = defineController<ICommentsService>({
  middlewares: [
    validatePathParams(PostIdParam),                   // ➡️ ctx.postId
    validateQuery(ListCommentsQuery),                  // ➡️ ctx.query
  ],
  responses: { 200: { schema: PaginatedCommentsResponse } },
  operationId: 'listComments',
  handler: async ({ postId, query, service }) =>      // flat: postId from params
    service.listForPost(postId, query),
});

Defining schemas in a *.schemas.ts file

Park your module's schemas in a co-located file. Use the framework's built-in chainable builder (s.*) — runtime validation, OpenAPI emission, and TypeScript types all come from one declaration. No Zod or any other dep required (see docs/schemas.md for the full reference, and docs/api-config.md for swapping the validator).

typescript
// src/modules/users/user.schemas.ts
import { s, type Infer } from '@supersec-ai/superman';

export const ListUsersQuery = s.object({
  page:  s.integer().min(1).default(1).describe('Page number.'),
  limit: s.integer().min(1).max(100).default(20).describe('Items per page.'),
});

export const TenancyHeaders = s.object({
  'X-Tenant-Id':     s.string().uuid().describe('Tenant identifier.'),
  'Idempotency-Key': s.string().min(8).optional().describe('Idempotency token.'),
});

export const SessionCookies = s.object({
  session: s.string().describe('Session token.'),
});

export const CreateUserBody = s.object({
  name:  s.string().min(1),
  email: s.string().email(),
})
  .describe('New user payload.')
  .example({ name: 'Ada', email: 'ada@example.com' });

export type CreateUserDto = Infer<typeof CreateUserBody>;

export const UserResponse = s.object({
  id:    s.string().uuid(),
  name:  s.string(),
  email: s.string().email(),
}).describe('A user record.');

Raw JSON Schema objects are still accepted everywhere s.* is — see docs/schemas.md.

Parameters: query, headers, cookies, path

  • Path params (/users/:id) are extracted automatically from the route — no declaration needed. Pass them to validatePathParams(schema) only if you want type coercion (e.g. "id must be a UUID") or richer per-param documentation.
  • Query / request-headers / cookies schemas pass to validateQuery / validateHeaders / validateCookies as object schemas (built with s.object({...}) or a raw JSON Schema). Each top-level property becomes one OpenAPI parameter; properties listed in required[] are marked required. Per-property .describe(), .deprecated(), .example(), and .examples() flow through.
  • Authorization, Accept, Content-Type headers cannot be declared as parameters under OpenAPI 3.1. Use requireAuth(...) for Authorization, validateContentType(...) for the others.

Response headers

X-RateLimit-Remaining is auto-injected on every response, and Retry-After is auto-injected on the 429 response — the framework actually sets those headers itself, so it documents them too. To declare additional response headers, pass a headers map on the status code:

typescript
responses: {
  200: {
    schema: UserResponse,
    headers: {
      'X-Request-Id': { schema: s.string(), description: 'Correlation id echoed back.' },
    },
  },
}

A user-declared header with the same name as an auto-injected one wins.

Security

Declare reusable security schemes and their verifiers at the app level in defineConfig, then reference them per-controller via requireAuth(scheme). See docs/api-config.md.

typescript
middlewares: [
  requireAuth('bearerAuth'),               // uses the verifier registered in defineConfig
  // — or, per-controller override —
  requireAuth({ scheme: 'bearerAuth', verify: async (req) => myCustomVerifier(req) }),
],

requireAuth auto-injects 401 and the operation's security requirement. requireRoles(...) / authorize({ scopes }) auto-injects 403 and merges its scopes onto the preceding auth scheme. Declare your own 401/403 via errors if you want a custom description or metadata shape.

Streaming responses (SSE, NDJSON, file downloads)

OpenAPI 3.1 doesn't model "streaming" as a first-class concept — it just sees the media type. Declare your stream's media type via content, with a schema describing one message/chunk:

typescript
responses: {
  200: {
    description: 'Server-Sent Events stream of order updates.',
    content: {
      'text/event-stream': {
        schema: OrderEventJsonSchema,                  // shape of one event payload
        example: 'data: {"orderId":"123","status":"shipped"}\n\n',
      },
    },
  },
}

The same pattern works for application/x-ndjson, application/octet-stream (binary downloads via schema: { type: 'string', format: 'binary' }), chunked text, etc.

Multiple media types

Responses — pass a content map on the status:

typescript
responses: {
  200: {
    description: 'User',
    content: {
      'application/json': { schema: UserResponse, example: { id: '1', name: 'Ada' } },
      'application/xml':  { schema: s.string(),    example: '<user><id>1</id></user>' },
    },
  },
}

Request bodies — pass validateBody a media-type-keyed map:

typescript
middlewares: [
  validateBody({
    'application/json':    CreateUserJsonSchema,
    'multipart/form-data': CreateUserMultipartSchema,
  }),
]

The middleware picks the right schema by inspecting the incoming Content-Type. Both keys appear under requestBody.content in the spec.

  • docs/schemas.md — JSON Schema authoring guide: validator's supported keywords, coercion rules, the full CRUD-module recipe (routes × middlewares × responses table), TypeScript-types ergonomics, and how to swap in AJV.
  • docs/api-middlewares.md — exhaustive reference for every shipped middleware (validateBody, validateQuery, validateHeaders, validateCookies, validatePathParams, validateContentType, requireAuth, requireRoles, authorize) and how to write your own.

Error Metadata

When throwing HttpException subclasses, pass an optional metadata object that's included in the error response envelope:

typescript
throw new NotFoundException('User not found', {
  code: 'USER_NOT_FOUND',
  userId: '123',
});
// Response: { "error": "User not found", "metadata": { "code": "USER_NOT_FOUND", "userId": "123" } }

If no metadata is passed, the response is just { "error": "message" }.

To document the shape of that metadata in the OpenAPI spec, declare the error in the controller's errors array with a metadataSchema — the generated spec will then render that exact shape under metadata so API consumers know what to expect.

Rate Limiting

Every controller has built-in per-IP rate limiting. Configure via throttleConfig in defineController.

PresetLimitTTLUse case
SECURITY5 req60sLogin, password reset, MFA
STRICT10 req60sWrite operations, payments
STANDARD100 req60sGeneral API endpoints (default)
PERMISSIVE1,000 req60sHigh-traffic reads
EXTRA_PERMISSIVE10,000 req60sPublic assets, health checks

When a client exceeds the limit, the framework returns HTTP 429 with a Retry-After header. Every response includes X-RateLimit-Remaining.

Custom config:

typescript
defineController<IPostsService>({
  throttleConfig: { limit: 3, ttl: 300_000 },                   // 3 requests per 5 minutes
  handler: async ({ service }) => service.findAll(),
});

Middleware

Per-Controller

typescript
defineController<ICommentsService>({
  middlewares: [
    requireAuth('bearerAuth'),
    validatePathParams(PostIdParam),                            // ➡️ params: { postId }
    validateBody(CreateCommentBody),
  ],
  responses: { 201: { schema: CommentResponse } },
  handler: async ({ postId, body, user, service }) =>           // flat: postId from params
    service.create(postId, { ...body, authorId: user.id }),
});

Middlewares run after rate limiting and before the handler. If a middleware throws an HttpException, the chain stops.

Exceptions

Throw anywhere in handlers or middleware — the framework catches and formats the response.

typescript
import { NotFoundException, BadRequestException } from '@supersec-ai/superman';

throw new NotFoundException('Order not found');
// -> 404 { "error": "Order not found" }

throw new BadRequestException('Invalid email');
// -> 400 { "error": "Invalid email" }
ClassStatus
BadRequestException400
UnauthorizedException401
ForbiddenException403
NotFoundException404
ConflictException409
GoneException410
PayloadTooLargeException413
UnprocessableEntityException422
TooManyRequestsException429
InternalServerErrorException500
NotImplementedException501
BadGatewayException502
ServiceUnavailableException503
GatewayTimeoutException504