# Scaffolding REST API

> Scaffolds a production-ready REST API in TypeScript using Fastify, Zod for input validation, pino for structured logging, and emits an OpenAPI 3.1 spec. Use when the user asks to "scaffold a REST API", "start a new backend", "bootstrap a Node API", or mentions Fastify, Zod, OpenAPI, rate limiting, or structured error responses. Covers project layout, validation pipeline, error envelope, rate limiting with @fastify/rate-limit, health and readiness endpoints, graceful shutdown, and OpenAPI generation. Do NOT use for GraphQL schemas, gRPC services, Python/Go/Ruby backends, full-stack frameworks (Next.js, Remix), or for adding routes to an existing non-Fastify codebase.

- Skill: `criseulises/scaffolding-rest-api` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add criseulises/scaffolding-rest-api`
- Raw SKILL.md: https://api.skillmd.com/api/skills/criseulises/scaffolding-rest-api/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: criseulises (https://skillmd.com/u/criseulises)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/criseulises/scaffolding-rest-api

---


# Scaffolding REST API

## Overview

This skill creates a new TypeScript REST API project or adds a new resource to an
existing Fastify project. It enforces one opinionated stack (Fastify + Zod +
pino + Vitest) and generates code that follows the same validation, error, and
observability patterns across every endpoint.

## Quick reference

| Task | Approach |
|---|---|
| New project | `scripts/init.sh <project-name>` |
| Add a resource | `scripts/gen_resource.ts <name> <fields-yaml>` |
| Input validation | Zod schema per route; `schema: { body, params, query }` in Fastify |
| Error envelope | `{ error: { code, message, details? } }` via `fastify.setErrorHandler` |
| Rate limiting | `@fastify/rate-limit`, 100 req/min default, configurable per route |
| Health check | `GET /healthz` (liveness), `GET /readyz` (readiness with DB ping) |
| OpenAPI spec | `@fastify/swagger` + Zod-to-JSON-Schema; exported at `GET /openapi.json` |
| Graceful shutdown | `closeGracefully` with 10s timeout on SIGTERM |
| Logging | pino with `req.id`, `req.method`, `req.url`, `res.statusCode`, `res.ms` |

## Workflow

```
Scaffold progress:
- [ ] Step 1: Confirm project name and target directory
- [ ] Step 2: Run init.sh to create the skeleton
- [ ] Step 3: Wire base plugins (sensible, cors, helmet, rate-limit, swagger)
- [ ] Step 4: Configure error handler + logger + graceful shutdown
- [ ] Step 5: Add health and readiness endpoints
- [ ] Step 6: For each resource the user describes, run gen_resource.ts
- [ ] Step 7: Generate OpenAPI spec and verify it parses
- [ ] Step 8: Run the test suite and lint
- [ ] Step 9: Print next steps (run locally, deploy, add auth)
```

### Step 1 — Confirm inputs

Ask the user:
- Project name (default kebab-case).
- Package manager (pnpm preferred; accept npm/yarn if user insists).
- Target directory (must not already contain a `package.json`).

### Step 2 — Create skeleton

```
scripts/init.sh <project-name>
```

Produces this layout:

```
<project>/
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── .env.example
├── src/
│   ├── app.ts                  # Fastify instance + plugin wiring
│   ├── server.ts               # HTTP listen + graceful shutdown
│   ├── lib/
│   │   ├── env.ts              # Zod-validated env loader
│   │   ├── errors.ts           # AppError class + codes
│   │   └── logger.ts           # pino instance
│   └── routes/
│       └── health.ts           # /healthz + /readyz
└── test/
    └── health.test.ts
```

### Step 3 — Base plugins

Wire these in `src/app.ts` in this exact order:

1. `@fastify/sensible` — adds `reply.notFound()`, `reply.badRequest()`, etc.
2. `@fastify/helmet` — secure headers by default.
3. `@fastify/cors` — configured from `env.CORS_ORIGINS` (comma-separated, no `*`
   in production).
4. `@fastify/rate-limit` — global: 100 req/min per IP; per-route overrides allowed.
5. `@fastify/swagger` + `@fastify/swagger-ui` — OpenAPI 3.1 spec generator.

### Step 4 — Error handler

Every error response uses this envelope:

```json
{
  "error": {
    "code": "validation_error",
    "message": "body.email is not a valid email",
    "details": [{"path": "body.email", "issue": "invalid_string"}]
  }
}
```

Map Zod issues to `validation_error`. Map unknown errors to `internal_error` and
**do not leak the stack**; log it at `error` level with the request id instead.

### Step 5 — Health endpoints

- `GET /healthz` — returns 200 immediately. Used by load balancer liveness.
- `GET /readyz` — pings every registered dependency (DB, cache, queue) with a
  2s timeout; returns 200 if all healthy, 503 otherwise.

### Step 6 — Resource generator

The user describes a resource like this:

```yaml
name: user
fields:
  id: { type: uuid, readonly: true }
  email: { type: email, required: true }
  name: { type: string, max: 120 }
  createdAt: { type: datetime, readonly: true }
