Concepts

Codegen pipeline

TypeSpec → OpenAPI 3.0 → strict, typed server code on chi/net-http.

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 compilego generate ./...go mod tidygo 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 @service with @server("/api/v1<base>"). The emitter names the output file after the namespace, so namespaces are deterministic: Project.Module for the api surface, Project.Module<Surface> for everything else (in the shop: Shop.Shop and Shop.ShopAdmin).
  • shared/errors.tsp mirrors httperr.Problem (Error model); shared/paginator.tsp mirrors paginate.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).
Never nest one @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:

spec/typespec/modules/shop/api.tsp
@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:

internal/modules/shop/surfaces/api/http/gen/api.gen.go (generated, do not edit)
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):

internal/modules/shop/surfaces/api/http/shop_handler.go
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:

internal/modules/shop/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:

gen/api.gen.go (generated)
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:

gpsystem.yaml
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]
  • db decides which template tree the generators render (add surface, add handler, ...); the choice is made at project creation.
  • The modules entries tell add surface what already exists, and add listener whether 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:

AnchorFileWho inserts here
gpsystem:modulesspec/typespec/main.tspnew module / add surface
gpsystem:operations<surface>.tspadd handler
gpsystem:surfaces, gpsystem:importsregister.gonew module / add surface
gpsystem:registrationscmd/<module>/main.goadd surface
gpsystem:dependencies, gpsystem:dependencies-initregister.go / entry pointsadd event (dispatcher wiring)
gpsystem:listeners, gpsystem:jobslisteners/register.go / jobs/register.goadd listener / add job
gpsystem:configinternal/platform/config/config.gofuture 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

  • add handler: scaffolding an operation into the .tsp and the handler.
  • Validation: how generated binding validates.
  • Policies: the enforcer and the policy registry.
Copyright © 2026