DEV Community

Alessio
Alessio

Posted on Edited on Originally published at py-forge.com

A Stateful Mock REST API in Pure Python: CRUD, Fake Data and Failure Injection

The frontend is ready, but the backend endpoint it needs won't exist until next sprint. Or your integration tests call a payment provider that rate-limits the sandbox, or that fails just often enough to make CI flaky in ways you can't reproduce. In both cases you want a fake API that behaves like the real thing: it keeps state between calls, returns realistic-looking data, and fails on purpose when you ask it to.

MockForge is a mock API server written in pure Python, standard library only. You describe endpoints in a few lines of Python, and you get a stateful CRUD resource, templated responses with fresh fake data, configurable latency, and a failure rate for chaos testing. Everything below was run locally with the shipped file and the outputs are copied as-is.

Setup

The download ships the tool as main.py. There's a built-in demo you can start right away:

python3 main.py start --port 8080
Enter fullscreen mode Exit fullscreen mode

That serves a demo with customers, orders, an analytics endpoint and a flaky payment endpoint, so you can click around. For your own API you import it. I saved the file as mockforge.py next to my script so the import reads naturally.

Define your fake API

# mock_api.py
from mockforge import MockForge, DataForge

forge = MockForge(port=8089, host="127.0.0.1")

# Stateful CRUD: GET/POST /api/v1/users, GET/PUT/PATCH/DELETE /api/v1/users/{id}
forge.resource("users", base_path="/api/v1/users")
forge.seed("users", 2, lambda: {"name": DataForge.name(), "plan": "free"})

# Templated endpoint: fresh synthetic values on every call
forge.mock("/api/v1/users/{user_id}/invoices/latest", response={
    "invoice_id": "{{uuid}}",
    "customer": "{{name}}",
    "amount": "{{currency}}",
    "issued_at": "{{iso_date}}",
}, latency_ms=(5, 20))

# Flaky third-party dependency: roughly 1 in 3 calls returns HTTP 500
forge.mock("/api/v1/payments", method="POST", response={"status": "succeeded", "id": "{{uuid}}"},
           latency_ms=(100, 300), failure_rate=0.33)

forge.serve()
Enter fullscreen mode Exit fullscreen mode
python3 mock_api.py
Enter fullscreen mode Exit fullscreen mode

A CRUD resource that remembers

One resource() call registers six routes, backed by an in-memory store. The two seeded users are already there:

curl -s localhost:8089/api/v1/users
Enter fullscreen mode Exit fullscreen mode
{
  "data": [
    {"name": "Morgan Nakamura", "plan": "free", "id": "59d4dfa6-8cc6-4199-a900-4aa73d0028b4", "_created_at": "2026-10-19T23:50:50.512371+00:00"},
    {"name": "Elena Patel", "plan": "free", "id": "25413ad9-737c-4889-8739-7a3811f621c7", "_created_at": "2026-10-19T23:50:50.512424+00:00"}
  ],
  "total": 2
}
Enter fullscreen mode Exit fullscreen mode

Create, update, read, delete. If the body includes an id, it's used; otherwise a UUID is generated:

curl -s -X POST localhost:8089/api/v1/users -H 'Content-Type: application/json' \
     -d '{"id":"u_100","name":"Ada Park","plan":"pro"}'
{"id": "u_100", "name": "Ada Park", "plan": "pro", "_created_at": "2026-10-19T23:50:51.995803+00:00"}

curl -s -X PATCH localhost:8089/api/v1/users/u_100 -d '{"plan":"team"}'
{"id": "u_100", "name": "Ada Park", "plan": "team", "_created_at": "...", "_updated_at": "2026-10-19T23:50:52.056231+00:00"}

curl -s -o /dev/null -w "DELETE %{http_code}\n" -X DELETE localhost:8089/api/v1/users/u_100
DELETE 204

curl -s localhost:8089/api/v1/users/u_100
{"error": "NotFound", "message": "users with id 'u_100' not found."}
Enter fullscreen mode Exit fullscreen mode

That's enough for a frontend to build its full create/edit/delete flow, 404 handling included, before the real backend exists.

Templates for read-only endpoints

