Saltar al contenido principal

Routing & middleware

Nucleus has two routing surfaces. pkg/nucleus is the module-facing layer and is the recommended entry point for application code. pkg/router is the lower-level implementation and is only needed when integrating third-party HTTP handlers or constructing an application directly with pkg/app.

Defining routes (module layer — pkg/nucleus)

Inside a Module[C].Routes callback, the nucleus.Router interface is the only surface you should use. It does not expose any pkg/router types, so modules do not take a hard dependency on the router implementation.

var articlesModule = nucleus.Module[struct{}]{
Name: "articles",
Prefix: "/api/articles",
Routes: func(r nucleus.Router, _ struct{}) {
r.Get("/", listArticles)
r.Post("/", createArticle)
r.Get("/{id}", showArticle)
r.Put("/{id}", updateArticle)
r.Delete("/{id}", deleteArticle)
},
}

nucleus.Router supports three coexisting styles:

  • Flat declarativer.Get("/path", handler) for simple or audit-sensitive modules.
  • REST resourcer.Resource("/path", controller, nucleus.Methods(...)) for CRUD modules. Only the requested verbs are registered; reflection is not used.
  • Nested groupsr.Group("/prefix", func(g nucleus.Router) { ... }) for areas with nested URL hierarchy. Middleware added inside the callback is scoped to the group.
Routes: func(r nucleus.Router, _ struct{}) {
r.Group("/admin", func(g nucleus.Router) {
g.Get("/stats", adminStats)
g.Get("/users", listUsers)
})
},

Middleware type: func(http.Handler) http.Handler — standard net/http middleware. No framework-specific wrapper type is needed.

Per-route middleware (Router.With)

Router.With(mw ...Middleware) Router returns a new Router whose middleware applies only to routes registered on it. Routes registered directly on the parent r are not affected — this is the per-route counterpart to the module-level Module.Middleware field, which wraps every route in the module.

// Guard a single route without affecting sibling routes.
// enforcer is *authz.Enforcer captured from OnStart.
Routes: func(r nucleus.Router, _ struct{}) {
r.Get("/products", listProducts) // no auth
r.With(enforcer.RequireRole("admin")).Get("/billing", billing) // admin only
},

With composes additively: chained or nested calls layer middleware outer-to-inner. Any func(http.Handler) http.Handler value works directly — Enforcer.RequireRole, router.CSRFMiddleware, or a hand-written guard — with no adapter needed.

Lower-level routing (pkg/router)

pkg/router is used directly only in two cases:

  1. You are assembling an app with pkg/app (not pkg/nucleus).
  2. You need to mount an arbitrary http.Handler via a.Router.Mount(prefix, handler).

In the pkg/app context, a.Router is a *router.Router and handlers receive *router.Context:

// pkg/app-level wiring (not module code)
a.Router.Get("/api/articles", listArticles)
a.Router.Post("/api/articles", createArticle)

a.Router.Mux.Route("/admin/api", func(sub *router.Mux) {
sub.Use(adminAuthMiddleware)
sub.Get("/stats", adminStats)
})

router.Handler is func(*router.Context) error; errors bubble up to the recovery / logging middleware.

Router.Mount(prefix, handler) mounts an arbitrary http.Handler — useful for embedding third-party handlers or a second app.

The Context type

Handlers receive a *router.Context (or, in fluent mode, a *nucleus.Context that wraps it). The context exposes:

  • Request / ResponseWriter
  • path parameters via c.Param("id")
  • query string helpers (c.Query, c.QueryInt, …)
  • body binding (c.BindJSON, c.BindXML, c.BindForm)
  • response helpers (c.JSON, c.XML, c.String, c.Status)

Body binding behaviour. c.BindJSON decodes the request body as JSON then runs validate struct tags, returning a *DomainError on failure. c.BindForm decodes application/x-www-form-urlencoded or multipart/form-data into a struct pointer, performs typed conversion, then runs validate tags — the same discipline as BindJSON. Field resolution order: a form:"name" tag wins, then json:"name", then the case-insensitive field name; form:"-" skips a field. Supported types: string, bool (HTML checkbox value "on" binds as true), signed and unsigned integers, floats, time.Time (RFC 3339, 2006-01-02T15:04, or 2006-01-02), and pointers to those. Embedded exported structs are flattened. Present-but-empty values leave the field at its zero value; unknown keys are ignored. c.BindXML decodes XML only — it does not run validate tags.

  • the request-scoped context.Context
  • the resolved request scope (site, tenant) when multi-site is on

Built-in middleware

The default middleware chain (full-stack mode) installs:

MiddlewarePurpose
RecoveryRecovers from panics, logs with stack trace.
Request IDGenerates / propagates an X-Request-ID.
Structured loggingEmits one slog line per request with timing.
OpenTelemetryWraps the handler in an OTel span (when enabled).
CORSConfigured from cors.* keys.
CSRFOpt-in, not auto-mounted. Use router.CSRFMiddleware(opts) / router.WithCSRF per module or at router construction. No config-key-driven CSRF is available yet — mount it explicitly in the modules that need it.
Rate limitingConfigured from rate_limit_* keys.
Request scopeResolves multi-site / multi-tenant context.

The auto-mounted middlewares are opt-out at the config level; none of them rely on hidden state. CSRF is opt-in — it is not mounted automatically and must be added explicitly where needed (see Auth & sessions for the module-scoped pattern). The order of auto-mounted middleware is fixed and documented — handlers can rely on the request having a logger, a request ID and a span by the time they run.

Custom middleware

func auditMiddleware(next router.Handler) router.Handler {
return router.Handler(func(c *router.Context) error {
start := time.Now()
err := next(c)
slog.InfoContext(c.Request.Context(),
"audit",
"method", c.Request.Method,
"path", c.Request.URL.Path,
"took", time.Since(start),
)
return err
})
}

r.Use(auditMiddleware)

router.Handler is a thin wrapper over http.Handler that returns an error. Errors bubble up to the recovery / logging middleware where they are translated into a JSON or HTML response according to the request Accept header.

Mounting an OpenAPI document

The runtime ships an explicit OpenAPI mount:

import "github.com/jcsvwinston/nucleus/pkg/openapi"

// MountOpenAPI takes an openapi.DocumentProvider — a func() *openapi.Document.
a.MountOpenAPI("/api/openapi.json", func() *openapi.Document { return myDoc })

There is no auto-generation of the document from handler reflection — that path was deliberately not taken. The contract you ship is the one you wrote.