@x12i/api-simulator
@x12i/api-simulator
Core TypeScript framework for building simulated API services.
Each endpoint defines exactly one behavior:
fixed— always return the configured response.relative— render a configured JSON response using values from the request and API-local data.simulation— run a function that receives the matched request and returns a response.
The core is transport-agnostic. It can be called directly from tests, wrapped by Fastify/Express, or exposed through the included Node.js HTTP adapter.
Data and metadata: this package does not load fixtures from disk, MongoDB, Memorix, or HTTP. Consumers pass an in-memory SimulatorDefinition (api.data + endpoints). Optional helpers (createStore, OpenAPI) still take data you supply. Product-owned package simulators ship their own logic, data, and (when needed) Memorix fixtures — see Package simulators.
New here?
- Try the Playground (anonymous, 30-second first dispatch).
- Author projects in Studio (local-first).
- Follow
examples/library-demo/TUTORIAL.mdfor a guided REST walkthrough. - Or
examples/flowstate-simulator/TUTORIAL.mdto compose with@x12i/static-memorix.
Install
npm install @x12i/api-simulator
Minimal example
import { createApiSimulator } from '@x12i/api-simulator';
const simulator = createApiSimulator({
apis: [
{
id: 'objects-api',
basePath: '/api/v1',
data: {
children: [
{
id: 'child-1',
parentId: '{{request.params.parentId}}'
},
{
id: 'child-2',
parentId: '{{request.params.parentId}}'
}
]
},
endpoints: [
{
id: 'health',
method: 'GET',
path: '/health',
behavior: {
type: 'fixed',
response: {
status: 200,
body: { status: 'ok' }
}
}
},
{
id: 'list-children',
method: 'GET',
path: '/parents/:parentId/children',
behavior: {
type: 'relative',
response: {
body: {
parentId: '{{request.params.parentId}}',
items: '{{data.children}}'
}
}
}
},
{
id: 'calculate-score',
method: 'POST',
path: '/score',
behavior: {
type: 'simulation',
handler: ({ request }) => {
const values = (request.body as { values: number[] }).values;
return {
body: {
score: values.reduce((sum, value) => sum + value, 0)
}
};
}
}
}
]
}
]
});
Dispatch directly
const result = await simulator.dispatch({
method: 'GET',
path: '/api/v1/parents/parent-42/children'
});
console.log(result.response.body);
dispatch() throws EndpointNotFoundError when nothing matches. tryDispatch() returns undefined instead.
The three endpoint behaviors
1. Fixed
Use fixed behavior for health endpoints, stable metadata, constants, and simple fixtures.
{
id: 'capabilities',
method: 'GET',
path: '/capabilities',
behavior: {
type: 'fixed',
response: {
status: 200,
headers: { 'x-simulator': 'true' },
body: { features: ['read', 'search'] }
}
}
}
2. Relative
Relative behavior starts with configured JSON, then resolves tokens against the matched request and the API's internal data object.
Supported token roots:
{{request.params.parentId}}{{request.query.page}}{{request.headers.x-tenant-id}}{{request.body.customer.id}}{{data.children}}- Array indexes, such as
{{data.users[0].id}}
{
id: 'children',
method: 'GET',
path: '/parents/:parentId/children',
behavior: {
type: 'relative',
response: {
body: {
parentId: '{{request.params.parentId}}',
items: '{{data.children}}'
}
}
}
}
A token occupying the entire string preserves its original type. Therefore, {{request.body.limit}} can produce a number and {{data.children}} can produce an array. A token embedded inside text is converted to a string:
{ message: 'Children for parent {{request.params.parentId}}' }
Tokens inside API-local data are rendered too. This lets a reusable internal fixture inherit an identifier from the incoming request:
data: {
children: [
{ id: 'child-1', parentId: '{{request.params.parentId}}' }
]
}
Relative behavior is strict by default. A missing token throws TemplateResolutionError. Set strict: false to leave unresolved tokens unchanged.
3. Simulation function
Use a simulation function when output needs conditions, calculations, validation, generated values, branching, or error responses.
{
id: 'quote',
method: 'POST',
path: '/quote',
behavior: {
type: 'simulation',
handler: async ({ request, data, apiId, endpointId }) => {
const quantity = Number((request.body as { quantity: number }).quantity);
const unitPrice = Number(data.unitPrice);
return {
status: 201,
body: {
apiId,
endpointId,
quantity,
unitPrice,
total: quantity * unitPrice
}
};
}
}
}
Expose as a Node.js service
import { createServer } from 'node:http';
import { createNodeHttpHandler } from '@x12i/api-simulator/node';
createServer(createNodeHttpHandler(simulator)).listen(5520);
The adapter:
- parses URL path and query values;
- normalizes request headers to lowercase keys (values may be
string | string[]); - parses JSON, urlencoded, and text bodies by default; returns
Bufferfor binary; - returns
404when no simulated endpoint matches; - returns
500when rendering or simulation fails; - limits request bodies to 1 MiB by default;
- writes
RawResponseBodyverbatim (HTML/XML/CSV/binary).
Multiple APIs
A single simulator can expose many APIs. Each API has its own id, optional basePath, internal data, and endpoints.
const simulator = createApiSimulator({
apis: [crmApi, billingApi, identityApi]
});
Duplicate METHOD + full path routes are rejected during startup. Structurally ambiguous routes such as /users/:id and /users/:name are also rejected. Static routes are matched before parameter routes, so /users/me wins over /users/:id.
Endpoint contract
type EndpointDefinition = {
id: string;
method: HttpMethod;
path: string;
enabled?: boolean;
delayMs?: number;
behavior:
| { type: 'fixed'; response: SimulatorResponse }
| { type: 'relative'; response: SimulatorResponse; strict?: boolean }
| { type: 'simulation'; handler: SimulationFunction };
};
The discriminated behavior union is the core guarantee: an endpoint cannot validly have two behaviors, and it cannot omit its behavior.
Artificial latency
{
id: 'slow-search',
method: 'GET',
path: '/search',
delayMs: 750,
behavior: {
type: 'fixed',
response: { body: { items: [] } }
}
}
Recommended structure for a provider simulator
my-provider-simulator/
├─ mocks/ # optional — shared with static-memorix when used
│ ├─ data/
│ ├─ metadata/
│ └─ metadata-packs/
├─ src/
│ ├─ data/ # seeds for api.data / createStore
│ ├─ simulations/ # package-specific handlers
│ ├─ api.ts # createApiSimulator({ apis: [...] })
│ ├─ memorix.ts # optional — buildServer({ MOCKS_DIR })
│ └─ server.ts # Node/HTTP entry (tryDispatch ± forward)
└─ package.json
Keep provider-specific data and simulation logic outside this package. @x12i/api-simulator remains the reusable engine.
- Plain REST:
examples/library-demo/—TUTORIAL.md - Memorix-shaped package simulator:
examples/flowstate-simulator/—TUTORIAL.md - Overview:
docs/package-simulators.md
Package simulators + static-memorix
A package simulator is a product-owned package that depends on @x12i/api-simulator and ships:
- Logic —
simulationhandlers (and optional@x12i/api-simulator/storestate) - Data —
api.dataseeds and/or JSON fixtures - Metadata — when the product uses Memorix, packs and explorer fixtures
For local/CI parity with Memorix Explorer and Metadata APIs, compose @x12i/static-memorix against a shared mocks/ directory:
| Surface | Owner |
|---|---|
/api/v1/<your-product>/* |
@x12i/api-simulator endpoints in your package |
/api/explorer/*, /api/metadata/* |
@x12i/static-memorix (MOCKS_DIR → shared mocks/) |
Pattern: load fixture JSON into createStore / api.data for package REST; set MOCKS_DIR and buildServer() for explorer/metadata; optionally one HTTP port that tryDispatchs package routes and forwards misses to static-memorix.
node examples/flowstate-simulator/demo.mjs
node examples/flowstate-simulator/src/server.mjs # :5522
static-memorix is not a substitute for full memorix-service (pipelines, relationship materialize, abstract reverse-write, etc.).
Development
npm install
npm run validate
validate runs type checking, tests, and the production build.
Playground and Studio
npm run build
npm run dev -w @x12i/playground # http://127.0.0.1:5523
npm run dev -w @x12i/studio # http://127.0.0.1:5521
See docs/playground-studio.md.
Default listen ports come from @x12i/ports-manager zone api-simulator (5520–5539, even = API, odd = UI). Wrapper: scripts/api-simulator-ports.mjs.
| Name | Port | Role |
|---|---|---|
api / library-demo / codegen |
5520 | API |
studio (Vite) |
5521 | UI |
flowstate compose server |
5522 | API |
playground (Vite) |
5523 | UI |
npx x12i-ports zone api-simulator
Raw (non-JSON) responses
import { RawResponseBody, createApiSimulator } from '@x12i/api-simulator';
// In a simulation handler:
return {
body: new RawResponseBody('<h1>hello</h1>', 'text/html; charset=utf-8')
};
Transport adapters write RawResponseBody verbatim instead of JSON.stringify.
In-memory store
import { createStore } from '@x12i/api-simulator/store';
const users = createStore([{ id: '1', name: 'Ada' }]);
users.create({ name: 'Grace' });
users.list({ name: 'Ada' });
users.reset(); // restore seed — useful between tests
Auth / rate-limit helpers
import { requireBearerToken, rateLimit } from '@x12i/api-simulator/helpers';
handler: rateLimit({ windowMs: 60_000, max: 30 })(
requireBearerToken((token) => token === 'secret')(
({ request }) => ({ body: { ok: true, path: request.path } })
)
)
Simulator options
const simulator = createApiSimulator(definition, {
recordHistory: { limit: 100 },
validateTemplates: true // checks {{data.*}} at construction
});
simulator.getHistory();
simulator.clearHistory();
OpenAPI skeletons
import { openapiToApiDefinition } from '@x12i/api-simulator/openapi';
const api = openapiToApiDefinition(parsedOpenApiObject, { id: 'pets-api', basePath: '/v1' });
Pass an already-parsed JS object (parse YAML yourself if needed).
Fetch adapter
import { createFetchHandler } from '@x12i/api-simulator/fetch';
export default {
fetch: createFetchHandler(simulator)
};
Express / Fastify recipes
// Express
app.use(async (req, res) => {
const match = await simulator.tryDispatch({
method: req.method,
path: req.path,
headers: req.headers,
query: req.query,
body: req.body
});
if (!match) return res.status(404).json({ error: 'NOT_FOUND' });
res.status(match.response.status ?? 200).set(match.response.headers ?? {}).send(match.response.body);
});
CORS, host routing, validators
{
id: 'api',
host: 'tenant.example.com',
cors: { origins: '*', methods: ['GET', 'POST', 'OPTIONS'] },
endpoints: [{
id: 'create',
method: 'POST',
path: '/items',
validate: ({ request }) => {
if (!request.body) return { status: 400, body: { error: 'EMPTY' } };
},
behavior: { type: 'fixed', response: { status: 201, body: { ok: true } } }
}]
}
Record and replay
node scripts/record-replay.mjs --base https://httpbin.org --paths /get,/uuid --out captured.json
Node body parsers
Built-in parsers: JSON, application/x-www-form-urlencoded, text/plain. Binary bodies are returned as Buffer. Plug in multipart yourself:
createNodeHttpHandler(simulator, {
bodyParsers: {
'multipart/form-data': async (raw, contentType) => {
// use busboy / your parser of choice
return { rawLength: raw.length, contentType };
}
}
});
Path patterns
Supports :id, :id(\\d+), and optional :slug? segments.
Release notes
1.1.2
- Local defaults follow
@x12i/ports-managerzoneapi-simulator(5520–5539).
1.1.1
- Default local ports moved into an org port band (superseded by 1.1.2 / ports-manager).
1.1.0
- REST gap-closure:
createStore, helpers, OpenAPI skeletons, fetch adapter, CORS/host/validators, raw bodies, record-replay script, Playground/Studio apps. - Package-simulator docs and
examples/flowstate-simulator: compose@x12i/static-memorixwith shared mocks and a tutorial that connects logic, data, and metadata.