Skip to content

Tutorial

Let's build an API step by step. Each step adds one concept on top of the last — and explains the why, not just the how. 💡

Before you start

If you haven't run an app yet, do Your first app first — it sets up the folder, installs everything and shows how to run with npx tsx. Here we assume you can create a .ts file and run it. Hit a strange term? The Glossary explains it.

Put each example in a file (e.g. app.ts) and run it with npx tsx app.ts.


1. The first app

createApp assembles a service's conventional "skeleton" — JSON parsing, a per-request id, CORS, a health check and error handling — and returns a ready Express app. runServer starts it.

app.ts
import { createApp, runServer } from "tempest-express-sdk";

const app = await createApp();
await runServer(app, { port: 8000 });

Run it (npx tsx app.ts) and open http://127.0.0.1:8000/health{"status":"ok","checks":{}}. ✅

What is await?

createApp and runServer are asynchronous (they return a Promise), hence the await. If that's new, take a look at async/await.

Recap: createApp() + runServer() = a live server, with /health for free.


2. A Zod schema with OpenAPI

A schema describes the shape of some data. Import z from the SDK (it's Zod already carrying .openapi()) and register the schema in a "registry" — it becomes documentation later:

import { createApp, createOpenApiRegistry, runServer, z } from "tempest-express-sdk";

const registry = createOpenApiRegistry();

const itemSchema = registry.register(
  "Item",
  z.object({
    id: z.string().uuid().openapi({ description: "The item identifier." }),
    name: z.string().openapi({ description: "The item name." }),
  }),
);

z.object({...}) says "an object with these fields"; z.string().uuid() says "a string that is a UUID". The .openapi({ description }) attaches the description that shows up in the docs.

Recap: you described Item once. Soon that becomes validation and documentation.


3. Routes + native documentation

A route links a path (/api/items) to a function that answers. Pass openapi to createApp and register routes inside configure:

const app = await createApp({
  openapi: { registry, info: { title: "Catalog", version: "1.0.0" } },
  configure: (app) => {
    registry.registerPath({
      method: "get",
      path: "/api/items",
      summary: "List items",
      responses: {
        200: { description: "OK", content: { "application/json": { schema: itemSchema.array() } } },
      },
    });
    app.get("/api/items", (_req, res) => res.json([]));
  },
});

await runServer(app, { port: 8000 });

Two things happen: registry.registerPath({...}) documents the route, and app.get(...) implements it. The function (_req, res) => res.json([]) is the handler — it takes the request (req) and the response (res), and returns an empty list for now.

Free docs

Open /docs (Swagger UI) and /redoc — both generated from the registry, no hand-written docs.

Why _req with an underscore?

A convention: the leading _ says "I don't use this parameter". Here the handler ignores the request and always returns [].

Recap: registerPath documents, app.get/post/... implements. Swagger and Redoc come ready.


4. Input validation

When the client sends data (a POST), you validate it with a schema. Invalid data becomes 422 in the standard envelope, automatically:

const createSchema = z.object({ name: z.string().min(1) });

app.post("/api/items", (req, res) => {
  const data = createSchema.parse(req.body); // throws ZodError → 422
  res.status(201).json({ id: crypto.randomUUID(), ...data });
});

createSchema.parse(req.body) checks the request body. If name is missing or empty, .parse throws an error that the SDK turns into this response:

{ "detail": "Validation error", "code": "VALIDATION_ERROR", "details": { "issues": [ ] } }

If it passes, you answer 201 Created with the new item (crypto.randomUUID() generates an id).

You don't write the try/catch

The error handler createApp installed catches the ZodError and builds the 422. You just validate and move on.

Recap: schema.parse(req.body) validates input; errors become 422 on their own; success answers 201.


5. Domain errors

When your rule fails (item doesn't exist, duplicate email), throw a subclass of AppException — the handler serializes it to the same envelope, with the right status:

import { NotFoundException } from "tempest-express-sdk";

app.get("/api/items/:id", (req) => {
  throw new NotFoundException({
    message: "Item not found",
    details: { id: req.params.id },
  });
});

→ HTTP 404, {"detail":"Item not found","code":"NOT_FOUND","details":{"id":"…"}}.

:id in the route is a parameter: in /api/items/42, req.params.id is "42". There are ready subclasses for the common cases — NotFoundException (404), ConflictException (409), UnauthorizedException (401), etc.

Recap: throw AppException (or a subclass) and the envelope + status stay consistent across the whole API.


6. Layered persistence

So far the data was made up on the spot. To store it for real, you define a model (the shape of a table) and get a typed repository over tempest-db-js:

import { BaseModel, column, tableNameFor } from "tempest-express-sdk";

class ItemModel extends BaseModel {
  static tablename = tableNameFor("ItemModel"); // "item"
  name = column.text().notNull();
}

BaseModel already ships id (UUID), isActive, createdAt and updatedAt — you only declare your domain columns (here, name). Over that model, the SDK offers the full repository → service → controller → router stack:

  • repository — reads and writes rows.
  • service — business logic; maps the row to the response.
  • controller — orchestrates.
  • router — exposes the routes.

Run npx tempest-express generate Item and all of it is generated for you.

The full database guide

Modeling tables, connecting the engine, filtering, paginating and migrating is in Database (models + repositories). To test without a real database, see Testing.

Recap: extend BaseModel, declare your columns, and get the typed stack — or generate it with one command.


Recap

You built an app, described a schema with OpenAPI, served native Swagger/Redoc, validated input (422), threw domain errors (with the right status) and met the data layer. That's the skeleton of any Tempest service. 🎉

Continue with: