Concepts

Codegen pipeline

TypeSpec → OpenAPI 3.0 → strict, typed server code: for Fiber and chi alike.

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. The pipeline is the same for both engines; only the template set differs:

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 the same strict interface into the gen package on both engines: the request/response type names carry no Object suffix, and the signature is engine-independent:

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), and because the interface is engine-independent, so is the 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 one place where the two engines genuinely differ is the generated wiring in register.go:

func RegisterApi(router fiber.Router, deps Dependencies) {
    apiSvc := apiservice.New()
    apiHandler := apihttp.New(apiSvc)
    apiPolicies := apipolicy.New()
    apiGroup := router.Group("/shop")
    shopapigen.RegisterHandlersWithOptions(apiGroup, shopapigen.NewStrictHandler(apiHandler, []shopapigen.StrictMiddlewareFunc{
        kitpolicy.FiberEnforcer[shopapigen.StrictHandlerFunc](apiPolicies, shopapigen.PermissionByOperation, shopapigen.PolicyByOperation),
    }), shopapigen.FiberServerOptions{})
}

The reasons for the differences: on Fiber, request validation is done at bind time by the app's StructValidator; on chi the same runs as the chix.StrictValidator strict middleware. On Fiber, error rendering goes through the central ErrorHandler; on chi the generated code's error hooks must be wired to the httperr writers. Neither touches your business code.

The two template sets

Fiber v3: the kit's own templates

oapi-codegen's built-in fiber-server generator targets Fiber v2; v3 support (upstream PR #1937) is unmerged. The kit therefore ships a v3 template set (internal/templates/oapi/), copied into every Fiber project under api/oapi-codegen/templates/. Deltas vs the v2 baseline:

  • *fiber.Ctxfiber.Ctx (v3 made the context an interface)
  • BodyParserctx.Bind().Body(), which runs the app's StructValidator, so generated body binding validates for free
  • UserContext()Context() and the rest of the v3 renames
  • an imports.tmpl override swaps the hardcoded fiber v2 import for v3
  • handler errors propagate unchanged: the upstream template wrapped every error into fiber.NewError(400), which would destroy the kit's typed problem responses

chi: upstream routing + a minimal strict override

--engine chi projects use oapi-codegen's built-in chi-server routing templates (current upstream, unlike fiber), plus a minimal kit 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), matching the Fiber set so the business handler templates stay engine-agnostic.

How this stays safe

Both sets are pinned and tested in the kit repo: the Fiber side is guarded by a golden test (byte-exact expected output) plus a compile test that builds the generated package against Fiber v3; the chi side by a contract test that pins the identifiers the register snippet uses, the shape of StrictHandlerFunc and the Object-free names, and compiles too. Bumping oapi-codegen or an engine breaks these first, never a consumer project.

If upstream merges Fiber v3 support, the migration is: drop the user-templates entries from the codegen configs, delete the local template directory, and pin the first v3-capable oapi-codegen release. The golden test tells you when output parity is reached.

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 enforcer middleware wired in register.go (FiberEnforcer / HTTPEnforcer)
    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
kit: github.com/gp-system/gpsystem
engine: fiber        # or chi; the generators follow it
db: pgx              # or bun
templatesSHA: 3f2a…  # checksum of the copied oapi template set
modules:
  shop:
    surfaces: [api, admin]
    events: [orderPlaced]
  • engine and db decide which template tree the generators render (add surface, add handler, ...); the choice is made at project creation.
  • templatesSHA is how upgrade templates detects that you modified the copied templates locally: a modified set is only overwritten with --force.
  • The modules entries tell add surface what already exists, and add listener whether you are subscribing to an existing event. The pre-rename domains:/domainDBs: keys are still read and migrated to surfaces:/surfaceDBs: automatically on the next save.

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