15 - gRPC Gateway
📋 Jump to TakeawaysYou built a gRPC service. But some clients need REST. Browsers, third-party integrations, curl-wielding developers. gRPC-Gateway generates a reverse proxy that translates RESTful JSON requests into gRPC calls. One service, two protocols.
How It Works
gRPC-Gateway is a protoc plugin. You annotate your .proto file with HTTP mappings, run the code generator, and get a Go HTTP handler that proxies requests to your gRPC server.
Browser/curl → HTTP/JSON → gRPC-Gateway → gRPC/protobuf → Your ServerThe gateway handles JSON serialization, HTTP method routing, and path parameter extraction. Your gRPC server doesn't change at all.
Installing the Gateway Plugin
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@latestAdd the gateway runtime to your module:
go get github.com/grpc-ecosystem/grpc-gateway/v2/runtimeYou also need the Google API annotations:
# In your project root
mkdir -p google/api
curl -L https://raw.githubusercontent.com/googleapis/googleapis/master/google/api/annotations.proto \
-o google/api/annotations.proto
curl -L https://raw.githubusercontent.com/googleapis/googleapis/master/google/api/http.proto \
-o google/api/http.protoAnnotate the Proto File
Add HTTP mappings to your service methods:
syntax = "proto3";
package shortener;
option go_package = "shortener/pb";
import "google/api/annotations.proto";
message Link {
int64 id = 1;
string url = 2;
string short_code = 3;
int64 clicks = 4;
}
message CreateLinkRequest {
string url = 1;
}
message CreateLinkResponse {
Link link = 1;
}
message GetLinkRequest {
string short_code = 1;
}
message GetLinkResponse {
Link link = 1;
}
message DeleteLinkRequest {
string short_code = 1;
}
message DeleteLinkResponse {}
message ListLinksRequest {}
message BatchCreateLinksResponse {
int64 created_count = 1;
repeated Link links = 2;
}
message SyncRequest {
string short_code = 1;
int64 clicks = 2;
}
message SyncResponse {
string short_code = 1;
int64 total_clicks = 2;
bool acknowledged = 3;
}
service LinkService {
rpc CreateLink(CreateLinkRequest) returns (CreateLinkResponse) {
option (google.api.http) = {
post: "/v1/links"
body: "*"
};
}
rpc GetLink(GetLinkRequest) returns (GetLinkResponse) {
option (google.api.http) = {
get: "/v1/links/{short_code}"
};
}
rpc DeleteLink(DeleteLinkRequest) returns (DeleteLinkResponse) {
option (google.api.http) = {
delete: "/v1/links/{short_code}"
};
}
rpc ListLinks(ListLinksRequest) returns (stream Link) {
option (google.api.http) = {
get: "/v1/links"
};
}
// Streaming RPCs don't get HTTP annotations — they only work over gRPC
rpc BatchCreateLinks(stream CreateLinkRequest) returns (BatchCreateLinksResponse);
rpc SyncClicks(stream SyncRequest) returns (stream SyncResponse);
}Why no annotations on streaming RPCs? Client streaming and bidirectional streaming cannot be mapped to REST endpoints through gRPC-Gateway. HTTP/1.1 doesn't support streaming requests from the client. Server streaming works (the gateway returns newline-delimited JSON), but client and bidirectional streaming are gRPC-only. Clients that need those must use the gRPC client directly.
The {short_code} in the URL path maps to the short_code field in the request message. body: "*" means the entire request body maps to the request message.
Generate the Gateway Code
Update your Makefile:
.PHONY: proto
proto:
mkdir -p pb
protoc \
--proto_path=proto \
--proto_path=. \
--go_out=pb --go_opt=paths=source_relative \
--go-grpc_out=pb --go-grpc_opt=paths=source_relative \
--grpc-gateway_out=pb --grpc-gateway_opt=paths=source_relative \
proto/*.protoThe second --proto_path=. lets protoc find google/api/annotations.proto from the project root. Two include paths are needed because:
--proto_path=proto— resolves yourlink.proto(so output files land inpb/notpb/proto/)--proto_path=.— resolvesimport "google/api/annotations.proto"which lives at./google/api/
Protoc searches them in order. Multiple --proto_path flags is the standard way to handle imports from different directories.
This generates a third file: link.pb.gw.go containing the HTTP handler.
After regenerating, run go mod tidy to resolve the new dependencies. Your existing server (go run .) and client (go run ./cmd/client/) should still work as before.
Gateway Startup
The gateway runs as a separate process that proxies HTTP requests to your gRPC server. Create cmd/gateway/main.go:
package main
import (
"context"
"fmt"
"log"
"net/http"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"shortener/pb"
)
func main() {
ctx := context.Background()
mux := runtime.NewServeMux()
err := pb.RegisterLinkServiceHandlerFromEndpoint(ctx, mux, "localhost:50051",
[]grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())},
)
if err != nil {
log.Fatal(err)
}
fmt.Println("HTTP gateway on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}Run your gRPC server first (go run .), then the gateway in a second terminal:
go run ./cmd/gateway/You need two terminals running:
- Terminal 1 — gRPC server:
go run . - Terminal 2 — HTTP gateway:
go run ./cmd/gateway/
The gateway proxies to localhost:50051. If the server isn't running, you'll get a "connection refused" error.
Now you have both:
- gRPC on port 50051 (your existing server)
- REST on port 8080 (the gateway proxy)
Test with curl
# Create a link
curl -X POST http://localhost:8080/v1/links \
-H "Content-Type: application/json" \
-d '{"url": "https://golang.org"}'
# Get a link
curl http://localhost:8080/v1/links/abc123
# Delete a link
curl -X DELETE http://localhost:8080/v1/links/abc123
# List all links
curl http://localhost:8080/v1/linksThe gateway translates JSON to protobuf, calls the gRPC server, and translates the response back to JSON. Your gRPC handlers don't know or care that the request came from HTTP.
Streaming responses:
ListLinksis a server-streaming RPC. The gateway returns newline-delimited JSON (NDJSON) — one JSON object per line, not a JSON array. Each line is aresultwrapper:{"result":{"id":"1","url":"https://example.com","shortCode":"abc123","clicks":"0"}} {"result":{"id":"2","url":"https://golang.org","shortCode":"def456","clicks":"0"}}
Custom JSON Marshaling
By default, the gateway uses protojson which outputs camelCase field names. You can customize this in cmd/gateway/main.go:
import "google.golang.org/protobuf/encoding/protojson"
mux := runtime.NewServeMux(
runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.JSONPb{
MarshalOptions: protojson.MarshalOptions{
UseProtoNames: true, // use snake_case instead of camelCase
},
UnmarshalOptions: protojson.UnmarshalOptions{
DiscardUnknown: true, // ignore unknown fields in requests
},
}),
)Error Mapping
gRPC status codes map to HTTP status codes automatically:
| gRPC Code | HTTP Status |
|---|---|
OK |
200 |
InvalidArgument |
400 |
Unauthenticated |
401 |
PermissionDenied |
403 |
NotFound |
404 |
AlreadyExists |
409 |
Internal |
500 |
Unavailable |
503 |
You don't configure this. The gateway handles it.
Key Takeaways
- gRPC-Gateway generates an HTTP reverse proxy from annotated
.protofiles - Annotate RPCs with
google.api.httpoptions to define REST endpoints - Path parameters like
{short_code}map to request message fields - The gateway runs as a separate HTTP server that proxies to your gRPC server
- gRPC status codes map to HTTP status codes automatically
- Your gRPC handlers don't change, the gateway handles the translation
- Use this when you need both gRPC for internal services and REST for external clients