Language: Rust Interface: CLI (single binary, zero runtime dependencies) License: MIT Repository: github.com/[you]/mockapi
- What Is mockapi
- Why It Exists — The Gap
- Core Concepts
- Architecture
- Project Structure
- Crate Dependencies
- Config File Format — Full Spec
- CLI Interface
- Route Matching Engine
- Response Engine — Static, Dynamic, Templated
- Hot Reload
- Record & Replay Mode
- Stateful Mode
- Request Logging
- MVP Scope
- Post-MVP Roadmap
- Build, Test & Distribution
- Launch Strategy
- Open Questions
mockapi is a single-binary mock API server that spins up a fake REST API from a YAML config file. No runtime, no dependencies, no build step.
# Install
cargo install mockapi
# Create a config
mockapi init
# Run it
mockapi serveThat's it. You have a mock API running on localhost with whatever routes, status codes, delays, and response bodies you defined in your YAML file.
The one-liner pitch: "json-server, but a single binary with YAML config, custom status codes, delays, headers, and hot-reload. No Node.js required."
json-server proved the demand: developers need fake APIs constantly. But it has real limitations that push users to heavier tools:
| Feature | json-server | WireMock | Mockoon | mockapi |
|---|---|---|---|---|
| Single binary, no runtime | No (Node.js) | No (Java) | No (Electron) | Yes |
| Custom status codes per route | No | Yes | Yes | Yes |
| Response delays | No | Yes | Yes | Yes |
| Custom headers per route | Limited | Yes | Yes | Yes |
| Dynamic/templated responses | No | Yes | Yes | Yes |
| Hot-reload on config change | Partial | No | Yes | Yes |
| Record & replay | No | Yes | No | Yes |
| YAML config | No (JSON data) | JSON | GUI-only | Yes |
| Lightweight / instant startup | ~200ms | ~2s (JVM) | ~1s (Electron) | <50ms |
| Works without OpenAPI spec | Yes | Yes | Yes | Yes |
| Stateful (POST creates, GET returns) | Built-in | Complex | Complex | Yes |
| CORS handling | Middleware | Config | Built-in | Built-in |
rs-mock-server, apimock-rs, and Chimera exist but are tiny projects with minimal features and no marketing. They prove the concept works in Rust but haven't captured json-server's audience.
- Frontend developers — need a fake backend while building UI
- Mobile developers — need mock endpoints for app development
- QA engineers — need reproducible API responses for test scenarios
- Backend developers — need to mock third-party APIs during development
- Demos & prototypes — need a working API for presentations
- CI/CD pipelines — need deterministic API responses for integration tests
A route maps an HTTP method + path to a response. Defined in YAML:
routes:
- method: GET
path: /users
status: 200
body: [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]Each route can specify: status code, headers, delay, body format (JSON, text, file), and templating (dynamic values from request data or fake data).
One YAML file defines everything. No code, no scripts, no compilation. Edit the file, mockapi hot-reloads, your mock API is updated.
- Static mode (default) — serves fixed responses from config
- Stateful mode — POST/PUT creates resources in memory, GET/DELETE reads/removes them
- Record mode — proxies to a real API, records responses, replays them later
- Proxy mode — forwards unmatched routes to a real API
┌──────────────────────────────────────────────────────┐
│ mockapi binary │
│ │
│ ┌──────────┐ ┌────────────────────────────────┐ │
│ │ CLI │ │ HTTP Server │ │
│ │ (clap) │────▶│ (axum) │ │
│ │ │ │ │ │
│ │ serve │ │ Request ──▶ Router ──▶ Handler │ │
│ │ init │ │ │ │ │
│ │ validate│ │ ▼ │ │
│ │ record │ │ Route Matcher │ │
│ └──────────┘ │ │ │ │ │
│ │ ▼ ▼ │ │
│ │ Static Dynamic │ │
│ │ Response Response │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Template Engine │ │
│ │ (minijinja) │ │
│ │ │ │
│ └──────────────┬────────────────────┘ │
│ │ │
│ ┌──────────────┐ ┌────────────┴──┐ ┌─────────────┐ │
│ │ Config │ │ Request │ │ File │ │
│ │ Loader │ │ Logger │ │ Watcher │ │
│ │ (YAML) │ │ (stdout/file) │ │ (notify) │ │
│ └──────────────┘ └───────────────┘ └─────────────┘ │
│ │
│ ┌──────────────┐ ┌───────────────┐ │
│ │ State Store │ │ Recorder │ │
│ │ (in-memory) │ │ (proxy+save) │ │
│ └──────────────┘ └───────────────┘ │
└──────────────────────────────────────────────────────┘
Key design decisions:
- axum for HTTP — fast, async, great middleware ecosystem, Tokio-based
- minijinja for templating — Jinja2 syntax, lightweight, Rust-native
- notify for hot-reload — watch config file for changes
- No database — state is in-memory, lost on restart (by design — it's a mock)
- No web UI — terminal-first, config-file-driven
mockapi/
├── Cargo.toml
├── Cargo.lock
├── README.md
├── LICENSE
├── CHANGELOG.md
│
├── src/
│ ├── main.rs # Entry point, clap dispatch
│ ├── lib.rs # Public API for embedding
│ │
│ ├── cli/ # CLI commands
│ │ ├── mod.rs # clap derive structs
│ │ ├── serve.rs # mockapi serve — start the server
│ │ ├── init.rs # mockapi init — generate starter config
│ │ ├── validate.rs # mockapi validate — check config syntax
│ │ └── record.rs # mockapi record — proxy & record mode
│ │
│ ├── config/ # Config file handling
│ │ ├── mod.rs
│ │ ├── loader.rs # Parse YAML config file
│ │ ├── types.rs # Config structs (Route, Response, etc.)
│ │ ├── validation.rs # Validate config for errors/warnings
│ │ └── watcher.rs # File watcher for hot-reload
│ │
│ ├── server/ # HTTP server
│ │ ├── mod.rs
│ │ ├── router.rs # Build axum router from config routes
│ │ ├── handler.rs # Request handler — match route, build response
│ │ ├── matcher.rs # Path matching (exact, param, wildcard, regex)
│ │ ├── cors.rs # CORS middleware
│ │ └── middleware.rs # Logging, delay, header injection
│ │
│ ├── response/ # Response building
│ │ ├── mod.rs
│ │ ├── static_resp.rs # Fixed JSON/text/file responses
│ │ ├── template.rs # Templated responses (minijinja)
│ │ ├── faker.rs # Fake data generators (names, emails, etc.)
│ │ └── file.rs # Serve response from external file
│ │
│ ├── state/ # Stateful mode
│ │ ├── mod.rs
│ │ └── store.rs # In-memory resource store
│ │
│ ├── record/ # Record & replay
│ │ ├── mod.rs
│ │ ├── proxy.rs # Proxy requests to real API
│ │ └── recorder.rs # Save responses to config file
│ │
│ └── log/ # Request logging
│ ├── mod.rs
│ └── formatter.rs # Colored terminal output, JSON log format
│
├── data/
│ └── starter.yaml # Template for `mockapi init`
│
├── tests/
│ ├── integration/
│ │ ├── basic_routes.rs # GET, POST, PUT, DELETE
│ │ ├── path_params.rs # /users/:id matching
│ │ ├── status_codes.rs # Custom status codes
│ │ ├── delays.rs # Response delays
│ │ ├── templates.rs # Dynamic responses
│ │ ├── hot_reload.rs # Config file change detection
│ │ ├── cors.rs # CORS headers
│ │ ├── stateful.rs # POST creates, GET returns
│ │ └── record.rs # Record & replay
│ └── unit/
│ ├── config_parse.rs
│ ├── matcher.rs
│ ├── template.rs
│ └── faker.rs
│
├── examples/
│ ├── basic.yaml # Simple GET/POST example
│ ├── ecommerce.yaml # E-commerce API mock (products, cart, orders)
│ ├── auth.yaml # Auth API mock (login, tokens, users)
│ ├── crud.yaml # Full CRUD with stateful mode
│ └── proxy.yaml # Proxy + selective mock example
│
└── docs/
└── CONFIG_REFERENCE.md # Full config file reference
[package]
name = "mockapi"
version = "0.1.0"
edition = "2021"
description = "A single-binary mock API server with YAML config, hot-reload, and zero dependencies"
license = "MIT"
repository = "https://github.com/[you]/mockapi"
keywords = ["mock", "api", "server", "testing", "development"]
categories = ["command-line-utilities", "development-tools::testing", "web-programming::http-server"]
[dependencies]
# CLI
clap = { version = "4", features = ["derive", "env"] }
# HTTP server
axum = "0.8"
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace"] }
hyper = "1"
# Config
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
# Templating
minijinja = { version = "2", features = ["builtins"] }
# File watching (hot-reload)
notify = "7"
# Utilities
chrono = "0.4"
colored = "2"
anyhow = "1"
thiserror = "2"
uuid = { version = "1", features = ["v4"] }
rand = "0.8"
regex = "1"
glob = "0.3"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# HTTP client (for record/proxy mode)
reqwest = { version = "0.12", features = ["json"], optional = true }
[features]
default = []
record = ["reqwest"] # opt-in record & proxy mode
[profile.release]
lto = true
strip = true
codegen-units = 1# mockapi.yaml — the only file you need
server:
port: 8080
host: 0.0.0.0
routes:
# Simple GET — returns a JSON array
- method: GET
path: /users
status: 200
headers:
X-Total-Count: "2"
body:
- id: 1
name: Alice
email: alice@example.com
- id: 2
name: Bob
email: bob@example.com
# GET with path parameter
- method: GET
path: /users/:id
status: 200
body:
id: "{{ params.id }}"
name: "User {{ params.id }}"
email: "user{{ params.id }}@example.com"
# POST — returns 201 with the request body echoed back
- method: POST
path: /users
status: 201
body:
id: "{{ fake.uuid }}"
name: "{{ request.body.name }}"
email: "{{ request.body.email }}"
created_at: "{{ now }}"
# Simulated error
- method: GET
path: /error
status: 500
body:
error: Internal Server Error
message: Something went wrong
# Slow endpoint (simulates latency)
- method: GET
path: /slow
delay: 2000 # milliseconds
status: 200
body:
message: Finally here
# Response from external file
- method: GET
path: /products
status: 200
file: responses/products.json
# Wildcard catch-all
- method: ANY
path: /api/**
status: 404
body:
error: Not Found
path: "{{ request.path }}"# ──────────────────────────────────────────────────
# SERVER SETTINGS
# ──────────────────────────────────────────────────
server:
# Port to listen on (default: 8080)
port: 8080
# Host to bind to (default: 0.0.0.0)
host: 0.0.0.0
# Enable CORS (default: true)
cors: true
# Allowed CORS origins (default: "*")
cors_origins:
- "http://localhost:3000"
- "http://localhost:5173"
# Global response delay in ms (default: 0)
delay: 0
# Global default headers added to every response
headers:
X-Powered-By: mockapi
# Log level: "none", "minimal", "verbose" (default: "minimal")
log: minimal
# ──────────────────────────────────────────────────
# ROUTES
# ──────────────────────────────────────────────────
routes:
- # HTTP method: GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD, ANY
method: GET
# Path pattern:
# /users — exact match
# /users/:id — named parameter (available as params.id)
# /users/:id/posts/:postId — multiple params
# /files/** — wildcard (matches any subpath)
# regex:/users/\d+ — regex pattern
path: /users/:id
# HTTP status code (default: 200)
status: 200
# Response delay in ms, overrides server.delay (default: 0)
delay: 0
# Response headers (merged with server.headers)
headers:
Content-Type: application/json
Cache-Control: no-cache
# Response body — inline YAML (serialized as JSON)
body:
id: 1
name: Alice
# OR response body from file (relative to config file location)
# file: responses/user.json
# OR response body as raw string
# raw: "OK"
# ── Conditional responses (respond differently based on request) ──
# Match query params, headers, or body to return different responses
# First match wins. If none match, the default body/status is used.
responses:
- when:
query:
role: admin
status: 200
body:
id: 1
name: Alice
role: admin
- when:
headers:
Authorization: "Bearer expired"
status: 401
body:
error: Token expired
# ──────────────────────────────────────────────────
# STATEFUL MODE (optional)
# ──────────────────────────────────────────────────
# When enabled, POST/PUT create resources in memory,
# GET returns them, DELETE removes them.
# Resources are keyed by path + id field.
stateful:
enabled: false
resources:
- path: /users # POST /users creates, GET /users lists, GET /users/:id gets
id_field: id # field used as unique identifier
seed: # optional initial data
- id: 1
name: Alice
- id: 2
name: Bob
- path: /posts
id_field: id
seed: []
# ──────────────────────────────────────────────────
# PROXY MODE (optional)
# ──────────────────────────────────────────────────
# Forward unmatched routes to a real API.
# Matched routes still return mock responses.
proxy:
enabled: false
target: https://api.example.com
# Pass through request headers to upstream
pass_headers: true# ── Request data ──
"{{ request.method }}" # GET, POST, etc.
"{{ request.path }}" # /users/42
"{{ request.body.name }}" # parsed JSON body field
"{{ request.body }}" # entire body as string
"{{ request.headers.Authorization }}" # request header value
# ── Path parameters ──
"{{ params.id }}" # from /users/:id
"{{ params.postId }}" # from /users/:id/posts/:postId
# ── Query parameters ──
"{{ query.page }}" # from ?page=2
"{{ query.limit }}" # from ?limit=10
# ── Fake data generators ──
"{{ fake.uuid }}" # random UUID v4
"{{ fake.name }}" # random full name
"{{ fake.firstName }}" # random first name
"{{ fake.lastName }}" # random last name
"{{ fake.email }}" # random email
"{{ fake.int(1, 1000) }}" # random integer in range
"{{ fake.float(0, 100, 2) }}" # random float with precision
"{{ fake.bool }}" # random boolean
"{{ fake.lorem(20) }}" # random lorem ipsum (n words)
"{{ fake.phone }}" # random phone number
"{{ fake.address }}" # random address
"{{ fake.company }}" # random company name
"{{ fake.url }}" # random URL
"{{ fake.color }}" # random hex color
"{{ fake.pick('a', 'b', 'c') }}" # random choice from list
# ── Timestamps ──
"{{ now }}" # ISO 8601 timestamp
"{{ now_unix }}" # Unix timestamp (seconds)
"{{ now_ms }}" # Unix timestamp (milliseconds)
# ── Utilities ──
"{{ seq }}" # auto-incrementing sequence number per routemockapi — A single-binary mock API server
USAGE:
mockapi [COMMAND] [OPTIONS]
COMMANDS:
serve Start the mock API server (default command)
init Generate a starter mockapi.yaml config file
validate Check a config file for errors
record Proxy to a real API and record responses into a config file
OPTIONS (serve):
-c, --config <path> Config file path (default: ./mockapi.yaml)
-p, --port <port> Override port from config
-h, --host <host> Override host from config
-d, --delay <ms> Add global delay to all responses
--no-cors Disable CORS
--log <level> Log level: none, minimal, verbose (default: minimal)
--watch Enable hot-reload on config change (default: true)
--no-watch Disable hot-reload
OPTIONS (init):
-o, --output <path> Output file path (default: ./mockapi.yaml)
--example <name> Use example template: basic, ecommerce, auth, crud
OPTIONS (validate):
-c, --config <path> Config file to validate (default: ./mockapi.yaml)
OPTIONS (record):
-t, --target <url> Target API URL to proxy and record
-o, --output <path> Output file for recorded routes (default: ./recorded.yaml)
-p, --port <port> Local port to listen on (default: 8080)
--filter <pattern> Only record routes matching this glob pattern
GLOBAL OPTIONS:
-h, --help Show help
-V, --version Show version
# Quickstart — generate config and serve
mockapi init
mockapi serve
# Serve with custom port
mockapi serve -p 3001
# Serve a specific config file
mockapi serve -c ./test/mocks.yaml
# Add artificial latency to every response (testing slow networks)
mockapi serve -d 500
# Verbose logging — show all request/response details
mockapi serve --log verbose
# Validate config without starting server
mockapi validate
# Generate config from example templates
mockapi init --example ecommerce
# Record a real API's responses for later replay
mockapi record -t https://api.github.com -o github_mock.yaml
# Serve with a subset of routes mocked, rest proxied to real API
# (uses proxy section in config)
mockapi serve -c dev_with_proxy.yaml$ mockapi serve
mockapi v0.1.0
Config: ./mockapi.yaml (12 routes)
Server: http://0.0.0.0:8080
CORS: enabled (*)
Watch: enabled
──────────────────────────────────────
GET /users → 200
GET /users/:id → 200 (templated)
POST /users → 201 (templated)
GET /users/:id/posts → 200
PUT /users/:id → 200
DELETE /users/:id → 204
GET /products → 200 (file: responses/products.json)
POST /auth/login → 200 (conditional)
GET /slow → 200 (delay: 2000ms)
GET /error → 500
GET /health → 200
ANY /api/** → 404 (wildcard)
──────────────────────────────────────
14:32:01 GET /users → 200 2ms
14:32:03 GET /users/42 → 200 1ms
14:32:05 POST /users → 201 1ms {"name":"Charlie"}
14:32:08 GET /slow → 200 2003ms
14:32:01 GET /users HTTP/1.1
Headers: Accept: application/json, Origin: http://localhost:3000
Query: page=1&limit=10
──▶ 200 OK (2ms)
Headers: Content-Type: application/json, X-Total-Count: 2
Body: [{"id":1,"name":"Alice"}, {"id":2,"name":"Bob"}]
When multiple routes could match a request, mockapi uses this priority order:
1. Exact method + exact path GET /users/42
2. Exact method + parameterized path GET /users/:id
3. Exact method + wildcard path GET /users/**
4. Exact method + regex path GET regex:/users/\d+
5. ANY method + exact path ANY /users/42
6. ANY method + parameterized path ANY /users/:id
7. ANY method + wildcard path ANY /**
If no route matches: return 404 with a helpful body:
{
"error": "No matching route",
"method": "GET",
"path": "/nonexistent",
"hint": "Define this route in your mockapi.yaml"
}// /users/:id/posts/:postId matched against /users/42/posts/7
// Produces: { "id": "42", "postId": "7" }
pub struct MatchResult {
pub route: Route,
pub params: HashMap<String, String>,
pub query: HashMap<String, String>,
}pub enum PathPattern {
/// /users — exact string match
Exact(String),
/// /users/:id — segments with named parameters
Parameterized {
segments: Vec<Segment>,
},
/// /files/** — matches any subpath
Wildcard(String), // prefix before **
/// regex:/users/\d+ — arbitrary regex
Regex(Regex),
}
pub enum Segment {
Literal(String), // "users"
Param(String), // ":id"
}Request arrives
│
├── Match route
│
├── Check conditional responses (route.responses[])
│ └── First matching `when` clause wins
│ └── If none match, use default body/status
│
├── Resolve body source
│ ├── body: (inline YAML → serialize to JSON)
│ ├── file: (read external file, cache it)
│ └── raw: (return as-is, text/plain)
│
├── Apply templates (if body contains {{ }})
│ ├── Inject: params, query, request.body, request.headers
│ ├── Inject: fake.* generators
│ ├── Inject: now, seq
│ └── Render with minijinja
│
├── Apply delay (if route.delay or server.delay > 0)
│ └── tokio::time::sleep
│
├── Apply headers
│ ├── server.headers (global)
│ ├── route.headers (per-route, overrides global)
│ └── Content-Type: application/json (default, overridable)
│
└── Send response
Allow a single route to return different responses based on request properties:
routes:
- method: POST
path: /auth/login
# Default response (if no conditions match)
status: 200
body:
token: "mock-jwt-token"
# Conditional overrides
responses:
# Correct credentials
- when:
body:
email: admin@test.com
password: admin123
status: 200
body:
token: "admin-jwt-token"
role: admin
# Wrong password
- when:
body:
password: wrong
status: 401
body:
error: Invalid credentials
# Missing fields
- when:
body_missing:
- email
- password
status: 400
body:
error: Missing required fieldswhen:
# Match query parameters
query:
page: "1"
sort: desc
# Match request headers
headers:
Authorization: "Bearer valid-token"
# Match request body fields (exact match)
body:
email: admin@test.com
# Match body field existence
body_has:
- email
- password
# Match body field absence
body_missing:
- tokennotifycrate watches the config file (and any referenced response files)- On change, debounce 500ms (avoid reloading mid-save)
- Parse new config file
- Validate — if invalid, log error and keep old config
- If valid, atomically swap the route table (using
Arc<RwLock<Config>>) - Log: "Config reloaded (12 routes → 14 routes)"
// Shared config state — cheaply cloneable, lockable
type SharedConfig = Arc<RwLock<Config>>;
// The watcher runs in a background task
async fn watch_config(path: PathBuf, config: SharedConfig) {
let (tx, rx) = std::sync::mpsc::channel();
let mut watcher = notify::recommended_watcher(tx)?;
watcher.watch(&path, RecursiveMode::NonRecursive)?;
loop {
match rx.recv() {
Ok(event) if event.kind.is_modify() => {
tokio::time::sleep(Duration::from_millis(500)).await; // debounce
match Config::load(&path) {
Ok(new_config) => {
let route_count = new_config.routes.len();
*config.write().await = new_config;
info!("Config reloaded ({route_count} routes)");
}
Err(e) => {
warn!("Config reload failed: {e} (keeping old config)");
}
}
}
_ => {}
}
}
}- Route definitions (add, remove, modify)
- Response bodies and templates
- Headers and status codes
- Delays
- CORS settings
- External response files (re-read from disk)
- Port and host (server bind address)
- Stateful mode seed data (already in memory)
mockapi record -t https://api.github.com -o github_mock.yaml- Start a local proxy server on the configured port
- Forward all incoming requests to the target API
- Capture the response (status, headers, body)
- Write each unique method+path combo as a route in the output YAML
- Deduplicate — same path + method only recorded once (first response wins)
- When user hits Ctrl+C, finalize and save the YAML file
The recorded file is a valid mockapi config, immediately usable:
# Recorded from https://api.github.com at 2026-02-07T14:30:00Z
# 8 routes captured
server:
port: 8080
routes:
- method: GET
path: /users/octocat
status: 200
headers:
Content-Type: application/json; charset=utf-8
X-RateLimit-Limit: "60"
body:
login: octocat
id: 583231
type: User
# ... (full response body captured)
- method: GET
path: /users/octocat/repos
status: 200
body:
# ... (array of repos)# Only record specific paths
mockapi record -t https://api.example.com --filter "/api/v1/**"
# Record with a specific port
mockapi record -t https://api.example.com -p 9090- Record a real API once, mock it forever — no config writing needed
- Useful for: capturing complex API responses, creating fixtures for tests, working offline after recording, snapshotting API behavior for regression testing
When stateful.enabled: true, mockapi automatically handles CRUD operations:
stateful:
enabled: true
resources:
- path: /users
id_field: id
seed:
- id: 1
name: Alice
- id: 2
name: BobThis auto-generates these behaviors:
| Method | Path | Behavior |
|---|---|---|
GET /users |
Returns all resources | 200 [...] |
GET /users/1 |
Returns resource by id | 200 {...} or 404 |
POST /users |
Creates resource (auto-generates id if missing) | 201 {...} |
PUT /users/1 |
Replaces resource | 200 {...} or 404 |
PATCH /users/1 |
Merges fields into resource | 200 {...} or 404 |
DELETE /users/1 |
Removes resource | 204 or 404 |
pub struct StateStore {
// resource_path → Vec<serde_json::Value>
resources: HashMap<String, Vec<Value>>,
// auto-increment counters per resource
counters: HashMap<String, u64>,
}
impl StateStore {
fn list(&self, path: &str) -> Vec<&Value>;
fn get(&self, path: &str, id: &str) -> Option<&Value>;
fn create(&mut self, path: &str, body: Value) -> Value; // returns created with id
fn update(&mut self, path: &str, id: &str, body: Value) -> Option<Value>;
fn patch(&mut self, path: &str, id: &str, body: Value) -> Option<Value>;
fn delete(&mut self, path: &str, id: &str) -> bool;
}Explicit routes take priority over stateful routes. If you define both:
stateful:
enabled: true
resources:
- path: /users
id_field: id
routes:
# This overrides the auto-generated GET /users
- method: GET
path: /users
status: 200
body:
- id: 1
name: Custom responseThe explicit route wins. Stateful mode only handles methods/paths that don't have an explicit route defined.
GET /users?name=Alice → filters resources where name == "Alice"
GET /users?_sort=name → sorts by field
GET /users?_page=1&_limit=10 → pagination
These are handled automatically in stateful mode, similar to json-server's behavior.
none — no output after startup banner minimal (default) — one line per request:
14:32:01 GET /users → 200 2ms
14:32:03 POST /users → 201 1ms {"name":"Charlie"}
14:32:05 GET /nonexistent → 404 0ms
verbose — full request and response details:
14:32:01 GET /users HTTP/1.1
Headers: Accept: application/json
Query: page=1
──▶ 200 OK (2ms)
Response Headers: Content-Type: application/json
Body: [{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]
- Default: colored stdout (using
coloredcrate) - Detects if stdout is a TTY — if piped, strips colors automatically
- Optional:
--log-file <path>to write JSON-formatted logs to file for parsing:
{"ts":"2026-02-07T14:32:01Z","method":"GET","path":"/users","status":200,"duration_ms":2}Goal: Replace json-server for common use cases. Usable immediately.
-
mockapi init— generate starter config -
mockapi serve— start server from YAML config -
mockapi validate— check config for errors - Route matching: exact, parameterized (
:id), wildcard (**) - All HTTP methods (GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD, ANY)
- Custom status codes per route
- Custom headers per route (+ global defaults)
- Response delay per route (+ global default)
- Inline body (YAML → JSON serialization)
- File-based responses (
file: responses/data.json) - Template responses ({{ params.id }}, {{ request.body.name }}, {{ now }})
- CORS enabled by default (configurable)
- Hot-reload on config file change
- Colored terminal logging (minimal + verbose)
- Helpful 404 for unmatched routes
- Startup banner showing all registered routes
- Conditional responses (
whenclauses) - Stateful mode
- Record & replay mode
- Proxy mode
- Fake data generators (
{{ fake.* }}) - Query parameter filtering for stateful resources
- Regex path patterns
- JSON log file output
- WebSocket mocking
Phase 1: Foundation (3-4 days)
├── Cargo project setup
├── clap CLI skeleton (serve, init, validate)
├── YAML config parsing with serde
├── Config types (ServerConfig, Route, etc.)
├── Config validation (missing fields, invalid methods, etc.)
├── `mockapi init` — write starter.yaml template
└── `mockapi validate` — parse and report errors
Phase 2: HTTP Server (4-5 days)
├── axum server setup with tokio
├── Route matching engine (exact, parameterized, wildcard)
├── Path parameter extraction
├── Query parameter parsing
├── Static response handler (inline body → JSON)
├── File response handler (read file, serve content)
├── Custom status codes and headers
├── Response delay (tokio::time::sleep)
├── CORS middleware (tower-http)
├── Startup banner showing route table
└── Request logging (minimal mode)
Phase 3: Templates (2-3 days)
├── minijinja integration
├── Template detection (scan body for {{ }})
├── Variable injection: params, query, request.body, request.headers
├── Built-in variables: now, now_unix, seq
├── Template error handling (show which variable failed)
└── Verbose logging mode
Phase 4: Hot Reload (1-2 days)
├── notify file watcher on config file
├── Debounced reload (500ms)
├── Atomic config swap (Arc<RwLock>)
├── Validation before swap (keep old config on error)
├── Log reload events
└── Watch external response files too
Phase 5: Polish & Ship (2-3 days)
├── Example configs (basic, ecommerce, auth)
├── Error messages (helpful, not cryptic)
├── README with GIF demo
├── Tests (integration: routes, params, templates, hot-reload)
├── cargo publish
├── AUR PKGBUILD
└── CHANGELOG.md
Total MVP estimate: ~2-3 weeks
- Conditional responses (
whenclauses for query, headers, body) - Fake data generators (
{{ fake.uuid }},{{ fake.name }},{{ fake.email }}, etc.) - Regex path patterns (
regex:/users/\d+) - JSON log file output (
--log-file) - Raw text responses (
raw: "OK")
- In-memory resource store
- Auto-CRUD: POST creates, GET lists/gets, PUT replaces, PATCH merges, DELETE removes
- Seed data from config
- Query filtering (
?name=Alice) - Sorting (
?_sort=name) - Pagination (
?_page=1&_limit=10) - Auto-generated IDs (integer or UUID, configurable)
-
mockapi recordcommand - Proxy to target API
- Capture responses into YAML config
- Deduplication (same method+path recorded once)
- Filter by path pattern (
--filter) - Merge recorded routes into existing config
- Proxy unmatched routes to target API
- Selective mocking (some routes mocked, some real)
- Request/response header pass-through
- Useful for: gradually replacing real API with mocks during development
- WebSocket mock support
- OpenAPI import (
mockapi import openapi spec.yaml) - Multiple config file support (
mockapi serve -c base.yaml -c overrides.yaml) - Docker image
- Stable config format (no breaking changes after 1.0)
# Development
cargo build
# Release (optimized, stripped)
cargo build --release
# With record/proxy feature
cargo build --release --features record
# Static binary for distribution
cargo build --release --target x86_64-unknown-linux-musl# Unit tests
cargo test
# Integration tests (spawns actual HTTP server, makes requests)
cargo test --test integration#[tokio::test]
async fn test_basic_get_route() {
// Start server with test config on random port
let server = TestServer::start("tests/fixtures/basic.yaml").await;
// Make request
let resp = reqwest::get(server.url("/users")).await.unwrap();
assert_eq!(resp.status(), 200);
let body: Vec<serde_json::Value> = resp.json().await.unwrap();
assert_eq!(body.len(), 2);
assert_eq!(body[0]["name"], "Alice");
server.shutdown().await;
}
#[tokio::test]
async fn test_path_params() {
let server = TestServer::start("tests/fixtures/params.yaml").await;
let resp = reqwest::get(server.url("/users/42")).await.unwrap();
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["id"], "42"); // from template {{ params.id }}
server.shutdown().await;
}
#[tokio::test]
async fn test_custom_status_code() {
let server = TestServer::start("tests/fixtures/errors.yaml").await;
let resp = reqwest::get(server.url("/error")).await.unwrap();
assert_eq!(resp.status(), 500);
server.shutdown().await;
}
#[tokio::test]
async fn test_response_delay() {
let server = TestServer::start("tests/fixtures/delay.yaml").await;
let start = Instant::now();
reqwest::get(server.url("/slow")).await.unwrap();
let elapsed = start.elapsed();
assert!(elapsed >= Duration::from_secs(2));
server.shutdown().await;
}| Method | Command | Notes |
|---|---|---|
| cargo install | cargo install mockapi |
Primary |
| AUR | yay -S mockapi |
Arch users |
| Homebrew | brew install mockapi |
macOS + Linux |
| GitHub Releases | Download binary | Pre-built static binaries |
| Docker | docker run mockapi |
For CI pipelines (post-MVP) |
| npx | npx @mockapi/cli serve |
Meet js devs where they are (post-MVP) |
on: [push, pull_request]
jobs:
test:
- cargo fmt --check
- cargo clippy -- -D warnings
- cargo test
- cargo build --release
release:
on: tag v*
- Build static binaries (x86_64, aarch64, macOS)
- Create GitHub Release
- Publish to crates.io"json-server, but a single binary. YAML config. Custom status codes, delays, templates, hot-reload. No Node.js required."
That single sentence is the README's first line, the HN post title, the Reddit title. Everyone who's used json-server knows its pain points. This pitch speaks directly to them.
-
README — clear, concise. GIF of:
mockapi init && mockapi serve, then curling the mock API and seeing responses. Show the terminal output with colored logging. -
Hacker News — "Show HN: mockapi — a single-binary mock API server (json-server alternative in Rust)"
- json-server discussions always have people asking for something better
- The Rust single-binary angle resonates on HN
-
Reddit:
- r/webdev — largest audience of json-server users
- r/rust — Rust community supports Rust CLI tools heavily
- r/programming — general reach
- r/frontend — direct target audience
- r/node — "tired of npm install just for a mock server?"
-
Dev.to / Medium — "Why I Built a json-server Alternative in Rust" tutorial post
-
Example configs — ship with real-world examples (ecommerce API, auth API, CRUD API) that people can copy and modify. These ARE the marketing.
- Developer discovers mockapi (HN, Reddit, search)
cargo install mockapi && mockapi init— running in 30 seconds- Works great → stars the repo
- Hits a limit → opens issue or PR
- You ship the fix → they tell colleagues
- Example configs get shared in team Slacks
- GitHub Sponsors / Buy Me a Coffee
- Lower donation potential than dotsmith (utility tool, not daily companion)
- But high volume potential — frontend developers are a massive audience
- Possible future: hosted version (mock APIs in the cloud) — but that's a different business
- Language: Rust
- HTTP framework: axum (fast, async, great middleware)
- Template engine: minijinja (Jinja2 syntax, lightweight, Rust-native)
- Config format: YAML (human-editable, more readable than JSON for this use case)
- File watching: notify crate
- No web UI — terminal-first, config-file-driven
-
Config file name convention:
mockapi.yamlvsmock.yamlvsapi.mock.yaml?- Leaning:
mockapi.yaml(matches the tool name, easy to find)
- Leaning:
-
Should
mockapi servework without a config file?- Option A: Error — "no config file found, run
mockapi init" - Option B: Start with empty server, show helpful message on every 404
- Leaning: Option A (explicit is better)
- Option A: Error — "no config file found, run
-
Response body format: YAML inline or JSON strings?
- Option A: YAML inline (current design) — body is native YAML, serialized to JSON
- Option B: JSON strings in YAML —
body: '{"id": 1, "name": "Alice"}' - Leaning: Option A — YAML inline is more readable and the whole point of using YAML
-
Project name collision: Is "mockapi" taken on crates.io?
- Need to check. Alternatives:
mockapi-server,yamock,mocka,apimock
- Need to check. Alternatives:
-
Should templates be opt-in or auto-detected?
- Option A: Auto-detect
{{ }}in body strings and template them - Option B: Require
template: trueon the route to enable templating - Leaning: Option A — less config, more magic, but document it clearly
- Option A: Auto-detect
mockapi is a focused, buildable project that fills the gap between json-server (too simple) and WireMock/Mockoon (too heavy). The MVP is tight — YAML config, axum server, route matching, templates, hot-reload. Ship in 2-3 weeks.
The one-liner pitch is strong: "json-server, but a single binary."
Build it after dotsmith is stable. Use it yourself. Post to HN. Let json-server's 73k-star audience come to you.