Skip to content

Commit 9e00a97

Browse files
committed
feat: Add unpack/2 to Protobuf.Any
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent 5b97f75 commit 9e00a97

6 files changed

Lines changed: 169 additions & 4 deletions

File tree

lib/protobuf/any.ex

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
defmodule Protobuf.Any do
2-
32
@moduledoc """
43
Provides functions for working with the `google.protobuf.Any` type.
54
"""
@@ -28,6 +27,60 @@ defmodule Protobuf.Any do
2827
}
2928
end
3029

30+
@doc """
31+
Unpacks a `Google.Protobuf.Any` message using a custom type provider.
32+
33+
The type provider module must implement the `Protobuf.Any.TypeProvider` behaviour,
34+
which defines how to convert type URLs to their corresponding message modules.
35+
36+
## Example
37+
38+
defmodule MyApp.AnyTypeProvider do
39+
@behaviour Protobuf.Any.TypeProvider
40+
41+
def to_module("type.googleapis.com/google.protobuf.Duration"), do: {:ok, Google.Protobuf.Duration}
42+
def to_module("type.googleapis.com/myapp.events.UserCreated"), do: {:ok, MyApp.Events.UserCreated}
43+
def to_module("myapp.internal/myapp.events.OrderPlaced"), do: {:ok, MyApp.Events.OrderPlaced}
44+
def to_module(_), do: {:error, "Unknown type_url"}
45+
end
46+
47+
any = %Google.Protobuf.Any{
48+
type_url: "type.googleapis.com/myapp.events.UserCreated",
49+
value: <<...>>
50+
}
51+
Protobuf.Any.unpack(any, MyApp.AnyTypeProvider)
52+
#=> {:ok, %MyApp.Events.UserCreated{...}}
53+
"""
54+
@spec unpack(Google.Protobuf.Any.t(), module()) ::
55+
{:ok, struct()} | {:error, reason :: any()}
56+
def unpack(%Google.Protobuf.Any{type_url: type_url, value: value}, type_provider) do
57+
with {:ok, module} <- resolve_module(type_provider, type_url) do
58+
decode(module, value)
59+
end
60+
end
61+
62+
defp resolve_module(type_provider, type_url) do
63+
case type_provider.to_module(type_url) do
64+
{:ok, module} when is_atom(module) ->
65+
{:ok, module}
66+
67+
{:ok, other} ->
68+
{:error,
69+
ArgumentError.exception(
70+
"expected type provider to return an atom module, got: #{inspect(other)}"
71+
)}
72+
73+
{:error, _} = error ->
74+
error
75+
end
76+
end
77+
78+
defp decode(module, value) do
79+
{:ok, module.decode(value)}
80+
rescue
81+
error -> {:error, error}
82+
end
83+
3184
@doc false
3285
@spec type_url_to_module(String.t()) :: module()
3386
def type_url_to_module(type_url) when is_binary(type_url) do

lib/protobuf/any/type_provider.ex

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
defmodule Protobuf.Any.TypeProvider do
2+
@moduledoc """
3+
Behaviour for resolving type URLs to Protobuf message modules for `Google.Protobuf.Any`.
4+
5+
Implementations of this behaviour define how to convert a type URL to its corresponding module,
6+
allowing customization of prefix handling and message routing.
7+
8+
## Example
9+
10+
defmodule MyApp.AnyTypeProvider do
11+
@behaviour Protobuf.Any.TypeProvider
12+
13+
def to_module("type.googleapis.com/google.protobuf.Duration"), do: {:ok, Google.Protobuf.Duration}
14+
def to_module("type.googleapis.com/myapp.events.UserCreated"), do: {:ok, MyApp.Events.UserCreated}
15+
def to_module("myapp.internal/myapp.events.OrderPlaced"), do: {:ok, MyApp.Events.OrderPlaced}
16+
def to_module(_), do: {:error, "Unknown type_url"}
17+
end
18+
19+
Then use it with `Protobuf.Any.unpack/2`:
20+
21+
Protobuf.Any.unpack(any_message, MyApp.AnyTypeProvider)
22+
#=> {:ok, decoded_struct}
23+
"""
24+
25+
@doc """
26+
Convert a type URL to its corresponding Protobuf message module.
27+
28+
Should return `{:ok, module}` if the type URL is recognized, or
29+
`{:error, reason}` if it cannot be resolved.
30+
"""
31+
@callback to_module(type_url :: String.t()) :: {:ok, module()} | {:error, reason :: any()}
32+
end

