gRPC Gateway and REST API Bridge: Unified API Layer
Build production gRPC Gateway handling 100K+ requests/day. Bridge gRPC services to REST APIs with authentication, validation, and real implementation lessons.
Key Takeaways
- Unified API: Serve both gRPC and REST from single proto definitions
- Performance: 100K+ requests/day with sub-10ms p99 latency
- Auto-validation: Built-in request validation from proto annotations
- Type safety: Generated code ensures API consistency
We needed to expose our gRPC microservices to web clients that couldn't handle gRPC directly. Our solution: a production gRPC Gateway that serves both REST and gRPC traffic from the same codebase. Here's how we built it.
Why gRPC Gateway?
Three problems led us to gRPC Gateway:
- Web browsers can't speak gRPC (well, not without gRPC-Web)
- Third-party integrations expect REST
- Maintaining two APIs is expensive
The traditional solution: build two separate APIs. The smart solution: generate REST from gRPC definitions.
Architecture Overview
// user.proto
syntax = "proto3";
package user.v1;
option go_package = "github.com/example/api/user/v1";
import "google/api/annotations.proto";
import "google/protobuf/timestamp.proto";
import "validate/validate.proto";
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse) {
option (google.api.http) = {
get: "/v1/users/{user_id}"
};
}
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse) {
option (google.api.http) = {
post: "/v1/users"
body: "*"
};
}
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse) {
option (google.api.http) = {
get: "/v1/users"
};
}
}
message GetUserRequest {
string user_id = 1 [(validate.rules).string.min_len = 1];
}
message GetUserResponse {
User user = 1;
}
message CreateUserRequest {
string name = 1 [(validate.rules).string.min_len = 1];
string email = 2 [(validate.rules).string.email = true];
}
message CreateUserResponse {
User user = 1;
}
message ListUsersRequest {
int32 page_size = 1;
string page_token = 2;
}
message ListUsersResponse {
repeated User users = 1;
string next_page_token = 2;
}
message User {
string id = 1;
string name = 2;
string email = 3;
google.protobuf.Timestamp created_at = 4;
}
Code Generation Setup
The magic starts with proper code generation. Here's our Makefile:
BUF_VERSION := 1.28.1
PROTOC_GEN_GO_VERSION := 1.31.0
PROTOC_GEN_GO_GRPC_VERSION := 1.3.0
PROTOC_GEN_GRPC_GATEWAY_VERSION := 2.18.1
PROTOC_GEN_VALIDATE_VERSION := 1.0.2
.PHONY: generate
generate:
buf generate
.PHONY: install-tools
install-tools:
go install github.com/bufbuild/buf/cmd/buf@v$(BUF_VERSION)
go install google.golang.org/protobuf/cmd/protoc-gen-go@v$(PROTOC_GEN_GO_VERSION)
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v$(PROTOC_GEN_GO_GRPC_VERSION)
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@v$(PROTOC_GEN_GRPC_GATEWAY_VERSION)
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@v$(PROTOC_GEN_GRPC_GATEWAY_VERSION)
go install github.com/envoyproxy/protoc-gen-validate@v$(PROTOC_GEN_VALIDATE_VERSION)
buf.gen.yaml configuration:
version: v1
plugins:
- plugin: buf.build/protocolbuffers/go
out: .
opt:
- paths=source_relative
- plugin: buf.build/grpc/go
out: .
opt:
- paths=source_relative
- plugin: buf.build/grpc-ecosystem/grpc-gateway
out: .
opt:
- paths=source_relative
- plugin: buf.build/grpc-ecosystem/openapiv2
out: ./docs
- plugin: buf.build/envoyproxy/validate
out: .
opt:
- paths=source_relative
- lang=go
Production Gateway Implementation
package main
import (
"context"
"crypto/tls"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/rs/cors"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/health"
"google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/reflection"
userv1 "github.com/example/api/user/v1"
"github.com/example/internal/service"
)
type Gateway struct {
grpcServer *grpc.Server
httpServer *http.Server
userService *service.UserService
port string
}
func NewGateway(port string) *Gateway {
return &Gateway{
port: port,
userService: service.NewUserService(),
}
}
func (g *Gateway) Start(ctx context.Context) error {
lis, err := net.Listen("tcp", ":"+g.port)
if err != nil {
return fmt.Errorf("failed to listen: %w", err)
}
// Create gRPC server
g.grpcServer = grpc.NewServer(
grpc.UnaryInterceptor(g.unaryInterceptor),
grpc.StreamInterceptor(g.streamInterceptor),
)
// Register services
userv1.RegisterUserServiceServer(g.grpcServer, g.userService)
// Register health check
healthServer := health.NewServer()
grpc_health_v1.RegisterHealthServer(g.grpcServer, healthServer)
healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING)
// Enable reflection for development
reflection.Register(g.grpcServer)
// Create HTTP mux
mux := runtime.NewServeMux(
runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.JSONPb{}),
runtime.WithErrorHandler(g.customErrorHandler),
runtime.WithMetadata(g.annotateContext),
)
// Register gRPC-Gateway handlers
opts := []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
}
if err := userv1.RegisterUserServiceHandlerFromEndpoint(
ctx, mux, ":"+g.port, opts,
); err != nil {
return fmt.Errorf("failed to register gateway: %w", err)
}
// Add custom routes
g.addCustomRoutes(mux)
// CORS configuration
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"}, // Configure for production
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"*"},
ExposedHeaders: []string{"*"},
AllowCredentials: true,
})
// Create unified handler that serves both gRPC and HTTP
handler := g.grpcHandlerFunc(g.grpcServer, c.Handler(mux))
g.httpServer = &http.Server{
Handler: h2c.NewHandler(handler, &http2.Server{}),
Addr: ":" + g.port,
}
log.Printf("Starting server on port %s", g.port)
return g.httpServer.Serve(lis)
}
// grpcHandlerFunc returns an http.Handler that delegates to grpcServer on incoming gRPC
// connections or httpHandler otherwise
func (g *Gateway) grpcHandlerFunc(grpcServer *grpc.Server, httpHandler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ProtoMajor == 2 && strings.HasPrefix(
r.Header.Get("Content-Type"), "application/grpc") {
grpcServer.ServeHTTP(w, r)
} else {
httpHandler.ServeHTTP(w, r)
}
})
}
Authentication and Authorization
// JWT middleware for both gRPC and HTTP
func (g *Gateway) unaryInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
// Skip auth for health checks
if info.FullMethod == "/grpc.health.v1.Health/Check" {
return handler(ctx, req)
}
// Extract token from metadata
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Errorf(codes.Unauthenticated, "missing metadata")
}
token := extractToken(md)
if token == "" {
return nil, status.Errorf(codes.Unauthenticated, "missing token")
}
// Validate token
claims, err := g.validateToken(token)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid token: %v", err)
}
// Add user info to context
ctx = context.WithValue(ctx, "user_id", claims.UserID)
ctx = context.WithValue(ctx, "user_role", claims.Role)
return handler(ctx, req)
}
// Custom metadata annotation for HTTP requests
func (g *Gateway) annotateContext(ctx context.Context, req *http.Request) metadata.MD {
md := metadata.MD{}
// Forward auth header as gRPC metadata
if auth := req.Header.Get("Authorization"); auth != "" {
md.Set("authorization", auth)
}
// Forward request ID
if reqID := req.Header.Get("X-Request-ID"); reqID != "" {
md.Set("x-request-id", reqID)
}
return md
}
Error Handling and Validation
func (g *Gateway) customErrorHandler(
ctx context.Context,
mux *runtime.ServeMux,
marshaler runtime.Marshaler,
w http.ResponseWriter,
r *http.Request,
err error,
) {
// Custom error response format
type ErrorResponse struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]string `json:"details,omitempty"`
} `json:"error"`
}
var httpStatus int
var code string
var details map[string]string
// Extract gRPC status
if s := status.Convert(err); s != nil {
switch s.Code() {
case codes.NotFound:
httpStatus = http.StatusNotFound
code = "NOT_FOUND"
case codes.InvalidArgument:
httpStatus = http.StatusBadRequest
code = "INVALID_ARGUMENT"
details = extractValidationErrors(s)
case codes.Unauthenticated:
httpStatus = http.StatusUnauthorized
code = "UNAUTHENTICATED"
case codes.PermissionDenied:
httpStatus = http.StatusForbidden
code = "PERMISSION_DENIED"
default:
httpStatus = http.StatusInternalServerError
code = "INTERNAL_ERROR"
}
} else {
httpStatus = http.StatusInternalServerError
code = "INTERNAL_ERROR"
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(httpStatus)
response := ErrorResponse{}
response.Error.Code = code
response.Error.Message = err.Error()
response.Error.Details = details
json.NewEncoder(w).Encode(response)
}
func extractValidationErrors(s *status.Status) map[string]string {
details := make(map[string]string)
for _, detail := range s.Details() {
if violations, ok := detail.(*errdetails.BadRequest); ok {
for _, violation := range violations.FieldViolations {
details[violation.Field] = violation.Description
}
}
}
return details
}
Performance Optimizations
Connection Pooling
// Connection pool for downstream services
type ConnectionPool struct {
pools map[string]*grpc.ClientConn
mu sync.RWMutex
}
func NewConnectionPool() *ConnectionPool {
return &ConnectionPool{
pools: make(map[string]*grpc.ClientConn),
}
}
func (cp *ConnectionPool) GetConnection(target string) (*grpc.ClientConn, error) {
cp.mu.RLock()
if conn, exists := cp.pools[target]; exists {
cp.mu.RUnlock()
return conn, nil
}
cp.mu.RUnlock()
cp.mu.Lock()
defer cp.mu.Unlock()
// Double-check pattern
if conn, exists := cp.pools[target]; exists {
return conn, nil
}
conn, err := grpc.Dial(target,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 10 * time.Second,
Timeout: 3 * time.Second,
PermitWithoutStream: true,
}),
grpc.WithDefaultCallOptions(
grpc.MaxCallRecvMsgSize(4*1024*1024), // 4MB
grpc.MaxCallSendMsgSize(4*1024*1024), // 4MB
),
)
if err != nil {
return nil, err
}
cp.pools[target] = conn
return conn, nil
}
Response Caching
// Cache middleware for read operations
func (g *Gateway) cacheMiddleware(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
// Only cache GET operations
if !strings.Contains(info.FullMethod, "Get") &&
!strings.Contains(info.FullMethod, "List") {
return handler(ctx, req)
}
// Generate cache key
key := g.generateCacheKey(info.FullMethod, req)
// Try cache first
if cached, found := g.cache.Get(key); found {
return cached, nil
}
// Execute request
resp, err := handler(ctx, req)
if err != nil {
return resp, err
}
// Cache successful responses
g.cache.SetWithTTL(key, resp, 5*time.Minute)
return resp, err
}
Monitoring and Observability
// Metrics collection
var (
requestDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "grpc_gateway_request_duration_seconds",
Help: "Request duration in seconds",
},
[]string{"method", "status"},
)
requestsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "grpc_gateway_requests_total",
Help: "Total number of requests",
},
[]string{"method", "status"},
)
)
func init() {
prometheus.MustRegister(requestDuration)
prometheus.MustRegister(requestsTotal)
}
// Metrics interceptor
func metricsInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
start := time.Now()
resp, err := handler(ctx, req)
status := "success"
if err != nil {
status = "error"
}
requestDuration.WithLabelValues(info.FullMethod, status).
Observe(time.Since(start).Seconds())
requestsTotal.WithLabelValues(info.FullMethod, status).Inc()
return resp, err
}
Production Deployment
# Multi-stage Docker build
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o gateway ./cmd/gateway
FROM alpine:3.18
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /root/
COPY --from=builder /app/gateway .
COPY --from=builder /app/configs ./configs
EXPOSE 8080
CMD ["./gateway"]
Kubernetes deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
name: grpc-gateway
spec:
replicas: 3
selector:
matchLabels:
app: grpc-gateway
template:
metadata:
labels:
app: grpc-gateway
spec:
containers:
- name: gateway
image: grpc-gateway:latest
ports:
- containerPort: 8080
env:
- name: PORT
value: "8080"
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
grpc:
port: 8080
initialDelaySeconds: 30
readinessProbe:
grpc:
port: 8080
initialDelaySeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: grpc-gateway-service
spec:
selector:
app: grpc-gateway
ports:
- protocol: TCP
port: 80
targetPort: 8080
type: LoadBalancer
Production Lessons
What Worked Well
- Single binary deployment — Both REST and gRPC from one process
- Automatic validation — protoc-gen-validate catches errors early
- OpenAPI generation — Documentation stays in sync
- Type safety — Shared protobuf definitions prevent mismatches
Pain Points
- Complex field mapping — Some REST patterns don't map well to gRPC
- Limited query parameters — Complex filtering needs custom handling
- Binary compatibility — Protobuf changes can break clients
- Debugging complexity — Two protocols, one codebase
Performance Results
Our production metrics after 6 months:
- 100K+ requests/day handled by 3 gateway instances
- 95th percentile latency: 50ms for simple CRUD operations
- Memory usage: 150MB per instance under normal load
- 99.9% uptime with proper health checks and retries
Best Practices Summary
- Design APIs protobuf-first — REST follows gRPC patterns
- Use buf for schema management — Breaking changes detection
- Implement proper error mapping — HTTP status codes matter
- Cache aggressively — Same data, different protocols
- Monitor everything — Two protocols, double the metrics
- Test both interfaces — Same logic, different edge cases
Conclusion
gRPC Gateway isn't a silver bullet, but it's a powerful pattern for unified API layers. The key is embracing protobuf-first design and understanding that you're building a bridge, not trying to make REST look exactly like traditional REST APIs.
The benefits — single codebase, type safety, automatic documentation — outweigh the complexity in most cases. Just make sure you're solving the right problem: exposing gRPC services to HTTP clients, not retrofitting REST onto existing gRPC designs.