-
Notifications
You must be signed in to change notification settings - Fork 0
docs: HTTP gateway extensions how-to and zstd config flags #85
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4873e83
30f4b3d
e8cce3a
c6b1d2a
0a5324d
77357ae
f5944a1
6f64623
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| ``` | ||
|
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) | ||
| } | ||
|
|
||
|
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) | ||
| }) | ||
|
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 | ||
| } | ||
|
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) | ||
|
ankurs marked this conversation as resolved.
|
||
| return | ||
| } | ||
| w.Header().Set("Content-Type", m.ContentType(nil)) | ||
| w.WriteHeader(runtime.HTTPStatusFromCode(s.Code())) | ||
| _, _ = w.Write(body) | ||
|
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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in f5944a1 — |
||
|
|
||
| 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) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in f5944a1 — added |
||
| ]; | ||
|
ankurs marked this conversation as resolved.
|
||
|
|
||
| test.describe("Page Loading", () => { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.