For AI agents: the complete documentation index is available at https://dotpress.dev/llms.txt, the full documentation bundle is available at https://dotpress.dev/llms-full.txt, and this page is available as Markdown at https://dotpress.dev/examples/express.md.

Express

A small tasks API on Express:

  • typed routes in a group (/tasks), with parameters, query and body validated by Zod schemas;
  • errors returned by the handlers (notFoundError(), conflictError()), documented in meta.responses;
  • an OpenAPI 3.1 document generated from the routes (/openapi.json), and its Scalar API reference (/docs);
  • tests of the app in memory with @dotpress/testing, and on the real Express server.

Source: examples/express.

Run it

npx degit dotpressjs/dotpress/examples/express my-api
cd my-api
npm install
npm run dev

The routes

import {
  conflictError,
  createRouter,
  empty,
  notFoundError,
} from '@dotpress/core'
import { z } from 'zod'
import type { TaskStore } from './tasks.ts'

// A schema with an identifier is declared once in the OpenAPI document
// (components/schemas), and referenced by the routes.
const Task = z
  .object({
    id: z.uuid(),
    title: z.string(),
    done: z.boolean(),
    createdAt: z.iso.datetime(),
  })
  .meta({ id: 'Task', description: 'A task to do' })

const Title = z.string().trim().min(1).max(200)

const TASK_NOT_FOUND = 'Task not found'
const DUPLICATE_TITLE = 'A task with this title already exists'

/**
 * Routes of the tasks. The store is a parameter: tests and environments
 * give their own, without a dependency injection container.
 */
export const tasksRouter = (store: TaskStore) => {
  const router = createRouter()
  const tasks = router.createGroup('/tasks')

  tasks.defineRoute({
    path: '/',
    schema: (z) => ({
      query: z.object({ done: z.stringbool().optional() }),
      response: z.array(Task),
    }),
    meta: { summary: 'List the tasks', tags: ['tasks'] },
    // req.query: { done?: boolean }
    handler: ({ req }) => store.list({ done: req.query.done }),
  })

  tasks.defineRoute({
    method: 'post',
    path: '/',
    status: 201,
    schema: (z) => ({
      body: z.object({ title: Title }),
      response: Task,
    }),
    meta: {
      summary: 'Create a task',
      tags: ['tasks'],
      responses: { 409: DUPLICATE_TITLE },
    },
    handler: async ({ req }) => {
      // Errors are returned, not thrown: they are part of the handler type.
      if (await store.findByTitle(req.body.title)) {
        return conflictError(DUPLICATE_TITLE)
      }
      return store.create(req.body.title)
    },
  })

  tasks.defineRoute({
    path: '/:id',
    schema: () => ({ response: Task }),
    meta: {
      summary: 'Get a task',
      tags: ['tasks'],
      responses: { 404: TASK_NOT_FOUND },
    },
    // req.params: { id: string }, from the path
    handler: async ({ req }) =>
      (await store.get(req.params.id)) ?? notFoundError(TASK_NOT_FOUND),
  })

  tasks.defineRoute({
    method: 'patch',
    path: '/:id',
    schema: (z) => ({
      body: z.object({ title: Title.optional(), done: z.boolean().optional() }),
      response: Task,
    }),
    meta: {
      summary: 'Update a task',
      tags: ['tasks'],
      responses: { 404: TASK_NOT_FOUND, 409: DUPLICATE_TITLE },
    },
    handler: async ({ req }) => {
      const { title } = req.body
      if (title !== undefined) {
        const existing = await store.findByTitle(title)
        if (existing && existing.id !== req.params.id) {
          return conflictError(DUPLICATE_TITLE)
        }
      }
      return (
        (await store.update(req.params.id, req.body)) ??
        notFoundError(TASK_NOT_FOUND)
      )
    },
  })

  tasks.defineRoute({
    method: 'delete',
    path: '/:id',
    status: 204,
    meta: {
      summary: 'Delete a task',
      tags: ['tasks'],
      responses: { 404: TASK_NOT_FOUND },
    },
    handler: async ({ req }) =>
      (await store.remove(req.params.id))
        ? empty()
        : notFoundError(TASK_NOT_FOUND),
  })

  return router
}

The app

The app takes its adapter as a parameter: Express in server.ts, the memory adapter of @dotpress/testing in the tests.

import { createApp, type HttpAdapter, type Logger } from '@dotpress/core'
import { openapiPlugin } from '@dotpress/openapi'
import { tasksRouter } from './routes.ts'
import { createTaskStore, type TaskStore } from './tasks.ts'

export interface TasksAppOptions<TAdapter extends HttpAdapter> {
  adapter: TAdapter
  store?: TaskStore
  logger?: Logger
}

/**
 * Creates the app on any adapter: Express in `server.ts`, the memory
 * adapter of `@dotpress/testing` in the tests.
 */
export const createTasksApp = <TAdapter extends HttpAdapter>({
  adapter,
  store = createTaskStore(),
  logger,
}: TasksAppOptions<TAdapter>) =>
  createApp({
    adapter,
    ...(logger && { logger }),
    routers: [tasksRouter(store)],
    plugins: [
      // GET /openapi.json: the OpenAPI document, GET /docs: the Scalar page
      openapiPlugin({
        info: { title: 'Tasks API', version: '1.0.0' },
        tags: [{ name: 'tasks', description: 'Tasks to do' }],
      }),
    ],
  })

The server

import { expressAdapter } from '@dotpress/adapter-express'
import { createTasksApp } from './app.ts'

const port = Number(process.env.PORT ?? 3000)

const app = await createTasksApp({ adapter: expressAdapter() })
await app.listen(port)
console.log(`Tasks API: http://localhost:${port}/tasks`)
console.log(`API reference: http://localhost:${port}/docs`)

// Waits for pending requests, then runs the onClose hooks of the plugins.
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
  process.once(signal, () => void app.close())
}

Learn more