This is a premium alert message you can set from Layout! Get Now!

An all-in-one guide to gRPC-Gateway

0

gRPC-Gateway is a plugin that generates a reverse proxy server for gRPC services that convert Restful/JSON into gRPC and vice versa.

In other words, gRPC-Gateway will create a layer over your gRPC services that will act as a Restful/JSON service to a client. gRPC-Gateway generates code from Protocol Buffer’s definitions of gRPC services.

Introduction

gRPC-Gateway is a plugin for protoc and will generate Go code from the gRPC definition.

The generated code can be used as a standalone server or mounted on an existing codebase. gRPC-Gateway is highly customizable with support for generating open API documentation from protoc files.

In this tutorial guide, we will cover both standalone servers and integration with existing code in detail. Take a look at this flow chart to get an understanding of how a gRPC gateway works.

gRPC-Gateway flowchart diagram

Why gRPC-Gateway?

gRPC gateways build a proxy for a gRPC service that acts as a Restful/JSON application to the client. It opens the possibility of using the same codebase for supporting both Restful/JSON and gRPC. There are two major use cases for this.

  1. Legacy clients might not support gRPC and require a Restful/JSON interface
  2. Browsers may not support gRPC out of the box; so for the web client that wants to interact with gRPC services, gRPC-Gateway is the go-to option.

The most common gRPC-Gateway pattern is to create a single gRPC gateway server (which might be running on multiple machines) that interfaces with multiple gRPC services as a proxy for clients.

The diagram below explains the working of this service.

gRPC-Gateway and service requests flowchart diagram

A gRPC gateway-generated reverse proxy is horizontally scaled to run on multiple machines and a load-balancer is used in front of these instances. A single instance can host multiple gRPC services’ reverse proxies.

Setting up gRPC-Gateway

gRPC-Gateway is a plugin for protoc. Before using it, the protocol buffer compiler must be installed on the system. Follow this guide on offical gRPC website to install protoc on your system according to the operating system you are using.

gRPC-Gateway uses and generates Go code. To install Go, follow the guide on the official website. Once you have installed Go on your system, you are all set to install the gRPC-Gateway plugin.

Create a directory named grpc-gateway-demo, which will hold the gRPC-Gateway project. For building protocol buffers and generating a gRPC gateway reverse proxy, Buf will be used. You can install Buf by following the guide on the official website.

Project structure

All the Protocol Buffers files will be in the proto directory, while Go files will be in root. For setting up the Go project, use go mod init grpc-gateway-demo and create a main.go file. Your project should look like this:

├── main.go
├── go.mod
└── proto

Configuring Buf

Buf requires three different files to generate stubs and reverse proxies.

buf.gen.yaml

These files specify all the plugins the compiler should use and related options.

With Buf, you can simply specify the name and option in a YAML file. Buf also allows building code to use remote plugins (i.e., specified plugins will be downloaded by Buf automatically during the build and maintained by Buf on the local system).

version: v1
plugins:
  # generate go structs for protocol buffer defination
  - remote: buf.build/library/plugins/go:v1.27.1-1
    out: gen/go
    opt:
      - paths=source_relative
  # generate gRPC stubs in golang
  - remote: buf.build/library/plugins/go-grpc:v1.1.0-2
    out: gen/go
    opt:
      - paths=source_relative
  # generate reverse proxy from protocol definations
  - remote: buf.build/grpc-ecosystem/plugins/grpc-gateway:v2.6.0-1
    out: gen/go
    opt:
      - paths=source_relative
  # generate openapi documentation for api
  - remote: buf.build/grpc-ecosystem/plugins/openapiv2:v2.6.0-1
    out: gen/openapiv2

buf.yaml

This file should be in the root of all the proto files. These files specify the required dependency for compiling proto files (Google APIs, for example).

 version: v1
 deps:
 # adding well known types by google
  - buf.build/googleapis/googleapis

buf.work.yaml

This file specifies all the folders/directories that contain Protocol Buffer definitions in your workspaces.

version: v1
directories:
  - proto

Once complete, your project structure should resemble this.

├── buf.gen.yaml
├── buf.work.yaml
├── go.mod
├── main.go
└── proto
    ├── buf.yaml

You can test your configuration by running the buf build command in your project root.

