Skip to content
gohacks.dev
APIs

gRPC Gateway REST and JSON Transcoding in Go Services

Expose a Go gRPC service as REST JSON with grpc-gateway: YAML route mapping, JSON naming, header matching, error bodies and the status code limits.

13 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response, with the JSON logo.

Your internal services speak gRPC. Some of their callers cannot: a browser, a partner who wants to try the API with curl, a tool that only knows JSON over HTTP. The usual fix is to write a REST layer by hand, duplicating every method, every message and every validation. grpc-gateway generates that layer from the same .proto file. It is a reverse proxy: an HTTP request comes in, is matched to a route, its JSON body and path parameters are turned into a protobuf message, a gRPC call is made, and the reply is turned back into JSON.

Tested with Go 1.25.6, grpc-gateway v2.27.1, grpc-go v1.75.0, protobuf-go v1.36.8, protoc 29.3, macOS on Apple Silicon (arm64)

This article builds it for a two-method service, shows what the generated gateway does with good and bad requests, and covers the places where the translation shows through: naming, status codes, errors and the rpcs that do not map well. It builds on the gRPC tutorial and error codes, and it is one of two common ways to give a gRPC service a REST face; the other is Connect. It ran on Go 1.25.6 with grpc-gateway v2.27.1.

Where the HTTP mapping lives

The mapping from a gRPC method to an HTTP route is described by the google.api.http option, normally written as an annotation inside the .proto:

proto
rpc GetItem(GetItemRequest) returns (Item) {
  option (google.api.http) = { get: "/v1/items/{id}" };
}

That needs import "google/api/annotations.proto", whose proto files come from the googleapis repository and have to be on the include path. A less well-known alternative avoids that: grpc-gateway can read the same mapping from a YAML file passed with the plugin option grpc_api_configuration. The lab uses the YAML form, which keeps the .proto free of HTTP concerns and the build free of the googleapis files:

api.yamlYAML
type: google.api.Service
config_version: 3
http:
  rules:
    - selector: items.v1.Items.GetItem
      get: /v1/items/{id}
    - selector: items.v1.Items.CreateItem
      post: /v1/items
      body: "*"

selector names the rpc by its full name. get: /v1/items/{id} maps the id path segment to the request field id. post: /v1/items with body: "*" reads the whole JSON body into the request message. Use whichever style your organisation prefers; the annotation form is better when you want the HTTP shape visible next to the method, and is what Google's API design guidance uses.

The proto itself has no HTTP in it:

proto/items.protoprotobuf
syntax = "proto3";
package items.v1;
option go_package = "example.test/lab/itemspb";

service Items {
  rpc GetItem(GetItemRequest) returns (Item);
  rpc CreateItem(CreateItemRequest) returns (Item);
}
message GetItemRequest { string id = 1; }
message CreateItemRequest { string name = 1; int64 price_cents = 2; }
message Item { string id = 1; string name = 2; int64 price_cents = 3; }

Generating the gateway

The gateway is one more protoc plugin, protoc-gen-grpc-gateway. As with the other plugins, pin it in go.mod through the tools file and build it from there:

tools.goGo
//go:build tools

package main

import (
	_ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway"
	_ "google.golang.org/grpc/cmd/protoc-gen-go-grpc"
	_ "google.golang.org/protobuf/cmd/protoc-gen-go"
)

Three outputs now come from one .proto: items.pb.go (messages), items_grpc.pb.go (the gRPC client and server) and items.pb.gw.go (the gateway handlers).

The lab

The program starts the real gRPC service, builds a gateway ServeMux with some options, registers the generated handlers pointing at the gRPC server's address, serves the gateway over HTTP and sends a set of requests.

main.goGo
package main

import (
	"context"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/metadata"
	"google.golang.org/grpc/status"
	"google.golang.org/protobuf/encoding/protojson"

	pb "example.test/lab/itemspb"
)

type items struct {
	pb.UnimplementedItemsServer
	store map[string]*pb.Item
}

func (s *items) GetItem(ctx context.Context, r *pb.GetItemRequest) (*pb.Item, error) {
	if md, ok := metadata.FromIncomingContext(ctx); ok {
		if v := md.Get("x-request-id"); len(v) > 0 {
			grpc.SetHeader(ctx, metadata.Pairs("x-request-id", v[0]))
		}
	}
	it, ok := s.store[r.GetId()]
	if !ok {
		return nil, status.Errorf(codes.NotFound, "item %q does not exist", r.GetId())
	}
	return it, nil
}

