A single-binary, runtime-dependency-free mock API server. Point it at a JSON or YAML config file and it serves REST endpoints with fake data, configurable latency, per-endpoint rate limiting, hot-reload, and schema-less SQLite CRUD emulation. Built for constrained environments: ~3.8 MiB idle RSS on ARM64, no JVM/Node/.NET, no shared-library runtime deps.
- Tiny: C++17, epoll event loop, vendored header-only deps (RapidJSON, rapidyaml amalgamation, SQLite amalgamation).
- Fast: O(log n) trie routing, O(1) token-bucket rate limiting, non-blocking latency timers.
- Live-reloading: edits to the config file swap routes atomically with zero downtime; a bad edit keeps the previous routes and logs the error.
- CRUD out of the box:
persist_to_sqlite: trueturns POST/GET/PUT/DELETE into insert/select/update/delete over an in-memory SQLite table. - No runtime deps: one static-ish binary; nothing to install at run time.
Requirements: CMake ≥ 3.16, a C++17 compiler, Ninja (or Make).
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
ctest --test-dir build --output-on-failure # unit tests (8 suites)
bash scripts/test_api.sh # end-to-end curl testspkg install proot-distro
proot-distro install debian
proot-distro login debian
apt update && apt install -y build-essential cmake ninja-build curlThen build as above from the repository directory.
./build/mae examples/configs/basic.json
./build/mae examples/configs/crud-with-db.yaml # YAML is supported too
./build/mae --versionThe server prints listening on :<port> (N endpoints) and runs until
SIGINT/SIGTERM.
Format is auto-detected from the file extension (.json, .yaml, .yml).
| Field | Type | Default | Description |
|---|---|---|---|
port |
int | 8080 |
Listen port (1–65535). |
endpoints |
array | [] |
Route table. |
Each endpoint:
| Field | Type | Default | Description |
|---|---|---|---|
method |
string | — (required) | GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS (case-insensitive). |
path |
string | — (required) | Route, e.g. /users/:id; must start with /. :name segments become path params. |
latency.min_ms |
int | 0 |
Minimum simulated delay. |
latency.max_ms |
int | 0 |
Maximum simulated delay. min == max → fixed; 0 → no delay. |
rate_limit.requests |
int | 0 |
Max requests per window; 0 → unlimited. |
rate_limit.window_ms |
int | 1000 |
Rate-limit window. Exceeding it returns 429 + Retry-After. |
persist_to_sqlite |
bool | false |
Enable in-memory SQLite CRUD for this endpoint. |
sqlite_table |
string | derived | Table name; if empty it is derived from the path (/users/:id → users). |
responses |
array | [{200, application/json, ""}] |
Response variants; the first is served. |
Each response:
| Field | Type | Default | Description |
|---|---|---|---|
status_code |
int | 200 |
HTTP status (100–599). |
content_type |
string | application/json |
Content-Type header. |
body_template |
string | "" |
Response body template (see below). |
| Token | Renders |
|---|---|
{{faker.name}} |
Random First Last |
{{faker.email}} |
first.last@example.net |
{{faker.uuid}} |
Hyphenated UUID v4-shaped string |
{{faker.integer}} |
Integer 0–1000 |
{{faker.integer:min:max}} |
Uniform integer in [min, max] |
{{req.param.id}} |
Path param value (from :id segment) |
{{req.query.x}} |
Query-string value (?x=...) |
{{req.body.field}} |
Field from the JSON request body (dot-path: {{req.body.profile.age}}) |
{{sqlite.last_insert_id}} |
Last inserted row id (after a POST to a persisted endpoint) |
Unresolved or malformed tokens are left verbatim.
When persist_to_sqlite is set, method maps to operation on sqlite_table:
| Method | Operation |
|---|---|
POST |
Insert body; response can echo {{sqlite.last_insert_id}}. |
GET |
With :id → single row; without → all rows as a JSON array. |
PUT / PATCH |
Update the row at :id. |
DELETE |
Delete the row at :id. |
The database is in-memory and is kept across hot-reloads (data survives a config edit), but not across process restarts.
While running, editing the config file is detected via inotify. A valid
change swaps the route table atomically — in-flight connections and SQLite data
are preserved. An invalid change is logged (config reload rejected: ...)
and the previous routes keep serving.
Note: editors that replace the file via rename (
mv tmp config) are handled on the first rename, but a file-inode watch can go silent after that; in-place edits (the common case) are always detected.
Measured on this build (aarch64, Release, idle + a couple of requests):
$ awk '/VmRSS/ {print $2/1024 " MiB"}' /proc/<pid>/status
3.75 MiB
The idle RSS stays well under the 10 MiB target because the process keeps no GC/VM, holds no per-connection threads, and frees connection buffers on close.
include/mae/
config/ config IR + JSON/YAML loader net/ epoll loop + HTTP parser
router/ trie router + rate limiter db/ sqlite CRUD handler
mock/ template engine + latency sim watcher/ inotify config watcher
server/ server orchestration util/ logging
src/ implementations tests/ ctest executables
scripts/ test_api.sh, fetch_third_party.sh examples/ sample configs
./build/mae examples/configs/basic.json &
curl localhost:8080/health
curl localhost:8080/users/42 # {"id":42,"name":"...","email":"..."}
curl localhost:8080/slow # delayed ~200-400ms
# 4 rapid requests to /limited -> 200,200,200,429