gRPC being a binary protocol, it poses challenge for quick validations using Postman or cURL. Envoy proxy can be deployed with grpc-json encoder to ease this pain.

In our deployment, all services exposes gRPC endpoints talking protobufs. Even the messages that are exchanged on kafka channels are protobuf messages. While we are convinced that gRPC was a correct choice, there are few downsides for the protocol is binary - there is no easy way to do a quick test of the interfaces. How do I curl the service? What about my Postman test cases? These are questions that came up quite often.

gRPC JSON Transcoder

gRPC has built-in support for JSON transcoding, by adding additional annotation int he proto service declaration. A descriptor file generated from the service declaration that contains the metadata can be used to build a transcoding proxy that sits in between REST clients and gRPC servers.

Envoy is one such proxy that can do the transcoding. Envoy suits us well - it is cloud native, available as docker container, easy to deploy in a k8s environment, metrics readily available. In fact, we have deployed a pod in each of the environments to support quick debug and for couple of legacy clients.

This article will show end-to-end, how to set up envoy proxy for gRPC transcoding. Also we will see the routing capability of Envoy as we have two services that needs transcoding.

Service Definition with annotations

Two minimal protobuf and service definitions - Account and Rfp are shown.

syntax = "proto3";
package app.types.account;

import "google/rpc/status.proto";
import "google/api/annotations.proto";

option java_package = "app.types.account";
option java_multiple_files = true;

message Account {
    string account_id = 1;
    string name = 2;
}

message CreateAccountRequest {
    Account account = 1;
}

message GetAccountRequest {
    string account_id = 1;
}

service AccountService {
    rpc createAccount (CreateAccountRequest) returns (google.rpc.Status) {
        option (google.api.http) = {
            post: "/accounts"
            body: "account"
        };
    }

    rpc getAccount (GetAccountRequest) returns (Account) {
        option (google.api.http) = {
            get: "/accounts/{account_id}"
        };
    }
}
account.proto
syntax = "proto3";
package app.types.rfp;

import "google/api/annotations.proto";

option java_package = "app.types.rfp";
option java_multiple_files = true;

message Rfp {
    string rfp_id = 1;
    string name = 2;
}

message GetRfpRequest {
    string rfp_id = 1;
}

service RfpService {
    rpc getRfp (GetRfpRequest) returns (Rfp) {
        option (google.api.http) = {
            get: "/rfps/{rfp_id}"
        };
    }
}
rfp.proto

During proxying for the AccountService.getAccount, the annotation get: "/accounts/{account_id}" creates a GetAccountRequest message instance and sets it matching attribute to the account_id path variable. Similarly for the AccountService.createAccount, the post: "/accounts"  body: "account" creates an Account message instance and sets the matching account attribute.

Build Proto Descriptors

In addition to generating the server and client bindings, the Protobuf Gradle Plugin is used to generate the descriptor used by Envoy proxy for transcoding.

Place the proto files in src/main/proto folder, where the plugin expects it to be. Below are the relevant sections of build file.

plugins {
    id("java-library")
    id("com.google.protobuf") version "0.8.12"
}

dependencies {
    api("io.grpc:grpc-protobuf:1.27.2")
    api("io.grpc:grpc-stub:1.27.2")

    if (JavaVersion.current().isJava9Compatible()) {
        // Workaround for @javax.annotation.Generated
        // see: https://github.com/grpc/grpc-java/issues/3633
        implementation("javax.annotation:javax.annotation-api")
    }
}

sourceSets {
    main {
        java {
            // add generated src to sourceset
            srcDirs("build/generated/source/proto/main/grpc")
            srcDirs("build/generated/source/proto/main/java")
        }
    }
}

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.11.4"
    }

    plugins {
        grpc {
            artifact = "io.grpc:protoc-gen-grpc-java:1.27.2"
        }
    }

    generateProtoTasks {
        all()*.plugins {
            grpc {}
        }
        all().each { task ->
            task.generateDescriptorSet = true
            task.descriptorSetOptions.includeSourceInfo = true
            task.descriptorSetOptions.includeImports = true
            task.descriptorSetOptions.path = "${buildDir}/resources/main/META-INF/proto/app-types.desc"
        }
    }
}
build.gradle

In addition to the usual artifacts, the build generates a single binary file app-types.desc. We will need this file for the proxy.

Envoy Configuration

Envoy can proxy based on host or path - in fact in service mesh configuration, Envoy is employed mainly as a proxy in sidecar deployment. This capability is used here to transcode for both account and rfp service.

  • 8080 is the http port
  • Cluster namesgrpc-account and grpc-rfp are for account and rfp grpc services respectively. The grpc services themselves run on port 12500.
admin:
  access_log_path: /tmp/admin_access.log
  address:
    socket_address: { address: 0.0.0.0, port_value: 8081 }

static_resources:
  listeners:
    - name: grpc-listener
      address:
        socket_address: { address: 0.0.0.0, port_value: 8080 }
      filter_chains:
        - filters:
            - name: envoy.filters.network.http_connection_manager
              typed_config:
                "@type": type.googleapis.com/envoy.config.filter.network.http_connection_manager.v2.HttpConnectionManager
                stat_prefix: grpc_json
                codec_type: AUTO
                route_config:
                  name: local_route
                  virtual_hosts:
                    - name: local_service
                      domains: ["*"]
                      routes:
                        - name: route-acount
                          match: { prefix: "/app.types.account.AccountService/", grpc: {} }
                          route: { cluster: grpc-account, timeout: { seconds: 60 } }
                        - name: route-rfp
                          match: { prefix: "/app.types.rfp.RfpService/", grpc: {} }
                          route: { cluster: grpc-rfp, timeout: { seconds: 60 } }
                http_filters:
                  - name: envoy.filters.http.grpc_json_transcoder
                    typed_config:
                      "@type": type.googleapis.com/envoy.config.filter.http.transcoder.v2.GrpcJsonTranscoder
                      proto_descriptor: "/data/app-types.desc"
                      services: ["app.types.account.AccountService", "app.types.rfp.RfpService"]
                      print_options:
                        add_whitespace: true
                        always_print_primitive_fields: true
                        always_print_enums_as_ints: false
                        preserve_proto_field_names: false
                  - name: envoy.filters.http.router

  clusters:
    - name: grpc-account
      connect_timeout: 1.25s
      type: logical_dns
      lb_policy: round_robin
      dns_lookup_family: V4_ONLY
      http2_protocol_options: {}
      load_assignment:
        cluster_name: grpc-account
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      address: account
                      port_value: 12500

    - name: grpc-rfp
      connect_timeout: 1.25s
      type: logical_dns
      lb_policy: round_robin
      dns_lookup_family: V4_ONLY
      http2_protocol_options: {}
      load_assignment:
        cluster_name: grpc-rfp
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      address: rfp
                      port_value: 12500
envoy.yaml

Start Envoy

For this demonstration, we will look at running a standalone docker instance of envoy. Use the descriptor and envoy configuration from above.

docker run -it --rm --name envoy \
    -p 8080:8080 \
    -v "app-types.desc:/data/app-types.desc" \
    -v "envoy.yaml:/etc/envoy/envoy.yaml" \
    envoyproxy/envoy

Http Requests

curl --location --request POST 'http://localhost:8080/accounts/' \
--header 'Content-Type: application/json' \
--data-raw '{
	account_id: 'A2020X001',
	name: "Account 1"
}'

curl --location --request GET 'http://localhost:8080/accounts/A2020X001'
--header 'Content-Type: application/json'

curl --location --request GET 'http://localhost:8080/rfps/RFP1001'
--header 'Content-Type: application/json'