Regeneration and extensibility
Edit on GitHubHow a project survives re-compilation, and how to add a target
Last updated:
Running compile again overwrites the spec-derived files; the generated README says so plainly, do
not edit them, because the next run will. What survives is the handful of files the compiler refuses to overwrite once they exist.
The extension point
Each generated project carries an app/extensions/ module with a single register function that
compile writes once as a stub
(ExtensionStub)
and never overwrites again:
from fastapi import FastAPI
def register(app: FastAPI) -> None:
"""Register custom routes, middleware, and lifecycle hooks.
This file is never overwritten by `spec-to-rest compile`. The generated
`app/main.py` calls this once BEFORE mounting any spec-derived router, so
middleware added here wraps every generated endpoint and routes declared
here take precedence on path collisions.
"""
del appThe generated main.py calls register(app) before it mounts the spec's routers, and that ordering
is what gives the hook its two powers: middleware added there wraps every generated endpoint, and a
route declared there wins a path collision against a generated one. That is the whole extension
surface, one function, called at a defined point. The Go and TypeScript targets ship the same shape,
a register in their own extensions package. There are no protected-region markers in the
generated files and no separate overrides mechanism; custom code lives in the extension file or not
at all. Internally each emitted file carries a preserve flag, and the same write-once rule keeps the
staging and production compose overrides from being clobbered, so hand-tuned environment settings
survive a recompile too.
Migrations are the incremental part
The application code is regenerated wholesale, but the database is not. A schema change does not
rewrite the first migration; it accrues as a new delta, computed by diffing the spec against the
.spec-snapshot.json the previous run left behind. That snapshot-driven delta generation, reversible
up and down across Alembic, golang-migrate, and Prisma, is the migrations
pipeline's job, not the emitter's.
Adding a target
A target is a
DeploymentProfile
entry plus a Handlebars template tree. The profile names the language and framework and carries the
type and dependency mappings, the templates live under templates/<language>/<framework>/, and
Emit
dispatches on the profile. The Go/chi and TypeScript/Express targets are exactly this: a profile
registered in Registry and a tree of .hbs files. Adding a Kotlin/Spring target, say, would mean
writing its profile and templates and one registry line:
object KotlinSpring:
val profile = DeploymentProfile(
name = "kotlin-spring",
language = "kotlin",
framework = "spring",
// type map, dependencies, and the rest
)There is no plugin-discovery step and no runtime loading. A target is code in the tree, registered at compile time, which is why a missing template is a build error rather than a deploy-time one.