func (s *items) CreateItem(_ context.Context, r *pb.CreateItemRequest) (*pb.Item, error) {
	if r.GetName() == "" {
		return nil, status.Error(codes.InvalidArgument, "name is required")
	}
	it := &pb.Item{Id: fmt.Sprintf("it_%d", len(s.store)+1), Name: r.GetName(), PriceCents: r.GetPriceCents()}
	s.store[it.Id] = it
	return it, nil
}

func main() {
	// The gRPC server: this is the real service. REST is a translation layer in front of it.
	lis, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		log.Fatal(err)
	}
	srv := grpc.NewServer()
	pb.RegisterItemsServer(srv, &items{store: map[string]*pb.Item{"it_1": {Id: "it_1", Name: "keyboard", PriceCents: 7900}}})
	go srv.Serve(lis)
	defer srv.Stop()

	// The gateway: an http.Handler that turns REST calls into gRPC calls.
	mux := runtime.NewServeMux(
		runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.JSONPb{
			MarshalOptions:   protojson.MarshalOptions{UseProtoNames: true, EmitUnpopulated: true}, // snake_case names, zero values included
			UnmarshalOptions: protojson.UnmarshalOptions{DiscardUnknown: false},
		}),
		runtime.WithIncomingHeaderMatcher(func(h string) (string, bool) { // which HTTP headers become gRPC metadata
			if strings.EqualFold(h, "X-Request-Id") {
				return "x-request-id", true
			}
			return runtime.DefaultHeaderMatcher(h)
		}),
		runtime.WithOutgoingHeaderMatcher(func(h string) (string, bool) { // and which gRPC headers become HTTP headers
			if h == "x-request-id" {
				return "X-Request-Id", true
			}
			return "", false
		}),
	)
	if err := pb.RegisterItemsHandlerFromEndpoint(context.Background(), mux, lis.Addr().String(),
		[]grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}); err != nil {
		log.Fatal(err)
	}
	web := httptest.NewServer(mux)
	defer web.Close()

	do := func(method, path, body string, headers ...string) {
		req, _ := http.NewRequest(method, web.URL+path, strings.NewReader(body))
		for i := 0; i+1 < len(headers); i += 2 {
			req.Header.Set(headers[i], headers[i+1])
		}
		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			log.Fatal(err)
		}
		b, _ := io.ReadAll(resp.Body)
		resp.Body.Close()
		extra := ""
		if v := resp.Header.Get("X-Request-Id"); v != "" {
			extra = "  [X-Request-Id: " + v + "]"
		}
		fmt.Printf("%-6s %-14s -> %d %s%s\n", method, path, resp.StatusCode, strings.TrimSpace(string(b)), extra)
	}
	do("GET", "/v1/items/it_1", "", "X-Request-Id", "req-9")
	do("GET", "/v1/items/nope", "")
	do("POST", "/v1/items", `{"name":"mouse","price_cents":"2900"}`, "Content-Type", "application/json")
	do("POST", "/v1/items", `{"name":"mouse","price_cents":2900}`, "Content-Type", "application/json")
	do("POST", "/v1/items", `{"name":""}`, "Content-Type", "application/json")
	do("POST", "/v1/items", `{"name":"x","colour":"red"}`, "Content-Type", "application/json")
	do("POST", "/v1/items", `{not json`, "Content-Type", "application/json")
	do("DELETE", "/v1/items/it_1", "")
	do("GET", "/v2/items/it_1", "")
}
Shell
mkdir -p bin
go build -o bin/protoc-gen-go google.golang.org/protobuf/cmd/protoc-gen-go
go build -o bin/protoc-gen-go-grpc google.golang.org/grpc/cmd/protoc-gen-go-grpc
go build -o bin/protoc-gen-grpc-gateway github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway
PATH="$PWD/bin:$PATH" protoc -I proto \
  --go_out=. --go_opt=module=example.test/lab \
  --go-grpc_out=. --go-grpc_opt=module=example.test/lab \
  --grpc-gateway_out=. --grpc-gateway_opt=module=example.test/lab \
  --grpc-gateway_opt=grpc_api_configuration=api.yaml \
  proto/items.proto