Using gRPC-Gateway

Until now, you have set up gRPC-Gateway as a plugin, but now the question arises of how to define basic API specifications like HTTP method, URL, or request body.

For defining what these specification options are using in Protocol Buffers’ definition of an rpc method on a service, the following example will make it more clear.

proto/hello/hello_world.proto:

// define syntax used in proto file
syntax = "proto3";
// options used by gRPC golang plugin(not related to gRPC gateway)
option go_package = "github.com/anshulrgoyal/grpc-gateway-demo;grpc_gateway_demo";

// well know type by google, gRPC gateway uses HTTP annotation.
import "google/api/annotations.proto";

package hello_world;

// simple message
message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

// a gRPC service
service Greeter {
 // SayHello is a rpc call and a option is defined for it
  rpc SayHello (HelloRequest) returns (HelloReply) {
  // option type is http
    option (google.api.http) = {
    // this is url, for RESTfull/JSON api and method
    // this line means when a HTTP post request comes with "/v1/sayHello" call this rpc method over this service
      post: "/v1/sayHello"
      body: "*"
    };
  }
}

The option keyword is used to add specifications for the Rest request. The option method is chosen and the path for that request is specified.

In above example, post is the HTTP method for request and /v1/sayHello is the response.

You can now build your code using the buf generate command in the root of your project directory.

After the command completes, there should be a gen directory in the root of your project with Go code inside. These files contain stubs for gRPC and the gRPC gateway reverse proxy. openapiv2 contains the open API documentation for Swagger UI.

