HTTP & Routing

The chi engine

server/chix, a chi router over the standard net/http with the kit's error handling and telemetry wired in, run with graceful shutdown.

server/chix is the kit's chi engine: it builds a *chi.Mux with the kit defaults (RFC 9457 problem responses for panics, 404 and 405, OTel middleware, CORS, body limit) and runs it with the shared lifecycle. Its positioning is stdlib-first: chi is not a framework, it is routing over net/http. Every middleware is a plain func(http.Handler) http.Handler, every handler is http.HandlerFunc-compatible. Any net/http tool in the Go ecosystem (middleware libraries, httptest, profilers) works unmodified.

import "github.com/gp-system/gpsystem/server/chix"

It is the chassis of projects generated with --engine chi; its ergonomics deliberately mirror fiberx, so the main.go diff between the two engines stays minimal.

API

func Run(ctx context.Context, cfg server.Config, register RegisterFunc, opts ...Option) error
func New(cfg server.Config, opts ...Option) *chi.Mux // for tests and special cases

type RegisterFunc func(r chi.Router) error

Run blocks until exit: telemetry bootstrap → router construction → register(r)http.Server + ListenAndServe → graceful shutdown on SIGINT/SIGTERM. It returns nil on clean shutdown. New returns the fully wired router without running it or bootstrapping telemetry: you pair it with httptest.

What Run wires

The middleware chain and the router-level handlers, in this order:

