Skip to main content

src/livery_grpc_codec.erl

-module(livery_grpc_codec).
-moduledoc """
Protobuf encode/decode glue over a gpb-generated module.

A gpb module (e.g. `helloworld_pb`, built from `proto/helloworld.proto`)
exposes `encode_msg/2` and `decode_msg/2`. This module wraps them behind a
stable interface so the server and client do not call gpb directly, and so
an alternative codec (for example JSON for `application/grpc+json`) can be
slotted in later behind the same calls.

Encoding errors are surfaced as `{error, {encode|decode, Reason}}` rather
than raised, so the caller can turn them into a gRPC `internal` status.
""".

-export([encode/3, decode/3]).
-export([content_type/0, content_subtypes/0, is_grpc_content_type/1]).

-export_type([proto_module/0, msg_name/0]).

-type proto_module() :: module().
-type msg_name() :: atom().

-doc "Encode a message map/record to protobuf bytes.".
-spec encode(proto_module(), msg_name(), map() | tuple()) ->
    {ok, binary()} | {error, {encode, term()}}.
encode(Mod, MsgName, Msg) ->
    try
        {ok, iolist_to_binary(Mod:encode_msg(Msg, MsgName))}
    catch
        Class:Reason:Stack ->
            {error, {encode, {Class, Reason, Stack}}}
    end.

-doc "Decode protobuf bytes into a message map (gpb maps mode).".
-spec decode(proto_module(), msg_name(), binary()) ->
    {ok, map() | tuple()} | {error, {decode, term()}}.
decode(Mod, MsgName, Bin) ->
    try
        {ok, Mod:decode_msg(Bin, MsgName)}
    catch
        Class:Reason:Stack ->
            {error, {decode, {Class, Reason, Stack}}}
    end.

%%====================================================================
%% Content types
%%====================================================================

-doc "The default gRPC content type.".
-spec content_type() -> binary().
content_type() ->
    <<"application/grpc+proto">>.

-doc "Recognised gRPC content-type values for proto framing.".
-spec content_subtypes() -> [binary()].
content_subtypes() ->
    [<<"application/grpc">>, <<"application/grpc+proto">>].

-doc """
Whether a `content-type` header value names a gRPC proto request. A
trailing parameter (e.g. `;charset=...`) is tolerated; the base type is
matched.
""".
-spec is_grpc_content_type(binary() | undefined) -> boolean().
is_grpc_content_type(undefined) ->
    false;
is_grpc_content_type(Value) ->
    Base = base_type(Value),
    lists:member(Base, content_subtypes()).

-spec base_type(binary()) -> binary().
base_type(Value) ->
    case binary:split(Value, <<";">>) of
        [Base | _] -> trim(Base);
        [] -> Value
    end.

-spec trim(binary()) -> binary().
trim(Bin) ->
    string:trim(Bin).