Skip to content

Latest commit

 

History

History
1464 lines (1165 loc) · 45.1 KB

File metadata and controls

1464 lines (1165 loc) · 45.1 KB

mockapi — The Modern Mock API Server

Complete Project Plan

Language: Rust Interface: CLI (single binary, zero runtime dependencies) License: MIT Repository: github.com/[you]/mockapi


Table of Contents

  1. What Is mockapi
  2. Why It Exists — The Gap
  3. Core Concepts
  4. Architecture
  5. Project Structure
  6. Crate Dependencies
  7. Config File Format — Full Spec
  8. CLI Interface
  9. Route Matching Engine
  10. Response Engine — Static, Dynamic, Templated
  11. Hot Reload
  12. Record & Replay Mode
  13. Stateful Mode
  14. Request Logging
  15. MVP Scope
  16. Post-MVP Roadmap
  17. Build, Test & Distribution
  18. Launch Strategy
  19. Open Questions

1. What Is mockapi

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 serve

That'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."


2. Why It Exists — The Gap

json-server (73k stars) — King, but flawed

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

The Rust alternatives — right idea, no traction

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.

Who uses this

  • 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

3. Core Concepts

Routes

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"}]

Response Modifiers

Each route can specify: status code, headers, delay, body format (JSON, text, file), and templating (dynamic values from request data or fake data).

Config File

One YAML file defines everything. No code, no scripts, no compilation. Edit the file, mockapi hot-reloads, your mock API is updated.

Modes

  • 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

4. Architecture

┌──────────────────────────────────────────────────────┐
│                     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

5. Project Structure

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

6. Crate Dependencies

[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

7. Config File Format — Full Spec

Basic Example

# 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 }}"

Full Config Reference

# ──────────────────────────────────────────────────
# 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

Template Variables Available in Responses

# ── 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 route

8. CLI Interface

mockapi — 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

Usage Examples

# 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

Example: Terminal Output (minimal log)

$ 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

Example: Terminal Output (verbose log)

  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"}]

9. Route Matching Engine

Match Priority

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"
}

Path Parameter Extraction

// /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>,
}

Implementation Approach

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"
}

10. Response Engine — Static, Dynamic, Templated

Response Resolution Flow

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

Conditional Responses

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 fields

When Clause Matching

when:
  # 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:
    - token

11. Hot Reload

How It Works

  1. notify crate watches the config file (and any referenced response files)
  2. On change, debounce 500ms (avoid reloading mid-save)
  3. Parse new config file
  4. Validate — if invalid, log error and keep old config
  5. If valid, atomically swap the route table (using Arc<RwLock<Config>>)
  6. Log: "Config reloaded (12 routes → 14 routes)"

Implementation

// 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)");
                    }
                }
            }
            _ => {}
        }
    }
}

What Gets Reloaded

  • Route definitions (add, remove, modify)
  • Response bodies and templates
  • Headers and status codes
  • Delays
  • CORS settings
  • External response files (re-read from disk)

What Does NOT Get Reloaded (requires restart)

  • Port and host (server bind address)
  • Stateful mode seed data (already in memory)

12. Record & Replay Mode

How It Works

mockapi record -t https://api.github.com -o github_mock.yaml
  1. Start a local proxy server on the configured port
  2. Forward all incoming requests to the target API
  3. Capture the response (status, headers, body)
  4. Write each unique method+path combo as a route in the output YAML
  5. Deduplicate — same path + method only recorded once (first response wins)
  6. When user hits Ctrl+C, finalize and save the YAML file

Output Format

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)

Record Options

# 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

Why This Matters

  • 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

13. Stateful Mode

How It Works

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: Bob

This 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

Implementation

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;
}

Stateful + Static Routes

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 response

The explicit route wins. Stateful mode only handles methods/paths that don't have an explicit route defined.

Query Parameter Support for Listing

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.


14. Request Logging

Log Levels

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"}]

Log Output

  • Default: colored stdout (using colored crate)
  • 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}

15. MVP Scope

MVP = version 0.1.0

Goal: Replace json-server for common use cases. Usable immediately.

Must Have (MVP)

  • 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

Won't Have (MVP)

  • Conditional responses (when clauses)
  • 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

MVP Development Phases

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


16. Post-MVP Roadmap

v0.2.0 — Intelligence

  • Conditional responses (when clauses 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")

v0.3.0 — Stateful Mode

  • 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)

v0.4.0 — Record & Replay

  • mockapi record command
  • 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

v0.5.0 — Proxy Mode

  • 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

v1.0.0 — Stable

  • 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)

17. Build, Test & Distribution

Building

# 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

Testing

# Unit tests
cargo test

# Integration tests (spawns actual HTTP server, makes requests)
cargo test --test integration

Integration Test Pattern

#[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;
}

Distribution

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)

CI/CD (GitHub Actions)

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

18. Launch Strategy

The Pitch

"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.

Launch Channels

  1. README — clear, concise. GIF of: mockapi init && mockapi serve, then curling the mock API and seeing responses. Show the terminal output with colored logging.

  2. 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
  3. 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?"
  4. Dev.to / Medium — "Why I Built a json-server Alternative in Rust" tutorial post

  5. Example configs — ship with real-world examples (ecommerce API, auth API, CRUD API) that people can copy and modify. These ARE the marketing.

Growth Loop

  1. Developer discovers mockapi (HN, Reddit, search)
  2. cargo install mockapi && mockapi init — running in 30 seconds
  3. Works great → stars the repo
  4. Hits a limit → opens issue or PR
  5. You ship the fix → they tell colleagues
  6. Example configs get shared in team Slacks

Funding

  • 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

19. Open Questions

Resolved

  • 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

To Decide During Implementation

  • Config file name convention: mockapi.yaml vs mock.yaml vs api.mock.yaml?

    • Leaning: mockapi.yaml (matches the tool name, easy to find)
  • Should mockapi serve work 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)
  • 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
  • Should templates be opt-in or auto-detected?

    • Option A: Auto-detect {{ }} in body strings and template them
    • Option B: Require template: true on the route to enable templating
    • Leaning: Option A — less config, more magic, but document it clearly

Summary

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.