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:
depot/responses.json— a pinned override, keyed"METHOD /path"- the store — rows imported from sheets, plus anything POSTed since
- the spec's
example, if the source document carried one datagen_<name>.py, synthesising from the schema{}
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.