-
Notifications
You must be signed in to change notification settings - Fork 145
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
v0idpwn
wants to merge
5
commits into
main
Choose a base branch
from
feat/text-encoding
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Add Protobuf.Text #398
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
71ac113
Add Protobuf.Text
v0idpwn ea071b2
Refactoring and dialyzer fixes
v0idpwn 6c73550
Use `Inspect.Algebra` in Protobuf.Text
v0idpwn a959410
Address review comments, misc improvements
v0idpwn 3b5374e
Refactoring/remove dead code
v0idpwn File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
|
||
defp skip_field?(_, _, _), do: false | ||
|
||
defp inspect_opts(), do: %Inspect.Opts{limit: :infinity} | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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...)There was a problem hiding this comment.
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