Table of contents
- Overview
- Ordering rules
- Recipe: MessagePack marshaler
- Recipe: Tune the default JSON marshaler
- Recipe: Gateway middleware
- Recipe: Custom error handler
- When to reach for these hooks
Overview
ColdBrew builds the HTTP gateway on top of grpc-gateway, which exposes its runtime.ServeMux configuration through runtime.ServeMuxOption values. Until recently, ColdBrew built that mux internally and didn’t surface a way to plug in your own options.
core now exposes two registration functions for this:
// Append any runtime.ServeMuxOption to the gateway's mux.
func RegisterServeMuxOption(opt runtime.ServeMuxOption)
// Convenience for the common case: register a marshaler for a MIME type.
// Equivalent to RegisterServeMuxOption(runtime.WithMarshalerOption(mime, m)).
func RegisterHTTPMarshaler(mime string, m runtime.Marshaler)
Use them to add custom marshalers (MessagePack, CBOR, vendor-specific JSON), tune the default protojson marshaler, register gateway middleware, install a custom error handler, or wire forward-response hooks — anything runtime.ServeMuxOption lets you do.
These functions follow ColdBrew’s init-only configuration pattern. Call them before starting the ColdBrew instance (for example, before cb.Run()) — typically from a service’s PreStart hook or a package-level init() function. They are not safe for concurrent registration and have no effect after the server is running.
Ordering rules
Registered options are applied after ColdBrew’s built-ins. Built-ins include:
- The incoming-header matcher derived from
HTTP_HEADER_PREFIXES - Marshalers for
application/protoandapplication/protobuf - The internal
spanRouteMiddleware(sets the OTEL span name +http.routeattribute) - Optionally the JSON builtin marshaler when
USE_JSON_BUILTIN_MARSHALLER=true
Because grpc-gateway’s option model is last-write-wins for some options and additive for others, the practical effect is:
| Option type | Behavior when you register one |
|---|---|
WithMarshalerOption(mime, …) | Overrides ColdBrew’s marshaler for that MIME (last-write-wins) |
WithErrorHandler / WithRoutingErrorHandler
| Overrides the gateway default |
WithIncomingHeaderMatcher |
Overrides HTTP_HEADER_PREFIXES wiring — reimplement that matcher yourself if you still need it |
WithMiddlewares(…) | Stacks after spanRouteMiddleware
|
WithMetadata, WithForwardResponseOption
| Stack additively with the gateway defaults |
Overriding WithIncomingHeaderMatcher silently disables the HTTP_HEADER_PREFIXES configuration. If you need both your custom matching and the prefix-forwarding behavior, port the prefix logic into your matcher.
Recipe: MessagePack marshaler
The following ~80-line marshaler bridges proto ↔ msgpack via protojson for correctness on well-known types (Timestamp, Duration, oneofs, enums). Drop it into your service and register it from PreStart.
package msgpackmarshaler
import (
"bytes"
"encoding/json"
"errors"
"io"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/shamaton/msgpack/v2"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
)
const ContentType = "application/msgpack"
type Marshaler struct{}
func (Marshaler) ContentType(any) string { return ContentType }
func (Marshaler) Marshal(v any) ([]byte, error) {
msg, ok := v.(proto.Message)
if !ok {
return nil, errors.New("msgpack: value is not a proto.Message")
}
j, err := protojson.Marshal(msg)
if err != nil {
return nil, err
}
var generic any
if err := json.Unmarshal(j, &generic); err != nil {
return nil, err
}
return msgpack.Marshal(generic)
}
func (Marshaler) Unmarshal(data []byte, v any) error {
msg, ok := v.(proto.Message)
if !ok {
return errors.New("msgpack: value is not a proto.Message")
}
var generic any
if err := msgpack.Unmarshal(data, &generic); err != nil {
return err
}
j, err := json.Marshal(generic)
if err != nil {
return err
}
return protojson.Unmarshal(j, msg)
}
func (m Marshaler) NewDecoder(r io.Reader) runtime.Decoder {
return runtime.DecoderFunc(func(v any) error {
b, err := io.ReadAll(r)
if err != nil {
return err
}
return m.Unmarshal(b, v)
})
}
func (m Marshaler) NewEncoder(w io.Writer) runtime.Encoder {
return runtime.EncoderFunc(func(v any) error {
b, err := m.Marshal(v)
if err != nil {
return err
}
_, err = io.Copy(w, bytes.NewReader(b))
return err
})
}
Wire it from your service:
import (
"context"
"github.com/go-coldbrew/core"
"yourorg/yourservice/msgpackmarshaler"
)
func (s *Service) PreStart(ctx context.Context) error {
core.RegisterHTTPMarshaler(msgpackmarshaler.ContentType, msgpackmarshaler.Marshaler{})
core.RegisterHTTPMarshaler("application/x-msgpack", msgpackmarshaler.Marshaler{}) // legacy alias
return nil
}
Now curl -H 'Accept: application/msgpack' … returns msgpack-encoded responses, and Content-Type: application/msgpack request bodies decode correctly.
The bridge round-trips through encoding/json decoded into any, which turns every JSON number into a float64. Integer fields larger than 2^53 − 1 (the limit of an exactly representable IEEE-754 double) lose precision. If your protos carry large int64/uint64 values, replace the protojson hop with a protoreflect-based encoder or use a different wire format for those fields.
NewDecoder reads the full request body into memory via io.ReadAll. Pair this marshaler with a request-size limit at the middleware layer (see the Gateway middleware recipe below using http.MaxBytesReader) so a hostile client can’t pin memory by streaming a giant body.
The protojson hop costs about 2× a single marshal compared to a hand-written protoreflect-based encoder. For hot paths consider implementing a direct encoder; for typical request volumes the bridge is fast enough and dramatically simpler.
Recipe: Tune the default JSON marshaler
The fallback marshaler for any MIME that isn’t explicitly registered is grpc-gateway’s runtime.JSONPb (protojson). It serves both inbound (request Content-Type) and outbound (response Accept) sides. Out of the box this catches application/json traffic too — the defaults emit camelCase field names and omit zero values. Override the fallback by re-registering for runtime.MIMEWildcard:
import (
"context"
"google.golang.org/protobuf/encoding/protojson"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/go-coldbrew/core"
)
func (s *Service) PreStart(ctx context.Context) error {
core.RegisterHTTPMarshaler(runtime.MIMEWildcard, &runtime.JSONPb{
MarshalOptions: protojson.MarshalOptions{
EmitUnpopulated: true, // include zero-valued fields
UseProtoNames: true, // snake_case instead of camelCase
Indent: " ", // pretty-print
},
UnmarshalOptions: protojson.UnmarshalOptions{
DiscardUnknown: true, // ignore fields the server doesn't recognize
},
})
return nil
}
The wildcard registration only takes effect for MIMEs with no concrete registration. If you’ve set USE_JSON_BUILTIN_MARSHALLER=true (which binds JSON_BUILTIN_MARSHALLER_MIME, default application/json, to runtime.JSONBuiltin{}) — or otherwise registered a marshaler for application/json — that registration wins for both inbound Content-Type and outbound Accept matching. Re-register the tuned JSONPb for that concrete MIME too, e.g. core.RegisterHTTPMarshaler("application/json", &runtime.JSONPb{...}).
Recipe: Gateway middleware
runtime.WithMiddlewares registers a middleware on the entire grpc-gateway mux — every gateway-routed request runs through it, stacking with ColdBrew’s internal middleware:
import (
"context"
"net/http"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/go-coldbrew/core"
)
func requestSizeLimit(maxBytes int64) func(runtime.HandlerFunc) runtime.HandlerFunc {
return func(next runtime.HandlerFunc) runtime.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request, p map[string]string) {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
next(w, r, p)
}
}
}
func (s *Service) PreStart(ctx context.Context) error {
core.RegisterServeMuxOption(runtime.WithMiddlewares(requestSizeLimit(10 << 20)))
return nil
}
Recipe: Custom error handler
To override how grpc-gateway translates gRPC errors into HTTP responses (for example to emit a vendor-specific error envelope), register WithErrorHandler:
import (
"context"
"net/http"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/go-coldbrew/core"
"google.golang.org/grpc/status"
)
func envelopeErrorHandler(ctx context.Context, mux *runtime.ServeMux, m runtime.Marshaler, w http.ResponseWriter, r *http.Request, err error) {
s, _ := status.FromError(err)
payload := map[string]any{
"error": map[string]any{
"code": s.Code().String(),
"message": s.Message(),
},
}
body, marshalErr := m.Marshal(payload)
if marshalErr != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", m.ContentType(nil))
w.WriteHeader(runtime.HTTPStatusFromCode(s.Code()))
_, _ = w.Write(body)
}
func (s *Service) PreStart(ctx context.Context) error {
core.RegisterServeMuxOption(runtime.WithErrorHandler(envelopeErrorHandler))
return nil
}
This snippet marshals a map[string]any envelope. Only runtime.JSONPb and runtime.JSONBuiltin accept arbitrary Go values; every other marshaler ColdBrew ships or this guide demonstrates — runtime.ProtoMarshaller (application/proto, application/protobuf) and the MessagePack recipe above (which type-asserts proto.Message) — will reject the freeform map and fall through to the http.Error path. For a portable envelope, marshal status.Convert(err).Proto() (a *google.golang.org/genproto/googleapis/rpc/status.Status that implements proto.Message) instead of a freeform map, or define your own envelope as a generated proto.
When to reach for these hooks
- You need a wire format ColdBrew doesn’t ship (msgpack, CBOR, YAML, vendor-specific binary).
- The defaults of
runtime.JSONPbneed adjusting (field naming, empty-value emission, indentation). - HTTP-layer concerns don’t fit in a gRPC interceptor — raw-body access, file uploads, response streaming wrappers, request size limits.
- The gateway’s default error envelope isn’t the shape your clients want.
For gRPC-side concerns — server-side auth, rate limiting, metrics, panic recovery, and client-side retries or circuit breaking — use Interceptors instead. They wrap gRPC server and client calls and are independent of the HTTP gateway.