Middleware
Middleware in Gas is the standard func(http.Handler) http.Handler. The router
accepts it in two forms, wrapped in a gas.Middleware value:
gas.MiddlewareByName(name)refers to a named middleware, registered on the router by a service that owns it.gas.MiddlewareFunc(fn)passes an inline function, which has no owner.
Named middleware
Section titled “Named middleware”A service registers a named middleware with Register(owner, name, fn),
usually in its Init:
// Auth registers a named middleware in Init. The router records Auth as its// owner, so tearing Auth down disables "require-auth" everywhere it is used.type Auth struct { router *gas.Router}
func NewAuth(router *gas.Router) *Auth { return &Auth{router: router} }
func (a *Auth) Name() string { return "auth" }func (a *Auth) Close(context.Context) error { return nil }
func (a *Auth) Init(context.Context) error { a.router.Register(a, "require-auth", func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") == "" { _ = gas.WriteError(w, r, gas.Unauthorized("missing credentials")) return } next.ServeHTTP(w, r) }) }) return nil}The router records the owner, as it does for routes. When the owning service is
torn down with CloseService, the middleware is disabled everywhere it is
referenced: each reference answers 503 instead of calling the function,
including on routes owned by other services and inside Group and Route
blocks. A request is never let through with the check skipped. Registering the
name again, which happens when RestartService re-runs the owner’s Init,
re-arms it. See Ownership and teardown. Passing nil as
the owner makes the router the owner.
Other services refer to it by name:
// Admin uses the middleware Auth registered. Taking *Auth as a constructor// parameter makes the container initialize Auth first, so the name is// registered by the time Admin's Init references it.type Admin struct { router *gas.Router}
func NewAdmin(router *gas.Router, _ *Auth) *Admin { return &Admin{router: router} }
func (a *Admin) Name() string { return "admin" }func (a *Admin) Close(context.Context) error { return nil }
func (a *Admin) Init(context.Context) error { // Per route. a.router.Get(a, "/admin/stats", a.stats, gas.MiddlewareByName("require-auth"))
// For a group of routes. a.router.Route("/admin/users", func(sub *gas.Router) { sub.UseMiddlewareByName("require-auth") sub.Get(a, "/", a.stats) sub.Delete(a, "/{id}", a.stats) }) return nil}
func (a *Admin) stats(ctx gas.Context) error { return ctx.NoContent() }A name must be registered before a call refers to it. On the app’s router,
Use and the route registrations panic at the call when the name is not
registered. Inside a Group or Route block, an unregistered name panics when
the router builds the block, which for a block registered before Start is
during Start. Make the service that refers to a name depend
on the service that registers it, as Admin does above, so the container runs
the registering service’s Init first.
NamedMiddleware() on the router maps each registered name to the name of the
service that owns it.
Inline middleware
Section titled “Inline middleware”gas.MiddlewareFunc(fn) and router.UseMiddlewareFunc(fn) apply a function
directly. It is not in the registry, has no owner, and no teardown disables it.
gas.MiddlewareFuncWithName(name, fn) is an inline middleware with a display
name for the route map. It is still inline: MiddlewareByName cannot refer to
it, and no teardown disables it.
Where to apply it
Section titled “Where to apply it”// Global middleware wraps every route on the router.
// By name, from the registry.s.router.UseMiddlewareByName("require-auth")
// Inline. It has no owner, so no teardown disables it.s.router.UseMiddlewareFunc(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Served-By", "notes") next.ServeHTTP(w, r) })})
// Inline with a display name, which shows in the route map.s.router.Use(gas.MiddlewareFuncWithName("timing", func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() next.ServeHTTP(w, r) log.Printf("%s took %s", r.URL.Path, time.Since(start)) })}))| Scope | Call |
|---|---|
| Every route on the router | router.Use(...), router.UseMiddlewareByName(name), router.UseMiddlewareFunc(fn) |
| The routes in a block | the same methods on the sub-router of Group or Route |
| One route | trailing arguments to Handle, Get, Post, and the other registration methods |
Global middleware added with Use on the app’s router applies to every route on
it, including routes registered before the Use call. Inside a Group or
Route block it applies only to routes registered after it in that block; see
Routing.
Middleware runs outermost first, in the order it was added:
- Cross-origin protection, which wraps the whole router (see below).
- The request-scope middleware
gas.NewAppinstalls on the router, which gives each request its own scope. It is added before any of yours, so every middleware on the router can resolve from the request scope. - Global middleware, in
Useorder. - Group and route-block middleware, outer blocks first.
- Per-route middleware, in the order of the arguments.
- The handler.
Register logging early so it observes everything after it, and authentication before anything that expects a principal.
Request logging
Section titled “Request logging”gas.RequestLogger[T]() logs every request through a logger type T resolved
from the request scope:
// RequestLogger is a scoped logger type built from the singleton gas.Logger.type RequestLogger interface{ gas.Logger }
func logging() *gas.App { app := gas.NewApp( gas.WithSingletonService[gas.Logger](gaslog.NewZeroLogLogger()), gas.WithScopedService[RequestLogger](func(l gas.Logger) RequestLogger { return l.With().Logger() }), )
r := app.Router() // chi's RequestID middleware supplies the request ID RequestLogger records. r.UseMiddlewareFunc(middleware.RequestID) r.UseMiddlewareFunc(gas.RequestLogger[RequestLogger]())
// Handlers that take RequestLogger get the per-request logger. r.Get(nil, "/hello", func(ctx gas.Context, l RequestLogger) error { l.Info("saying hello").Send() return ctx.Text(http.StatusOK, "hello") }) return app}For each request it:
- resolves
Tfrom the request scope; if that fails, it passes the request through and logs nothing; - derives a per-request logger from it with
With, adding arequest_idfield from chi’smiddleware.RequestID, which must be mounted before it or the field is empty (gas.WithRequestLoggerAppendRequestID(false)skips this step); - passes that logger through the function set with
gas.WithRequestLoggerBuilder, if any, which can add fields or replace it; - stores the result on the request context, where
gas.LoggerFromContextfinds it, and, whenTis registered scoped and the per-request logger’s type is assignable toT, puts it in the request scope in place ofT’s instance, so handlers and services that takeTlater in the request get it; - after the handler, logs the message
requestwithmethod,path,status,bytes,duration, andremote, at error level when the status is 400 or above and at info level otherwise.
The resolved logger itself is never changed, so a singleton T shared by every
request is safe. A singleton T is not replaced in the scope, and neither is a
scoped T whose instance implements gas.Service.
The per-request logger is whatever With().Logger() returns, which is the
backend’s own logger type. That type is assignable to an interface T, such
as the interface{ gas.Logger } above, and to T when T is the backend
type itself. It is not assignable to a concrete wrapper type such as
*wrapLogger (a struct{ gas.Logger }). For a T like that, the scope keeps
its original instance without any error, and handlers that take T get a
logger without the request_id field; the logger on the request context still
carries it. This is tracked as KI-050 in
Known issues. Logging setup is covered in
Logging.
Security headers
Section titled “Security headers”gas.SecurityHeaders(...) sets response headers before calling the next
handler:
// Secure defaults out of the box; override individually, or pass an empty// string to drop a header entirely.router.UseMiddlewareFunc(gas.SecurityHeaders( gas.WithSecurityHeadersFrameOptions("SAMEORIGIN"), gas.WithSecurityHeadersContentSecurityPolicy("default-src 'self'"),))Set by default:
| Header | Default | Option |
|---|---|---|
X-Content-Type-Options |
nosniff |
WithSecurityHeadersContentTypeOptions |
X-Frame-Options |
DENY |
WithSecurityHeadersFrameOptions |
Referrer-Policy |
strict-origin-when-cross-origin |
WithSecurityHeadersReferrerPolicy |
Permissions-Policy |
camera=(), microphone=(), geolocation=() |
WithSecurityHeadersPermissionsPolicy |
Set only when configured:
| Header | Option |
|---|---|
Content-Security-Policy |
WithSecurityHeadersContentSecurityPolicy |
Strict-Transport-Security |
WithSecurityHeadersStrictTransportSecurity |
Cross-Origin-Opener-Policy |
WithSecurityHeadersCrossOriginOpenerPolicy |
Cross-Origin-Resource-Policy |
WithSecurityHeadersCrossOriginResourcePolicy |
Passing an empty string to any option leaves that header out.
Cache-Control
Section titled “Cache-Control”gas.CacheControl(...) sets Cache-Control on matching requests:
// Fingerprinted assets: cache hard.router.UseMiddlewareFunc(gas.CacheControl( gas.WithCacheControlPathPrefix("/static/"), gas.WithCacheControlPublic(), gas.WithCacheControlMaxAge(365*24*time.Hour), gas.WithCacheControlImmutable(),))
// API responses: never store.router.UseMiddlewareFunc(gas.CacheControl( gas.WithCacheControlPathPrefix("/api/"), gas.WithCacheControlNoStore(),))Path filters match an exact path (WithCacheControlPath,
WithCacheControlPaths), a prefix (WithCacheControlPathPrefix,
WithCacheControlPathPrefixes), or a suffix (WithCacheControlPathSuffix,
WithCacheControlPathSuffixes); a request matching any one filter gets the
header. With no filters, every request gets it.
Each directive option appends one directive, and the header value is the
directives joined by , in the order given: WithCacheControlMaxAge,
WithCacheControlSMaxAge, WithCacheControlStaleWhileRevalidate, and
WithCacheControlStaleIfError take a time.Duration written in whole seconds;
WithCacheControlNoCache, NoStore, NoTransform, MustRevalidate,
ProxyRevalidate, MustUnderstand, Private, Public, and Immutable take
no argument. With no directives, the middleware sets nothing.
Cross-origin protection
Section titled “Cross-origin protection”Every gas.App serves its router behind Go’s
http.CrossOriginProtection.
It is not router middleware: App.Handler() wraps the router with it, so it
runs before any middleware on the router, before the request scope exists.
It rejects non-safe cross-origin requests from browsers, such as a POST,
PUT, PATCH, or DELETE from another site, with 403 Forbidden. GET,
HEAD, and OPTIONS always pass, and so do requests with neither a
Sec-Fetch-Site nor an Origin header, such as curl and server-to-server
calls. A same-origin app needs no configuration. Three AppOptions configure
it:
// Cross-origin protection is on by default. These options configure it.func csrf() []gas.Option { return []gas.Option{ gas.WithTrustedOrigin("https://app.example.com"),
// Webhook receivers verify their own signatures. gas.WithCSRFInsecureBypassPattern("/webhooks/stripe"),
gas.WithCSRFDenyHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = gas.WriteError(w, r, gas.Forbidden("cross-origin request denied")) })), }}gas.WithTrustedOrigin(origin)allows non-safe requests from another origin. The origin is a scheme and host, such ashttps://app.example.com;NewApppanics whenhttp.CrossOriginProtectionrejects it.gas.WithCSRFInsecureBypassPattern(pattern)turns the protection off for requests matching the pattern, which useshttp.ServeMuxpattern syntax.gas.WithCSRFDenyHandler(h)replaces the handler that writes the rejection.
A deny handler runs before the request scope exists, so it must not resolve
anything from the container. gas.WriteError is safe there: it renders the
standard error envelope without logging or touching the scope (see
Errors).