operations: [list, get, create, update, delete]
auth: required
```

`scripts/gen_resource.ts` emits:
- `src/routes/<name>.ts` — one file per resource, all handlers present.
- `src/schemas/<name>.ts` — Zod schemas (Create, Update, Public).
- `test/<name>.test.ts` — 5 tests per operation (200, 400, 401, 404, 500).

### Step 7 — OpenAPI spec

Run the app in "spec" mode:

```bash
pnpm spec > openapi.json
```

Validate with a JSON Schema validator before shipping. The `GET /openapi.json`
endpoint is live in dev; disable in production unless the API is public.

### Step 8 — Tests and lint

```bash
pnpm test
pnpm lint
pnpm tsc --noEmit
```

All three must pass before declaring done.

### Step 9 — Next steps

Print a short checklist:
- How to run locally: `pnpm dev`
- How to build: `pnpm build`
- Where to add auth next (suggest `@fastify/jwt` with asymmetric keys)
- Where to add DB layer next (suggest Drizzle + Postgres)

## Validation conventions

- One Zod schema per route at `route.schema = { body, params, query, response }`.
- `response` schemas are REQUIRED on every route. No implicit serialization.
- Error responses always match the envelope; do not pass through library errors.
- Every `POST`/`PUT`/`PATCH` has a body schema with `strict()` to reject unknown keys.

## Rate limiting defaults

- Global: 100 req/min per IP.
- `POST /auth/*` (if added later): 10 req/min per IP.
- `POST /password-reset`: 3 req/15min per IP + 3 req/15min per email hash.

## Logging conventions

Use pino bindings:

```ts
req.log.info({ userId, action: "user.create" }, "user created");
```

Do not log request bodies. Do not log Authorization headers. Do not log any field
named `password`, `token`, `secret`, `key`, or matching `*_secret`.

## Examples

**Example 1 — Bootstrap a greenfield API**
Input: "bootstrap a REST API for a URL shortener with create/redirect/list"
Behavior: Creates the project skeleton, generates a `link` resource with operations
`[create, get, list]`, adds a custom `GET /:shortcode` redirect route, emits
OpenAPI spec, runs tests, prints deploy notes.

**Example 2 — Add a resource to an existing project**
Input: "add an Invoice resource to my Fastify API"
Behavior: Detects existing `src/routes` layout, generates `invoice.ts`, schemas,
and tests following the same patterns as existing resources. Does NOT overwrite
existing files — asks before each write.

**Example 3 — Generate only the OpenAPI spec**
Input: "export the OpenAPI spec for my API"
Behavior: Runs the app in spec mode, writes `openapi.json`, validates it, prints
a summary of endpoints/schemas.

## Non-goals

- No GraphQL. No gRPC. No tRPC. (Other skills exist.)
- No database schema design (use a DB skill).
- No auth implementation (that's a separate skill; this one leaves a clear hook).
- No deployment scripts (that's the deployment category).
- No framework migration (does not convert Express → Fastify).

## Security

- **Scope**: operates within the target project directory only. Refuses paths with
  `..` or absolute paths outside the project root.
- **No silent network calls**. Package installs use the user's local registry; no
  `curl | sh`, no vendored binaries. Confirms before running any `pnpm install`.
- **No env exfiltration**. Never reads `~/.ssh`, `~/.aws`, `.env` from other
  projects, or shell history. The `.env.example` file generated has placeholders only.
- **Destructive confirm**. Before overwriting any existing file (including
  `package.json`, `tsconfig.json`), prints a diff and asks for y/N.
- **Least-privilege `allowed-tools`**: `Read`, `Write`, `Edit`, `Glob`, `Grep`,
  `Bash(pnpm *)`, `Bash(npm *)`, `Bash(node *)`. No `WebFetch`, no `WebSearch`.
- **Secure defaults in generated code**: helmet on, CORS origin list (never `*` in
  prod), rate limiter on, error handler that never leaks stack traces, logger
  that never logs bodies or auth headers.
- **Dependencies**: pinned exact versions in generated `package.json`. Documents
  that `pnpm audit` should be run before shipping.