ls itemspb
go mod tidy
go run .
Text
items.pb.go
items.pb.gw.go
items_grpc.pb.go
GET    /v1/items/it_1 -> 200 {"id":"it_1","name":"keyboard","price_cents":"7900"}  [X-Request-Id: req-9]
GET    /v1/items/nope -> 404 {"code":5,"message":"item \"nope\" does not exist","details":[]}
POST   /v1/items      -> 200 {"id":"it_2","name":"mouse","price_cents":"2900"}
POST   /v1/items      -> 200 {"id":"it_3","name":"mouse","price_cents":"2900"}
POST   /v1/items      -> 400 {"code":3,"message":"name is required","details":[]}
POST   /v1/items      -> 400 {"code":3,"message":"proto: (line 1:13): unknown field \"colour\"","details":[]}
POST   /v1/items      -> 400 {"code":3,"message":"invalid character 'n' looking for beginning of object key string","details":[]}
DELETE /v1/items/it_1 -> 501 {"code":12,"message":"Method Not Allowed","details":[]}
GET    /v2/items/it_1 -> 404 {"code":5,"message":"Not Found","details":[]}
go: found github.com/kr/text in github.com/kr/text v0.2.0
go: found github.com/rogpeppe/go-internal/fmtsort in github.com/rogpeppe/go-internal v1.16.0

What the translation does

Reads. GET /v1/items/it_1 returned 200 with the item as JSON. The path segment it_1 became the id field of GetItemRequest. The X-Request-Id request header, which the matcher passes through, reached the gRPC handler as metadata, and the handler echoed it as a header that the outgoing matcher turned back into an X-Request-Id response header (shown in brackets).

Not found. A missing id produced HTTP 404. The gRPC status was NotFound (code 5); the gateway mapped it to 404 and wrote the status as the body: a JSON object with code 5, the handler's message and an empty details array. The mapping table is the standard one: InvalidArgument to 400, Unauthenticated to 401, PermissionDenied to 403, NotFound to 404, AlreadyExists and Aborted to 409, ResourceExhausted to 429, Unimplemented to 501, Unavailable to 503, DeadlineExceeded to 504 and so on (error codes).

Writes. POST /v1/items with a JSON body created an item. The first request sent "price_cents":"2900" (a string), the second sent 2900 (a number). Both worked. protojson, the JSON mapping for protobuf, accepts either for 64-bit integers on input and always emits them as strings on output, because JavaScript numbers cannot represent all 64-bit values. That is the first place the translation shows: a REST client that expects "price_cents": 7900 as a number gets "7900".

Validation errors. An empty name returned 400 with the handler's message. An unknown field colour returned 400 with proto: (line 1:13): unknown field "colour", because the gateway unmarshals strictly by default (the lab set DiscardUnknownFields false explicitly). Malformed JSON was a 400 with the parser's error. Note that the unmarshal errors are InvalidArgument statuses produced in the gateway, before any gRPC call.

Unmapped routes. A DELETE /v1/items/it_1 got 501 with the message "Method Not Allowed", and GET /v2/items/it_1 got 404. The gateway does not produce a 405 for a known path with an unsupported method; it answers 501. If your clients or tests expect 405 and an Allow header, you can add a route handler for it with mux.HandlePath or put a thin middleware in front.

JSON naming and options

The JSON mapping is the part that most often surprises REST users.

  • Field names. The default JSON name is lowerCamelCase (priceCents). The lab sets UseProtoNames: true on the marshaller, so output uses the proto names (price_cents). Choose deliberately and write it down: it is a public contract, and the generated OpenAPI document must agree.
  • Zero values. protojson omits fields with default values unless EmitUnpopulated is true. For REST clients, which often expect every field present, set it. The lab does, and the error body shows "details":[].
  • Whitespace is random. Look closely at the output: {"id":"it_1", "name":"keyboard", ...}. protojson deliberately adds or omits a space after commas in a way that varies between builds, to stop people depending on byte-for-byte output. Never compare JSON as strings in tests; parse it. And never hash it.
  • Enums are strings by default ("STATUS_ACTIVE"), and Timestamp fields are RFC 3339 strings.
  • Unknown fields can be rejected or ignored on input; rejecting catches typos, ignoring helps forward compatibility. Pick one for the whole gateway.

Because the gateway uses the protobuf JSON rules, it is not the same as encoding/json on your Go structs. Do not use encoding/json on generated messages; use protojson (protobuf for Go developers).

Headers, metadata and status codes

Request headers to metadata. The default matcher forwards Authorization and a few others, and prefixes the rest with Grpc-Metadata-. The lab's WithIncomingHeaderMatcher lets X-Request-Id through unchanged. Be explicit: anything you pass through becomes visible to your handlers, and a header you do not intend to trust must not be forwarded as identity (metadata).

Response metadata to headers. The default does not forward gRPC headers as HTTP headers unless prefixed; the lab's WithOutgoingHeaderMatcher maps one. Set cookies, Location and ETag through this mechanism or a forward-response option.

Success status codes. Every successful gRPC reply becomes 200. The POST above created a resource and answered 200, not 201. To change it, use a WithForwardResponseOption function that sets the status from a response header or from the message type, or add a middleware. If correct REST status codes matter to your consumers, plan for this; it is the second place where the seams show.

