# Tutorial: building a library simulator

A worked, runnable example for `@x12i/api-simulator`. It builds a small
book-lending API step by step, using all three endpoint behaviors, two
composed APIs, path parameters, query filtering, request-driven templates,
custom in-memory state, artificial latency, and the Node HTTP adapter.

Everything described here lives in this folder:

```text
examples/library-demo/
├─ TUTORIAL.md      <- you are here
├─ demo.mjs         <- run this right now, no build step needed
├─ package.json     <- what a real consuming project's package.json looks like
└─ src/
   ├─ data/
   │  ├─ books.ts     seed catalog
   │  └─ members.ts   seed member list
   ├─ state/
   │  └─ checkout-ledger.ts   the in-memory "database" simulations mutate
   ├─ simulations/
   │  ├─ get-book.ts
   │  ├─ search-books.ts
   │  ├─ checkout-book.ts
   │  ├─ return-book.ts
   │  ├─ member-checkouts.ts
   │  └─ get-member.ts
   ├─ api.ts        wires data + simulations into a SimulatorDefinition
   └─ server.ts      exposes it over HTTP
```

`demo.mjs` and `src/` model the *same* API two ways: `demo.mjs` is a single
self-contained script you can run immediately to see everything happen,
while `src/` shows how you'd actually structure this in a real project,
matching the "Recommended structure for a provider simulator" section of the
package README.

## 1. Run it

From the package root, after `npm install && npm run build` (this only needs
to happen once, since the demo imports the compiled `dist/` output, exactly
like `tests/engine.test.mjs` does):

```bash
node examples/library-demo/demo.mjs
```

You'll see output like this (trimmed):

```text
--- Step 1: fixed behavior: health check, always the same response ---
GET /api/v1/library/health
-> 200 {"status":"ok","service":"library-simulator"}

--- Step 2: relative behavior: list the full catalog straight from `data` ---
GET /api/v1/library/books
-> 200 {"items":[{"id":"book-atomic-habits", ...}, {"id":"book-clean-code", ...}, {"id":"book-dune", ...}]}

--- Step 8: checking out Dune, which starts with 0 available copies -> 409 conflict ---
POST /api/v1/library/books/book-dune/checkout
-> 409 {"error":"BOOK_UNAVAILABLE","message":"Dune has no available copies."}

--- Step 9: checking out Clean Code, which has 1 available copy -> 201 created ---
POST /api/v1/library/books/book-clean-code/checkout
-> 201 {"bookId":"book-clean-code","memberId":"member-ami","checkedOutAt":"...","dueAt":"...","status":"checked-out"}
```

The rest of this tutorial walks through *why* each step behaves that way,
pointing at the actual source for each piece.

## 2. The domain

Two APIs, combined into one simulator (`src/api.ts`):

- **`library-api`** (`basePath: /api/v1/library`) -- books, search, checkout,
  return.
- **`members-api`** (`basePath: /api/v1`) -- members and what each member
  currently has checked out.

```ts
export const simulator = createApiSimulator({
  apis: [
    { id: 'library-api', basePath: '/api/v1/library', data: { books }, endpoints: [ /* ... */ ] },
    { id: 'members-api', basePath: '/api/v1', data: { members }, endpoints: [ /* ... */ ] }
  ]
});
```

Each API's `data` is independent -- `library-api` only ever sees `data.books`,
`members-api` only ever sees `data.members`. Simulation handlers are plain
functions, though, so they're free to `import` from a shared module to reuse
logic across APIs (`member-checkouts.ts` does exactly that, reading from the
same ledger `checkout-book.ts` writes to).

## 3. The three behaviors, in this domain

### Fixed: `GET /health`

Always the same response. Good for health checks and static metadata.

```ts
{
  id: 'health',
  method: 'GET',
  path: '/health',
  behavior: {
    type: 'fixed',
    response: { status: 200, body: { status: 'ok', service: 'library-simulator' } }
  }
}
```

### Relative: `GET /books`

Configured JSON with tokens resolved against `data`. `{{data.books}}`
occupies the *entire* value, so it keeps its original type -- an array --
rather than being stringified.

```ts
{
  id: 'list-books',
  method: 'GET',
  path: '/books',
  behavior: { type: 'relative', response: { body: { items: '{{data.books}}' } } }
}
```

