Response Middleware
The maniflex/middleware/response package shapes the outgoing response —
headers, body transforms, redactions, and observability hooks — on the
Response step. CORS is the exception: it is HTTP middleware because
preflight must run before route dispatch and authentication.
Cross-cutting headers
CORSHeaders
Adds CORS headers to allowed cross-origin responses and validates browser
preflight before route dispatch. At least one origin is required — pass
explicit origins (recommended) or "*" to allow any origin. Calling it with no
origins panics at startup, so a permissive wildcard is never applied by accident.
import "github.com/xaleel/maniflex/middleware/response"
// Configure before calling maniflex.New(cfg).
// Explicit origins (recommended)
cfg.HTTPMiddlewares = append(cfg.HTTPMiddlewares,
response.CORSHeaders("https://app.example.com"))
// Public API: opt in to any origin explicitly
cfg.HTTPMiddlewares = append(cfg.HTTPMiddlewares, response.CORSHeaders("*"))
For credentials or custom allowed headers/methods/max-age, use
CORSHeadersWithConfig. AllowCredentials cannot be combined with a "*"
origin (browsers reject that combination) and panics if you try:
cfg.HTTPMiddlewares = append(cfg.HTTPMiddlewares,
response.CORSHeadersWithConfig(response.CORSConfig{
AllowOrigins: []string{"https://app.example.com"},
AllowCredentials: true,
}))
CORSHeaders returns router-level middleware. A true preflight is OPTIONS
with both Origin and Access-Control-Request-Method. Allowed preflights
return 204 No Content with an empty body before Auth runs. Disallowed origins
and request headers return 403; disallowed methods return 405. Plain
OPTIONS requests continue to the generated route and retain its Allow
response.
The default allowed request headers include Authorization, Content-Type,
If-Match, and If-None-Match. ETag and X-Request-ID are exposed by
default so browser code can perform optimistic-locking updates and correlate
failures. Override AllowHeaders, AllowMethods, or ExposeHeaders when the
application needs a narrower or broader policy.
AddHeader
Sets one static header on every response:
server.Pipeline.Response.Register(
response.AddHeader("Strict-Transport-Security", "max-age=63072000"),
)
Caching
Cache
Sets an explicit cache policy and ETag on reads. The zero policy is
private, max-age=0; shared caches are never enabled implicitly. Register at
maniflex.After so the framework’s own headers do not override yours:
server.Pipeline.Response.Register(
response.Cache(response.CacheConfig{
MaxAge: 300, // private, 5 minutes
Vary: []string{"Authorization"},
}),
maniflex.ForOperation(maniflex.OpRead, maniflex.OpList),
maniflex.AtPosition(maniflex.After),
)
CacheConfig supports:
Private: true— only a browser/user-agent cache may store the response. This is the default when no storage mode is selected.NoStore: true— no cache may store the response. This also disables ETags andIf-None-Matchhandling.Public: true— explicitly permits shared proxy/CDN storage.MaxAge— freshness lifetime in seconds.Vary— request-header names that form part of the representation’s cache key; values are canonicalized, deduplicated, and merged with existing entries such as CORS’sVary: Origin.
Public, Private, and NoStore are mutually exclusive. Public caching is a
security decision, not a performance default:
server.Pipeline.Response.Register(
response.Cache(response.CacheConfig{
Public: true,
MaxAge: 60,
Vary: []string{"Accept-Encoding"},
}),
maniflex.ForModel("PublicArticle"),
maniflex.ForOperation(maniflex.OpRead, maniflex.OpList),
maniflex.AtPosition(maniflex.After),
)
Do not set Public on authenticated, tenant-filtered, role-dependent, or
dynamically redacted responses unless every authorization input is included in
the shared cache key and the proxy/CDN is verified to honor it. Common inputs
include Authorization, Cookie, tenant headers, locale, and negotiated
representation headers. When that guarantee is difficult to prove, use the
private default or NoStore.
Body transforms
TransformField
Rewrites a single field value before serialisation. Common use: rebasing a stored relative path onto a CDN host.
server.Pipeline.Response.Register(
response.TransformField("avatar_url", func(v any) any {
return cdnBase + v.(string)
}),
)
RedactField
Hides a field from the response conditionally. The predicate decides per
request, often based on ctx.Auth:
server.Pipeline.Response.Register(
response.RedactField("phone", func(ctx *maniflex.ServerContext) bool {
return !ctx.HasRole("support")
}),
)
RedactField is the right tool for view-time access control on individual
columns. For all-or-nothing exclusion across an entire model, the hidden or
writeonly field tag is simpler.
For the write side — “only a superuser may set this field” — use
validate.FieldRole / validate.RestrictField,
which takes the same predicate on the Validate step. Note the two differ on
purpose when the predicate fails: a redacted read returns the record without the
field, while a refused write returns 403 rather than quietly dropping it.
It covers exports too: GET /:model/export masks the same field for the
same callers, and drops it from the CSV/XLSX header rather than emitting an
empty column.
Writing your own masking middleware
A Response-step middleware normally masks by editing ctx.Response after
next() returns. That is not enough on its own, because an export has no
ctx.Response — it streams its bytes during next() — so a middleware that
only edits one masks the JSON and leaves the export in full.
Declare the field instead, before calling next():
func maskSalary(ctx *maniflex.ServerContext, next func() error) error {
if !ctx.HasRole("admin") {
ctx.RedactResponseField("salary") // before next(), so the export sees it
}
return next()
}
The declaration is honoured by every read path — list, single read, create and
update echoes, and both export formats. response.RedactField does this for
you.
Before v0.2.5 a masking middleware applied to JSON responses only. An app that hid a column from non-admins served it in full at
/:model/export.
Envelope
Replaces the default {"data": ...} envelope with one of your own:
server.Pipeline.Response.Register(
response.Envelope(func(ctx *maniflex.ServerContext, data any, meta *maniflex.ResponseMeta) any {
return map[string]any{
"result": data,
"paging": meta,
"trace_id": ctx.TraceID,
}
}),
)
Useful when integrating with a frontend or API gateway that expects a different shape. Error responses are unaffected; only success responses are re-enveloped.
Observability
Logging
Writes a structured access log after the complete HTTP request:
server.ObserveRequests(
response.Logging(slog.Default()),
)
The line carries request ID, method, path, model, operation, final status, full router-to-response duration, and the authenticated user when set. Router-level observation also records requests rejected during Auth, before they can reach the Response step.
Metrics
Records per-request metrics — count, latency, and exact status — into a configured collector:
server.ObserveRequests(
response.Metrics(myCollector),
)
Any sink implementing MetricsCollector works. Counters and histograms retain
model, operation, and status labels; non-model routes use empty model and
operation labels.
Wiring Prometheus
maniflex ships no exporter. Metrics leave through this interface, so the framework depends on no metrics library and you can use any — but that leaves the other side of the interface to you, so here is a complete one.
The two sides disagree about when labels are fixed: MetricsCollector passes a
label map with every observation, while a Prometheus vector binds its label
names when it is constructed. The adapter builds a vector on first sight of a
metric name and projects every later observation onto those names, which is what
keeps the exposition valid — Prometheus rejects a metric whose label set varies
between samples.
// promCollector adapts prometheus/client_golang to response.MetricsCollector.
//
// The two sides disagree about when labels are fixed: MetricsCollector passes a
// label map with every observation, while a Prometheus vector binds its label
// names when it is constructed. So a vector is built on first sight of a metric
// name, its label names taken from that first observation, and every later
// observation is projected onto those names — missing keys become empty, extra
// keys are dropped. Prometheus rejects a metric whose label set varies between
// samples, so projecting is what keeps the exposition valid.
type promCollector struct {
reg *prometheus.Registry
mu sync.Mutex
counters map[string]*prometheus.CounterVec
histograms map[string]*prometheus.HistogramVec
labelNames map[string][]string
}
func newPromCollector(reg *prometheus.Registry) *promCollector {
return &promCollector{
reg: reg,
counters: map[string]*prometheus.CounterVec{},
histograms: map[string]*prometheus.HistogramVec{},
labelNames: map[string][]string{},
}
}
// namesFor returns the label names bound to metric on its first observation.
func (c *promCollector) namesFor(metric string, labels map[string]string) []string {
if names, ok := c.labelNames[metric]; ok {
return names
}
names := make([]string, 0, len(labels))
for k := range labels {
names = append(names, k)
}
sort.Strings(names) // stable order, so the values line up on every call
c.labelNames[metric] = names
return names
}
// valuesFor projects labels onto the names this metric was created with.
func valuesFor(names []string, labels map[string]string) []string {
values := make([]string, len(names))
for i, n := range names {
values[i] = labels[n]
}
return values
}
func (c *promCollector) IncCounter(name string, labels map[string]string) {
c.mu.Lock()
defer c.mu.Unlock()
names := c.namesFor(name, labels)
vec, ok := c.counters[name]
if !ok {
vec = prometheus.NewCounterVec(
prometheus.CounterOpts{Name: name, Help: "maniflex " + name}, names)
c.reg.MustRegister(vec)
c.counters[name] = vec
}
vec.WithLabelValues(valuesFor(names, labels)...).Inc()
}
func (c *promCollector) ObserveHistogram(name string, value float64, labels map[string]string) {
c.mu.Lock()
defer c.mu.Unlock()
names := c.namesFor(name, labels)
vec, ok := c.histograms[name]
if !ok {
vec = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: name,
Help: "maniflex " + name,
// response.Metrics records seconds, which is what DefBuckets covers
// (5ms to 10s). Widen it for an API that streams or exports.
Buckets: prometheus.DefBuckets,
}, names)
c.reg.MustRegister(vec)
c.histograms[name] = vec
}
vec.WithLabelValues(valuesFor(names, labels)...).Observe(value)
}
/metrics is not a maniflex route. Mount the API under your own router with
maniflex.Mount and
register the scrape endpoint beside it:
func main() {
registry := prometheus.NewRegistry()
collector := newPromCollector(registry)
server := maniflex.New(maniflex.Config{
PathPrefix: "/api",
MaxConcurrentRequests: 64,
})
server.MustRegister(Order{})
// response.Metrics observes at the router level, so it also counts requests
// rejected during Auth — the ones that never reach a model.
server.ObserveRequests(response.Metrics(collector))
// /metrics is not a maniflex route: mount the API under your own router and
// register the scrape endpoint beside it. Keep it off the public listener,
// or put an auth middleware in front — the label set names every model and
// operation your API exposes.
r := chi.NewRouter()
r.Handle("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{}))
maniflex.Mount(r, server)
log.Println("API on /api, metrics on /metrics")
if err := http.ListenAndServe(":8080", r); err != nil {
log.Fatal(err)
}
}
Keep that endpoint off the public listener, or put an auth middleware in front of it: the label set names every model and operation your API exposes.
This example is compiled in CI, so an API change cannot leave it broken.