WhatFromWhat it does
recovererkita panic becomes an errs error whose stack points at the panic site, rendered as a 500 problem response (it re-raises http.ErrAbortHandler, net/http's flow-control signal)
validator injectorkitputs the validator given via WithValidator into the request context, where StrictValidator(nil) finds it (see below)
OTel middlewareotelhttpper-request server span + HTTP metrics: OpenTelemetry
Sentry request hubkitan isolated Sentry hub per request, so breadcrumbs never mix across requests; no-op when Sentry is off
dev loggerchi/middleware.Loggeronly with OTEL_DEV_MODE=true: request log on the console
CORSgo-chi/corsfrom CORS_ORIGINS; GET/POST/PUT/PATCH/DELETE/OPTIONS, Content-Type + Authorization headers
body limitchi/middleware.RequestSizewraps the body in http.MaxBytesReader with cfg.EffectiveBodyLimit(): exceeding it yields a 413 problem
NotFound / MethodNotAllowedkit404/405 as problem+json instead of chi's stock plain-text errors; mounted subrouters inherit them

The server.Config timeouts translate into the http.Server (ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout): ReadHeaderTimeout is used as a separate field only by this engine; it is the Slowloris defense.

What net/http forces to be different

Unlike Fiber, net/http has no central error handler: a handler doesn't "return" an error to the framework, it writes the response itself. The kit bridges this at three points so the on-the-wire behavior stays identical across the two engines:

  • the generated server code's error hooks are wired to the httperr net/http writers (WriteError, WriteBadRequest): an error returned from your handler goes through the same mapping as on Fiber;
  • the router-level cases (panic, 404, 405, body limit) are rendered as problems per the table above;
  • request validation does not run at bind time (the strict server decodes with json.Decoder) but in the chix.StrictValidator strict middleware (see below).

The shop entry point

The sample app's HTTP binary on chi, every line of which was written by the generator:

cmd/shop/main.go
package main

import (
    "context"
    "log"

    "github.com/go-chi/chi/v5"

    "github.com/gp-system/gpsystem/dbx/pg"
    "github.com/gp-system/gpsystem/envconf"
    "github.com/gp-system/gpsystem/events/outbox"
    "github.com/gp-system/gpsystem/server/chix"

    "github.com/acme/shop/internal/modules/shop"
    "github.com/acme/shop/internal/platform/config"
)

func main() {
    cfg := envconf.MustLoad[config.Config]()
    ctx := context.Background()
    pool := pg.MustNewPool(ctx, cfg.DB)

    err := chix.Run(ctx, cfg.Server, func(r chi.Router) error {
        api := chi.NewRouter()
        deps := shop.Dependencies{
            DB:         pg.NewDB(pool),
            Transactor: pg.NewTransactor(pool),
            Dispatcher: outbox.NewDispatcher(outbox.NewStore(pg.NewDB(pool))),
        }
        shop.RegisterApi(api, deps)
        shop.RegisterAdmin(api, deps)
        r.Mount("/api/v1", api)
        return nil
    }, chix.WithCloser("pgxpool", func(context.Context) error {
        pool.Close()
        return nil
    }))
    if err != nil {
        log.Fatal(err)
    }
}

The only structural difference from the Fiber variant: in chi, prefixing happens with Mount: the surfaces register on a fresh subrouter that gets mounted under /api/v1.

Options

chix.WithCloser("pgxpool", func(ctx context.Context) error { pool.Close(); return nil })
chix.WithMiddleware(myRateLimiter, myRequestID)          // ...func(http.Handler) http.Handler
chix.WithHTTPServer(func(s *http.Server) { s.MaxHeaderBytes = 1 << 16 })
chix.WithValidator(validate.New(validate.WithRegister(registerCustomTags)))
chix.WithoutTelemetry()                                  // tests / external OTel setup
  • WithCloser(name, fn): cleanup during graceful shutdown, after the server has stopped accepting requests. Closers run in LIFO order; telemetry flushes last, so a span emitted in a closer still gets exported.
  • WithMiddleware(...func(http.Handler) http.Handler): router-level middleware after the kit defaults. The standard net/http middleware shape: any ecosystem middleware fits here. Anything needed by a single surface only should go on that surface's Route instead (see below).
  • WithHTTPServer(func(*http.Server)): escape hatch for http.Server fields the kit does not expose (TLS, MaxHeaderBytes, ...); the counterpart of fiberx.WithConfig. You may also override the kit-set fields: your mutation runs last.
  • WithValidator(*validate.Validator): the application-wide request validator, typically one carrying custom validation tags; StrictValidator(nil) middlewares resolve it at request time.
  • WithoutTelemetry(): skips telemetry.Setup and the OTel middleware; for tests, or when the process configures telemetry itself.

The generated wiring: RegisterApi

add surface splices one Register<Surface> function per surface into the module's register.go. For the shop's api surface this is generated verbatim:

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{
                chix.StrictValidator[shopapigen.StrictHandlerFunc](nil),
                // Last = outermost: authorization runs before validation.
                kitpolicy.HTTPEnforcer[shopapigen.StrictHandlerFunc](apiPolicies, shopapigen.PermissionByOperation, shopapigen.PolicyByOperation),
            },
            shopapigen.StrictHTTPServerOptions{
                RequestErrorHandlerFunc:  httperr.WriteBadRequest,
                ResponseErrorHandlerFunc: httperr.WriteError,
            },
        ), shopapigen.ChiServerOptions{
            BaseRouter:       r,
            ErrorHandlerFunc: httperr.WriteBadRequest,
        })
    })
}

Line by line:

  1. Service → handler → policy registry: the same explicit construction as on Fiber.
  2. router.Route("/shop", ...): the api surface sits at the module root (/api/v1/shop/...); every other surface under its own prefix (/admin/shop).
  3. NewStrictHandlerWithOptions: the generated strict server, plus the error hooks: request-decoding failures (RequestErrorHandlerFunc, ErrorHandlerFunc) render as 400 via WriteBadRequest, your handler's errors (ResponseErrorHandlerFunc) via WriteError through the full mapping.
  4. chix.StrictValidator + kitpolicy.HTTPEnforcer: validation and policy enforcement as strict middleware; the last element of the slice wraps outermost, so the enforcer runs before the validator.

StrictValidator: validation before the handler

func StrictValidator[H ~func(ctx context.Context, w http.ResponseWriter, r *http.Request, request any) (any, error)](
    v *validate.Validator,
) func(f H, operationID string) H