### Simulation: `GET /books/:bookId`

`relative` can only ever echo back what's already in `data` -- it has no way
to say "return 404 if this id isn't found." That conditional needs a
function (`src/simulations/get-book.ts`):

```ts
export const getBook: SimulationFunction = ({ request }) => {
  const book = findBook(request.params.bookId);
  if (!book) {
    return { status: 404, body: { error: 'BOOK_NOT_FOUND', message: `No book with id ${request.params.bookId}.` } };
  }
  return { status: 200, body: book };
};
```

Same idea for `GET /books/search?author=&available=` (`search-books.ts`):
query-string filtering is conditional logic, so it's a simulation, not a
relative template.

## 4. Path matching: static routes beat parameter routes

`library-api` registers both `/books/search` and `/books/:bookId`. A request
for `/books/search` could technically match `:bookId` too (with `bookId`
bound to the literal string `"search"`) -- but it doesn't, because the engine
always tries more specific (static) routes before parameterized ones,
regardless of the order they're declared in:

```text
--- Step 6: static routes win over parameter routes: /books/search vs /books/:bookId ---
GET /api/v1/library/books/search?author=herbert
-> 200 {"count":1,"items":[{"id":"book-dune", ...}]}
```

If two routes have the *same* structural shape (say, `/users/:id` and
`/users/:name`), that's genuinely ambiguous, and `createApiSimulator` rejects
it at startup with `InvalidSimulatorDefinitionError` rather than guessing.

## 5. Templating: whole tokens, embedded tokens, and `strict`

`GET /books/:bookId/shelf-note` demonstrates two more template details:

```ts
{
  id: 'book-shelf-note',
  method: 'GET',
  path: '/books/:bookId/shelf-note',
  behavior: {
    type: 'relative',
    strict: false,
    response: {
      body: {
        bookId: '{{request.params.bookId}}',
        note: 'Shelf note: {{request.headers.x-shelf-note}}'
      }
    }
  }
}
```

Calling it without an `x-shelf-note` header:

```text
--- Step 7: relative behavior with strict: false -- an unresolved token is left as-is ---
GET /api/v1/library/books/book-dune/shelf-note (no x-shelf-note header sent)
-> 200 {"bookId":"book-dune","note":"Shelf note: {{request.headers.x-shelf-note}}"}
```

`bookId` resolves normally. `note` embeds a token inside a longer string, so
it's converted to text either way -- but since the header is missing and this
endpoint sets `strict: false`, the unresolved token is left in place instead
of throwing. Endpoints are strict (throw `TemplateResolutionError` on a
missing token) by default; the last section of `demo.mjs` shows that failure
mode directly against a throwaway simulator.

## 6. Simulation + state: checkout and return

`relative` and `fixed` behaviors are both stateless -- they can't remember
that a book was checked out five requests ago. `src/state/checkout-ledger.ts`
gives the checkout/return simulations a small in-memory store to mutate:

```ts
const catalog = new Map<string, Book>(books.map((book) => [book.id, { ...book }]));
const activeCheckouts = new Map<string, CheckoutRecord>();

export const checkoutBook = (bookId: string, memberId: string): CheckoutRecord => {
  const book = catalog.get(bookId);
  if (!book) throw new LibraryError(404, 'BOOK_NOT_FOUND', `No book with id ${bookId}.`);
  if (book.copiesAvailable < 1) {
    throw new LibraryError(409, 'BOOK_UNAVAILABLE', `${book.title} has no available copies.`);
  }
  book.copiesAvailable -= 1;
  // ...records a CheckoutRecord with a 14-day due date and returns it
};
```

`checkout-book.ts` validates the request (member id present, member known)
and translates thrown `LibraryError`s into HTTP-shaped responses:

```ts
try {
  const record = checkoutBook(bookId, memberId);
  return { status: 201, body: { ...record, status: 'checked-out' } };
} catch (error) {
  if (error instanceof LibraryError) {
    return { status: error.status, body: { error: error.code, message: error.message } };
  }
  throw error;
}
```

Watch the same book get rejected, then succeed, then show up for the member,
then get returned:

```text
--- Step 8: checking out Dune, which starts with 0 available copies -> 409 conflict ---
POST /api/v1/library/books/book-dune/checkout
-> 409 {"error":"BOOK_UNAVAILABLE","message":"Dune has no available copies."}

--- Step 9: checking out Clean Code, which has 1 available copy -> 201 created ---
POST /api/v1/library/books/book-clean-code/checkout
-> 201 {"bookId":"book-clean-code","memberId":"member-ami", ... ,"status":"checked-out"}

--- Step 10: Ami's active checkouts, read back from the members API ---
GET /api/v1/members/member-ami/checkouts
-> 200 {"memberId":"member-ami","count":1,"items":[{"bookId":"book-clean-code", ... ,"title":"Clean Code"}]}

--- Step 11: returning the book releases the copy again ---
POST /api/v1/library/books/book-clean-code/return
-> 200 {"bookId":"book-clean-code","memberId":"member-ami", ... ,"status":"returned"}
```

## 7. Errors: `dispatch` vs. `tryDispatch`

`dispatch()` throws `EndpointNotFoundError` for a route nothing matches;
`tryDispatch()` returns `undefined` instead. Use whichever fits the caller --
the Node adapter in `src/server.ts` uses `tryDispatch` so it can turn a miss
into a normal `404` response rather than an unhandled rejection.

```text
--- Step 12: tryDispatch() on an unknown route returns undefined instead of throwing ---
tryDispatch result: undefined

--- Step 13: dispatch() on the same unknown route throws EndpointNotFoundError ---
EndpointNotFoundError: No simulated endpoint matches GET /api/v1/library/nope.
instanceof EndpointNotFoundError: true

--- Step 14: strict relative behavior (the default) throws TemplateResolutionError for a missing token ---
TemplateResolutionError: Could not resolve template token: request.query.missing
instanceof TemplateResolutionError: true
```

## 8. Exposing it over HTTP

`src/server.ts` is the entire adapter -- the simulator itself never knows
it's being served over HTTP:

```ts
import { createServer } from 'node:http';
import { createNodeHttpHandler } from '@x12i/api-simulator/node';
import { simulator } from './api.js';

const port = Number(process.env.PORT ?? 5520);
createServer(createNodeHttpHandler(simulator)).listen(port, () => {
  console.log(`Library simulator listening on http://localhost:${port}`);
});
```

Run it (see the Node version note below), then in another terminal:

```bash
curl http://localhost:5520/api/v1/library/health
curl http://localhost:5520/api/v1/library/books
curl -X POST http://localhost:5520/api/v1/library/books/book-atomic-habits/checkout \
  -H 'content-type: application/json' \
  -d '{"memberId":"member-nina"}'
```

This was exercised directly (bypassing the shell, via Node's `fetch`) while
writing this tutorial:

```text
health status 200 { status: 'ok' }
books status 200 { items: [ { id: 'book-dune', title: 'Dune' } ] }
404 status 404 { error: 'SIMULATED_ENDPOINT_NOT_FOUND', message: 'No simulated endpoint matches GET /nope.' }
```

**Running `src/server.ts` and the other files under `src/`:** they're
TypeScript and use the package's self-referencing import
(`@x12i/api-simulator`), exactly like `examples/node-server.ts` and
`examples/parent-children.ts` one level up. On Node 22.18+ / 23.6+ you can
run them directly (`node src/server.ts`); on older Node 20-22 builds, run them
through `tsx`/`ts-node`, or compile first with `tsc`. `demo.mjs` has none of
these requirements -- it's plain JavaScript against the compiled `dist/`
output, which is why it's the fastest way to see the package work.

## 9. Extending this demo

A few natural next steps, if you want to keep going:

- Add a `DELETE /books/:bookId/checkout` variant that lets a librarian force
  a return regardless of who checked it out, to see a second simulation
  reusing `checkout-ledger.ts`.
- Add `enabled: false` to an endpoint and rerun `demo.mjs` -- disabled
  endpoints are excluded from routing entirely, so requests to them fall
  through to `EndpointNotFoundError`/`404`, just like an unregistered path.
- Wrap `search-books.ts`'s filter in a second `relative`-only version (no
  query filtering, just the full list) to feel the difference between what
  `relative` can and can't express on its own.
- Point `src/server.ts` at a real frontend or integration test suite instead
  of `curl`, the way the package README suggests: "wrapped by Fastify/Express,
  or exposed through the included Node.js HTTP adapter."
