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:
@fastify/sensible — adds reply.notFound(), reply.badRequest(), etc.
@fastify/helmet — secure headers by default.
@fastify/cors — configured from env.CORS_ORIGINS (comma-separated, no *
in production).
@fastify/rate-limit — global: 100 req/min per IP; per-route overrides allowed.
@fastify/swagger + @fastify/swagger-ui — OpenAPI 3.1 spec generator.
Step 4 — Error handler
Every error response uses this envelope:
{
"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:
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:
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
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:
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.
1---2name: scaffolding-rest-api3description: 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.4---56# Scaffolding REST API78## Overview910This skill creates a new TypeScript REST API project or adds a new resource to an11existing Fastify project. It enforces one opinionated stack (Fastify + Zod +12pino + Vitest) and generates code that follows the same validation, error, and13observability patterns across every endpoint.1415## Quick reference1617| Task | Approach |18|---|---|19| New project | `scripts/init.sh <project-name>` |20| Add a resource | `scripts/gen_resource.ts <name> <fields-yaml>` |21| Input validation | Zod schema per route; `schema: { body, params, query }` in Fastify |22| Error envelope | `{ error: { code, message, details? } }` via `fastify.setErrorHandler` |23| Rate limiting | `@fastify/rate-limit`, 100 req/min default, configurable per route |24| Health check | `GET /healthz` (liveness), `GET /readyz` (readiness with DB ping) |25| OpenAPI spec | `@fastify/swagger` + Zod-to-JSON-Schema; exported at `GET /openapi.json` |26| Graceful shutdown | `closeGracefully` with 10s timeout on SIGTERM |27| Logging | pino with `req.id`, `req.method`, `req.url`, `res.statusCode`, `res.ms` |2829## Workflow3031```32Scaffold progress:33- [ ] Step 1: Confirm project name and target directory34- [ ] Step 2: Run init.sh to create the skeleton35- [ ] Step 3: Wire base plugins (sensible, cors, helmet, rate-limit, swagger)36- [ ] Step 4: Configure error handler + logger + graceful shutdown37- [ ] Step 5: Add health and readiness endpoints38- [ ] Step 6: For each resource the user describes, run gen_resource.ts39- [ ] Step 7: Generate OpenAPI spec and verify it parses40- [ ] Step 8: Run the test suite and lint41- [ ] Step 9: Print next steps (run locally, deploy, add auth)42```4344### Step 1 — Confirm inputs4546Ask the user:47- Project name (default kebab-case).48- Package manager (pnpm preferred; accept npm/yarn if user insists).49- Target directory (must not already contain a `package.json`).5051### Step 2 — Create skeleton5253```54scripts/init.sh <project-name>55```5657Produces this layout:5859```60<project>/61├── package.json62├── tsconfig.json63├── vitest.config.ts64├── .env.example65├── src/66│ ├── app.ts # Fastify instance + plugin wiring67│ ├── server.ts # HTTP listen + graceful shutdown68│ ├── lib/69│ │ ├── env.ts # Zod-validated env loader70│ │ ├── errors.ts # AppError class + codes71│ │ └── logger.ts # pino instance72│ └── routes/73│ └── health.ts # /healthz + /readyz74└── test/75 └── health.test.ts76```7778### Step 3 — Base plugins7980Wire these in `src/app.ts` in this exact order:81821. `@fastify/sensible` — adds `reply.notFound()`, `reply.badRequest()`, etc.832. `@fastify/helmet` — secure headers by default.843. `@fastify/cors` — configured from `env.CORS_ORIGINS` (comma-separated, no `*`85 in production).864. `@fastify/rate-limit` — global: 100 req/min per IP; per-route overrides allowed.875. `@fastify/swagger` + `@fastify/swagger-ui` — OpenAPI 3.1 spec generator.8889### Step 4 — Error handler9091Every error response uses this envelope:9293```json94{95 "error": {96 "code": "validation_error",97 "message": "body.email is not a valid email",98 "details": [{"path": "body.email", "issue": "invalid_string"}]99 }100}101```102103Map Zod issues to `validation_error`. Map unknown errors to `internal_error` and104**do not leak the stack**; log it at `error` level with the request id instead.105106### Step 5 — Health endpoints107108- `GET /healthz` — returns 200 immediately. Used by load balancer liveness.109- `GET /readyz` — pings every registered dependency (DB, cache, queue) with a110 2s timeout; returns 200 if all healthy, 503 otherwise.111112### Step 6 — Resource generator113114The user describes a resource like this:115116```yaml117name: user118fields:119 id: { type: uuid, readonly: true }120 email: { type: email, required: true }121 name: { type: string, max: 120 }122 createdAt: { type: datetime, readonly: true }123operations: [list, get, create, update, delete]124auth: required125```126127`scripts/gen_resource.ts` emits:128- `src/routes/<name>.ts` — one file per resource, all handlers present.129- `src/schemas/<name>.ts` — Zod schemas (Create, Update, Public).130- `test/<name>.test.ts` — 5 tests per operation (200, 400, 401, 404, 500).131132### Step 7 — OpenAPI spec133134Run the app in "spec" mode:135136```bash137pnpm spec > openapi.json138```139140Validate with a JSON Schema validator before shipping. The `GET /openapi.json`141endpoint is live in dev; disable in production unless the API is public.142143### Step 8 — Tests and lint144145```bash146pnpm test147pnpm lint148pnpm tsc --noEmit149```150151All three must pass before declaring done.152153### Step 9 — Next steps154155Print a short checklist:156- How to run locally: `pnpm dev`157- How to build: `pnpm build`158- Where to add auth next (suggest `@fastify/jwt` with asymmetric keys)159- Where to add DB layer next (suggest Drizzle + Postgres)160161## Validation conventions162163- One Zod schema per route at `route.schema = { body, params, query, response }`.164- `response` schemas are REQUIRED on every route. No implicit serialization.165- Error responses always match the envelope; do not pass through library errors.166- Every `POST`/`PUT`/`PATCH` has a body schema with `strict()` to reject unknown keys.167168## Rate limiting defaults169170- Global: 100 req/min per IP.171- `POST /auth/*` (if added later): 10 req/min per IP.172- `POST /password-reset`: 3 req/15min per IP + 3 req/15min per email hash.173174## Logging conventions175176Use pino bindings:177178```ts179req.log.info({ userId, action: "user.create" }, "user created");180```181182Do not log request bodies. Do not log Authorization headers. Do not log any field183named `password`, `token`, `secret`, `key`, or matching `*_secret`.184185## Examples186187**Example 1 — Bootstrap a greenfield API**188Input: "bootstrap a REST API for a URL shortener with create/redirect/list"189Behavior: Creates the project skeleton, generates a `link` resource with operations190`[create, get, list]`, adds a custom `GET /:shortcode` redirect route, emits191OpenAPI spec, runs tests, prints deploy notes.192193**Example 2 — Add a resource to an existing project**194Input: "add an Invoice resource to my Fastify API"195Behavior: Detects existing `src/routes` layout, generates `invoice.ts`, schemas,196and tests following the same patterns as existing resources. Does NOT overwrite197existing files — asks before each write.198199**Example 3 — Generate only the OpenAPI spec**200Input: "export the OpenAPI spec for my API"201Behavior: Runs the app in spec mode, writes `openapi.json`, validates it, prints202a summary of endpoints/schemas.203204## Non-goals205206- No GraphQL. No gRPC. No tRPC. (Other skills exist.)207- No database schema design (use a DB skill).208- No auth implementation (that's a separate skill; this one leaves a clear hook).209- No deployment scripts (that's the deployment category).210- No framework migration (does not convert Express → Fastify).211212## Security213214- **Scope**: operates within the target project directory only. Refuses paths with215 `..` or absolute paths outside the project root.216- **No silent network calls**. Package installs use the user's local registry; no217 `curl | sh`, no vendored binaries. Confirms before running any `pnpm install`.218- **No env exfiltration**. Never reads `~/.ssh`, `~/.aws`, `.env` from other219 projects, or shell history. The `.env.example` file generated has placeholders only.220- **Destructive confirm**. Before overwriting any existing file (including221 `package.json`, `tsconfig.json`), prints a diff and asks for y/N.222- **Least-privilege `allowed-tools`**: `Read`, `Write`, `Edit`, `Glob`, `Grep`,223 `Bash(pnpm *)`, `Bash(npm *)`, `Bash(node *)`. No `WebFetch`, no `WebSearch`.224- **Secure defaults in generated code**: helmet on, CORS origin list (never `*` in225 prod), rate limiter on, error handler that never leaks stack traces, logger226 that never logs bodies or auth headers.227- **Dependencies**: pinned exact versions in generated `package.json`. Documents228 that `pnpm audit` should be run before shipping.