A small library-management REST service written in TypeScript on the
de-facto standard Node.js web stack: Fastify for HTTP, Zod for
schema-first validation and serialization (typed end-to-end via
fastify-type-provider-zod),
Pino (bundled with Fastify) for structured logging, @fastify/basic-auth
for HTTP Basic, and @fastify/swagger + @fastify/swagger-ui for an
auto-generated OpenAPI surface at /docs. Tests run on Vitest using
Fastify's inject() for fast, in-process HTTP simulation.
The book catalog itself is kept in an in-process Map — there is no database,
no persistence, and no external infrastructure. All state is lost on restart.
- TypeScript-first. Strict
tsconfig(strict,noUncheckedIndexedAccess,noImplicitOverride,noUnusedLocals/Parameters,NodeNextmodule resolution). Zod schemas are the single source of truth for runtime validation and TypeScript types viaz.infer<...>. - Fastify 5 with the official Zod type provider — request/response bodies, query strings, and path params are validated by Zod and typed in handlers without manual annotation.
- OpenAPI 3 generated automatically from the Zod schemas and exposed at
GET /docs/jsonandGET /docs(Swagger UI). - HTTP Basic auth via
@fastify/basic-authwith constant-time credential comparison (crypto.timingSafeEqual) and aWWW-Authenticatechallenge on 401. Default credentialsadmin/admin, overridable via environment. - Configurable port (
PORT, default8080) and host/log-level via env; configuration itself is validated by a Zod schema. - Pino structured logging out of the box.
- Graceful shutdown on
SIGINT/SIGTERM. - 19-case Vitest suite covering auth, full CRUD, validation, error mapping, pagination/filtering, and OpenAPI exposure.
- Node.js 22.x LTS or newer (
node --version≥v22.0.0). npm.
npm install
# Production mode (compile then run) — one command
npm run build:start
# Same, with custom library info applied to GET /library, via env vars
LIBRARY_ADDRESS="42 Wallaby Way, Sydney" \
LIBRARY_PHONE="+61-2-9999-0042" \
npm run build:start
# Same, via CLI flags forwarded to the server (note the `--` separator):
npm run build:start -- \
--library-address="42 Wallaby Way, Sydney" \
--library-phone="+61-2-9999-0042"
# Or development mode (tsx watch — no build step)
npm run devOn startup the app logs the URL it bound to and the path of the Swagger UI:
API ready at http://0.0.0.0:8080/rest/api/v1 — docs at http://0.0.0.0:8080/docs
Try it:
curl -u admin:admin http://localhost:8080/rest/api/v1/books
# => {"total":0,"offset":0,"limit":50,"items":[]}Open Swagger UI in a browser at http://localhost:8080/docs (you will be prompted for the credentials).
All configuration is via environment variables (loaded with dotenv from
.env if present). Defaults are filled in by a Zod schema; invalid values
will fail fast at startup. See .env.example.
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
TCP port to listen on. 0 selects an ephemeral port (tests). |
HOST |
0.0.0.0 |
Interface to bind to. |
AUTH_USER |
admin |
Username accepted by HTTP Basic auth. |
AUTH_PASSWORD |
admin |
Password accepted by HTTP Basic auth. |
LOG_LEVEL |
info |
Pino level — trace/debug/info/warn/error/fatal/silent. |
API_BASE_PATH |
/rest/api/v1 |
Prefix mounted under (must start with /). |
LIBRARY_ADDRESS |
123 Library Lane, Booktown |
Address returned by GET /library. |
LIBRARY_PHONE |
+1-555-0100 |
Phone number returned by GET /library. |
Example (env vars):
PORT=9090 AUTH_USER=alice AUTH_PASSWORD='s3cret!' LOG_LEVEL=debug npm startEvery setting can also be supplied as a long CLI flag. The kebab-case flag
matches the env-var name, and CLI flags win over env vars, which win over
defaults. Use -- after the npm run script to forward flags through npm:
npm run build:start -- \
--port=9090 --auth-user=alice --auth-password='s3cret!' --log-level=debug
# Direct (no npm) — same flags, no `--` needed
node dist/server.js --port=9090 --library-address="42 Wallaby Way"
# Show usage:
node dist/server.js --helpSecurity note. The default credentials (
admin/admin) are for local experimentation only. OverrideAUTH_USERandAUTH_PASSWORDfor any deployment beyond your laptop, and put the service behind TLS — HTTP Basic sends credentials in clear text (after base64-encoding).
| Command | What it does |
|---|---|
npm run dev |
Run src/server.ts directly with tsx watch (auto-restart on edits). |
npm run build |
Compile to dist/ with tsc. |
npm start |
Run the compiled dist/server.js. |
npm run build:start |
Compile and then start in one command. |
npm stop |
Send SIGTERM to whatever is bound to $PORT (default 8080). |
npm test |
Run the Vitest suite once. |
npm run test:watch |
Run Vitest in watch mode. |
npm run typecheck |
Run tsc --noEmit for fast TS checking without producing output. |
This repo doubles as the canonical sample for Dirigible's
native applications feature. The
sample-library-native-app-nodejs.native-app artefact in the repo root tells
Dirigible to spawn this server as a managed local process and proxy traffic to
it under /services/native-apps-proxy/v1/library-native-app-nodejs/....
The contract:
- Dirigible exports
DIRIGIBLE_NATIVE_APP_PORTinto the child process; theloadConfigfunction insrc/config.tsprefers that variable overPORT, so the platform's resolved free port wins. - The lifecycle
start.commandsinvokesh -c "npm install ... && exec npm run build:start -- \"$@\""(orcmd /c ...on Windows) — bootstrappingnode_modulesif needed, then handing off to the existingnpm run build:startscript. The trailing entries inarguments[]are positional args to the shell; the script forwards them via"$@"tonode dist/server.js. The shipped artefact uses this to set--library-addressand--library-phoneat startup, demonstrating how authors can declare ad-hoc CLI overrides in their.native-app.stop.commandsinvokePORT=$DIRIGIBLE_NATIVE_APP_PORT npm stopso the existingnpm stopscript targets the right port. - HTTP Basic auth credentials come from Dirigible at proxy time: the artefact
declares
user/passwordin thecredentialsblock as${SAMPLE_APP_USER}.{admin}/${SAMPLE_APP_PASS}.{admin}, which Dirigible expands from its own environment (falling back toadmin). Clients of the proxy don't see these credentials; they hit the proxy and Dirigible attachesAuthorization: Basic ...outbound. - Only
/rest/api/v1is whitelisted viasecurity.exposedPaths; anything else through the proxy answers404. The whitelist requires thelibrary-adminrole (defined inroles.rolesand registered by Dirigible's role synchronizer), so callers must be assigned that role —403otherwise.
Base path: /rest/api/v1 • All endpoints require HTTP Basic auth.
The Swagger UI at /docs (also behind auth) is the authoritative, interactive
reference. The summary below mirrors what's in the OpenAPI document.
| Field | Type | Notes |
|---|---|---|
id |
string (UUID) |
Server-assigned, immutable. |
title |
string |
Required, 1–500 chars, trimmed. |
author |
string |
Required, 1–500 chars, trimmed. |
isbn |
string opt. |
ISBN-10 or ISBN-13. Hyphens/spaces stripped before validation. |
publishedYear |
integer opt. |
-3000 ≤ year ≤ currentYear + 5. |
genre |
string opt. |
Free-form, 1–500 chars. |
available |
boolean |
Defaults to true on create. |
createdAt |
string (ISO-8601) |
Server-assigned, immutable. |
updatedAt |
string (ISO-8601) |
Updated on every write. |
ISBN uniqueness is enforced across the collection — a duplicate ISBN returns
409 Conflict.
| Method | Path | Status (success) | Description |
|---|---|---|---|
GET |
/books |
200 OK |
List books (paginated, filterable). |
POST |
/books |
201 Created |
Create a book. Location header points to the new resource. |
GET |
/books/{id} |
200 OK |
Fetch a single book. |
PUT |
/books/{id} |
200 OK |
Replace a book (full representation). |
PATCH |
/books/{id} |
200 OK |
Partial update — send only the fields you want to change. |
DELETE |
/books/{id} |
204 No Content |
Delete a book. |
GET |
/library |
200 OK |
Read-only library info (address, phone). Defaults from env. |
A read-only singleton describing the library itself. Both fields are optional from the environment's perspective — defaults are applied by the Zod schema if the matching env var is unset.
| Field | Type | Default | Source env var |
|---|---|---|---|
address |
string |
123 Library Lane, Booktown |
LIBRARY_ADDRESS |
phoneNumber |
string |
+1-555-0100 |
LIBRARY_PHONE |
curl -u admin:admin http://localhost:8080/rest/api/v1/library
# => {"address":"123 Library Lane, Booktown","phoneNumber":"+1-555-0100"}| Param | Default | Description |
|---|---|---|
offset |
0 |
Records to skip. Non-negative integer. |
limit |
50 |
Page size, 1 ≤ limit ≤ 200. |
author |
— | Case-insensitive substring match on author. |
genre |
— | Case-insensitive exact match on genre. |
available |
— | true or false. |
Response envelope:
All errors share one envelope:
{ "error": { "status": 400, "message": "Request validation failed", "details": [] } }| Status | When |
|---|---|
400 Bad Request |
Malformed JSON, schema validation failure, unknown fields, bad query. |
401 Unauthorized |
Missing or invalid HTTP Basic credentials. Response includes WWW-Authenticate: Basic. |
404 Not Found |
No route matches, or the book id does not exist. |
409 Conflict |
Uniqueness violation (currently: duplicate isbn). |
500 Internal Server Error |
Unexpected failure (logged with stack trace). |
# Create
curl -i -u admin:admin -H 'Content-Type: application/json' \
-X POST http://localhost:8080/rest/api/v1/books \
-d '{
"title": "Domain-Driven Design",
"author": "Eric Evans",
"isbn": "978-0321125217",
"publishedYear": 2003,
"genre": "Software"
}'
# HTTP/1.1 201 Created
# Location: /rest/api/v1/books/c00f4b14-f4d7-41f0-a871-dcfe8d40eb64
# List
curl -u admin:admin http://localhost:8080/rest/api/v1/books
# Fetch one
curl -u admin:admin http://localhost:8080/rest/api/v1/books/c00f4b14-f4d7-41f0-a871-dcfe8d40eb64
# Partial update — mark as checked-out
curl -u admin:admin -H 'Content-Type: application/json' \
-X PATCH http://localhost:8080/rest/api/v1/books/c00f4b14-f4d7-41f0-a871-dcfe8d40eb64 \
-d '{"available": false}'
# Replace
curl -u admin:admin -H 'Content-Type: application/json' \
-X PUT http://localhost:8080/rest/api/v1/books/c00f4b14-f4d7-41f0-a871-dcfe8d40eb64 \
-d '{"title":"Domain-Driven Design, Reference","author":"Eric Evans"}'
# Delete
curl -i -u admin:admin -X DELETE \
http://localhost:8080/rest/api/v1/books/c00f4b14-f4d7-41f0-a871-dcfe8d40eb64
# HTTP/1.1 204 No Content.
├── src/
│ ├── server.ts # Entry point: loads config, builds app, listens, handles shutdown.
│ ├── app.ts # Fastify factory — wires Zod type provider, swagger, auth, routes, error handler.
│ ├── config.ts # Zod-validated environment configuration.
│ ├── errors.ts # HttpError class + helpers.
│ ├── plugins/
│ │ └── auth.ts # @fastify/basic-auth registration + global onRequest hook.
│ ├── routes/
│ │ └── books.ts # Books routes with Zod schemas on every request/response.
│ ├── schemas/
│ │ └── book.ts # Zod schemas (create/replace/patch/list/response) + inferred TS types.
│ └── services/
│ └── bookStore.ts # In-memory Map-backed store + ISBN uniqueness index.
├── test/
│ ├── helpers.ts # Boots an isolated Fastify app on an ephemeral port.
│ └── books.test.ts # Vitest suite (19 cases) using fastify.inject().
├── dist/ # Compiled JS output (created by `npm run build`).
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── .env.example
├── LICENSE # Eclipse Public License 2.0
└── README.md
Runtime:
| Package | Purpose |
|---|---|
fastify |
The HTTP server. Mature, schema-first, faster than alternatives, first-class TypeScript support. |
@fastify/basic-auth |
Official Fastify plugin for HTTP Basic — used for the password check. |
@fastify/swagger |
Generates an OpenAPI 3 document from the route schemas. |
@fastify/swagger-ui |
Serves the Swagger UI under /docs. |
fastify-type-provider-zod |
Wires Zod schemas into Fastify so requests/responses get validated and TypeScript types are inferred in handlers. |
zod |
Runtime + type-level schema validation. Single source of truth for the Book shape. |
dotenv |
Loads .env files into process.env during development. |
Dev:
| Package | Purpose |
|---|---|
typescript |
The compiler. |
tsx |
Runs .ts directly during development (npm run dev). |
vitest |
Test runner. Native ESM/TS, fast, modern API. |
@types/node |
Node.js type declarations. |
-
Schemas are the contract. Each route declares Zod schemas for its body / query / params / response. The Zod type provider:
- validates incoming requests at the edge (returning a structured 400 with field-level details on failure), and
- types
req.body/req.query/req.paramsinside the handler so they're fully typed, no manual casts. - feeds
@fastify/swaggerto produce the OpenAPI document.
-
Auth is global.
@fastify/basic-authis registered as a Fastify plugin and applied as anonRequesthook at the root, so every route — including/docsand/docs/json— requires credentials. Comparisons usecrypto.timingSafeEqualagainst equal-length buffers. -
Error handling is centralised.
setErrorHandlerruns before routes register (Fastify v5 binds the handler at route-registration time) and maps:- Zod validation errors →
400with field-leveldetails. HttpError(thrown by the store) → its status code with structured body.- Other errors with a 4xx
statusCode(e.g.@fastify/basic-auth) → that status, preserving theWWW-Authenticateheader on 401. - Anything else →
500plus a logged stack trace.
- Zod validation errors →
-
In-memory store lives in
src/services/bookStore.ts. Books are stored in aMap<id, Book>with an auxiliaryMap<isbn, id>for the uniqueness check. Everything is lost on restart — intentional. -
Tests use
app.inject()(Fastify's built-in in-process HTTP simulator) rather than a real listening socket. This avoids port management, is faster, and matches the recommended Fastify testing pattern.
Licensed under the Eclipse Public License 2.0. See LICENSE.
{ "total": 42, "offset": 0, "limit": 50, "items": [ /* book objects */ ] }