38.7 gRPC-Gateway: Exposing gRPC Services as REST
Right, so you’ve built this beautiful, efficient gRPC service. It’s humming along, all type-safe and binary-efficient, and you’re feeling pretty good about yourself. Then someone—probably a product manager, or maybe a frontend developer who refuses to embrace the future—asks, “Cool, but how do we call it from the browser?” or “Our mobile app needs a REST API.” Your heart sinks. You’re not going to rewrite your entire service as a JSON-speaking HTTP server, are you?
Of course not. You’re going to pull in gRPC-Gateway, the duct tape and baling wire of the gRPC world that lets you have your cake (performance, type safety) and eat it too (broad compatibility, RESTful idioms). It’s a brilliant piece of kit that acts as a reverse proxy, translating incoming REST/JSON calls into gRPC calls your backend understands.
Here’s the core idea: you annotate your gRPC service definitions to tell the gateway how to map HTTP verbs and paths to your gRPC methods. Then, you run a small HTTP server alongside your gRPC server that does the heavy lifting of translation.
The Annotated Protobuf is King
Everything starts in your .proto file. You’ll need to import the Google API annotations, which are the special sauce that makes this work. Without these, the gateway has no idea what to do.
syntax = "proto3";
package myapp.v1;
import "google/api/annotations.proto"; // This is the crucial import
option go_package = "github.com/you/proto/myapp/v1;myappv1";
service UserService {
rpc GetUser (GetUserRequest) returns (User) {
option (google.api.http) = {
get: "/v1/users/{user_id}" // Maps the `user_id` path param to our request field
};
}
rpc CreateUser (CreateUserRequest) returns (User) {
option (google.api.http) = {
post: "/v1/users" // POST to this path
body: "*" // The entire request message becomes the HTTP body
};
}
}
message GetUserRequest {
string user_id = 1;
}
message CreateUserRequest {
string name = 1;
string email = 2;
}
message User {
string user_id = 1;
string name = 2;
string email = 3;
}
Notice the magic: in GetUser, the {user_id} in the HTTP path is directly mapped to the user_id field in the GetUserRequest message. For CreateUser, the body: "*" tells the gateway to slurp the entire JSON HTTP request body and parse it into a CreateUserRequest message. You could also use body: "name" to only map, say, the name field from the body, passing the rest as query params—a feature I find almost comically specific and rarely used.
Generating the Proxy Code
You can’t just go run this. You need to generate the proxy code from your annotated proto. This is where many people get tripped up. Your protobuf build command gets a whole lot longer.
# Install the protoc-gen-grpc-gateway plugin first:
# go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@latest
protoc \
-I. \ # Your source directory
-I${GO_PATH}/src/github.com/grpc-ecosystem/grpc-gateway/ \ # For the annotations proto
-I${GO_PATH}/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \ # For httpbody.proto
--grpc-gateway_out=. \ # Generate the gateway code
--grpc-gateway_opt=logtostderr=true \
--grpc-gateway_opt=paths=source_relative \
--go_out=. --go_opt=paths=source_relative \ # Your normal Go codegen
--go-grpc_out=. --go-grpc_opt=paths=source_relative \ # Your normal gRPC codegen
./your_service.proto
Yes, it’s a mouthful. The -I includes are particularly annoying because you have to point it to the actual .proto files for the annotations that you downloaded with your Go modules. In practice, most people use buf.build to manage this complexity, and I highly recommend you do too. It’s a topic for another day, but it makes this process infinitely less painful.
Wiring It Up in Your Go Server
Now, you have a generated file (your_service.pb.gw.go). This contains an HTTP handler. Your job is to start an HTTP server that uses it. The classic pattern is to run both servers concurrently.
package main
import (
"context"
"fmt"
"log"
"net"
"net/http"
"github.com/you/proto/myapp/v1/myappv1" // your generated Go protobuf package
"google.golang.org/grpc"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime" // Important!
)
func main() {
// 1. Create a listener for your gRPC server
grpcListener, err := net.Listen("tcp", ":9090")
if err != nil {
log.Fatalf("failed to listen on 9090: %v", err)
}
// 2. Create and register your gRPC server as usual
grpcServer := grpc.NewServer()
myappv1.RegisterUserServiceServer(grpcServer, &yourRealServerImplementation{}) // Your actual logic
// 3. Start the gRPC server in a goroutine
go func() {
fmt.Println("gRPC server listening on :9090")
log.Fatal(grpcServer.Serve(grpcListener))
}()
// 4. Create a connection to *ourselves* for the gateway to use
conn, err := grpc.DialContext(
context.Background(),
"localhost:9090",
grpc.WithBlock(),
grpc.WithInsecure(), // Please use real security in production
)
if err != nil {
log.Fatalf("failed to dial server: %v", err)
}
// 5. Create the gateway mux and register the generated handler
gwMux := runtime.NewServeMux()
err = myappv1.RegisterUserServiceHandler(context.Background(), gwMux, conn)
if err != nil {
log.Fatalf("failed to register gateway: %v", err)
}
// 6. Create and start the HTTP reverse proxy server
gwServer := &http.Server{
Addr: ":8080",
Handler: gwMux,
}
fmt.Println("gRPC-Gateway server listening on :8080")
log.Fatal(gwServer.ListenAndServe())
}
The key insight here is step 4: the gateway isn’t magic. It’s just a gRPC client. It needs a connection to your gRPC server. In this simple example, it’s dialing itself, but it could just as easily be dialing a separate, internal-only gRPC server.
Common Pitfalls and The Sharp Edges
First, error handling. By default, gRPC errors get translated into not-very-helpful HTTP status codes (like almost everything becoming a 500 Internal Server Error). This is terrible. You must implement a custom error handler using runtime.WithRoutingErrorHandler and runtime.WithForwardResponseOption to map gRPC status codes to meaningful HTTP ones and to format error messages consistently. Don’t ship without this.
Second, the translation from JSON to Protobuf isn’t always one-to-one. Protobuf’s int64 and uint64 are represented as strings in JSON because JavaScript’s number type can’t handle their full range. This can surprise frontend clients. Similarly, the often-misunderstood google.protobuf.Timestamp becomes a string in RFC3339 format.
Finally, remember that the gateway is now a public-facing component. You need to think about its lifecycle, health checks, and observability (logging, metrics, tracing) separately from your gRPC server. It’s no longer just an internal detail.
It adds complexity, no doubt. But it’s a controlled, well-understood complexity that saves you from the nightmare of maintaining two separate API facades. It lets your system speak its native, efficient language internally while still being a good citizen in a wider, REST-oriented ecosystem.