Skip to content

Commit

Permalink
Add helper functions for Google.Protobuf modules (#354)
Browse files Browse the repository at this point in the history
* feat: add helper functions for Google.Protobuf modules

* update documentation

* Address review comments

- Remove `to_time_unit/1` and `from_time_unit/2`
- Fix type reference in documentation
- Add better example

---------

Co-authored-by: v0idpwn <[email protected]>
  • Loading branch information
btkostner and v0idpwn authored Dec 6, 2024
1 parent 47553f9 commit 8e89af1
Show file tree
Hide file tree
Showing 2 changed files with 238 additions and 0 deletions.
137 changes: 137 additions & 0 deletions lib/google/protobuf.ex
Original file line number Diff line number Diff line change
@@ -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
101 changes: 101 additions & 0 deletions test/google/protobuf_test.exs
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 8e89af1

Please sign in to comment.