Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ To allow connections to remain open indefinitely, set both `GRPC_SERVER_MAX_CONN
| `TRACE_HEADER_NAME` | string | `x-trace-id` | HTTP header name for trace ID propagation to log/trace contexts |
| `DISABLE_HTTP_COMPRESSION` | bool | `false` | Disable gzip/zstd compression for HTTP gateway responses |
| `HTTP_COMPRESSION_MIN_SIZE` | int | `256` | Minimum response body size (bytes) before compression is applied. Responses smaller than this are sent uncompressed |
| `DISABLE_ZSTD_COMPRESSION` | bool | `false` | Disable zstd compression on the HTTP gateway. When `false`, zstd is offered alongside gzip and selected via `Accept-Encoding` negotiation. Ignored when `DISABLE_HTTP_COMPRESSION=true` |
| `PREFER_ZSTD` | bool | `true` | Prefer zstd over gzip when a client advertises both in `Accept-Encoding`. Ignored when zstd is disabled |
Comment thread
ankurs marked this conversation as resolved.
| `DISABLE_UNIX_GATEWAY` | bool | `true` | Disable Unix domain socket for HTTP gateway's internal gRPC connection. Set to `false` to enable (~1.9x faster than TCP loopback). Ignored when gRPC TLS is configured. See [Gateway Performance Options](/architecture#gateway-performance-options) |

## Prometheus Metrics
Expand Down
275 changes: 275 additions & 0 deletions howto/gateway-extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
---
layout: default
title: "HTTP Gateway Extensions"
parent: "How To"
nav_order: 20
description: "Register custom HTTP marshalers, middleware, error handlers, and other grpc-gateway ServeMuxOptions in a ColdBrew service"
---
## Table of contents
{: .no_toc .text-delta }

1. TOC
{:toc}

## Overview

ColdBrew builds the HTTP gateway on top of [grpc-gateway][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:

```go
// 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)
```
Comment thread
ankurs marked this conversation as resolved.

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.

{: .note }
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/proto` and `application/protobuf`
- The internal `spanRouteMiddleware` (sets the OTEL span name + `http.route` attribute)
- 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 |

{: .warning }
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][protojson] for correctness on well-known types (`Timestamp`, `Duration`, oneofs, enums). Drop it into your service and register it from `PreStart`.

```go
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)
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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)
})
Comment thread
ankurs marked this conversation as resolved.
}

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:

```go
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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```

Now `curl -H 'Accept: application/msgpack' …` returns msgpack-encoded responses, and `Content-Type: application/msgpack` request bodies decode correctly.

{: .warning }
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.

{: .warning }
`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-gateway-middleware) recipe below using `http.MaxBytesReader`) so a hostile client can't pin memory by streaming a giant body.

{: .note }
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`:

```go
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
}
```

{: .note }
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:

```go
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`:

```go
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)
Comment thread
ankurs marked this conversation as resolved.
return
}
w.Header().Set("Content-Type", m.ContentType(nil))
w.WriteHeader(runtime.HTTPStatusFromCode(s.Code()))
_, _ = w.Write(body)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

func (s *Service) PreStart(ctx context.Context) error {
core.RegisterServeMuxOption(runtime.WithErrorHandler(envelopeErrorHandler))
return nil
}
```

{: .warning }
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.JSONPb` need 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](/howto/interceptors) instead. They wrap gRPC server and client calls and are independent of the HTTP gateway.

---

[grpc-gateway]: https://github.com/grpc-ecosystem/grpc-gateway
[protojson]: https://pkg.go.dev/google.golang.org/protobuf/encoding/protojson
1 change: 1 addition & 0 deletions howto/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Step-by-step guides for building, running, and operating ColdBrew services.
| Manage readiness with workers | [Readiness Patterns](/howto/readiness) |
| Set up local dev with Docker | [Local Development](/howto/local-dev) |
| Add JWT / API key auth | [Authentication](/howto/auth) |
| Add custom HTTP marshalers or middleware | [HTTP Gateway Extensions](/howto/gateway-extensions) |
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in f5944a1/howto/ is now in pagesToCrawl, so a typo in this row's href would surface as a broken link in CI.


If you have a How To that you would like to share, please [open an issue](https://github.com/go-coldbrew/docs.coldbrew.cloud/issues)

4 changes: 4 additions & 0 deletions tests/content.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ test.describe("SEO", () => {
"/howto/production/",
"/howto/interceptors/",
"/howto/auth/",
"/howto/local-dev/",
"/howto/gateway-extensions/",
Comment thread
ankurs marked this conversation as resolved.
];

for (const pagePath of pagesWithDescriptions) {
Expand Down Expand Up @@ -217,6 +219,8 @@ test.describe("Table of Contents", () => {
"/howto/readiness/",
"/howto/production/",
"/howto/auth/",
Comment thread
ankurs marked this conversation as resolved.
"/howto/local-dev/",
"/howto/gateway-extensions/",
];

for (const pagePath of howtoPages) {
Expand Down
3 changes: 3 additions & 0 deletions tests/links.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@ async function getInternalLinks(
test.describe("Internal Links", () => {
const pagesToCrawl = [
"/",
"/howto/",
"/howto/APIs/",
"/integrations/",
"/packages/",
"/howto/production/",
"/howto/workers/",
"/howto/readiness/",
"/howto/gateway-extensions/",
Comment thread
ankurs marked this conversation as resolved.
"/architecture/",
"/config-reference/",
];
Expand Down Expand Up @@ -69,6 +71,7 @@ test.describe("Anchor Links", () => {
{ path: "/integrations/", anchor: "prometheus" },
{ path: "/integrations/", anchor: "new-relic" },
{ path: "/integrations/", anchor: "sentry" },
{ path: "/howto/gateway-extensions/", anchor: "recipe-gateway-middleware" },
];

for (const { path, anchor } of pagesWithAnchors) {
Expand Down
2 changes: 2 additions & 0 deletions tests/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const howtoPages = [
"/howto/private-modules/",
"/howto/auth/",
"/howto/readiness/",
"/howto/local-dev/",
"/howto/gateway-extensions/",
Comment on lines +33 to +34
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in f5944a1 — added /howto/gateway-extensions/ to the SEO meta-description list and the TOC howtoPages list in content.spec.ts, and added it (plus /howto/) to the link-crawl pagesToCrawl in links.spec.ts so broken links and missing meta/TOC on the new page now fail CI.

];
Comment thread
ankurs marked this conversation as resolved.

test.describe("Page Loading", () => {
Expand Down
Loading