Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Protobuf.Text #398

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions lib/protobuf/text.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
defmodule Protobuf.Text do
@moduledoc """
Text encoding of Protobufs

Compliant with the
[textproto spec](https://protobuf.dev/reference/protobuf/textformat-spec/),
but without extensions or `Google.Protobuf.Any` support (yet).

Useful for inspecting Protobuf data.
"""

alias Protobuf.FieldProps
alias Protobuf.MessageProps
alias Inspect.Algebra

@doc """
Encodes a Protobuf struct to text.

Accepts the following options:

- `:max_line_width` - maximum line width, in columns. Defaults to 80. Lines
may still be bigger than the limit, depending on the data.

Doesn't perform type validations. If input data is invalid, it produces
undecodable output.
"""
@spec encode(struct(), Keyword.t()) :: binary()
def encode(%mod{} = struct, opts \\ []) do
max_line_width = Keyword.get(opts, :max_line_width, 80)
message_props = mod.__message_props__()

struct
|> transform_module(mod)
|> encode_struct(message_props)
|> Algebra.format(max_line_width)
|> IO.iodata_to_binary()
end

@spec encode_struct(struct() | nil, MessageProps.t()) :: Algebra.t()
defp encode_struct(%_{} = struct, message_props) do
%{syntax: syntax} = message_props

fields =
struct
|> Map.drop([:__unknown_fields__, :__struct__, :__pb_extensions__])
|> Enum.sort()

fun = fn value, _opts ->
encode_struct_field(value, syntax, message_props)
end

Algebra.container_doc("{", fields, "}", inspect_opts(), fun, break: :strict)
end

@spec encode_struct_field({atom(), term()}, :proto2 | :proto3, MessageProps.t()) :: Algebra.t()
defp encode_struct_field({name, value}, syntax, message_props) do
case Enum.find(message_props.field_props, fn {_, prop} -> prop.name_atom == name end) do
{_fnum, field_prop} ->
if skip_field?(syntax, value, field_prop) do
Algebra.empty()
else
Algebra.concat([
to_string(name),
": ",
encode_value(value, syntax, field_prop)
])
end

nil ->
if Enum.any?(message_props.oneof, fn {oneof_name, _} -> name == oneof_name end) do
case value do
{field_name, field_value} ->
encode_struct_field({field_name, field_value}, syntax, message_props)

nil ->
Algebra.empty()

_ ->
raise "invalid value for oneof `#{inspect(name)}`: #{inspect(value)}"
end
else
raise "unknown field #{inspect(name)}"
end
end
end

@spec encode_value(term(), :proto2 | :proto3, FieldProps.t()) :: Algebra.t()
defp encode_value(value, syntax, %{repeated?: true} = field_prop) when is_list(value) do
fun = fn val, _opts -> encode_value(val, syntax, field_prop) end
Algebra.container_doc("[", value, "]", inspect_opts(), fun, break: :strict)
end

defp encode_value(value, syntax, %{map?: true, repeated?: false} = field_prop) do
as_list =
Enum.map(value, fn {k, v} -> struct(field_prop.type, key: k, value: v) end)

encode_value(as_list, syntax, %{field_prop | repeated?: true})
end

defp encode_value(value, _syntax, %{embedded?: true, type: mod}) do
value
|> transform_module(mod)
|> encode_struct(mod.__message_props__())
end

defp encode_value(nil, :proto2, %FieldProps{required?: true, name_atom: name}) do
raise "field #{inspect(name)} is required"
end

defp encode_value(value, _, _) when is_atom(value) do
to_string(value)
end

defp encode_value(value, _, _) do
inspect(value)
end

defp transform_module(message, module) do
if transform_module = module.transform_module() do
transform_module.encode(message, module)
else
message
end
end

# Copied from Protobuf.Encoder. Should it be shared?
defp skip_field?(_syntax, [], _prop), do: true
defp skip_field?(_syntax, val, _prop) when is_map(val), do: map_size(val) == 0
defp skip_field?(:proto2, nil, %FieldProps{optional?: optional?}), do: optional?
defp skip_field?(:proto2, value, %FieldProps{default: value, oneof: nil}), do: true
defp skip_field?(:proto3, val, %FieldProps{proto3_optional?: true}), do: is_nil(val)

defp skip_field?(:proto3, nil, _prop), do: true
defp skip_field?(:proto3, 0, %FieldProps{oneof: nil}), do: true
defp skip_field?(:proto3, +0.0, %FieldProps{oneof: nil}), do: true
defp skip_field?(:proto3, "", %FieldProps{oneof: nil}), do: true
defp skip_field?(:proto3, false, %FieldProps{oneof: nil}), do: true

