Written by Anshul Goyal✏️
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
- Why gRPC-Gateway
- Setting up gRPC-Gateway
- Using gRPC-Gateway
- Common usage patterns
- Using gRPC-Gateway with Gin
- Running reverse proxy and gRPC service on the same port
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.
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.
- Legacy clients might not support gRPC and require a Restful/JSON interface
- 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.
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 ServerMux
and 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
-
ctx
: Context; holds metadata about execution -
mux
: This isServerMux
; it holds config data about the server like which header should be passed to response -
marshaler
: Converts Protocol Buffer response to JSON response -
writer
: This is the response writer for the client -
request
: This requests objects that contain information sent by the client -
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.
LogRocket: Full visibility into your web apps
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Top comments (0)