Files
soleprint/docs/data/en/station-shuntgen.md
2026-08-10 05:36:32 -03:00

4.9 KiB

Shuntgen

Generates runnable shunts from the two things people actually have: a service contract, or a folder of spreadsheets.

Writing a shunt by hand means copying artery/shunts/example/ and filling in responses.json entry by entry. That is fine for three endpoints and untenable for eighty — and it is the wrong work anyway, because the endpoints are already described in the spec somebody handed you.

# a spec you were handed
python -m station.tools.shuntgen from-openapi -s api.yaml -o artery/shunts/petstore

# sheets a client sent
python -m station.tools.shuntgen from-tabular -s ./sheets -o artery/shunts/books

python -m station.tools.shuntgen list

Run from soleprint/. Also in the browser at /station/tools/shuntgen/, where you can upload a spec, preview the routes it would serve, and generate.

What comes out

artery/shunts/<name>/
  main.py               builds the app from the spec
  run.py                uvicorn entry point (PORT, or depot/config.json)
  shunt_runtime.py      vendored runtime — no soleprint import
  models.py             pydantic, via modelgen
  datagen_<name>.py     BaseDataGenerator subclass, via modelgen
  depot/spec.json       routes, collections and schema
  depot/responses.json  pinned overrides — yours, never overwritten
  depot/config.json     delays, error rate, prefill — yours, never overwritten
  depot/data.json       imported rows
  templates/index.html  config UI
  README.md
cd artery/shunts/books && python run.py
curl localhost:8098/customers

The routes are built at startup from spec.json rather than written out as source. That keeps the generated code short enough to read, and puts the behaviour in one reviewable place: fixing runtime.py fixes every shunt, and regenerating is a copy.

Where a response comes from

First hit wins:

  1. depot/responses.json — a pinned override, keyed "METHOD /path"
  2. the store — rows imported from sheets, plus anything POSTed since
  3. the spec's example, if the source document carried one
  4. datagen_<name>.py, synthesising from the schema
  5. {}

The store is what makes it behave like a service rather than a random-value faucet: POST something and GET it back, ask for /pets/7 and get the pet whose id is 7. Collections that arrived with no rows are prefilled with generated ones, so the first call answers with something.

Two sources, one pipeline

Both inputs are modelgen extractors, so the same shapes also generate pydantic, TypeScript, prisma and a graphgen schema:

python -m station.tools.modelgen from-openapi -s api.yaml -o out/ -t pydantic,typescript
python -m station.tools.modelgen from-tabular -s ./sheets -o out/ -t schema,datagen
Source Becomes Routes
OpenAPI 3.x / Swagger 2.0 one model per schema; enums become real Enums, $ref becomes a relation the operations the document declares
.csv / .tsv / .ods one model per file or sheet, types inferred per column five CRUD routes per table

Keys and relations are inferred by name and then checked against the data: an id column that is not unique is not treated as a key, and customer_id is only a foreign key if a customers sheet came with it.

ODS is read with zipfile and ElementTree — no odfpy, no pandas — which is what lets modelgen stay dependency-free and publishable on its own.

Control endpoints

Every generated shunt serves these:

Endpoint Purpose
GET /health liveness
GET /mock/spec the routes it was built from
GET /mock/stats call counts and row counts
POST /mock/reset restore the imported rows, clear counters
GET,POST /mock/config delays, error rate, unknown_id, page size
GET,POST /mock/responses pin an override; set a key to null to drop it
# make it slow and flaky, the way the real thing is
curl -X POST localhost:8098/mock/config \
  -H 'content-type: application/json' \
  -d '{"enable_random_delays": true, "error_rate": 0.2}'

# make one call answer something specific
curl -X POST localhost:8098/mock/responses \
  -H 'content-type: application/json' \
  -d '{"GET /customers/1": {"id": 1, "name": "PINNED"}}'

unknown_id decides what an unknown key does: generate (the default) invents a record wearing the id that was asked for; 404 refuses it. Generate by default, because a client pointed at a fresh shunt should just work — flip it when the error path is what you are testing.

Dependency containers

--cabinet postgres,redis writes a cabinet.json declaring what the shunt expects. build.py composes those services into the room's compose file, and on a cluster they install as rig addons of the same name. See Cabinets.

Regenerating

Everything is overwritten except depot/responses.json and depot/config.json. Those two are yours.