diff --git a/config-reference.md b/config-reference.md index aff573c..7a08553 100644 --- a/config-reference.md +++ b/config-reference.md @@ -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 | | `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 diff --git a/howto/gateway-extensions.md b/howto/gateway-extensions.md new file mode 100644 index 0000000..933c6fb --- /dev/null +++ b/howto/gateway-extensions.md @@ -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) +``` + +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) +} + +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: + +```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 +} +``` + +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) + 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 +} +``` + +{: .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 diff --git a/howto/index.md b/howto/index.md index 4cc515b..65da35f 100644 --- a/howto/index.md +++ b/howto/index.md @@ -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) | 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) diff --git a/tests/content.spec.ts b/tests/content.spec.ts index e3cd0f2..75d5b32 100644 --- a/tests/content.spec.ts +++ b/tests/content.spec.ts @@ -189,6 +189,8 @@ test.describe("SEO", () => { "/howto/production/", "/howto/interceptors/", "/howto/auth/", + "/howto/local-dev/", + "/howto/gateway-extensions/", ]; for (const pagePath of pagesWithDescriptions) { @@ -217,6 +219,8 @@ test.describe("Table of Contents", () => { "/howto/readiness/", "/howto/production/", "/howto/auth/", + "/howto/local-dev/", + "/howto/gateway-extensions/", ]; for (const pagePath of howtoPages) { diff --git a/tests/links.spec.ts b/tests/links.spec.ts index 76137d2..301fd51 100644 --- a/tests/links.spec.ts +++ b/tests/links.spec.ts @@ -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/", "/architecture/", "/config-reference/", ]; @@ -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) { diff --git a/tests/navigation.spec.ts b/tests/navigation.spec.ts index 8e0dcdb..c513a3e 100644 --- a/tests/navigation.spec.ts +++ b/tests/navigation.spec.ts @@ -30,6 +30,8 @@ const howtoPages = [ "/howto/private-modules/", "/howto/auth/", "/howto/readiness/", + "/howto/local-dev/", + "/howto/gateway-extensions/", ]; test.describe("Page Loading", () => {