On Fiber, validation runs at bind time (StructValidator); the net/http strict server instead decodes with json.Decoder, without validation. StrictValidator brings the two engines to parity: it validates the fully bound request object (parameters + body) with the kit validator before your handler runs; a failure surfaces as a 422 problem through ResponseErrorHandlerFunc. The type parameter exists because oapi-codegen defines StrictHandlerFunc per generated package.

The validator is resolved per request, in this order: the explicit v argument → the one set via chix.WithValidator (from the context) → validate.New(). That's why the generated code passes nil: a single WithValidator option in main.go applies application-wide, and the generated files need no edits.

Surface-level middleware: the admin JWT

Router-level middleware comes from WithMiddleware; anything scoped to one surface (like the JWT protection of the shop's admin surface) you wire inside the surface's Route block with the net/http auth middleware pair:

internal/modules/shop/register.go
import (
    "github.com/gp-system/gpsystem/auth"
    "github.com/gp-system/gpsystem/rbac"
    // ...
)

type Dependencies struct {
    DB         *pg.DB
    Transactor dbx.Transactor
    Auth       auth.Config // JWT_SECRET, JWT_ISSUER, ..., from the platform config
    // gpsystem:dependencies
}

func RegisterAdmin(router chi.Router, deps Dependencies) {
    adminSvc := adminservice.New()
    adminHandler := adminhttp.New(adminSvc)
    adminPolicies := adminpolicy.New()
    router.Route("/admin/shop", func(r chi.Router) {
        r.Use(auth.MiddlewareHTTP(deps.Auth), rbac.RequireRoleHTTP("admin"))
        shopadmingen.HandlerWithOptions(shopadmingen.NewStrictHandlerWithOptions(
            adminHandler,
            []shopadmingen.StrictMiddlewareFunc{
                chix.StrictValidator[shopadmingen.StrictHandlerFunc](nil),
                kitpolicy.HTTPEnforcer[shopadmingen.StrictHandlerFunc](adminPolicies, shopadmingen.PermissionByOperation, shopadmingen.PolicyByOperation),
            },
            shopadmingen.StrictHTTPServerOptions{
                RequestErrorHandlerFunc:  httperr.WriteBadRequest,
                ResponseErrorHandlerFunc: httperr.WriteError,
            },
        ), shopadmingen.ChiServerOptions{
            BaseRouter:       r,
            ErrorHandlerFunc: httperr.WriteBadRequest,
        })
    })
}

auth.MiddlewareHTTP parses the Bearer token and puts an rbac.Identity into the context; rbac.RequireRoleHTTP("admin") short-circuits with a 403 problem without that role. Every /api/v1/admin/shop/... route is protected. The api surface's public routes are untouched.

Rule of thumb for middleware levels: process-level (rate limit, request ID) → chix.WithMiddleware; surface-level (auth, role) → r.Use in the surface's Route block; operation-level (permission, ownership check) → @permission/@policy in the spec, enforced by the enforcer.

The point: a standard http.Server

With the chi engine your server is a standard http.Server. Everything the Go world knows about net/http (middleware patterns, testing with httptest, pprof, any reverse-proxy recipe) applies unmodified.

Testing

New returns the fully wired router; you test with plain net/http/httptest:

r := chix.New(server.Config{}, chix.WithoutTelemetry())
r.Post("/things", createThing)

srv := httptest.NewServer(r)
defer srv.Close()
resp, err := http.Post(srv.URL+"/things", "application/json", body)

The panic recoverer, the 404/405 problem handlers and the body limit are active here too, so the test sees exactly the problem+json response the client will.

Patterns used

  • Context-injected validator (injectValidator/validatorFromContext, with an unexported context key): Design patterns.
  • Generic middleware adapter (StrictValidator[H ~func(...)], fitting every generated StrictHandlerFunc): Design patterns.

The same page for the other engine: the Fiber engine. Error rendering in detail: Error responses.

Copyright © 2026