Codegen pipeline
The API contract of a gpsystem project is spec-first: the source of truth is TypeSpec, compiled to OpenAPI, from which a strict, typed server interface is generated. So a spec deviation is not a runtime surprise but a compile error:
spec/typespec/**.tsp ──tsp compile──▶ api/openapi/<Namespace>.yaml
│ │
│ source of truth │ oapi-codegen v2.8.x
│ (by hand + generators) │ + engine-specific templates
▼ ▼
gpsystem CLI internal/modules/<m>/surfaces/<s>/http/gen/api.gen.go
mise run generate runs the whole chain: tsp compile → go generate ./... → go mod tidy → go build ./.... The go:generate directive lives in the surface's http/handler.go and drives the api/oapi-codegen/<module>-<surface>.yaml config (because of the surfaces/ wrapper, the relative paths it references go six levels up: ../../../../../../). That file only holds the Handler struct, New and the interface assertion; the handler methods themselves live in one category file per group (<module>_handler.go, or <module>_<group>_handler.go for a category like notifications), so an add handler run groups by operation rather than scattering one file per function (see add handler).
TypeSpec conventions
- Each surface is its own
@servicewith@server("/api/v1<base>"). The emitter names the output file after the namespace, so namespaces are deterministic:Project.Modulefor theapisurface,Project.Module<Surface>for everything else (in the shop:Shop.ShopandShop.ShopAdmin). shared/errors.tspmirrorshttperr.Problem(Error model);shared/paginator.tspmirrorspaginate.Page[T]/paginate.CursorPage[T](Pagination). Keep them in sync with the Go side.- The OpenAPI output is pinned to 3.0 (oapi-codegen does not read 3.1).
@service namespace inside another (e.g. Shop.Shop.Admin under Shop.Shop). TypeSpec merges their routes and fails with duplicate-operation errors. The generators always emit sibling namespaces.One operation's journey: the shop's product list
1. The contract. In the shop api surface's .tsp, add handler inserts at the // gpsystem:operations anchor, but you can also write to the same spot by hand:
@tag("shop")
interface ShopApi {
// gpsystem:operations
@get
@route("/products")
@operationId("ListProducts")
@summary("List products")
listProducts(...CursorQuery): CursorPage<Product>;
}
CursorQuery / CursorPage<T> come from the scaffolded shared/paginator.tsp: a cursor-paginated list in a single line.
2. The generated interface. After tsp compile, oapi-codegen emits a strict interface into the gen package: the request/response type names carry no Object suffix, and the signature depends on nothing but context.Context and plain Go types:
type StrictServerInterface interface {
// (GET /products)
ListProducts(ctx context.Context, request ListProductsRequest) (ListProductsResponse, error)
// ...
}
3. The handler. This one you write (the skeleton comes from add handler):
func (h *Handler) ListProducts(ctx context.Context, req gen.ListProductsRequest) (gen.ListProductsResponse, error) {
page, err := h.svc.ListProducts(ctx, req.Params.Cursor, req.Params.Limit)
if err != nil {
return nil, service.ErrListProducts.Wrap(err, "api: list products")
}
return gen.ListProducts200JSONResponse(page), nil
}
4. The wiring. The generated wiring in register.go:
func RegisterApi(router chi.Router, deps Dependencies) {
apiSvc := apiservice.New()
apiHandler := apihttp.New(apiSvc)
apiPolicies := apipolicy.New()
router.Route("/shop", func(r chi.Router) {
shopapigen.HandlerWithOptions(shopapigen.NewStrictHandlerWithOptions(
apiHandler,
[]shopapigen.StrictMiddlewareFunc{
server.StrictValidator[shopapigen.StrictHandlerFunc](nil),
// Last = outermost: authorization runs before validation.
policy.Enforcer[shopapigen.StrictHandlerFunc](apiPolicies, shopapigen.PermissionByOperation, shopapigen.PolicyByOperation),
},
shopapigen.StrictHTTPServerOptions{
RequestErrorHandlerFunc: httperr.WriteBadRequest,
ResponseErrorHandlerFunc: server.WriteError,
},
), shopapigen.ChiServerOptions{
BaseRouter: r,
ErrorHandlerFunc: httperr.WriteBadRequest,
})
})
}
Request validation runs as the server.StrictValidator strict middleware, innermost in the chain; error rendering (both from validation/decoding and from a returned business error) goes through server.WriteError and httperr.WriteBadRequest, wired into the generated code's error hooks. server.WriteError maps the auth/rbac/policy sentinels (see Policies) onto Problems before delegating everything else to httperr.WriteError. Neither touches your business code.
The template set: upstream routing + a minimal strict override
Projects use oapi-codegen's built-in chi-server routing templates (current upstream), plus a minimal framework override (internal/templates/oapichi/): the two strict templates (strict/strict-interface.tmpl, strict/strict-http.tmpl) with a single substantive delta: the Object suffix is dropped from the generated request/response type names (ListProductsRequest, not ListProductsRequestObject), so the business handler code you write stays free of that suffix everywhere.
How this stays safe
The override is pinned and tested in the framework repo, by a contract test that pins the identifiers the register snippet uses, the shape of StrictHandlerFunc and the Object-free names, and compiles the generated package. Bumping oapi-codegen breaks this test first, never a consumer project.
Declarative authorization: @permission and @policy
The project scaffold ships a small decorator lib (spec/typespec/lib/policy.tsp + policy.js), making @permission("...") and @policy("...") available in every surface .tsp. The chain is machine-driven end to end:
@permission("orders.place") / @policy("orders.owner") on the operation
→ x-permission / x-policy extensions in the emitted OpenAPI (setExtension)
→ the strict template generates PermissionByOperation / PolicyByOperation maps into the gen package
→ the policy.Enforcer middleware wired in register.go
checks the RBAC permission and runs the policy from the surface's policy/ registry
The generated maps look like this:
var PermissionByOperation = map[string]string{
"PlaceOrder": "orders.place",
}
var PolicyByOperation = map[string]string{
"GetOrder": "orders.owner",
}
The enforcer is the outermost element of the strict middleware chain: it sees the fully bound, typed request, reads the authenticated user from the context, and short-circuits with 401/403 before the business handler runs. Fail-closed: an operation tagged with @policy whose policy is not registered does not pass; it errors. Semantics and policy writing are covered on the Policies page.
The gpsystem.yaml manifest
The gpsystem.yaml in the project root marks the project root for the CLI and records what has been generated so far:
version: 1
name: shop
module: github.com/acme/shop
framework: github.com/gp-system/framework@v0.0.0
db: pgx # or bun
modules:
shop:
surfaces: [api, admin]
events: [orderPlaced]
dbdecides which template tree the generators render (add surface,add handler, ...); the choice is made at project creation.- The
modulesentries telladd surfacewhat already exists, andadd listenerwhether you are subscribing to an existing event.
The // gpsystem:* anchors
The generators never overwrite an existing file; they only insert at marked anchor comments. The important ones:
| Anchor | File | Who inserts here |
|---|---|---|
gpsystem:modules | spec/typespec/main.tsp | new module / add surface |
gpsystem:operations | <surface>.tsp | add handler |
gpsystem:surfaces, gpsystem:imports | register.go | new module / add surface |
gpsystem:registrations | cmd/<module>/main.go | add surface |
gpsystem:dependencies, gpsystem:dependencies-init | register.go / entry points | add event (dispatcher wiring) |
gpsystem:listeners, gpsystem:jobs | listeners/register.go / jobs/register.go | add listener / add job |
gpsystem:config | internal/platform/config/config.go | future config additions |
Insertions are idempotent (running twice does not duplicate), and the file is reformatted with goimports afterwards. If you delete an anchor, the affected generator fails loudly instead of guessing: before the error, it prints the snippet that would have been inserted and the anchor line to restore, so you can paste it in by hand at the right spot. Leave the anchors in place, as the sample app does.
Patterns used
- Spec-first codegen (TypeSpec → OpenAPI 3.0 → strict oapi-codegen server): Design patterns. TypeSpec's official documentation: typespec.io/docs; the oapi-codegen project: github.com/oapi-codegen/oapi-codegen.
- Anchor-comment code injection (
// gpsystem:*, idempotent, whitespace-normalized): Design patterns. - Fail-closed policy enforcement in the
@permission/@policyenforcer chain: Design patterns.
Related pages
- add handler: scaffolding an operation into the
.tspand the handler. - Validation: how generated binding validates.
- Policies: the enforcer and the policy registry.