Endpoints registered with mock() return the template with the macros filled in again on every call:

curl -s localhost:8089/api/v1/users/u_1/invoices/latest
Enter fullscreen mode Exit fullscreen mode
{
  "invoice_id": "2cd0f124-88ef-447b-a7a9-b3c34e7aea7f",
  "customer": "Siddharth Al-Mansoor",
  "amount": 1256.04,
  "issued_at": "2026-10-20T00:55:09.847633+00:00"
}
Enter fullscreen mode Exit fullscreen mode

The available macros are {{uuid}}, {{name}}, {{email}}, {{company}}, {{status}}, {{currency}}, {{iso_date}} and {{int:min:max}}. A value that is exactly one {{currency}} or {{int:min:max}} macro comes back as a JSON number, as with amount above. Inside a longer string it's substituted as text, so "tier-{{int:1:3}}" gave me "tier-1". Macros work at any depth in nested dicts and lists. Path parameters like {user_id} turn into named regex groups, so any value matches.

Failing on purpose

The payments mock has failure_rate=0.33. Nine calls in a row:

for i in $(seq 1 9); do curl -s -o /dev/null -w "%{http_code} " -X POST localhost:8089/api/v1/payments -d '{}'; done
200 200 200 200 200 500 200 500 200
Enter fullscreen mode Exit fullscreen mode

The failures return a JSON body:

{"error": "InjectedChaosFailure", "message": "Chaos fault injection triggered by contract specification.", "timestamp": "..."}
Enter fullscreen mode Exit fullscreen mode

Each call also waits a random 100–300 ms first, so your client's timeouts, retries and loading states get exercised against something that behaves like a slow, flaky third-party API.

Introspection

Unmatched requests return a helpful 404 instead of hanging, and a CRUD route that receives a body that isn't a JSON object answers 400:

{"error": "Contract Unmatched", "message": "No mock or state contract matches GET /api/v1/nope",
 "mockforge_hint": "Inspect registered endpoints at /_mockforge/routes"}
Enter fullscreen mode Exit fullscreen mode

/_mockforge/routes lists every registered pattern, /_mockforge/logs shows the last 50 requests with status and latency, and /_mockforge/metrics gives request and failure counts. Responses include permissive CORS headers and OPTIONS is answered, so a browser app on another port can call the mock directly.

How it works

resource() compiles two regexes and registers one route per verb, each tagged with an action:

base = base_path or f"/api/v1/{resource_name}"
list_pat = re.compile(f"^{base}/?$")
item_pat = re.compile(f"^{base}/(?P<id>[^/]+)/?$")
self.routes.append(RouteContract(list_pat, "GET", stateful_resource=resource_name, is_stateful=True, action="list"))
self.routes.append(RouteContract(list_pat, "POST", stateful_resource=resource_name, is_stateful=True, action="create"))
# ... GET / PUT / PATCH / DELETE on item_pat
Enter fullscreen mode Exit fullscreen mode

The handler (a ThreadingMixIn HTTP server) checks routes in registration order and applies the simulated latency. It then rolls against failure_rate and either runs the CRUD action against a lock-protected StateStore or renders the template. Because it's plain Python, you can seed data from a JSON fixture, a loop or a factory function.

Limitations

  • State lives in memory. Restart the process and you're back to the seeded data, which is usually what you want for tests.
  • Macros embedded in longer strings stay strings, and there's no boolean or date-range macro. For anything more specific, return a seeded record from a stateful resource.
  • export-spec prints the OpenAPI outline of the built-in demo, not of your own routes.
  • Route matching is by path and method only. There's no matching on headers or bodies, and no request validation against a schema.

FAQ

Do I need Flask or FastAPI? No. It's built on http.server and has no dependencies.

Can I run it in CI? Yes. Start your mock_api.py in the background, point the app's base URL at it, and run your tests.

Can I make a single test deterministic? Set failure_rate=0.0 (the default) for the routes the test relies on, and keep a separate flaky route for resilience tests.


MockForge is free and MIT-licensed: get it on GitHub. Details are on the MockForge product page.

Want every PyForge tool? See the All-Access Pass.

Top comments (0)