gen
|-- go
|   `-- hello
|       |-- hello_world.pb.go
|       |-- hello_world.pb.gw.go
|       `-- hello_world_grpc.pb.go
`-- openapiv2
    `-- hello
        `-- hello_world.swagger.json

Implementing the service

As an example, this tutorial will implement the gRPC server in Go. Any gRPC implementations will work perfectly fine for the gRPC gateway.

The advantage of using Go is that you can run both gRPC service- and gRPC-Gateway generated code in the same process. Here is Go’s implementation for the Greeter service.

sever/main.go:

package main
import (
    "context"
    "fmt"
    "log"
    "net"
    // importing generated stubs
    gen "grpc-gateway-demo/gen/go/hello"
    "google.golang.org/grpc"
)
// GreeterServerImpl will implement the service defined in protocol buffer definitions
type GreeterServerImpl struct {
    gen.UnimplementedGreeterServer
}
// SayHello is the implementation of RPC call defined in protocol definitions.
// This will take HelloRequest message and return HelloReply
func (g *GreeterServerImpl) SayHello(ctx context.Context, request *gen.HelloRequest) (*gen.HelloReply, error) {
    return &gen.HelloReply{
        Message: fmt.Sprintf("hello %s",request.Name),
    },nil
}
func main() {
    // create new gRPC server
    server := grpc.NewServer()
    // register the GreeterServerImpl on the gRPC server
    gen.RegisterGreeterServer(server, &GreeterServerImpl{})
    // start listening on port :8080 for a tcp connection
    if l, err := net.Listen("tcp", ":8080"); err != nil {
        log.Fatal("error in listening on port :8080", err)
    } else {
        // the gRPC server
        if err:=server.Serve(l);err!=nil {
            log.Fatal("unable to start server",err)
        }
    }
}

The above file is a basic implementation for the gRPC service. It listens on port 8080. You can test it on any gRPC client.

Registering services on a gRPC gateway proxy

Each gRPC server supported by the gRPC gateway proxy needs to be registered on it.

Under the hood, the gRPC gateway server will create a gRPC client and use it to make gRPC requests to the provided endpoint. You can provide various DailOptions to the Register function.

proxy/main.go

package main
import (
    "context"
    "log"
    "net"
    "net/http"
    "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
    "google.golang.org/grpc"
    gen "grpc-gateway-demo/gen/go/hello"
)
func main() {
    // creating mux for gRPC gateway. This will multiplex or route request different gRPC service
    mux:=runtime.NewServeMux()
    // setting up a dail up for gRPC service by specifying endpoint/target url
    err := gen.RegisterGreeterHandlerFromEndpoint(context.Background(), mux, "localhost:8080", []grpc.DialOption{grpc.WithInsecure()})
    if err != nil {
        log.Fatal(err)
    }
    // Creating a normal HTTP server
    server:=http.Server{
        Handler: mux,
    }
    // creating a listener for server
    l,err:=net.Listen("tcp",":8081")
    if err!=nil {
        log.Fatal(err)
    }
    // start server
    err = server.Serve(l)
    if err != nil {
        log.Fatal(err)
    }
}

ServerMux is a multiplexer that will route requests to various registered services based on the path of the JSON/Restful request.

The grpc.WithInsecure() dial option is used to allow a service to connect to gRPC without using authentication. localhost:8080 is the URL where the gPRC service is running — since the Greet (gRPC service build seen earlier) service is running on port 8080, localhost:8080 is used.

Once the handlers are registered, mux is ready to handle HTTP requests. Here, the Go standard HTTP server from http package is used. You are also free to use other implementations, and later on this article will demonstrate this using Gin with the gRPC gateway proxy.

ServerMux implements the ServeHTTP interface — it can be used as Handler in the HTTP server. The server is running on port 8081.

For starting the server, just run go run proxy/main.go in the root of your project directory.

Using path parameters

Now, if you want to make the v1/sayHello API a GET call inside of a POST call and pass data as a path parameter, then with a gRPC gateway setup complete, you don’t need to change anything in code — just by changing protocol buffer definitions and regenerating the stubs, you are all set to use the new API.

message HelloRequest {
  string name = 1;
}

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply) {
    option (google.api.http) = {
     get:"/v1/sayHello/{name}"
    };
  }
}

The path provided in the above snippet is /v1/sayHello/{name}. You can use any key from the request payload (HelloRequest in this case) as a path parameter. If you use a GET request with the path /v1/sayHello/jane, the request will be routed to the Greeter.sayHello gRPC call. You can use any number of path parameters in the URL.

Now you have some basic understanding of the gRPC gateway and its setup.

The example we have used is just an introduction to the gRPC gateway, but to run something in production you need to have logging, tracing, and error handling.

Common usage patterns

For any system to be production-ready, it should have some error handling and allow some kind of error logging.

Adding logging

This section of the article will demonstrate how to use middleware with a gRPC gateway-generated proxy.

ServerMux implements a Handler interface so you can use any middleware to wrap the ServerMuxand log incoming and outgoing requests.

type Handler interface {
        ServeHTTP(ResponseWriter, *Request)
}

To create a middleware for logging, you can extract information related to an HTTP request from *Request and the information about the response is extracted using the httpsnoop package.

func withLogger(handler http.Handler) http.Handler {
    // the create a handler
    return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
        // pass the handler to httpsnoop to get http status and latency
        m:=httpsnoop.CaptureMetrics(handler,writer,request)
        // printing exracted data
        log.Printf("http[%d]-- %s -- %s\n",m.Code,m.Duration,request.URL.Path)
    })
}

The withLogger method will wrap the Handler interface and call snoop to extract information. Under the hood, the ServerHTTP method is called by the httpsnoop package.

server:=http.Server{
        Handler: withLogger(mux),
    }

This is no different from any other handler used in the Go ecosystem. Since ServerMux is a normal handler, any middleware available will also work with a gRPC gateway-generated reverse proxy.

Error handling

gRPC gateways already come with mapping for translating gRPC error codes to HTTP status used by the client. It will automatically map well known and used gRPC codes to the HTTP status, for example.

InvalidArgument is converted to 400 (bad request). For a complete list you can check this link. If you have custom requirements, like needing a non-conventional status code, you can use the WithErrorhandler option that takes an error handler function — all errors will be passed to this function with the request-and-response writer.

runtime.WithErrorHandler(
  func(ctx context.Context, mux *runtime.ServeMux, marshaler runtime.Marshaler, writer http.ResponseWriter, request *http.Request, err error) {}
)

The error handler function gets the following arguments

  1. ctx: Context; holds metadata about execution
  2. mux: This is ServerMux; it holds config data about the server like which header should be passed to response
  3. marshaler: Converts Protocol Buffer response to JSON response
  4. writer: This is the response writer for the client
  5. request: This requests objects that contain information sent by the client
  6. err: Error sent by the gRPC service

Here is a simple example of WithErrorHandler. In this example, the HTTP status for the request is changed to 400 when an error occurs, irrespective of the error.

mux:=runtime.NewServeMux(
        runtime.WithErrorHandler(func(ctx context.Context, mux *runtime.ServeMux, marshaler runtime.Marshaler, writer http.ResponseWriter, request *http.Request, err error) {
            //creating a new HTTTPStatusError with a custom status, and passing error
            newError:=runtime.HTTPStatusError{
                HTTPStatus: 400,
                Err:        err,
            }
            // using default handler to do the rest of heavy lifting of marshaling error and adding headers
            runtime.DefaultHTTPErrorHandler(ctx,mux,marshaler,writer,request,&newError)
        }))

The status is changed by creating a new error and passing it to DefaultHTTPErrorHandler. It is important to note that DefaultHTTPErrorHandler performs a lot of work under the hood to convert the error to a valid JSON response — try to use it wherever possible.

HTTP headers and gRPC metadata

gRPC and Restful/JSON pass metadata differently.

In Restful/JSON HTTP, headers are used to send HTTP headers, whereas gRPC abstracts out sending metadata by providing a metadata interface depending on the language used.

The gRPC gateway provides a simple mapping interface to convert gRPC metadata to HTTP headers and vice versa. It also allows for two different methods to handle header-to-metadata conversion.

Firstly,WithOutgoingHeaderMatcher handles the header going from the gRPC gateway back to the client. It converts metadata into HTTP headers (i.e., any metadata passed by the gRPC service will be sent back to the client as HTTP headers).

var allowedHeaders=map[string]struct{}{
    "x-request-id": {},
}
func isHeaderAllowed(s string)( string,bool) {
// check if allowedHeaders contain the header
    if _,isAllowed:=allowedHeaders[s];isAllowed {
// send uppercase header
       return strings.ToUpper(s),true
    }
// if not in the allowed header, don't send the header
     return s, false
}
// usage
mux:=runtime.NewServeMux(
// convert header in response(going from gateway) from metadata received.
runtime.WithOutgoingHeaderMatcher(isHeaderAllowed))

This method takes a string and returns true if the header is passed to the client, or false if not.

Secondly, WithMetadata handles incoming HTTP headers (i.e., cookies, content-type, etc.). Its most common use case is to get an authentication token and pass it to metadata. HTTP headers extracted here will be sent to the gRPC service in metadata.

mux:=runtime.NewServeMux(
handle incoming headers
runtime.WithMetadata(func(ctx context.Context, request *http.Request) metadata.MD {
header:=request.Header.Get("Authorization")
// send all the headers received from the client
md:=metadata.Pairs("auth",header)
return md
}),

It takes a function that takes requests and returns metadata. Be careful about headers converted to metadata since the client, browsers, load balancer, and CDN are in many of them. There are also some restrictions on keys for gRPC.

Here is a complete example:

package main
import (
    "context"
    "log"
    "net"
    "net/http"
    "strings"
    "github.com/felixge/httpsnoop"
    "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
    "google.golang.org/grpc"
    "google.golang.org/grpc/metadata"
    gen "grpc-gateway-demo/gen/go/hello"
)
func withLogger(handler http.Handler) http.Handler {
    return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
        m:=httpsnoop.CaptureMetrics(handler,writer,request)
        log.Printf("http[%d]-- %s -- %s\n",m.Code,m.Duration,request.URL.Path)
    })
}
var allowedHeaders=map[string]struct{}{
    "x-request-id": {},
}
func isHeaderAllowed(s string)( string,bool) {
    // check if allowedHeaders contain the header
    if _,isAllowed:=allowedHeaders[s];isAllowed {
        // send uppercase header
        return strings.ToUpper(s),true
    }
    // if not in the allowed header, don't send the header
    return s, false
}
func main() {
    // creating mux for gRPC gateway. This will multiplex or route request different gRPC service
    mux:=runtime.NewServeMux(
        // convert header in response(going from gateway) from metadata received.
        runtime.WithOutgoingHeaderMatcher(isHeaderAllowed),
        runtime.WithMetadata(func(ctx context.Context, request *http.Request) metadata.MD {
            header:=request.Header.Get("Authorization")
            // send all the headers received from the client
            md:=metadata.Pairs("auth",header)
            return md
        }),
        runtime.WithErrorHandler(func(ctx context.Context, mux *runtime.ServeMux, marshaler runtime.Marshaler, writer http.ResponseWriter, request *http.Request, err error) {
            //creating a new HTTTPStatusError with a custom status, and passing error
            newError:=runtime.HTTPStatusError{
                HTTPStatus: 400,
                Err:        err,
            }
            // using default handler to do the rest of heavy lifting of marshaling error and adding headers
            runtime.DefaultHTTPErrorHandler(ctx,mux,marshaler,writer,request,&newError)
        }))
    // setting up a dail up for gRPC service by specifying endpoint/target url
    err := gen.RegisterGreeterHandlerFromEndpoint(context.Background(), mux, "localhost:8080", []grpc.DialOption{grpc.WithInsecure()})
    if err != nil {
        log.Fatal(err)
    }
    // Creating a normal HTTP server
    server:=http.Server{
        Handler: withLogger(mux),
    }
    // creating a listener for server
    l,err:=net.Listen("tcp",":8081")
    if err!=nil {
        log.Fatal(err)
    }
    // start server
    err = server.Serve(l)
    if err != nil {
        log.Fatal(err)
    }
}

Query parameters

Query parameters are supported by default. You can add them in the path with the same key in message definitions. So, if you had a key named last_name in HelloResponse, you can enter the path v1/sayHello/anshul?last_name=goyal without changing anything in the gateway code.

Customizing the response

gRPC-Gateway allows you to customize if you want keys in your response in original case or camelCase. By default it is camelCase, but you can edit Marshaler configuration to change it.

mux:=runtime.NewServeMux(runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.HTTPBodyMarshaler{
            Marshaler: &runtime.JSONPb{
                MarshalOptions: protojson.MarshalOptions{
                    UseProtoNames:   true,
                    EmitUnpopulated: true,
                },
                UnmarshalOptions: protojson.UnmarshalOptions{
                    DiscardUnknown: true,
                },
            },
        }),)

Using gRPC-Gateway with Gin

Gin is a very popular Go web framework. You can use gRPC-Gateway with Gin, since it is just a handler. It will allow you to add additional routes on your server that may not be generated by gRPC-Gateway.

package main
import (
    "context"
    "log"
    "net/http"
    "strings"
    "github.com/gin-gonic/gin"
    "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
    "google.golang.org/grpc"
    "google.golang.org/grpc/metadata"
    gen "grpc-gateway-demo/gen/go/hello"
)
var allowedHeaders=map[string]struct{}{
    "x-request-id": {},
}
func isHeaderAllowed(s string)( string,bool) {
    // check if allowedHeaders contain the header
    if _,isAllowed:=allowedHeaders[s];isAllowed {
        // send uppercase header
        return strings.ToUpper(s),true
    }
    // if not in the allowed header, don't send the header
    return s, false
}
func main() {
    // creating mux for gRPC gateway. This will multiplex or route request different gRPC service
    mux:=runtime.NewServeMux(
        // convert header in response(going from gateway) from metadata received.
        runtime.WithOutgoingHeaderMatcher(isHeaderAllowed),
        runtime.WithMetadata(func(ctx context.Context, request *http.Request) metadata.MD {
            header:=request.Header.Get("Authorization")
            // send all the headers received from the client
            md:=metadata.Pairs("auth",header)
            return md
        }),
        runtime.WithErrorHandler(func(ctx context.Context, mux *runtime.ServeMux, marshaler runtime.Marshaler, writer http.ResponseWriter, request *http.Request, err error) {
            //creating a new HTTTPStatusError with a custom status, and passing error
            newError:=runtime.HTTPStatusError{
                HTTPStatus: 400,
                Err:        err,
            }
            // using default handler to do the rest of heavy lifting of marshaling error and adding headers
            runtime.DefaultHTTPErrorHandler(ctx,mux,marshaler,writer,request,&newError)
        }))
    // setting up a dail up for gRPC service by specifying endpoint/target url
    err := gen.RegisterGreeterHandlerFromEndpoint(context.Background(), mux, "localhost:8080", []grpc.DialOption{grpc.WithInsecure()})
    if err != nil {
        log.Fatal(err)
    }
    // Creating a normal HTTP server
    server:=gin.New()
    server.Use(gin.Logger())
    server.Group("v1/*{grpc_gateway}").Any("",gin.WrapH(mux))
    // additonal route
    server.GET("/test", func(c *gin.Context) {
        c.String(http.StatusOK,"Ok")
    })

    // start server
    err = server.Run(":8081")
    if err != nil {
        log.Fatal(err)
    }
}

Simply use the gin. WrapH method with a wildcard path and you are ready to use gin with your server. It allows you to add a route to your server if you want. You can also add routes directly to ServerMux using HandlePath.

err = mux.HandlePath("GET", "test", func(w http.ResponseWriter, r *http.Request, pathParams map[string]string) {
    w.Write([]byte("ok")
})

Running reverse proxy and gRPC service on the same port

It is possible to run both services on a single port. You can do this by using the cmux package.

cmux will split the gRPC traffic and RestFull/JSON by differentiating between the protocol used, because gRPC will use HTTP2 and RestFull/JSON will use HTTP1.

package main
import (
    "context"
    "fmt"
    "log"
    "net"
    "net/http"
    "github.com/felixge/httpsnoop"
    "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
    "github.com/soheilhy/cmux"
    // importing generated stubs
    gen "grpc-gateway-demo/gen/go/hello"
    "google.golang.org/grpc"
)
// GreeterServerImpl will implement the service defined in protocol buffer definitions
type GreeterServerImpl struct {
    gen.UnimplementedGreeterServer
}
// SayHello is the implementation of RPC call defined in protocol definitions.
// This will take HelloRequest message and return HelloReply
func (g *GreeterServerImpl) SayHello(ctx context.Context, request *gen.HelloRequest) (*gen.HelloReply, error) {
    if err:=request.Validate();err!=nil {
        return nil,err
    }
    return &gen.HelloReply{
        Message: fmt.Sprintf("hello %s %s",request.Name,request.LastName),
    },nil
}
func main() {
    // create new gRPC server
    grpcSever := grpc.NewServer()
    // register the GreeterServerImpl on the gRPC server
    gen.RegisterGreeterServer(grpcSever, &GreeterServerImpl{})
    // creating mux for gRPC gateway. This will multiplex or route request different gRPC service
    mux:=runtime.NewServeMux()
    // setting up a dail up for gRPC service by specifying endpoint/target url
    err := gen.RegisterGreeterHandlerFromEndpoint(context.Background(), mux, "localhost:8081", []grpc.DialOption{grpc.WithInsecure()})
    if err != nil {
        log.Fatal(err)
    }
    // Creating a normal HTTP server
    server:=http.Server{
        Handler: withLogger(mux),
    }
    // creating a listener for server
    l,err:=net.Listen("tcp",":8081")
    if err!=nil {
        log.Fatal(err)
    }
    m := cmux.New(l)
    // a different listener for HTTP1
    httpL := m.Match(cmux.HTTP1Fast())
    // a different listener for HTTP2 since gRPC uses HTTP2
    grpcL := m.Match(cmux.HTTP2())
    // start server
    // passing dummy listener
    go server.Serve(httpL)
    // passing dummy listener
    go grpcSever.Serve(grpcL)
    // actual listener
    m.Serve()
}
func withLogger(handler http.Handler) http.Handler {
    return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
        m:=httpsnoop.CaptureMetrics(handler,writer,request)
        log.Printf("http[%d]-- %s -- %s\n",m.Code,m.Duration,request.URL.Path)
    })
}

Conclusion

This tutorial explained all the essentials required for building an excellent gRPC-Gateway reverse proxy for your gRPC service.

Since gRPC-Gateway, ServerMux is now just a handler you can build on top of by adding more middleware like body compression, authentication, and panic handling.

You can also play with the gRPC gateway configuration. All the code examples can be found here.

The post An all-in-one guide to gRPC-Gateway appeared first on LogRocket Blog.



from LogRocket Blog https://ift.tt/6qIa3PM
via Read more

Post a Comment

0 Comments
* Please Don't Spam Here. All the Comments are Reviewed by Admin.
Post a Comment

Search This Blog

To Top