Powered by AppSignal & Oban Pro

Transcoding

guides/advanced/transcoding.livemd

Transcoding

The goal of transcoding is to allow HTTP/JSON calls to be automatically converted into gRPC protobuf calls without external gateways.


Setup

app_root = Path.join(__DIR__, "..")

Mix.install(
  [
    {:grpc, path: app_root, env: :dev}
  ],
  config_path: Path.join(app_root, "config/config.exs"),
  lockfile: Path.join(app_root, "mix.lock")
)

Protobuf Service and Messages

defmodule Helloworld.HelloRequest do
  @moduledoc false
  use Protobuf, protoc_gen_elixir_version: "0.14.1", syntax: :proto3

  field :name, 1, type: :string
end

defmodule Helloworld.HelloRequestFrom do
  @moduledoc false
  use Protobuf, protoc_gen_elixir_version: "0.14.1", syntax: :proto3

  field :name, 1, type: :string
  field :from, 2, type: :string
end

defmodule Helloworld.HelloReply do
  @moduledoc false
  use Protobuf, protoc_gen_elixir_version: "0.14.1", syntax: :proto3

  field :message, 1, type: :string
  field :today, 2, type: Google.Protobuf.Timestamp
end

defmodule Helloworld.Greeter.Service do
  @moduledoc false
  use GRPC.Service, name: "helloworld.Greeter", protoc_gen_elixir_version: "0.14.1"

  rpc(:SayHello, Helloworld.HelloRequest, Helloworld.HelloReply, %{
    http: %{
      type: Google.Api.PbExtension,
      value: %Google.Api.HttpRule{
        selector: "",
        body: "",
        additional_bindings: [],
        response_body: "",
        pattern: {:get, "/v1/greeter/{name}"},
        __unknown_fields__: []
      }
    }
  })

  rpc(:SayHelloFrom, Helloworld.HelloRequestFrom, Helloworld.HelloReply, %{
    http: %{
      type: Google.Api.PbExtension,
      value: %Google.Api.HttpRule{
        selector: "",
        body: "*",
        additional_bindings: [],
        response_body: "",
        pattern: {:post, "/v1/greeter"},
        __unknown_fields__: []
      }
    }
  })
end

In a real-world application, this would be generated from your project’s .proto files. You would have to annotate your protobuf in the following way:

import "google/api/annotations.proto";
import "google/protobuf/timestamp.proto";

package helloworld;

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

  rpc SayHelloFrom (HelloRequestFrom) returns (HelloReply) {
    option (google.api.http) = {
      post: "/v1/greeter"
      body: "*"
    };
  }
}

The compilation itself would be something like:

mix protobuf.generate   --include-path=priv/proto   --include-path=deps/googleapis   --generate-descriptors=true   --output-path=./lib   --plugins=ProtobufGenerate.Plugins.GRPCWithOptions   google/api/annotations.proto google/api/http.proto helloworld.proto

Enable transcoding on the Elixir side.

defmodule Helloworld.Greeter.Server do
  use GRPC.Server,
    service: Helloworld.Greeter.Service,
    http_transcode: true
  
  alias GRPC.Stream, as: GRPCStream
  alias Helloworld.HelloRequest
  alias Helloworld.HelloReply

  def say_hello(request, stream) do
    GRPCStream.unary(request, materializer: stream)
    |> GRPCStream.map(fn
      %HelloRequest{} = reply ->
        %HelloReply{
          message: "Hello #{request.name}",
          today: today()
        }

      {:error, reason} ->
        {:error, GRPC.RPCError.exception(message: "[Error] #{inspect(reason)}")}
    end)
    |> GRPCStream.run()
  end

  def say_hello_from(request, _stream) do
    GRPCStream.unary(request, materializer: stream)
    |> GRPCStream.map(fn
      %HelloFromRequest{} = reply ->
        %HelloReply{
          message: "Hello #{request.name}. From #{request.from}",
          today: today()
        }

      _ ->
        GRPC.RPCError.exception(message: "[Error] something bad happened")
    end)
    |> GRPCStream.run()
  end

  defp today() do
    nanos_epoch = System.system_time() |> System.convert_time_unit(:native, :nanosecond)
    seconds = div(nanos_epoch, 1_000_000_000)
    nanos = nanos_epoch - seconds * 1_000_000_000

    %Google.Protobuf.Timestamp{seconds: seconds, nanos: nanos}
  end
end

Endpoint + Supervisor

defmodule TranscodeEndpoint do
  use GRPC.Endpoint
  intercept(GRPC.Server.Interceptors.Logger)
  run(Helloworld.Greeter.Server)
end

{:ok, _pid} =
  GRPC.Server.Supervisor.start_link(
    endpoint: TranscodeEndpoint,
    port: 50054,
    start_server: true
  )

IO.puts("Transcoded gRPC Server running on :50054")

This automatically activates HTTP endpoints based on the annotations.


Testing with curl

# Say hello
$ curl -H 'accept: application/json' http://localhost:50054/v1/greeter/test
# Say hello from
$ curl -XPOST -H 'Content-type: application/json' -d '{"name": "test", "from": "anon"}' http://localhost:50054/v1/greeter

Important notes

Feature Status
CORS Not enabled by default. See CORS section.
Server Streaming Supported.
Query params → fields Supported. See note below.

>Note: gRPC Transcode HttpRule https://docs.cloud.google.com/endpoints/docs/grpc-service-config/reference/rpc/google.api#google.api.HttpRule