diff --git a/lib/google/protobuf.ex b/lib/google/protobuf.ex new file mode 100644 index 00000000..6e1812b2 --- /dev/null +++ b/lib/google/protobuf.ex @@ -0,0 +1,137 @@ +defmodule Google.Protobuf do + @moduledoc """ + Utility functions for working with Google Protobuf structs. + """ + + @doc """ + Converts a `Google.Protobuf.Struct` struct to a `t:map()` recursively + converting values to their Elixir equivalents. + + ## Examples + + iex> to_map(%Google.Protobuf.Struct{}) + %{} + + iex> to_map(%Google.Protobuf.Struct{ + fields: %{ + "key_one" => %Google.Protobuf.Value{ + kind: {:string_value, "value_one"}, + }, + "key_two" => %Google.Protobuf.Value{ + kind: {:number_value, 1234.0}, + } + }, + }) + %{"key_one" => "value_one", "key_two" => 1234.0} + + """ + @spec to_map(Google.Protobuf.Struct.t()) :: map() + def to_map(struct) do + Map.new(struct.fields, fn {k, v} -> + {k, to_map_value(v)} + end) + end + + defp to_map_value(%{kind: {:null_value, :NULL_VALUE}}), do: nil + defp to_map_value(%{kind: {:number_value, value}}), do: value + defp to_map_value(%{kind: {:string_value, value}}), do: value + defp to_map_value(%{kind: {:bool_value, value}}), do: value + + defp to_map_value(%{kind: {:struct_value, struct}}), + do: to_map(struct) + + defp to_map_value(%{kind: {:list_value, %{values: values}}}), + do: Enum.map(values, &to_map_value/1) + + @doc """ + Converts a `t:map()` to a `Google.Protobuf.Struct` struct recursively + wrapping values in their `Google.Protobuf.Value` equivalents. + + ## Examples + + iex> from_map(%{}) + %Google.Protobuf.Struct{} + + """ + @spec from_map(map()) :: Google.Protobuf.Struct.t() + def from_map(map) do + struct(Google.Protobuf.Struct, %{ + fields: + Map.new(map, fn {k, v} -> + {to_string(k), from_map_value(v)} + end) + }) + end + + defp from_map_value(nil) do + struct(Google.Protobuf.Value, %{kind: {:null_value, :NULL_VALUE}}) + end + + defp from_map_value(value) when is_number(value) do + struct(Google.Protobuf.Value, %{kind: {:number_value, value}}) + end + + defp from_map_value(value) when is_binary(value) do + struct(Google.Protobuf.Value, %{kind: {:string_value, value}}) + end + + defp from_map_value(value) when is_boolean(value) do + struct(Google.Protobuf.Value, %{kind: {:bool_value, value}}) + end + + defp from_map_value(value) when is_map(value) do + struct(Google.Protobuf.Value, %{kind: {:struct_value, from_map(value)}}) + end + + defp from_map_value(value) when is_list(value) do + struct(Google.Protobuf.Value, %{ + kind: + {:list_value, + struct(Google.Protobuf.ListValue, %{ + values: Enum.map(value, &from_map_value/1) + })} + }) + end + + @doc """ + Converts a `DateTime` struct to a `Google.Protobuf.Timestamp` struct. + + Note: Elixir `DateTime.from_unix!/2` will convert units to + microseconds internally. Nanosecond precision is not guaranteed. + See examples for details. + + ## Examples + + iex> to_datetime(%Google.Protobuf.Timestamp{seconds: 5, nanos: 0}) + ~U[1970-01-01 00:00:05.000000Z] + + iex> one = to_datetime(%Google.Protobuf.Timestamp{seconds: 10, nanos: 100}) + ...> two = to_datetime(%Google.Protobuf.Timestamp{seconds: 10, nanos: 105}) + ...> DateTime.diff(one, two, :nanosecond) + 0 + + """ + @spec to_datetime(Google.Protobuf.Timestamp.t()) :: DateTime.t() + def to_datetime(%{seconds: seconds, nanos: nanos}) do + DateTime.from_unix!(seconds * 1_000_000_000 + nanos, :nanosecond) + end + + @doc """ + Converts a `Google.Protobuf.Timestamp` struct to a `DateTime` struct. + + ## Examples + + iex> from_datetime(~U[1970-01-01 00:00:05.000000Z]) + %Google.Protobuf.Timestamp{seconds: 5, nanos: 0} + + """ + @spec from_datetime(DateTime.t()) :: Google.Protobuf.Timestamp.t() + def from_datetime(%DateTime{} = datetime) do + nanoseconds = DateTime.to_unix(datetime, :nanosecond) + + struct(Google.Protobuf.Timestamp, %{ + seconds: div(nanoseconds, 1_000_000_000), + nanos: rem(nanoseconds, 1_000_000_000) + }) + end +end diff --git a/test/google/protobuf_test.exs b/test/google/protobuf_test.exs new file mode 100644 index 00000000..427e87f6 --- /dev/null +++ b/test/google/protobuf_test.exs @@ -0,0 +1,101 @@ +defmodule Google.ProtobufTest do + use ExUnit.Case, async: true + + import Google.Protobuf + + alias Google.Protobuf.{Struct, Timestamp} + + @basic_json """ + { + "key_one": "value_one", + "key_two": 1234, + "key_three": null, + "key_four": true + } + """ + + @basic_elixir %{ + "key_one" => "value_one", + "key_two" => 1234, + "key_three" => nil, + "key_four" => true + } + + @advanced_json """ + { + "key_two": [1, 2, 3, null, true, "value"], + "key_three": { + "key_four": "value_four", + "key_five": { + "key_six": 99, + "key_seven": { + "key_eight": "value_eight" + } + } + } + } + """ + + @advanced_elixir %{ + "key_two" => [1, 2, 3, nil, true, "value"], + "key_three" => %{ + "key_four" => "value_four", + "key_five" => %{ + "key_six" => 99, + "key_seven" => %{ + "key_eight" => "value_eight" + } + } + } + } + + describe "to_map/1" do + test "converts nil values to empty map" do + assert %{} == to_map(%Struct{}) + end + + test "converts basic json to map" do + assert @basic_elixir == to_map(Protobuf.JSON.decode!(@basic_json, Struct)) + end + + test "converts advanced json to map" do + assert @advanced_elixir == to_map(Protobuf.JSON.decode!(@advanced_json, Struct)) + end + end + + describe "from_map/1" do + test "converts basic elixir to struct" do + assert Protobuf.JSON.decode!(@basic_json, Struct) == from_map(@basic_elixir) + end + + test "converts advanced elixir to struct" do + assert Protobuf.JSON.decode!(@advanced_json, Struct) == from_map(@advanced_elixir) + end + end + + describe "to_datetime/1" do + # This matches golang behaviour + # https://github.com/golang/protobuf/blob/5d5e8c018a13017f9d5b8bf4fad64aaa42a87308/ptypes/timestamp.go#L43 + test "converts nil values to unix time start" do + assert ~U[1970-01-01 00:00:00.000000Z] == to_datetime(%Timestamp{}) + end + + test "converts to DateTime" do + assert ~U[1970-01-01 00:00:05.000000Z] == + to_datetime(%Timestamp{seconds: 5, nanos: 0}) + end + + test "nanosecond precision" do + one = to_datetime(%Timestamp{seconds: 10, nanos: 100}) + two = to_datetime(%Timestamp{seconds: 10, nanos: 105}) + assert 0 == DateTime.diff(one, two, :nanosecond) + end + end + + describe "from_datetime/1" do + test "converts from DateTime" do + assert %Timestamp{seconds: 5, nanos: 0} == + from_datetime(~U[1970-01-01 00:00:05.000000Z]) + end + end +end