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 native Elixir JSON support #394

Merged
merged 6 commits into from
Jan 13, 2025
Merged
Show file tree
Hide file tree
Changes from 5 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
4 changes: 3 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ jobs:
matrix:
include:
- otp: 27.0
elixir: 1.17.0
elixir: 1.18.1
- otp: 27.0
elixir: 1.17.3
- otp: 24.3
elixir: 1.12.3

Expand Down
28 changes: 13 additions & 15 deletions lib/protobuf/json.ex
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ defmodule Protobuf.JSON do
exist, decoding will raise an error.
"""

alias Protobuf.JSON.{Encode, EncodeError, Decode, DecodeError}
alias Protobuf.JSON.{Encode, EncodeError, Decode, DecodeError, JSONLibrary}

@type encode_opt() ::
{:use_proto_names, boolean()}
Expand Down Expand Up @@ -202,11 +202,7 @@ defmodule Protobuf.JSON do
@spec encode_to_iodata(struct, [encode_opt]) ::
{:ok, iodata()} | {:error, EncodeError.t() | Exception.t()}
def encode_to_iodata(%_{} = struct, opts \\ []) when is_list(opts) do
if jason = load_jason() do
with {:ok, map} <- to_encodable(struct, opts), do: jason.encode_to_iodata(map)
else
{:error, EncodeError.new(:no_json_lib)}
end
with {:ok, map} <- to_encodable(struct, opts), do: JSONLibrary.encode_to_iodata(map)
end

@doc """
Expand Down Expand Up @@ -273,8 +269,11 @@ defmodule Protobuf.JSON do
@spec decode!(iodata, module) :: struct | no_return
def decode!(iodata, module) do
case decode(iodata, module) do
{:ok, json} -> json
{:error, error} -> raise error
{:ok, json} ->
json

{:error, error} ->
raise error
v0idpwn marked this conversation as resolved.
Show resolved Hide resolved
end
end

