API
Use @putnami/application for HTTP servers, middleware, WebSockets, and file-based API routes. Build REST APIs with automatic route discovery, typed request handling, and built-in schema validation.
Getting started
Entry point
import { application, api, http } from '@putnami/application';
export const app = () =>
application()
.use(http({ port: 3000 }))
.use(api());Configuration options
api({
scanFolder: 'api', // Folder to scan for routes (default: 'api')
autoScan: true, // Auto-discover routes (default: true)
prefix: '/api', // URL prefix for all routes (optional)
})When used inside a module with .path(), the module path is automatically used as the route prefix (unless an explicit prefix is set):
const tasksModule = module('tasks')
.path('/tasks')
.use(api()); // Routes prefixed with /tasksFile-based routing
Routes are discovered from your src/api/ folder. File names determine the HTTP method.
Route file conventions
- get.ts - GET request handler
- post.ts - POST request handler
- put.ts - PUT request handler
- patch.ts - PATCH request handler
- delete.ts - DELETE request handler
- *.get.ts - Named GET handler (e.g.,
users.get.ts) - *.post.ts - Named POST handler
- route.ts or routes.ts - Multiple methods in one file
- *.ws.ts - WebSocket handler
Example structure
src/api/
health/
get.ts # GET /health
users/
get.ts # GET /users
post.ts # POST /users
[id]/
get.ts # GET /users/:id
put.ts # PUT /users/:id
delete.ts # DELETE /users/:id
search/
users.get.ts # GET /search/users
products.get.ts # GET /search/productsRoute handlers
All route handlers use the endpoint() builder. It provides type safety, automatic validation, and a consistent API across HTTP and WebSocket routes.
Basic handler
// src/api/health/get.ts
import { endpoint } from '@putnami/application';
export default endpoint(() => {
return { status: 'ok', timestamp: Date.now() };
});Handler with query parameters
// src/api/users/get.ts
import { endpoint, Optional } from '@putnami/application';
export default endpoint()
.query({ limit: Optional(Number), offset: Optional(Number) })
.handle((ctx) => {
const { limit, offset } = ctx.queryParams();
return { users: [], limit: limit ?? 10, offset: offset ?? 0 };
});Handler with body validation
// src/api/users/post.ts
import { endpoint, Email } from '@putnami/application';
export default endpoint()
.body({ email: Email, name: String })
.handle(async (ctx) => {
const body = await ctx.body();
// body.email and body.name are validated — invalid requests never reach here
const user = await createUser(body);
return user;
});Dynamic route parameters
// src/api/users/[id]/get.ts
import { endpoint, Uuid } from '@putnami/application';
import { NotFoundException } from '@putnami/runtime';
export default endpoint()
.params({ id: Uuid })
.handle(async (ctx) => {
const user = await findUser(ctx.params.id);
if (!user) {
throw new NotFoundException(`User ${ctx.params.id} not found`);
}
return user;
});Catch-all routes
// src/api/files/[...path]/get.ts
import { endpoint } from '@putnami/application';
export default endpoint((ctx) => {
const { path } = ctx.params;
// path contains the full path after /api/files/
// e.g., /api/files/docs/2024/report.pdf -> path = 'docs/2024/report.pdf'
return { file: path };
});Endpoint builder
The endpoint() function provides a fluent builder for defining API handlers (HTTP and WebSocket) with built-in schema validation, type inference, and security. Inputs are validated before your handler runs — invalid requests are rejected automatically with structured error responses.
Simple mode
For handlers that don't need validation, pass a function directly:
// src/api/health/get.ts
import { endpoint } from '@putnami/application';
export default endpoint((ctx) => {
return { status: 'ok' };
});Builder mode
Chain .params(), .query(), .body() to declare and validate inputs:
// src/api/users/[id]/get.ts
import { endpoint, Uuid } from '@putnami/application';
export default endpoint()
.params({ id: Uuid })
.handle((ctx) => {
// ctx.params.id — typed as string, validated as UUID
return { id: ctx.params.id };
});Invalid requests are rejected automatically:
GET /users/not-a-uuid
→ 400 { "message": "params.id must be a valid UUID" }Builder methods
.params(schema)
Validate path parameters. Values are coerced from strings automatically (e.g., "42" → 42 for Number).
export default endpoint()
.params({ id: Uuid })
.handle((ctx) => {
ctx.params.id; // string, validated as UUID
});.query(schema)
Validate query string parameters. Values are coerced from strings automatically.
export default endpoint()
.query({ page: Number, limit: Number, search: Optional(String) })
.handle((ctx) => {
const query = ctx.queryParams();
query.page; // number
query.limit; // number
query.search; // string | undefined
});.body(schema)
Validate the request body (JSON). No coercion — types must match exactly.
export default endpoint()
.body({ name: String, email: Email, tags: ArrayOf(String) })
.handle(async (ctx) => {
const body = await ctx.body();
body.name; // string
body.email; // string, validated as email
body.tags; // string[]
});.returns(schema) / .returns(status, description?, schema?)
Declare the expected response shape. Used for OpenAPI spec generation and dev-mode response validation.
Plain return — sets the primary type and default 200 response:
export default endpoint()
.returns({ id: String, name: String, email: Email })
.handle((ctx) => {
return { id: '1', name: 'John', email: 'john@example.com' };
});Status-specific returns — document multiple response codes for OpenAPI:
export default endpoint()
.returns(200, 'Success', { users: ArrayOf(String) })
.returns(201, 'Created', { id: String })
.handle((ctx) => ({ users: [] }));In non-production environments, the framework validates handler responses against the primary schema and logs warnings on mismatches — without breaking the request. This catches contract drift early during development.
.throws(status, description, schema?)
Document error responses for OpenAPI. Metadata only — does not affect runtime.
export default endpoint()
.throws(400, 'Validation failed', { message: String })
.throws(404, 'Not found')
.handle((ctx) => ({ ok: true }));Global throws on the api() plugin apply to all routes:
api({ autoScan: false })
.throws(401, 'Unauthorized', { message: String })
.throws(500, 'Internal error').description(text)
Add a human-readable description to the OpenAPI operation.
export default endpoint()
.description('List all users with optional pagination')
.query({ page: Optional(Number) })
.handle((ctx) => ({ users: [] }));.cors(options)
Enable CORS for a single route.
export default endpoint()
.cors({ origin: 'https://app.example.com', credentials: true })
.handle(() => ({ ok: true }));.rateLimit(options)
Apply rate limiting per route.
export default endpoint()
.rateLimit({ max: 10, windowMs: 60_000 })
.handle(() => ({ ok: true }));.secure(optionsOrGuard?)
Require authentication and enforce access rules.
export default endpoint()
.secure({
scopes: ['notes:read'],
roles: ['admin'],
})
.handle(() => ({ ok: true }));Use .secure() to enforce authentication and access rules on any endpoint.
.handle(handler)
Provide the handler function. This finalises the endpoint definition and must be called last.
When .body() or .returns() use Stream(), .handle() produces a StreamEndpointDefinition (WebSocket/SSE). Otherwise it produces a standard EndpointDefinition (HTTP).
Streaming with Stream()
Wrap a schema in Stream() to mark it as a stream of messages. The mode is derived from which slots are streams:
body |
returns |
Mode | Transport |
|---|---|---|---|
T |
T |
Unary | REST (standard HTTP) |
T |
Stream(T) |
Server-stream | SSE or WebSocket |
Stream(T) |
T |
Client-stream | WebSocket |
Stream(T) |
Stream(T) |
Bidirectional | WebSocket |
// src/api/chat/[roomId]/ws.ts
import { endpoint, Stream, Uuid } from '@putnami/application';
export default endpoint()
.params({ roomId: Uuid })
.body(Stream({ type: String, content: String }))
.returns(Stream({ event: String, payload: String }))
.handle(async (ctx) => {
ctx.send({ event: 'welcome', payload: ctx.params.roomId });
for await (const msg of ctx.messages()) {
ctx.send({ event: 'echo', payload: msg.content });
}
});Simple server-stream:
import { endpoint, Stream } from '@putnami/application';
export default endpoint()
.returns(Stream({ event: String }))
.handle(async (ctx) => {
ctx.send({ event: 'hello' });
});See WebSockets & Streaming for protocol negotiation, SSE, and advanced patterns.
Schema validation
Primitive types
Use JavaScript constructors as schema types:
| Schema | TypeScript Type | Description |
|---|---|---|
String |
string |
String value |
Number |
number |
Numeric value |
Boolean |
boolean |
Boolean value |
Constrained types
Import these for stricter validation:
import { Uuid, Email, Int, Url, DateIso, Min, Max, MinLength, MaxLength, Pattern } from '@putnami/application';| Schema | TypeScript Type | Validation |
|---|---|---|
Uuid |
string |
UUID v4 format |
Email |
string |
Valid email address |
Int |
number |
Integer (no decimals) |
Url |
string |
Valid URL (parsed by new URL()) |
DateIso |
string |
ISO 8601 date (e.g. 2024-01-15T10:30:00Z) |
Min(n) |
number |
Value >= n |
Max(n) |
number |
Value <= n |
MinLength(n) |
string |
String length >= n |
MaxLength(n) |
string |
String length <= n |
Pattern(regex) |
string |
Must match regular expression |
endpoint().body({
age: Min(18),
score: Max(100),
username: MinLength(3),
bio: MaxLength(500),
slug: Pattern(/^[a-z0-9-]+$/),
website: Url,
publishedAt: DateIso,
});Combinators
import { Optional, ArrayOf } from '@putnami/application';| Combinator | Example | TypeScript Type |
|---|---|---|
Optional(T) |
Optional(String) |
string | undefined |
ArrayOf(T) |
ArrayOf(String) |
string[] |
Desc(text, T) |
Desc('User name', String) |
string (adds OpenAPI description) |
Combinators compose with constrained types:
Optional(Uuid) // string | undefined, validated as UUID when present
Optional(ArrayOf(Email)) // string[] | undefined
ArrayOf(Int) // number[], each validated as integerNested object schemas
Schema properties can be plain objects to validate nested structures:
endpoint()
.body({
name: String,
address: {
street: String,
city: String,
zip: Optional(String),
},
})
.handle(async (ctx) => {
const body = await ctx.body();
body.address.city; // string — fully typed and validated
});Sharing schemas
Extract shared schemas into modules and reuse them across routes:
import { ArrayOf, MinLength, Optional, schema } from '@putnami/application';
export const NoteBodySchema = schema({
title: MinLength(1),
content: MinLength(2),
tags: Optional(ArrayOf(String)),
});You can derive the TypeScript type from a schema:
import type { InferSchema } from '@putnami/application';
export type NoteBody = InferSchema<typeof NoteBodySchema>;Validation errors
Validation errors return a 400 Bad Request with a descriptive message and a structured list of issues:
POST /users
Content-Type: application/json
{ "name": "John" }
→ 400 { "message": "body.email is required", "errors": [{ "field": "body.email", "message": "body.email is required" }] }POST /users
Content-Type: application/json
{ "name": "John", "email": "invalid" }
→ 400 { "message": "body.email must be a valid email address", "errors": [{ "field": "body.email", "message": "body.email must be a valid email address" }] }Multiple validation errors are combined:
POST /users
Content-Type: application/json
{}
→ 400 { "message": "body.name is required; body.email is required", "errors": [
{ "field": "body.name", "message": "body.name is required" },
{ "field": "body.email", "message": "body.email is required" }
] }Content negotiation
Responses are automatically serialized based on the request's Accept header. When a handler returns an object or array, the framework picks the best format:
| Accept Header | Response Format |
|---|---|
application/json (or */*) |
JSON (default) |
text/plain |
YAML as plain text (human-readable) |
text/html |
JSON wrapped in a minimal HTML page |
application/xml / text/xml |
Simple XML serialization |
application/yaml / text/yaml |
YAML serialization |
If the client requests a type that isn't supported, a 406 Not Acceptable response is returned.
GET /users/1
Accept: application/xml
→ 200
Content-Type: application/xml; charset=utf-8
<?xml version="1.0" encoding="UTF-8"?>
<root><id>1</id><name>John</name></root>Handlers that return an HttpResponse or a plain string bypass content negotiation — they are sent as-is.
Request context
The handler context (ctx) provides access to all request information. When using endpoint() with schemas, params, query, and body are automatically validated and typed.
Properties
ctx.url; // Full URL string
ctx.req; // Raw Bun Request object
ctx.headers; // Headers object
ctx.route; // Matched route pattern
ctx.params; // Route parameters { id: '123' } — typed when .params() is used
ctx.statusCode; // Status code (can be modified)
ctx.server; // Bun server instanceMethods
ctx.queryParams(); // Validated query params (typed when .query() is used)
ctx.body(); // Validated body (typed when .body() is used)
ctx.secured(); // true if HTTPS
ctx.host(); // 'example.com:3000'
ctx.domain(); // 'example.com'
ctx.path(); // '/api/users'
ctx.query(); // '?limit=10'Response handling
Return values
Handlers can return different types:
| Return type | Response |
|---|---|
| Object / Array | JSON response |
| String | Text response |
HttpResponse |
Full control over status, headers, body |
undefined |
204 No Content |
HttpResponse
Use HttpResponse and standalone factory functions for full response control:
import { endpoint, HttpResponse, json, notFound, unauthorized, forbidden, badRequest } from '@putnami/application';
// JSON with custom status
export default endpoint(() => {
return json({ created: true }, { status: 201 });
});
// Redirect
HttpResponse.redirect('/dashboard', 302);
// Error responses
notFound('Resource not found');
unauthorized('Please log in');
forbidden('Access denied');
badRequest('Invalid input');
// Custom headers
json({ data: 'value' })
.setHeader('X-Custom-Header', 'value')
.setHeader('Cache-Control', 'max-age=3600');Multi-method routes
Use route.ts to define multiple HTTP methods in a single file. Each method is an endpoint() named export:
// src/api/items/route.ts
import { endpoint, Optional } from '@putnami/application';
export const GET = endpoint()
.query({ category: Optional(String) })
.handle((ctx) => {
return { items: [] };
});
export const POST = endpoint()
.body({ name: String, price: Number })
.handle(async (ctx) => {
const item = await ctx.body();
return { created: item };
});Error handling
Throwing HTTP exceptions
import { endpoint, Uuid } from '@putnami/application';
import { NotFoundException, ForbiddenException } from '@putnami/runtime';
export default endpoint()
.params({ id: Uuid })
.handle(async (ctx) => {
const user = await findUser(ctx.params.id);
if (!user) {
throw new NotFoundException(`User ${ctx.params.id} not found`);
}
if (!hasAccess(ctx, user)) {
throw new ForbiddenException('You cannot access this user');
}
return user;
});Available exception classes: BadRequestException, UnauthorizedException, ForbiddenException, NotFoundException, ConflictException.
OpenAPI generation
The API plugin can generate an OpenAPI 3.0.3 specification from your routes. HTTP routes defined with the endpoint() builder automatically contribute their .params(), .query(), .body(), .returns(), .throws(), .description(), and .secure() metadata to the spec. Schema-level Desc() descriptions are included on individual properties.
Build-time generation
Enable build-time spec generation with the openapi config option. An openapi.json file is written to .gen/ during the build step.
export const app = () =>
application()
.use(http())
.use(
api({
openapi: {
title: 'My API',
version: '1.0.0',
description: 'Service description',
servers: [{ url: 'https://api.example.com' }],
},
}),
);Runtime generation
You can also generate the spec at runtime from the plugin instance:
const apiPlugin = api();
// ... after warmup
const spec = apiPlugin.openapi({
info: { title: 'My API', version: '1.0.0' },
});OpenAPI configuration options
api({
scanFolder: 'api', // Folder to scan for routes (default: 'api')
autoScan: true, // Auto-discover routes (default: true)
prefix: '/api', // URL prefix for all routes (optional)
openapi: { // OpenAPI spec generation (optional)
title: 'My API', // Required: API title
version: '1.0.0', // Required: API version
description: '...', // Optional: API description
servers: [ // Optional: server URLs
{ url: 'https://api.example.com', description: 'Production' },
],
},
})Response validation
When a route declares a .returns() schema, the framework validates handler responses in non-production environments (NODE_ENV !== 'production'). Mismatches are logged as warnings without interrupting the request, so your dev server keeps running while you catch contract drift early.
WARN [response-validation] /users — response.email is requiredResponse validation catches:
- Missing required fields
- Type mismatches (e.g., returning a number where a string is expected)
- Constraint violations (e.g., invalid UUID format)
Responses that are HttpResponse instances, undefined/null, or non-JSON strings are skipped. In production, response validation is disabled entirely.