# This is actually new. Should it be ported to Protobuf.Encoder?
defp skip_field?(:proto3, value, %FieldProps{type: {:enum, enum_mod}, oneof: nil}) do
enum_props = enum_mod.__message_props__()
[first_tag | _] = enum_props.ordered_tags
%{name_atom: name_atom, fnum: fnum} = enum_props.field_props[first_tag]
value == name_atom or value == fnum
end
Comment on lines +139 to +145
Copy link
Collaborator Author

@v0idpwn v0idpwn Feb 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried this on Protobuf.Encoder and it broke conformance tests, despite the fact that in the docs it seems like this is the expected behaviour there as well (just as we skip 0 for non-optional integers, I'd expect it to skip the default enum value...)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll investigate this further


defp skip_field?(_, _, _), do: false

defp inspect_opts(), do: %Inspect.Opts{limit: :infinity}
end
158 changes: 158 additions & 0 deletions test/protobuf/text_test.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
defmodule Protobuf.TextTest do
use ExUnit.Case, async: true

alias Protobuf.Text

test "default fields aren't encoded" do
# proto3
assert "{}" == Text.encode(%TestMsg.Foo{})

# proto2
assert "{a: 1}" == Text.encode(%TestMsg.Foo2{a: 1})
end

test "encoding basic types" do
assert ~S({a: 1, b: "foo"}) == Text.encode(%TestMsg.Foo.Bar{a: 1, b: "foo"})
end

test "encoding enums" do
assert ~S({j: D}) == Text.encode(%TestMsg.Foo{j: :D})
end

test "encoding repeated" do
assert ~S({g: [1, 2, 3, 4, 5, 6]}) == Text.encode(%TestMsg.Foo{g: [1, 2, 3, 4, 5, 6]})
end

test "encoding large repeated breaks lines" do
result = Text.encode(%TestMsg.Foo{a: 1, g: List.duplicate(1_111_111_111, 7), j: :D})

assert result == """
{
a: 1,
g: [
1111111111,
1111111111,
1111111111,
1111111111,
1111111111,
1111111111,
1111111111
],
j: D
}\
"""
end

test "encoding nested structs" do
result = Text.encode(%TestMsg.Foo{e: nil})
assert result == ~S({})

result = Text.encode(%TestMsg.Foo{e: %TestMsg.Foo.Bar{}})
assert result == ~S({e: {}})

result = Text.encode(%TestMsg.Foo{e: %TestMsg.Foo.Bar{a: 1, b: "Hello"}})

assert result == ~S({e: {a: 1, b: "Hello"}})
end

test "encoding large nested structs" do
result =
Text.encode(%TestMsg.Foo{
a: 1,
e: %TestMsg.Foo.Bar{a: 1, b: String.duplicate("Hello", 15)},
h: [
%TestMsg.Foo.Bar{a: 5},
%TestMsg.Foo.Bar{a: 1, b: String.duplicate("Hello", 15)},
%TestMsg.Foo.Bar{a: 7}
]
})

assert result == """
{
a: 1,
e: {
a: 1,
b: "HelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHello"
},
h: [
{a: 5},
{
a: 1,
b: "HelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHello"
},
{a: 7}
]
}\
"""
end

test "respects max line width option" do
input = %TestMsg.Foo{
a: 1,
e: %TestMsg.Foo.Bar{a: 1, b: String.duplicate("Hello", 15)},
h: [
%TestMsg.Foo.Bar{a: 5, b: "hi"},
%TestMsg.Foo.Bar{a: 1, b: String.duplicate("Hello", 15)},
%TestMsg.Foo.Bar{a: 7}
]
}

result_with_large_limit = Text.encode(input, max_line_width: 1000)

assert result_with_large_limit == """
{\
a: 1, \
e: {\
a: 1, \
b: "HelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHello"\
}, \
h: [\
{a: 5, b: "hi"}, \
{\
a: 1, \
b: "HelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHello"\
}, \
{a: 7}\
]\
}\
"""

result_with_small_limit = Text.encode(input, max_line_width: 10)

assert result_with_small_limit == """
{
a: 1,
e: {
a: 1,
b: "HelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHello"
},
h: [
{
a: 5,
b: "hi"
},
{
a: 1,
b: "HelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHelloHello"
},
{a: 7}
]
}\
"""
end

test "encoding oneofs" do
assert "{a: 50}" == Text.encode(%TestMsg.Oneof{first: {:a, 50}})
end

test "encoding maps" do
assert ~S({l: [{key: "a", value: 1}, {key: "b", value: 2}]}) ==
Text.encode(%TestMsg.Foo{l: %{"a" => 1, "b" => 2}})
end

test "raises on absent proto2 required" do
assert_raise RuntimeError, "field :a is required", fn ->
Text.encode(%TestMsg.Foo2{})
end
end
end
Loading