Expand Down Expand Up @@ -311,11 +310,12 @@ defmodule Protobuf.JSON do
"""
@spec decode(iodata, module) :: {:ok, struct} | {:error, DecodeError.t() | Exception.t()}
def decode(iodata, module) when is_atom(module) do
if jason = load_jason() do
with {:ok, json_data} <- jason.decode(iodata),
do: from_decoded(json_data, module)
else
{:error, DecodeError.new(:no_json_lib)}
case JSONLibrary.decode(iodata) do
{:ok, json_data} ->
from_decoded(json_data, module)

{:error, exception} ->
{:error, exception}
end
end

Expand Down Expand Up @@ -344,6 +344,4 @@ defmodule Protobuf.JSON do
catch
error -> {:error, DecodeError.new(error)}
end

defp load_jason, do: Code.ensure_loaded?(Jason) and Jason
end
15 changes: 15 additions & 0 deletions lib/protobuf/json/decode_error.ex
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,19 @@ defmodule Protobuf.JSON.DecodeError do
def new({:bad_repeated, field, value}) do
%__MODULE__{message: "Repeated field '#{field}' expected a list, got #{inspect(value)}"}
end

def new({:unexpected_end, position}) do
%__MODULE__{message: "Unexpected end at position #{inspect(position)}"}
end

def new({:invalid_byte, position, byte}) do
%__MODULE__{message: "Invalid byte at position #{inspect(position)}, byte: #{inspect(byte)}"}
end

def new({:unexpected_sequence, position, sequence}) do
%__MODULE__{
message:
"Unexpected sequence at position #{inspect(position)}, sequence: #{inspect(sequence)}"
}
end
end
32 changes: 32 additions & 0 deletions lib/protobuf/json/json_library.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
defmodule Protobuf.JSON.JSONLibrary do
@moduledoc false
# Uses `JSON` for Elixir >= 1.18, Jason if Elixir < 1.18 and Jason available,
# or returns error otherwise

cond do
Code.ensure_loaded?(JSON) ->
def encode_to_iodata(encodable) do
try do
{:ok, JSON.encode_to_iodata!(encodable)}
rescue
exception ->
{:error, exception}
end
end

def decode(data) do
case JSON.decode(data) do
{:ok, decoded} -> {:ok, decoded}
{:error, error} -> {:error, Protobuf.JSON.DecodeError.new(error)}
end
end

Code.ensure_loaded?(Jason) ->
def encode_to_iodata(encodable), do: Jason.encode_to_iodata(encodable)
def decode(data), do: Jason.decode(data)

true ->
def encode_to_iodata(_), do: {:error, EncodeError.new(:no_json_lib)}
def decode(_), do: {:error, EncodeError.new(:no_json_lib)}
end
end
16 changes: 11 additions & 5 deletions test/protobuf/builder_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,21 @@
end

test "raises an error for non-list repeated embedded msgs" do
assert_raise Protocol.UndefinedError,
~r/protocol Enumerable not implemented for %TestMsg.Foo.Bar/,
fn ->
Foo.new(h: Foo.Bar.new())
end
# TODO: remove conditional once we support only Elixir 1.18+
message =
if System.version() >= "1.18.0" do
~r/protocol Enumerable not implemented for type TestMsg.Foo.Ba/
else
~r/protocol Enumerable not implemented for %TestMsg.Foo.Bar/
end

assert_raise Protocol.UndefinedError, message, fn ->
Foo.new(h: Foo.Bar.new())

Check warning on line 57 in test/protobuf/builder_test.exs

View workflow job for this annotation

GitHub Actions / Test (Elixir 1.12.3 | Erlang/OTP 24.3)

TestMsg.Foo.Bar.new/0 is deprecated. Build the struct by hand with %MyMessage{...} or use struct/1
end
end

test "builds correct message for non matched struct" do
foo = Foo.new(Foo2.new(non_matched: 1))

Check warning on line 62 in test/protobuf/builder_test.exs

View workflow job for this annotation

GitHub Actions / Test (Elixir 1.12.3 | Erlang/OTP 24.3)

TestMsg.Foo2.new/1 is deprecated. Build the struct by hand with %MyMessage{...} or use struct/2

assert_raise Protobuf.EncodeError, fn ->
Foo.encode(foo)
Expand All @@ -61,7 +67,7 @@
end

test "ignores structs with transform modules" do
assert ContainsTransformModule.new(field: 123) == %ContainsTransformModule{field: 123}

Check warning on line 70 in test/protobuf/builder_test.exs

View workflow job for this annotation

GitHub Actions / Test (Elixir 1.12.3 | Erlang/OTP 24.3)

TestMsg.ContainsTransformModule.new/1 is deprecated. Build the struct by hand with %MyMessage{...} or use struct/2
end
end

Expand Down
4 changes: 3 additions & 1 deletion test/protobuf/encoder_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,9 @@ defmodule Protobuf.EncoderTest do
Encoder.encode(%TestMsg.Foo{c: 123})
end

message = ~r/protocol Enumerable not implemented for 123/
# For Elixir 1.18+ it's `type Integer`, before, it was just `123`
# TODO: fix once we require Elixir 1.18+
message = ~r/protocol Enumerable not implemented for (123|type Integer)/

assert_raise Protobuf.EncodeError, message, fn ->
Encoder.encode(%TestMsg.Foo{e: 123})
Expand Down
25 changes: 19 additions & 6 deletions test/protobuf/json_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,33 @@ defmodule Protobuf.JSONTest do

test "encoding string field with invalid UTF-8 data" do
message = %Scalars{string: " \xff "}
assert {:error, %Jason.EncodeError{}} = Protobuf.JSON.encode(message)
assert {:error, exception} = Protobuf.JSON.encode(message)
assert is_exception(exception)
end

test "decoding string field with invalid UTF-8 data" do
json = ~S|{"string":" \xff "}|
assert {:error, %Jason.DecodeError{}} = Protobuf.JSON.decode(json, Scalars)
assert {:error, exception} = Protobuf.JSON.decode(json, Scalars)
assert is_exception(exception)
end

describe "bang variants of encode and decode" do
test "decode!/2" do
json = ~S|{"string":" \xff "}|
# TODO: remove Jason when we require Elixir 1.18
if Code.ensure_loaded?(JSON) do
test "decode!/2" do
json = ~S|{"string":" \xff "}|

assert_raise Jason.DecodeError, fn ->
Protobuf.JSON.decode!(json, Scalars)
assert_raise Protobuf.JSON.DecodeError, fn ->
Protobuf.JSON.decode!(json, Scalars)
end
end
else
test "decode!/2" do
json = ~S|{"string":" \xff "}|

assert_raise Jason.DecodeError, fn ->
Protobuf.JSON.decode!(json, Scalars)
end
end
end
end
Expand Down
Loading