The Fiber engine
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:
| What | From | What it does |
|---|---|---|
ErrorHandler | httperr.NewErrorHandler | renders every handler error as problem+json; logs 5xx causes |
StructValidator | validate.New() | every c.Bind().Body(&dto) validates automatically (including generated binding) |
| recover middleware | kit | a 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 middleware | gofiber/contrib/otel | per-request server span + HTTP metrics: OpenTelemetry |
| Sentry request hub | kit | an isolated Sentry hub per request, so breadcrumbs never mix across requests; no-op when Sentry is off |
| dev logger | fiber/middleware/logger | only with OTEL_DEV_MODE=true: request log on the console |
| CORS | fiber/middleware/cors | from 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:
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 forfiber.Configfields 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(): skipstelemetry.Setupand 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:
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:
- 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
depsand the diff shows it. router.Group("/shop"): theapisurface mounts at the module root (/api/v1/shop/...); every other surface under its own prefix (/admin/shop).NewStrictHandler: the generated strict server: it binds and types the request before calling your handler. Body binding goes throughc.Bind().Body(), so theStructValidatorvalidates here for free.kitpolicy.FiberEnforcer: the outermost element of the strict middleware chain: based on the maps generated from the spec's@permission/@policytags, 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:
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.
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.