REST API example
Try it
Start your own copy of this example:
npm create @setu-ts my-app -- --example rest-api
cd my-app && npm install
npm start
The commands further down run inside a clone of the repository.
An in-memory todo API built with the REST starter. It enables the starter’s auth and OpenAPI arms
and adds POST /todos and GET /todos/:id.
pnpm --dir apps/rest-api run start
pnpm --dir apps/rest-api run smoke
The smoke check writes a todo, reads it back through the same API, and then verifies the OpenAPI
document is actually usable: it declares the bearerAuth scheme (so Swagger UI renders an
Authorize button and the protected routes can be tried), it carries a document-level security
requirement, it does not list /openapi.json and /docs as API operations, and its id path
parameter is typed as a string rather than rendering as any.
Open http://localhost:3000/docs after pnpm --dir apps/rest-api run start to exercise the API from
the browser: click Authorize, paste a token, and call GET /todos/{id}.
Read the example deep dive for the concept and source
walkthrough.
Read, in order
src/modules/todos/index.ts
import type { IRouterApi } from '@setu-ts/common';
import { registerTodosRoutes } from './todos.controller.ts';
import { createTodoStore } from './todos.service.ts';
/** Registers the todos feature, with a store owned by this application. */
export function registerTodosModule(router: IRouterApi): void {
registerTodosRoutes(router, createTodoStore());
}
src/modules/todos/todos.controller.ts
import { CAPABILITIES } from '@setu-ts/common';
import type { IRouterApi, IRuntimeServices } from '@setu-ts/common';
import { requireAuth } from '@setu-ts/auth-plugin';
import { notFound } from '@setu-ts/exceptions';
import { validateBody } from '@setu-ts/validation-plugin';
import { ProblemDetails } from '../../shared/schemas/problem-details.schemas.ts';
import { CreateTodoBody, TodoResponse } from './todos.schemas.ts';
import type { TodoStore } from './todos.service.ts';
const createBody = validateBody(CreateTodoBody);
/**
* Registers the authenticated todo routes.
*
* The request body is documented from `validateBody`, and the `:id` path
* parameter is documented as a string without a `params` schema.
*/
export function registerTodosRoutes(router: IRouterApi, todos: TodoStore): void {
router.group('/todos', (routes) => {
routes.post('/', {
middleware: [requireAuth(), createBody],
schema: {
tags: ['Todos'],
summary: 'Create a todo',
response: { 201: TodoResponse, 401: ProblemDetails },
},
handler: (ctx) => {
const id = ctx.services.get<IRuntimeServices>(CAPABILITIES.RUNTIME).uuid();
return ctx.response.status(201).json(todos.create(id, createBody.value(ctx)));
},
});
routes.get('/:id', {
middleware: [requireAuth()],
schema: {
tags: ['Todos'],
summary: 'Read a todo',
response: { 200: TodoResponse, 401: ProblemDetails, 404: ProblemDetails },
},
handler: (ctx) => {
const todo = todos.get(ctx.params.id);
if (todo === undefined) throw notFound(`Todo ${ctx.params.id} was not found.`);
return ctx.response.json(todo);
},
});
});
}
src/modules/todos/todos.schemas.ts
import { z } from 'zod';
export const CreateTodoBody = z.object({ title: z.string().min(1) });
export type CreateTodoBody = z.infer<typeof CreateTodoBody>;
export const TodoResponse = CreateTodoBody.extend({ id: z.string() });
export type TodoResponse = z.infer<typeof TodoResponse>;
src/modules/todos/todos.service.ts
import type { CreateTodoBody, TodoResponse } from './todos.schemas.ts';
/** Stores todos. A real service would use a repository from `DatabasePlugin`. */
export interface TodoStore {
create(id: string, body: CreateTodoBody): TodoResponse;
get(id: string): TodoResponse | undefined;
}
/** Creates an in-memory store owned by one application. */
export function createTodoStore(): TodoStore {
const todos = new Map<string, TodoResponse>();
return {
create(id, body) {
const todo = { ...body, id };
todos.set(id, todo);
return todo;
},
get: (id) => todos.get(id),
};
}
setu.config.ts
import type { IKernelApplication } from '@setu-ts/kernel';
import { createRestApp } from '@setu-ts/rest-starter';
import { DEMO_JWT_SECRET } from './src/auth/demo-token.ts';
import { registerModules } from './src/modules/index.ts';
/** Builds the todo API on the REST starter, without starting it. */
export function createApp(): IKernelApplication {
const app = createRestApp({
auth: {
jwt: { secret: DEMO_JWT_SECRET },
rbac: { roles: {} },
},
openapi: {
title: 'Todo API',
version: '1.0.0',
// The security scheme is what gives Swagger UI its Authorize button.
securitySchemes: {
bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
},
security: [{ bearerAuth: [] }],
// Probe and metrics endpoints are operational, not part of the todo API.
exclude: ['/health', '/live', '/ready', '/metrics'],
},
});
registerModules(app.router);
return app;
}
smoke.ts
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import type { IKernelApplication } from '@setu-ts/kernel';
import { issueDemoToken } from './src/auth/demo-token.ts';
import { createApp } from './setu.config.ts';
interface OpenApiDocument {
readonly security?: unknown;
readonly components?: { readonly securitySchemes?: Record<string, unknown> };
readonly paths: Record<
string,
Record<string, { readonly parameters?: readonly { readonly schema?: unknown }[] }>
>;
}
describe('rest-api example', () => {
let app: IKernelApplication;
let authorization: { authorization: string };
before(async () => {
app = createApp();
await app.start();
authorization = { authorization: `Bearer ${await issueDemoToken(app)}` };
});
after(async () => {
await app.stop();
});
it('refuses an unauthenticated write', async () => {
const response = await app.inject({ method: 'POST', url: '/todos', body: { title: 'x' } });
assert.equal(response.statusCode, 401);
});
it('refuses an invalid body before creating anything', async () => {
const response = await app.inject({
method: 'POST',
url: '/todos',
headers: authorization,
body: { title: '' },
});
assert.equal(response.statusCode, 400);
});
it('reads back a todo it created', async () => {
const created = await app.inject({
method: 'POST',
url: '/todos',
headers: authorization,
body: { title: 'Write an example' },
});
assert.equal(created.statusCode, 201);
const todo = created.json<{ id: string; title: string }>();
const read = await app.inject({
method: 'GET',
url: `/todos/${todo.id}`,
headers: authorization,
});
assert.equal(read.statusCode, 200);
assert.deepEqual(read.json(), todo);
});
it('keeps each application its own store', async () => {
const created = await app.inject({
method: 'POST',
url: '/todos',
headers: authorization,
body: { title: 'Only in the first application' },
});
const { id } = created.json<{ id: string }>();
const other = createApp();
await other.start();
try {
const token = { authorization: `Bearer ${await issueDemoToken(other)}` };
const read = await other.inject({ method: 'GET', url: `/todos/${id}`, headers: token });
assert.equal(read.statusCode, 404);
} finally {
await other.stop();
}
});
it('answers an unknown todo with a Problem Details 404', async () => {
const response = await app.inject({
method: 'GET',
url: '/todos/missing',
headers: authorization,
});
assert.equal(response.statusCode, 404);
assert.equal(response.json<{ status: number }>().status, 404);
});
describe('the OpenAPI document', () => {
let spec: OpenApiDocument;
before(async () => {
const response = await app.inject({ method: 'GET', url: '/openapi.json' });
assert.equal(response.statusCode, 200);
spec = response.json<OpenApiDocument>();
});
it('declares the bearer scheme Swagger UI needs for its Authorize button', () => {
assert.ok(spec.components?.securitySchemes?.['bearerAuth']);
assert.deepEqual(spec.security, [{ bearerAuth: [] }]);
});
it('documents exactly the todo operations', () => {
assert.deepEqual(Object.keys(spec.paths).sort(), ['/todos', '/todos/{id}']);
});
it('types the undeclared id path parameter as a string', () => {
const parameter = spec.paths['/todos/{id}']?.['get']?.parameters?.[0];
assert.deepEqual(parameter?.schema, { type: 'string' });
});
});
});