Repository: elixir-protobuf/protobuf
Pull request #354 (2023-06-17 20:45:43)

Add helper functions for Google.Protobuf modules
Replaces https://github.com/elixir-protobuf/google-protos/pull/21. Should be merged after https://github.com/elixir-protobuf/protobuf/pull/353.

v0idpwn: @btkostner, would you like to address Andrea's comments, or would you prefer for me to take this one over? Happy either way :)
btkostner: Go for it! I'm on a deadline for work right now so I doubt I'll have free time to work on this 👍

Patch:
diff --git a/lib/google/protobuf.ex b/lib/google/protobuf.ex
new file mode 100644
index 0000000..6e1812b
--- /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/lib/protobuf/json/decode.ex b/lib/protobuf/json/decode.ex
index 1fbaf23..f12df6c 100644
--- a/lib/protobuf/json/decode.ex
+++ b/lib/protobuf/json/decode.ex
@@ -392,6 +392,7 @@ defmodule Protobuf.JSON.Decode do
   end
 
   defp decode_float(float) when is_float(float), do: {:ok, float}
+  defp decode_float(integer) when is_integer(integer), do: {:ok, integer / 1}
   defp decode_float(string) when is_binary(string), do: parse_float(string)
   defp decode_float(_bad), do: :error
 
diff --git a/lib/protobuf/transform_module.ex b/lib/protobuf/transform_module.ex
index d8f280d..0959753 100644
--- a/lib/protobuf/transform_module.ex
+++ b/lib/protobuf/transform_module.ex
@@ -13,8 +13,6 @@ defmodule Protobuf.TransformModule do
       defmodule StringMessage do
         use Protobuf, syntax: :proto3
 
-        defstruct [:value]
-
         field :value, 1, type: :string
 
         def transform_module(), do: MyTransformModule


Test patch:
diff --git a/test/google/protobuf_test.exs b/test/google/protobuf_test.exs
new file mode 100644
index 0000000..427e87f
--- /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
diff --git a/test/protobuf/json/decode_test.exs b/test/protobuf/json/decode_test.exs
index 924e6cc..8684df0 100644
--- a/test/protobuf/json/decode_test.exs
+++ b/test/protobuf/json/decode_test.exs
@@ -238,6 +238,10 @@ defmodule Protobuf.JSON.DecodeTest do
       data = %{"float" => 1.23e3, "double" => 1.23e-2}
       decoded = %Scalars{float: 1230.0, double: 0.0123}
       assert decode(data, Scalars) == {:ok, decoded}
+
+      data = %{"float" => 5, "double" => 9}
+      decoded = %Scalars{float: 5.0, double: 9.0}
+      assert decode(data, Scalars) == {:ok, decoded}
     end
 
     test "constants are valid" do
@@ -279,8 +283,8 @@ defmodule Protobuf.JSON.DecodeTest do
     end
 
     test "other types are invalid" do
-      data = %{"float" => 5}
-      msg = "Field 'float' has an invalid floating point (5)"
+      data = %{"float" => <<1::15>>}
+      msg = "Field 'float' has an invalid floating point (<<0, 1::size(7)>>)"
       assert decode(data, Scalars) == error(msg)
 
       data = %{"double" => true}