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' });
    });
  });
});