@yrest/cli
v0.12.0
Published
YAML-powered json-server alternative. Zero-config REST API mock server with full CRUD, relations, filters and snapshots from a db.yml file.
Maintainers
Readme
YAML-powered json-server alternative. Zero-config REST API mock server with full CRUD, relations, filters and snapshots from a db.yml file.
Think
json-server, but powered by YAML — with relations, filters, pagination, nested routes, snapshots and custom handlers.
# db.yml
users:
- id: 1
name: Ana
email: [email protected]
posts:
- id: 1
title: First post
userId: 1npx @yrest/cli serve db.ymlGET /users → [{ id: 1, name: "Ana", email: "[email protected]" }]
GET /users/1 → { id: 1, name: "Ana", email: "[email protected]" }
POST /users → 201 Created
PUT /users/1 → 200 OK
PATCH /users/1 → 200 OK
DELETE /users/1 → 200 OKWhy yRest?
A YAML-first alternative to json-server for frontend development.
| Feature | yrest | json-server |
| --------------------------------------------------- | :---: | :---------: |
| YAML database | ✅ | ❌ |
| Zero config | ✅ | ✅ |
| Full CRUD | ✅ | ✅ |
| Field operators (_gte, _like, _regex…) | ✅ | ⚠️ |
| Full-text search | ✅ | ✅ |
| Relations: many2one, one2one, many2many | ✅ | ⚠️ |
| Nested routes + bidirectional many2many | ✅ | ✅ |
| Auto-embedding (nested: true) | ✅ | ❌ |
| Field projection (_fields) | ✅ | ❌ |
| Pageable mode (envelope response) | ✅ | ❌ |
| Custom static routes (_routes) | ✅ | ❌ |
| Template variables in responses | ✅ | ❌ |
| Handler functions (JS logic) | ✅ | ❌ |
| Conditional scenarios (scenarios:) | ✅ | ❌ |
| Snapshot endpoints | ✅ | ❌ |
| Config file | ✅ | ⚠️ |
| API overview page (/_about) | ✅ | ❌ |
| Watch mode | ✅ | ✅ |
| Readonly mode | ✅ | ❌ |
| Atomic writes | ✅ | ✅ |
| TypeScript types | ✅ | ❌ |
| Programmatic API for test frameworks | ✅ | ❌ |
| OpenAPI 3.0 spec (GET /_openapi, yrest openapi) | ✅ | ❌ |
| Field annotations (_schema) | ✅ | ❌ |
| Compact relation DSL (m2o:target@fk[card]+nested) | ✅ | ❌ |
| SSE stream routes (_method: SSE) | ✅ | ❌ |
Install
npm install -D @yrest/cliOr run directly with npx (no install needed):
npx @yrest/cli serve db.ymlQuick start
# Create a sample db.yml and yrest.config.yml
npx @yrest/cli init
# Start the server
npx @yrest/cli serve db.ymlyrest · http://localhost:3070
Collections (base: /):
CRUD /users
CRUD /posts
Meta:
GET /_aboutOpen http://localhost:3070/_about for a live overview of all generated endpoints, active modes and ready-to-run curl examples.
Commands
init
Creates a sample db.yml and a yrest.config.yml template in the current directory.
npx @yrest/cli init # basic sample (default)
npx @yrest/cli init --sample relational # with _rel relations
npx @yrest/cli init --file api.yml # custom filename
npx @yrest/cli init --sample relational --file api.yml| Flag | Default | Description |
| ---------- | -------- | ------------------------------------------------ |
| --file | db.yml | Output filename |
| --sample | basic | Sample data (basic, relational, ecommerce) |
Samples:
basic— three independent collections:users,productsandcategoriesrelational— blog domain with all three relation types:users,posts,comments,tagsand a pivot tableecommerce— e-commerce domain with products, orders, reviews and custom_routes(scenarios, template vars, error injection)
openapi
Generates an OpenAPI 3.0.3 spec from a db.yml file without starting a server.
npx @yrest/cli openapi db.yml # writes openapi.yaml
npx @yrest/cli openapi db.yml --format json # writes openapi.json
npx @yrest/cli openapi db.yml --stdout # prints to stdout
npx @yrest/cli openapi db.yml --output api-spec.yaml # custom output path| Flag | Default | Description |
| ----------------- | ----------- | ----------------------------------------------------------- |
| --output <file> | (auto) | Output file path (default: openapi.yaml / openapi.json) |
| --format <fmt> | yaml | Output format: yaml or json |
| --stdout | false | Print to stdout instead of writing a file |
| --base <base> | (none) | Base path prefix applied to all routes |
| --port <port> | 3070 | Server port shown in the servers block |
| --host <host> | localhost | Server host shown in the servers block |
| --title <title> | yRest API | API title for the info block |
The spec is also available live at GET /_openapi (YAML) and GET /_openapi.json (JSON) while the server is running — no extra flag or config needed.
serve
Starts the mock server.
npx @yrest/cli serve db.yml
npx @yrest/cli serve db.yml --port 3001 --host 0.0.0.0
npx @yrest/cli serve db.yml --base /api --watch
npx @yrest/cli serve db.yml --readonly --delay 300
npx @yrest/cli serve db.yml --pageable 20
npx @yrest/cli serve db.yml --snapshot
npx @yrest/cli serve db.yml --handlers yrest.handlers.js| Flag | Default | Description |
| ------------------- | ----------- | ----------------------------------------------------------------------- |
| --port | 3070 | Port to listen on |
| --host | localhost | Host to bind |
| --base | (none) | Prefix for all routes (e.g. /api) |
| --watch | false | Reload db.yml automatically when it changes on disk |
| --readonly | false | Reject all write operations (POST, PUT, PATCH, DELETE) with 405 |
| --delay <ms> | 0 | Add a fixed delay to all responses (simulates network latency) |
| --pageable [n] | false | Wrap GET collection responses in { data, pagination }. Optional limit |
| --snapshot | false | Save initial state snapshot and expose /_snapshot endpoints |
| --handlers <file> | (none) | Path to a JS file exporting handler functions for custom routes |
| --id-strategy | increment | Id generation for new items: increment (auto-int) or uuid |
All flags can also be set in yrest.config.yml (see below). CLI flags always take priority over the config file.
Configuration file
yrest init creates a yrest.config.yml alongside db.yml. Options defined here apply every time you run serve without needing to type flags:
# yrest.config.yml
file: db.yml
port: 3070
host: localhost
# base: /api
# watch: false
# readonly: false
# delay: 0
# pageable: false # true (limit 10), or a number (custom limit)
# snapshot: false
# handlers: yrest.handlers.js
# idStrategy: increment # increment or uuidPriority order (highest wins): CLI flags → yrest.config.yml → schema defaults.
Database format
users:
- id: 1
name: Ana
email: [email protected]
- id: 2
name: Luis
email: [email protected]
posts:
- id: 1
title: First post
userId: 1Each top-level key becomes a resource with full CRUD endpoints.
Alternatively, collections can be grouped under a _data block:
_data:
users:
- id: 1
name: Ana
posts:
- id: 1
title: First post
userId: 1Both formats are equivalent. You can also mix them — if the same collection name appears both inside _data and at the root level, the root-level entry wins. The file is persisted in whichever format it was originally written.
Field annotations (_schema)
Add a _schema block to db.yml to declare field-level metadata per collection. Used by the OpenAPI generator to produce accurate schemas — has no effect on runtime CRUD behavior.
All keys inside _schema field objects use the _ prefix convention. Two shorthand strings are also accepted at the field level:
_schema:
users:
name: required # shorthand → { _required: true }
email:
_required: true
_type: string
_format: email
_minLength: 5
_maxLength: 100
age:
_type: integer
_minimum: 0
_maximum: 150
_nullable: true
role:
_type: string
_enum: [admin, editor, viewer]
_default: viewer
_description: User role
tags:
_type: array
_items:
_type: string
_minItems: 1
_uniqueItems: true
createdAt:
_type: string
_format: date-time
_readOnly: true
users:
- id: 1
name: Ana
email: [email protected]
age: 28
role: adminCore
| Key | Type | Description |
| -------------- | --------- | ----------------------------------------------------------------------------------------- |
| _required | boolean | Marks the field as required in the OpenAPI schema |
| _type | string | Overrides the inferred type (string, integer, number, boolean, array, object) |
| _format | string | OpenAPI format hint (email, date, date-time, uuid, uri, …) |
| _enum | array | Restricts the field to a fixed set of values |
| _description | string | Field description included in the OpenAPI schema |
| _default | any | Default value shown in the OpenAPI schema |
| _example | any | Example value shown in the OpenAPI schema |
| _nullable | boolean | Allows the field to be null |
String constraints
| Key | Type | Description |
| ------------ | -------- | ------------------------------------ |
| _minLength | number | Minimum string length |
| _maxLength | number | Maximum string length |
| _pattern | string | Regex pattern the value must satisfy |
Number / integer constraints
| Key | Type | Description |
| ------------------- | -------- | ----------------------------- |
| _minimum | number | Inclusive lower bound |
| _maximum | number | Inclusive upper bound |
| _exclusiveMinimum | number | Exclusive lower bound |
| _exclusiveMaximum | number | Exclusive upper bound |
| _multipleOf | number | Value must be a multiple of N |
Array constraints
| Key | Type | Description |
| -------------- | --------- | ------------------------------------------------------------ |
| _minItems | number | Minimum number of array items |
| _maxItems | number | Maximum number of array items |
| _uniqueItems | boolean | All items must be unique |
| _items | object | Schema for array items — accepts _type, _format, _enum |
OpenAPI meta
| Key | Type | Description |
| ------------- | --------- | --------------------------------------- |
| _deprecated | boolean | Marks the field as deprecated |
| _readOnly | boolean | Field is read-only (excluded from POST) |
| _writeOnly | boolean | Field is write-only (excluded from GET) |
Fields not listed in _schema are inferred from the first items in the collection and treated as optional. _schema itself is excluded from the generated REST resources.
Generated endpoints
For each resource in db.yml:
GET /users List all
GET /users/:id Get one
POST /users Create
PUT /users/:id Replace
PATCH /users/:id Partial update
DELETE /users/:id DeleteWith --base /api all routes are prefixed: /api/users, /api/users/:id, etc.
Query params
All query params can be combined freely.
Filtering
Return only items that match one or more field values:
GET /users?name=Ana
GET /users?role=admin&active=trueComparison is case-sensitive and converts types to string (?id=1 matches numeric id: 1).
Repeated params are treated as OR — any match passes:
GET /users?role=admin&role=editor # returns admins and editorsField operators
Append an operator suffix to any field name:
GET /users?age_gte=18 # age >= 18
GET /users?age_lte=65 # age <= 65
GET /users?status_ne=inactive # status != "inactive"
GET /users?name_like=ana # name contains "ana" (case-insensitive)
GET /users?name_start=A # name starts with "A" (case-insensitive)
GET /users?email_regex=@gmail\.com # email matches regex (case-insensitive)| Suffix | Type | Description |
| -------- | ---------------- | -------------------------------- |
| _gte | numeric / string | Greater than or equal |
| _lte | numeric / string | Less than or equal |
| _ne | any | Not equal |
| _like | string | Case-insensitive substring match |
| _start | string | Case-insensitive prefix match |
| _regex | string | Case-insensitive regex match |
Full-text search
Search across all scalar fields of every item (case-insensitive substring match):
GET /users?_q=ana
GET /posts?_q=javascriptAn item passes if any string or number field contains the search term.
Sorting
GET /users?_sort=name # ascending (default)
GET /users?_sort=name&_order=desc # descendingString fields are compared case-insensitively. Items missing the sort field are pushed to the end.
Pagination
Without --pageable (default):
GET /users?_page=1&_limit=10 # page 1, 10 items per page
GET /users?_limit=5 # first 5 itemsWhen _page or _limit are used, the response includes an X-Total-Count header with the total number of items before pagination.
With --pageable (or pageable: true in config):
Every GET collection response is automatically wrapped in a { data, pagination } envelope:
npx @yrest/cli serve db.yml --pageable # default limit: 10
npx @yrest/cli serve db.yml --pageable 20 # custom limit: 20{
"data": [
{ "id": 1, "name": "Ana" },
{ "id": 2, "name": "Luis" }
],
"pagination": {
"page": 1,
"limit": 10,
"totalItems": 23,
"totalPages": 3,
"isFirst": true,
"isLast": false,
"hasNext": true,
"hasPrev": false
}
}The ?_page and ?_limit query params still work in pageable mode to navigate pages.
Field projection
Return only specific fields in the response:
GET /users?_fields=id,name
GET /posts?_fields=id,title,userIdWorks on both collection and single-item endpoints.
Relation embedding (_expand)
Embed a related parent object directly into the response using the _rel block (see Relational data):
GET /posts?_expand=user # embed user object in each post
GET /posts/1?_expand=user # embed in a single itemBoth syntaxes are supported:
?_expand=author,category # comma-separated
?_expand=author&_expand=category # repeated paramUnresolvable keys are silently ignored. Works on all operations: GET, POST, PUT, PATCH, DELETE.
Embed children (_embed)
Embed related child collections directly into a parent item:
GET /users/1?_embed=posts # embed all posts where userId === 1
GET /users?_embed=posts # embed posts in every userBoth syntaxes are supported:
?_embed=posts,comments # comma-separated
?_embed=posts&_embed=comments # repeated paramRequires _rel to be declared (see Relational data).
Combined example
GET /posts?userId=1&_sort=title&_order=asc&_page=1&_limit=5&_expand=user&_fields=id,title,userReturns the first 5 posts by user 1, sorted alphabetically by title, with the user object embedded, returning only id, title and user fields.
Relational data
Use _rel to declare relationships between collections. Three relation types are supported.
many2one (default)
A child record holds a foreign key pointing to a parent. The string shorthand implicitly declares many2one:
_rel:
posts:
userId: users # shorthand: many2oneone2one
One child record belongs to exactly one parent, and a parent has at most one child:
_rel:
profiles:
userId:
_type: one2one
_target: usersmany2many
Two collections are linked through a pivot (join) table:
_rel:
posts:
tags:
_type: many2many
_target: tags
_through: post_tags # pivot collection
_foreignKey: postId
_otherKey: tagId
post_tags:
- id: 1
postId: 1
tagId: 2Auto-embedding with _nested: true
Add _nested: true to any relation to embed the related data automatically in every GET response, without needing ?_expand or ?_embed:
_rel:
posts:
userId:
_type: many2one
_target: users
_nested: true # user object embedded in every /posts response
tags:
_type: many2many
_target: tags
_through: post_tags
_foreignKey: postId
_otherKey: tagId
_nested: true # tags array embedded in every /posts responseGET /posts/1 → { id: 1, title: "...", userId: 1, user: { ... }, tags: [...] }many2one / one2one with _nested: true embed the parent object under the derived key (userId → user). The original FK field is preserved. many2many with _nested: true embeds the resolved target array under the alias key.
Compact DSL syntax
Relations can also be declared using a compact string format — useful when you want to express type, target, foreign key and cardinality in a single line:
_rel:
payments:
bookingId: "m2o:bookings[1..1->0..n]" # type:target[carDirect->carInverse]
userId: "m2o:users[1..1->0..n]+nested" # +nested flag auto-embeds on every GET
profiles:
userId: "o2o:users[1..1->1..1]"
posts:
authorId: "m2o:users@userId[1..1->0..n]" # @foreignKey when field name ≠ FK column
tags:
posts: "m2m:posts@post_tags(postId,tagId)[0..n->0..n]" # m2m with pivotType aliases:
| Alias | Full name |
| ----- | ----------- |
| m2o | many2one |
| o2o | one2one |
| m2m | many2many |
Cardinality notation: [carDirect->carInverse] — format min..max where min is 0 or 1 and max is 1 or n. Both segments are optional: omitting them applies defaults (1..1->1..n for m2o, 1..1->1..1 for o2o, 0..n->0..n for m2m).
+nested flag: appended at the end of any DSL string, equivalent to _nested: true.
The three forms — shorthand string, compact DSL, and verbose object — are fully interchangeable and can be mixed freely within the same _rel block.
What relations enable
Nested routes (many2one / one2one):
GET /users/1/posts # all posts where userId === 1
GET /users/1/posts/3 # post 3 only if it belongs to user 1
GET /users/1/profiles # profile for user 1 (one2one: returns object, not array)Nested routes (many2many — bidirectional):
Both directions are registered automatically from a single declaration:
GET /posts/1/tags # all tags linked to post 1 via pivot
GET /tags/2/posts # all posts linked to tag 2 via pivot (inverse — auto-created)Embed parent with ?_expand:
GET /posts/1?_expand=user → { id: 1, title: "First post", userId: 1, user: { id: 1, name: "Ana" } }Embed children with ?_embed:
GET /users/1?_embed=posts → { id: 1, name: "Ana", posts: [...] }
GET /posts/1?_embed=tags → { id: 1, title: "...", tags: [...] } # many2many
GET /users/1?_embed=profiles → { id: 1, name: "Ana", profiles: { ... } } # one2one: objectCustom routes
Define endpoints that don't fit CRUD directly in db.yml using the _routes block.
Static responses
_routes:
- method: POST
path: /login
response:
status: 200
body:
token: fake-jwt-token-abc123
- method: POST
path: /logout
response:
status: 204
- method: GET
path: /dashboard/stats
response:
status: 200
headers:
Cache-Control: no-store
body:
users: 150
revenue: 4820.50methodis case-insensitive.pathsupports Fastify params (:id).response.statusdefaults to200.response.bodyis any YAML value.- Custom routes are registered before resource routes and always take priority.
- Shown in
/_aboutunder "Custom routes".
Template variables
Interpolate request data into the response body using {{}} syntax:
_routes:
- method: GET
path: /users/:id/summary
response:
status: 200
body:
requestedId: "{{params.id}}"
timestamp: "{{now}}"
- method: POST
path: /echo
response:
status: 200
body:
received: "{{body}}"
query: "{{query}}"
requestId: "{{uuid}}"Available variables:
| Variable | Description |
| --------------- | --------------------------------------------------- |
| {{params.X}} | URL parameter (e.g. {{params.id}}) |
| {{query.X}} | Query string param (e.g. {{query.page}}) |
| {{body}} | Full request body |
| {{body.X}} | Field from the request body (e.g. {{body.email}}) |
| {{headers.X}} | Request header value |
| {{now}} | Current UTC timestamp (ISO 8601) |
| {{uuid}} | Random UUID v4 |
When a field contains only a single {{variable}} placeholder, the resolved value preserves its original type (number, boolean, object). When embedded in a larger string it is stringified.
Conditional scenarios
Define multiple conditional response variants for a custom route. Scenarios are evaluated in declaration order — the first matching when: wins. If none match, the otherwise: block is used (if defined), otherwise the static response: block.
_routes:
- method: POST
path: /login
scenarios:
- when:
body.email: [email protected]
body.password: secret
response:
status: 200
body:
token: tok-ana
- when:
body.email: [email protected]
body.password: admin
response:
status: 200
body:
token: tok-admin
role: admin
otherwise:
status: 401
body:
error: Invalid credentialswhen: as an object — all entries must match (AND semantics):
when:
body.email: [email protected]
body.password: secretwhen: as an array of objects — any group satisfying all its conditions matches (OR of ANDs):
when:
- body.role: admin
- body.role: superadminCondition keys use dot-notation to address request data. Both bare and _-prefixed forms are accepted:
| Prefix | Example | Resolves to |
| -------------------------- | ------------------- | -------------------------- |
| body.X / _body.X | body.email | req.body.email |
| params.X / _params.X | params.id | req.params.id |
| query.X / _query.X | query.page | req.query.page |
| headers.X / _headers.X | headers.x-api-key | req.headers["x-api-key"] |
Field operator suffixes (_ne, _like, _start, _regex, _gte, _lte) work on condition keys exactly as they do on query params:
scenarios:
- when:
body.name_like: ana # name contains "ana" (case-insensitive)
body.age_gte: "18" # age >= 18
response:
status: 200
body: { ok: true }Template variables ({{}}) are supported in both scenario and otherwise response bodies:
scenarios:
- when:
body.email: [email protected]
response:
status: 200
body:
message: "Welcome {{body.email}}"
otherwise:
status: 401
body:
error: "Unknown user: {{body.email}}"Per-route delay
Add a fixed delay (ms) to a specific route without affecting the rest of the server. Takes priority over the global --delay option for that route:
_routes:
- method: GET
path: /slow-endpoint
delay: 800
response:
status: 200
body: { data: loaded }Error injection
Force a custom route to always return a specific HTTP error, regardless of handlers, scenarios or the static response. Useful for simulating outages, payment failures or auth errors:
_routes:
- method: GET
path: /payments
error: 503
errorBody:
message: Service temporarily unavailable
retryAfter: 30
- method: POST
path: /checkout
error: 402
errorBody:
error: Payment required
- method: GET
path: /slow-failure
delay: 400
error: 500errortakes priority overhandler,scenariosandresponse— the route always returns this status.errorBodyis optional. If omitted, the default body is{ "error": "Forced error NNN" }.delaystill applies before the error is returned.- Shown in
/_aboutwith a rederror·NNNbadge.
Handler functions
For routes that need real logic (conditional responses, stateful mocks, request inspection), reference a JavaScript function via the handler: field:
_routes:
- method: POST
path: /login
handler: login
response: # optional fallback if handler throws
status: 200
body: { token: fake }
- method: GET
path: /auth/me
handler: getCurrentUserCreate a yrest.handlers.js file in the same directory as db.yml:
// yrest.handlers.js
export async function login(req) {
const { email, password } = req.body ?? {};
if (password !== "secret") return { status: 401, body: { error: "Invalid credentials" } };
return { status: 200, body: { token: `tok-${email}` } };
}
export async function getCurrentUser(req) {
return { status: 200, body: { id: 1, name: "Ana", role: "admin" } };
}Pass the file to the server with --handlers:
npx @yrest/cli serve db.yml --handlers yrest.handlers.jsHandler signature:
type HandlerRequest = {
params: Record<string, string>;
query: Record<string, string | string[]>;
body: unknown;
headers: Record<string, string | string[]>;
};
type HandlerResponse = {
status?: number; // defaults to 200
body?: unknown;
headers?: Record<string, string>;
};
type Handler = (req: HandlerRequest) => HandlerResponse | Promise<HandlerResponse>;If a named handler is not found in the file, the server returns 501. If the handler throws, it returns 500. If a response: block is defined alongside handler:, it is used as fallback only when the handler itself throws.
SSE streams
Mock Server-Sent Events streams by adding _method: SSE and an _sse block to a _routes entry. Clients connect with a regular GET request and receive a continuous stream of events until the connection is closed.
_routes:
- _method: SSE
_path: /events/orders
_sse:
_interval: 1500 # ms between frames (default: 1000)
_loop: true # restart after last event (default: true)
_repeat: 3 # stop after N full cycles (omit = infinite)
_events:
- _event: update
_data:
orderId: 1
status: processing
- _event: update
_data:
orderId: 1
status: shipped
- _event: done
_data:
orderId: 1
status: delivered_sse options:
| Key | Default | Description |
| ----------- | ------- | ---------------------------------------------------------------------------------------------------- |
| _interval | 1000 | Milliseconds between frames |
| _loop | true | Restart the sequence after the last frame. Set to false to close after one cycle |
| _repeat | — | Stop after N complete cycles and close the stream. Omit for infinite |
| _events | — | Ordered list of frames. Each entry needs _data (required) and optionally _event (SSE event name) |
Template variables — resolved per frame at emit time, so each event gets a fresh value:
_events:
- _event: tick
_data:
ts: "{{now}}" # ISO timestamp, unique per frame
id: "{{uuid}}" # random UUID v4
channel: "{{params.channel}}" # from URL params
filter: "{{query.filter}}" # from query stringConsuming from the browser:
const es = new EventSource("http://localhost:3070/events/orders");
es.addEventListener("update", (e) => console.log(JSON.parse(e.data)));
es.addEventListener("done", (e) => {
console.log(JSON.parse(e.data));
es.close();
});Consuming from the terminal:
curl -N http://localhost:3070/events/ordersThe -N flag disables curl's output buffering, which is required to see SSE frames as they arrive.
A keep-alive comment (: ping) is sent every 15 s to prevent proxy timeouts. SSE routes are shown in /_about under their own SSE streams accordion with a sky-blue SSE badge.
Server modes
Watch mode
Automatically reloads db.yml when it changes on disk — useful when you edit the file manually while the server is running:
npx @yrest/cli serve db.yml --watchNote: Watch mode reloads data in existing collections. Adding or removing entire collections requires a server restart.
Readonly mode
Rejects all write operations with 405 Method Not Allowed:
npx @yrest/cli serve db.yml --readonlyUseful to expose a stable read-only snapshot for demos or CI environments.
Delay mode
Adds a fixed delay (in milliseconds) to every response to simulate real network latency:
npx @yrest/cli serve db.yml --delay 500 # 500ms on every responseSnapshot mode
Saves the initial database state at startup and exposes three meta endpoints to inspect, save and restore it:
npx @yrest/cli serve db.yml --snapshot| Endpoint | Description |
| ----------------------- | ------------------------------------------------------------------- |
| GET /_snapshot | Returns snapshot metadata (saved time + item counts per collection) |
| POST /_snapshot/save | Replaces the snapshot with the current database state |
| POST /_snapshot/reset | Restores the database to the last saved snapshot |
Useful for test suites that need a clean reset between runs or demos that need a predictable starting state.
Meta endpoints
Every running server exposes the following meta endpoints without any extra configuration:
| Endpoint | Description |
| -------------------- | ---------------------------------------------------------------------------------------------------- |
| GET /_about | Self-contained HTML page listing all endpoints, modes, query params and ready-to-run curl examples |
| GET /_openapi | OpenAPI 3.0.3 spec in YAML format — regenerated on every request |
| GET /_openapi.json | OpenAPI 3.0.3 spec in JSON format |
open http://localhost:3070/_about
curl http://localhost:3070/_openapi.json | npx swagger-ui-watcher -Both /_about and the OpenAPI spec reflect the live storage state and update automatically in watch mode.
HTTP responses
| Status | When |
| ------ | -------------------------------------------------------------------- |
| 200 | Successful GET, PUT, PATCH, DELETE |
| 201 | Successful POST |
| 400 | Invalid or missing request body |
| 404 | Resource or id not found |
| 405 | Write operation in readonly mode |
| 500 | Error reading or writing the YAML file |
| 501 | Handler referenced in _routes is not exported by the handlers file |
DELETE returns the deleted item as confirmation.
ID generation
If a POST body does not include an id, yrest assigns the next incremental integer automatically. If the body includes an id, it is respected.
Persistence
All write operations (POST, PUT, PATCH, DELETE) are saved back to db.yml immediately using an atomic write strategy (write to temp file → rename), so data is never corrupted even if the process is interrupted.
CORS
CORS is enabled by default, so you can call the API from any frontend running on a different port without extra configuration.
Frontend usage
// List all
const users = await fetch("http://localhost:3070/users").then((r) => r.json());
// Filter + operators + search
const res = await fetch("http://localhost:3070/users?age_gte=18&name_like=ana&_q=dev");
// Sort + paginate + project fields
const res = await fetch("http://localhost:3070/users?_sort=name&_page=1&_limit=10&_fields=id,name");
// Embed related object (parent)
const post = await fetch("http://localhost:3070/posts/1?_expand=user").then((r) => r.json());
// → { id: 1, title: "...", userId: 1, user: { id: 1, name: "Ana" } }
// Embed children
const user = await fetch("http://localhost:3070/users/1?_embed=posts").then((r) => r.json());
// → { id: 1, name: "Ana", posts: [{ id: 1, title: "First post", userId: 1 }] }
// Create
await fetch("http://localhost:3070/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Carlos", email: "[email protected]" }),
});
// Partial update
await fetch("http://localhost:3070/users/1", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Ana Updated" }),
});
// Delete
await fetch("http://localhost:3070/users/1", { method: "DELETE" });
// Custom route with handler
const session = await fetch("http://localhost:3070/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "[email protected]", password: "secret" }),
}).then((r) => r.json());
// → { token: "[email protected]" }Programmatic API
Use yRest directly inside your test suite — no CLI, no separate process to manage.
Install as a dev dependency:
npm install -D @yrest/clicreateYrestServer
Creates a server instance that you control with start() and stop(). Accepts either inline YAML data or a path to a db.yml file.
import { createYrestServer, yrest } from "@yrest/cli";Options:
| Option | Type | Default | Description |
| ---------- | ----------------- | ----------- | ------------------------------------------------------ |
| data | Data | — | Inline data object (use with yrest\...`) |
| file |string | — | Path to adb.yml file (dataorfileis required) |
|port |number |3070 | TCP port. Use0to get a random available port |
|host |string |localhost| Host to bind |
|base |string | — | URL prefix for all routes (e.g."/api") |
| readonly|boolean |false | Reject all write operations with405 |
|delay |number |0 | Fixed delay in ms added to every response |
|pageable|boolean|number|false | Wrap GET responses in{ data, pagination }envelope |
|snapshot|boolean |false | Enable snapshot endpoints (/_snapshot) |
| handlers|string` | — | Path to a JS file exporting handler functions |
Returned handle:
| Member | Description |
| --------- | -------------------------------------------------------- |
| start() | Starts the server and begins listening |
| stop() | Gracefully closes the server |
| port | The actual port after start() (useful when port: 0) |
| url | Base URL after start() (e.g. http://localhost:49821) |
yrest tagged template literal
Parses inline YAML into a data object. Strips common leading indentation automatically, so you can indent naturally inside your test files. Supports interpolated values.
const data = yrest`
users:
- id: 1
name: Ana
posts:
- id: 1
title: First post
userId: 1
`;Vitest example
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createYrestServer, yrest } from "@yrest/cli";
const server = createYrestServer({
data: yrest`
users:
- id: 1
name: Ana
- id: 2
name: Luis
`,
port: 0, // random port — no conflicts between parallel tests
readonly: true,
});
beforeAll(() => server.start());
afterAll(() => server.stop());
describe("users API", () => {
it("returns all users", async () => {
const res = await fetch(`${server.url}/users`);
expect(res.status).toBe(200);
expect(await res.json()).toHaveLength(2);
});
it("returns a single user", async () => {
const res = await fetch(`${server.url}/users/1`);
expect(await res.json()).toMatchObject({ name: "Ana" });
});
});Playwright example
// tests/api.spec.ts
import { test, expect, beforeAll, afterAll } from "@playwright/test";
import { createYrestServer, yrest } from "@yrest/cli";
const server = createYrestServer({
data: yrest`
users:
- id: 1
name: Ana
`,
port: 0,
readonly: true,
});
beforeAll(() => server.start());
afterAll(() => server.stop());
test("lists users", async () => {
const res = await fetch(`${server.url}/users`);
expect(await res.json()).toHaveLength(1);
});File-based example
When your test data is too large for inline YAML, point to a file:
const server = createYrestServer({
file: "./tests/fixtures/db.yml",
port: 0,
readonly: true,
});Use in package.json scripts
{
"scripts": {
"mock": "yrest serve db.yml",
"mock:watch": "yrest serve db.yml --watch",
"mock:readonly": "yrest serve db.yml --readonly --delay 200"
}
}Roadmap
| Feature | Status |
| -------------------------------------------------- | ------ |
| Full CRUD from db.yml | ✅ |
| Field filters, operators, full-text search | ✅ |
| Relations, _expand, _embed, nested routes | ✅ |
| Pagination, sorting, field projection | ✅ |
| Watch, readonly, delay, snapshot modes | ✅ |
| Custom routes (_routes) with static responses | ✅ |
| Template variables in responses ({{params.id}}) | ✅ |
| Handler functions (yrest.handlers.js) | ✅ |
| Visual panel (/_panel) | 🔜 |
| Programmatic API for Vitest / Playwright | ✅ |
| Docker image | 🔜 |
| OpenAPI export (yrest openapi db.yml) | ✅ |
| VS Code extension with YAML snippets | 🔜 |
| Request validation with JSON Schema | 🔜 |
| Conditional scenarios (scenarios:, otherwise:) | ✅ |
| Per-route delay (delay:) | ✅ |
| Error injection (error: in _routes) | ✅ |
| Configurable ID strategy (idStrategy) | ✅ |
| Explicit relation types (one2one, many2many) | ✅ |
| Bidirectional many2many nested routes | ✅ |
| Auto-embedding (nested: true in _rel) | ✅ |
| SSE stream routes (_method: SSE) | ✅ |
| WebSocket mock channels (_method: WS) | 🔜 |
| TypeScript handlers + typed collections | 🔜 |
Contributing
Prerequisites
- Node.js >= 20
- Task — task runner (
brew install go-task/scoop install task/ other methods)
Task commands
Run task --list to see all available commands.
Development
| Command | What it does |
| ----------------------- | ---------------------------------------------------------------------- |
| task test | Runs the full test suite once |
| task test:watch | Runs tests in watch mode — reruns on every file change |
| task build | Compiles TypeScript to dist/ via tsup |
| task dev | Builds once, then starts watch-build + server from dist/ in parallel |
| task serve:dist | Builds and starts the server from the local dist/ |
| task serve:dist:watch | Builds and starts the server with --watch (reloads db.yml on change) |
| task serve:npx | Starts the published npm version (useful to compare against local) |
| task serve:npx:watch | Starts the published npm version with --watch |
| task preflight | Full pre-push check: format, lint, typecheck and tests in order |
Release
| Command | What it does |
| -------------------- | -------------------------------------------------------------- |
| task release:patch | Bumps x.x.N, creates a git commit and tag |
| task release:minor | Bumps x.N.0, creates a git commit and tag |
| task release:major | Bumps N.0.0, creates a git commit and tag |
| task publish | Runs tests + build and publishes to npm (requires npm login) |
Development workflow
Day-to-day work:
task test:watch # keep this running in one terminal
task dev # keep this running in another terminaltest:watch reruns the suite on every save so you catch regressions immediately. dev rebuilds and serves so you can call the endpoints manually while you work.
Before pushing:
task preflight # format + lint + typecheck + tests in one commandGood practices:
- Write tests for every new feature or bug fix before opening a PR.
- Keep
db.ymlin a valid state — it is used as the default file when running local servers. dist/is gitignored and generated at build time; never commit it manually.- Version bumps are done with
task release:*, not by editingpackage.jsondirectly — the Task command also creates the git tag that triggers the publish pipeline.
Release workflow
Releases are fully automated via GitHub Actions once a version tag is pushed:
# 1. Make sure everything is clean
task preflight
# 2. Bump the version (choose one)
task release:patch # bug fixes
task release:minor # new features, backwards compatible
task release:major # breaking changes
# 3. Push the commit and the tag
git push && git push --tagsStep 3 triggers two GitHub Actions pipelines automatically:
- CI — runs on every push to
mainand on every PR. Executes typecheck + tests on Node 20 and Node 22. - Publish — runs only when a
v*tag is pushed. Runs tests + build and publishes to npm using Trusted Publishing (OIDC — no tokens stored as secrets).
CI/CD pipelines
push to main / PR open
│
▼
[CI workflow]
├── Node 20: lint + format check + typecheck + tests
└── Node 22: lint + format check + typecheck + tests
push tag v*
│
▼
[Publish workflow]
├── tests + build (via prepublishOnly)
└── npm publish --provenance (via Trusted Publishing / OIDC, Node 24)Changelog
See CHANGELOG.md for the full version history.
License
MIT