test/protobuf/any_test.exs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,64 @@ defmodule Protobuf.AnyTest do
1919
end
2020
end
2121

22+
describe "unpack/2" do
23+
test "unpacks a message from Any" do
24+
message = %Google.Protobuf.Duration{seconds: 42}
25+
any = Protobuf.Any.pack(message)
26+
27+
assert {:ok, unpacked} = Protobuf.Any.unpack(any, Protobuf.AnyTypeProviderSupport)
28+
assert unpacked == message
29+
end
30+
31+
test "returns error for unknown type_url" do
32+
any = %Google.Protobuf.Any{
33+
type_url: "custom.prefix/unknown.Type",
34+
value: <<>>
35+
}
36+
37+
assert {:error, "Unknown type_url"} =
38+
Protobuf.Any.unpack(any, Protobuf.AnyTypeProviderSupport)
39+
end
40+
41+
test "returns error for unmapped type_url" do
42+
any = %Google.Protobuf.Any{
43+
type_url: "type.googleapis.com/unknown.Message",
44+
value: <<>>
45+
}
46+
47+
assert {:error, "Unknown type_url"} =
48+
Protobuf.Any.unpack(any, Protobuf.AnyTypeProviderSupport)
49+
end
50+
51+
test "returns error when type provider returns a non-atom module" do
52+
defmodule BadTypeProvider do
53+
@behaviour Protobuf.Any.TypeProvider
54+
55+
def to_module(_type_url), do: {:ok, "not_an_atom"}
56+
end
57+
58+
any = %Google.Protobuf.Any{
59+
type_url: "type.googleapis.com/google.protobuf.Duration",
60+
value: <<>>
61+
}
62+
63+
assert {:error, %ArgumentError{message: message}} =
64+
Protobuf.Any.unpack(any, BadTypeProvider)
65+
66+
assert message =~ "expected type provider to return an atom module"
67+
end
68+
69+
test "returns error when decode fails" do
70+
any = %Google.Protobuf.Any{
71+
type_url: "type.googleapis.com/google.protobuf.Duration",
72+
value: <<255, 255, 255>>
73+
}
74+
75+
assert {:error, %Protobuf.DecodeError{}} =
76+
Protobuf.Any.unpack(any, Protobuf.AnyTypeProviderSupport)
77+
end
78+
end
79+
2280
describe "type_url_to_module/1" do
2381
test "returns the module for a valid type_url" do
2482
assert Protobuf.Any.type_url_to_module("type.googleapis.com/google.protobuf.Duration") ==

test/protobuf/protoc/cli_integration_test.exs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,8 @@ defmodule Protobuf.Protoc.CLIIntegrationTest do
178178
proto_path
179179
])
180180

181-
assert [mod] = compile_file_and_clean_modules_on_exit("#{tmp_dir}/my_type/timestamp_wrapper.pb.ex")
181+
assert [mod] =
182+
compile_file_and_clean_modules_on_exit("#{tmp_dir}/my_type/timestamp_wrapper.pb.ex")
182183

183184
assert mod == MyType.TimestampWrapper
184185
assert Map.fetch!(mod.__message_props__().field_props, 1).type == Google.Protobuf.Timestamp

test/support/any_type_provider.ex

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
defmodule Protobuf.AnyTypeProviderSupport do
2+
@moduledoc false
3+
4+
@behaviour Protobuf.Any.TypeProvider
5+
6+
@mappings %{
7+
"type.googleapis.com/google.protobuf.Duration" => Google.Protobuf.Duration,
8+
"type.googleapis.com/test.Request.SomeGroup" => My.Test.Request.SomeGroup
9+
}
10+
11+
def to_module(type_url) do
12+
case Map.fetch(@mappings, type_url) do
13+
{:ok, module} -> {:ok, module}
14+
:error -> {:error, "Unknown type_url"}
15+
end
16+
end
17+
end

test/test_helper.exs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,17 @@ defmodule Protobuf.TestHelpers do
3636
path = Path.join(dir, entry)
3737

3838
cond do
39-
entry == filename -> {:ok, path}
39+
entry == filename ->
40+
{:ok, path}
41+
4042
File.dir?(path) ->
4143
case find_file_in_dir(path, filename) do
4244
{:ok, found_path} -> {:ok, found_path}
4345
:not_found -> nil
4446
end
45-
true -> nil
47+
48+
true ->
49+
nil
4650
end
4751
end)
4852

0 commit comments

Comments
 (0)