Redirects and non-JSON. For file downloads or HTML, gateway handlers can return google.api.HttpBody messages. Streaming rpcs are supported: server streams become newline-delimited JSON.

Errors

The default error body is the gRPC Status as JSON: code, message and details. If your REST API already has an error format (problem details, for instance), install runtime.WithErrorHandler to translate: it receives the status and writes whatever you like (problem details). Do this once; it removes a visible sign of the translation layer.

Query parameters, pagination and field masks

Fields of the request message that are not bound to the path or the body become query parameters for GET and DELETE. A ListItemsRequest with page_size and page_token fields is called as GET /v1/items?page_size=20&page_token=abc, with no extra configuration. Nested fields use dots (filter.status=active) and repeated fields repeat the parameter. This is a good reason to design requests with REST in mind: flat, with obvious names, and pagination in the Google style (page_size, page_token, next_page_token) that both the gRPC and the REST client understand (pagination).

For partial updates, PATCH /v1/items/{item.id} with body: "item" and an update_mask field of type google.protobuf.FieldMask is the convention. The gateway parses ?update_mask=name,price_cents into the mask. It is more exact than guessing from zero values, and it works across both faces (PATCH semantics).

Testing the gateway

Test it as a whole, with the same technique as the lab: start the gRPC server on a local listener (or bufconn), mount the gateway on httptest, send real HTTP requests, assert on status, headers and parsed JSON. Cover each route and each error class (not found, invalid argument, unauthenticated), the header mapping both ways, large bodies, and a route with no mapping. Compare JSON by decoding, not as strings, given the random whitespace. Run the same suite against the gRPC face and the REST face for behaviours that must be identical, such as validation and authorisation, so a gap between the two shows up as a failing test.

Pin the plugin version in go.mod and regenerate in CI, as described in code generation. A new gateway release can change generated code and, rarely, behaviour, and the diff tells you.

Evolving two contracts at once

With a gateway you maintain two public contracts from one source: the protobuf API and the REST API. Adding fields is compatible in both. Renaming a proto field is wire-compatible and breaks REST JSON (the name changes) unless you keep the JSON name. Moving an rpc's route breaks REST callers. Treat the YAML or annotations as part of the API, review changes to them like schema changes, and use a breaking-change check on the generated OpenAPI as well as Buf's check on the proto (breaking change detection). Version the REST paths (/v1/...) with the proto package version, so a future v2 of both can run beside the old one.

Where it runs

A separate process in front of the gRPC service, dialling it over the network. It scales independently, and its failures are separate. This is the typical deployment, and what the lab does with RegisterItemsHandlerFromEndpoint.

In the same process as the gRPC server: you can call RegisterItemsHandlerServer, which invokes the service implementation directly and bypasses the gRPC stack. That means interceptors do not run for REST calls, so authentication and logging implemented as gRPC interceptors are skipped. If you use this form, apply the same checks in HTTP middleware, or use RegisterItemsHandlerFromEndpoint against the local listener so calls go through the real server.

Authentication and limits

The gateway should authenticate at the edge (HTTP middleware verifying a token) and pass the credentials to the backend as metadata, where the gRPC interceptors verify them again or trust the gateway's mTLS identity (authentication). Put the usual HTTP protections around the gateway mux: body size limits, server timeouts, CORS and rate limiting. The gateway forwards the HTTP request's context, so a client disconnect cancels the gRPC call and deadlines can be set from a header (Grpc-Timeout).

Documentation

The sibling plugin protoc-gen-openapiv2 generates an OpenAPI description of the REST face from the same files, so your docs follow the real routes. Check that its naming options match the marshaller options you set at run time; mismatches (camelCase in the docs and snake_case in responses) are a common bug.

Alternatives and when to choose

Connect serves gRPC, gRPC-Web and a JSON protocol from one handler, but its JSON routes are fixed (POST /pkg.Service/Method), not the RESTful paths you design. If your goal is a conventional REST URL space for external consumers, grpc-gateway or hand-written handlers fit better; if the goal is browsers calling RPCs, Connect is simpler. Envoy's gRPC-JSON transcoder filter does the same translation inside the proxy, from a descriptor set, with no Go gateway to run. Hand-written REST handlers over your domain code remain a good choice when the REST API is a product of its own and not a mirror of the RPCs (REST API design).

Use the gateway when the REST face is a straightforward mirror of the RPCs, and accept the compromises above. When the REST design diverges (different resources, aggregation, different error semantics), write a proper REST service on top of the same domain layer. The configuration reference is in the grpc-gateway documentation.

Also inWeb
Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.