HTTP & Routing

The Fiber engine

server/fiberx, a Fiber v3 app with the kit's error handling, validation and telemetry wired in, run with graceful shutdown.

server/fiberx is the kit's Fiber v3 engine: it builds a *fiber.App with the kit defaults (RFC 9457 error handler, bind-time validation, panic recovery, OTel middleware, CORS) and runs it with the shared lifecycle. The "centralize, don't abstract" principle means: your register function receives a native *fiber.App, your handlers a native fiber.Ctx. Fiber's documentation and every Stack Overflow answer stay valid for you.

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

API

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

type RegisterFunc func(app *fiber.App) error

Run blocks until exit: telemetry bootstrap → app construction → register(app)Listen → graceful shutdown on SIGINT/SIGTERM. It returns nil on clean shutdown. New returns the same fully wired app without running it or bootstrapping telemetry: in tests you pair it with Fiber's app.Test.

What Run wires

The following go into fiber.Config and the middleware chain, in this order:

WhatFromWhat it does
ErrorHandlerhttperr.NewErrorHandlerrenders every handler error as problem+json; logs 5xx causes
StructValidatorvalidate.New()every c.Bind().Body(&dto) validates automatically (including generated binding)
recover middlewarekita panic becomes an errs error whose stack points at the panic site (Fiber's built-in recover would discard it); from there it flows down the normal error path
OTel middlewaregofiber/contrib/otelper-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 loggerfiber/middleware/loggeronly with OTEL_DEV_MODE=true: request log on the console
CORSfiber/middleware/corsfrom CORS_ORIGINS; GET/POST/PUT/PATCH/DELETE/OPTIONS, Content-Type + Authorization headers

The server.Config timeouts and body limit translate into fiber.Config (BodyLimit: cfg.EffectiveBodyLimit(), ReadTimeout, WriteTimeout, IdleTimeout). Middleware added with WithMiddleware runs after the kit defaults and before route registration.

The shop entry point

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

cmd/shop/main.go
package main

import (
    "context"
    "log"

    "github.com/gofiber/fiber/v3"

    "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/fiberx"

    "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 := fiberx.Run(ctx, cfg.Server, func(app *fiber.App) error {
        api := app.Group("/api/v1")
        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)
        return nil
    }, fiberx.WithCloser("pgxpool", func(context.Context) error {
        pool.Close()
        return nil
    }))
    if err != nil {
        log.Fatal(err)
    }
}

The register function's job: build the Dependencies and mount every surface on the /api/v1 group. Everything else (validation, error rendering, telemetry, shutdown) is already wired.

Options

fiberx.WithCloser("pgxpool", func(ctx context.Context) error { pool.Close(); return nil })
fiberx.WithMiddleware(myRateLimiter, myRequestID)          // ...fiber.Handler
fiberx.WithConfig(func(c *fiber.Config) { c.ProxyHeader = "X-Forwarded-For" })
fiberx.WithValidator(validate.New(validate.WithRegister(registerCustomTags)))
fiberx.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(...fiber.Handler): app-level middleware after the kit defaults. Anything needed by a single surface only should go on that surface's group instead (see below).
  • WithConfig(func(*fiber.Config)): escape hatch for fiber.Config fields the kit does not expose. You may also override the kit-set fields: your mutation runs last.
  • WithValidator(*validate.Validator): replaces the default request validator, typically one carrying custom validation tags.
  • 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 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{})
}

Line by line:

  1. Service → handler → policy registry. The surface's layers are built explicitly: no container, no autowiring; when the service needs a dependency, you pass it from deps and the diff shows it.
  2. router.Group("/shop"): the api surface mounts at the module root (/api/v1/shop/...); every other surface under its own prefix (/admin/shop).
  3. NewStrictHandler: the generated strict server: it binds and types the request before calling your handler. Body binding goes through c.Bind().Body(), so the StructValidator validates here for free.
  4. kitpolicy.FiberEnforcer: the outermost element of the strict middleware chain: based on the maps generated from the spec's @permission/@policy tags, it checks the RBAC permission and runs the policy before your business handler runs.

Surface-level middleware: the admin JWT

Global middleware comes from WithMiddleware; anything scoped to one surface (like the JWT protection of the shop's admin surface) goes on the surface's group inside Register<Surface>. Extend Dependencies with the auth config (there is room under the // gpsystem:dependencies anchor), then:

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 fiber.Router, deps Dependencies) {
    adminSvc := adminservice.New()
    adminHandler := adminhttp.New(adminSvc)
    adminPolicies := adminpolicy.New()
    adminGroup := router.Group("/admin/shop")
    adminGroup.Use(auth.Middleware(deps.Auth), rbac.RequireRole("admin"))
    shopadmingen.RegisterHandlersWithOptions(adminGroup, shopadmingen.NewStrictHandler(adminHandler, []shopadmingen.StrictMiddlewareFunc{
        kitpolicy.FiberEnforcer[shopadmingen.StrictHandlerFunc](adminPolicies, shopadmingen.PermissionByOperation, shopadmingen.PolicyByOperation),
    }), shopadmingen.FiberServerOptions{})
}

auth.Middleware parses the Bearer token and puts an rbac.Identity into the context; rbac.RequireRole("admin") short-circuits with 403 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) → fiberx.WithMiddleware; surface-level (auth, role) → group.Use inside Register<Surface>; operation-level (permission, ownership check) → @permission/@policy in the spec, enforced by the enforcer.

An important property: Register<Surface> is a plain Go function, not a declarative file discovered by the framework: the call chain from main.go to the handler is cmd/shop/main.go → RegisterApi → generated routing, and every step of it is clickable in your editor.

Testing

New returns the fully wired app; with Fiber's app.Test you test without a network:

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

resp, err := app.Test(httptest.NewRequest("POST", "/things", body))

The error handler and the validator are active here too, so the test sees exactly the problem+json response the client will.

Patterns used

  • Functional options (WithCloser, WithMiddleware, WithConfig, WithValidator, WithoutTelemetry): see Design patterns.
  • Fixed-order middleware chain in newApp (recover → otel → sentry-hub → cors): Design patterns.

Fiber v3's official documentation: docs.gofiber.io.


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

Copyright © 2026