Skip to content

Commit

Permalink
feat!: configure JSON library at compile time
Browse files Browse the repository at this point in the history
  • Loading branch information
ahamez committed Nov 26, 2024
1 parent 97745a6 commit 3effd4e
Show file tree
Hide file tree
Showing 10 changed files with 117 additions and 164 deletions.
1 change: 1 addition & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# 2.0.0 (unreleased)
- BREAKING CHANGE: Remove generated defs/0 function
- BREAKING CHANGE: Remove Protox.Encode.encode/1 and Protox.Encode.encode!/1
- BREAKING CHANGE: The JSON library is now configurable via the `:json_library` option at compile time
- Drop support for Elixir < 1.15

# 1.7.8
Expand Down
58 changes: 18 additions & 40 deletions lib/protox.ex
Original file line number Diff line number Diff line change
Expand Up @@ -159,32 +159,21 @@ defmodule Protox do
- `input` could not be decoded to JSON; `reason` is a `Protox.JsonDecodingError` error
## JSON library configuration
The default library to decode JSON is [`Jason`](https://github.com/michalmuskala/jason).
However, you can chose to use [`Poison`](https://github.com/devinus/poison):
iex> Protox.json_decode("{\\"a\\":\\"BAR\\"}", Namespace.Fiz.Foo, json_decoder: Poison)
{:ok, %Namespace.Fiz.Foo{__uf__: [], a: :BAR, b: %{}}}
You can also use another library as long as it exports an `decode!` function. You can easily
create a module to wrap a library that would not have this interface (like [`jiffy`](https://github.com/davisp/jiffy)):
defmodule Jiffy do
def decode!(input) do
:jiffy.decode(input, [:return_maps, :use_nil])
end
end
TODO: document json library configuration
"""
@doc since: "1.6.0"
@spec json_decode(iodata(), atom(), keyword()) :: {:ok, struct()} | {:error, any()}
def json_decode(input, message_module, opts \\ []) do
message_module.json_decode(input, opts)
@doc since: "2.0.0"
@spec json_decode(iodata(), atom()) :: {:ok, struct()} | {:error, any()}
def json_decode(input, message_module) do
message_module.json_decode(input)
end

@doc """
Throwing version of `json_decode/2`.
"""
@doc since: "1.6.0"
@spec json_decode!(iodata(), atom(), keyword()) :: struct() | no_return()
def json_decode!(input, message_module, opts \\ []) do
message_module.json_decode!(input, opts)
@doc since: "2.0.0"
@spec json_decode!(iodata(), atom()) :: struct() | no_return()
def json_decode!(input, message_module) do
message_module.json_decode!(input)
end

@doc """
Expand All @@ -211,36 +200,25 @@ defmodule Protox do
"{\\"msgK\\":{\\"2\\":\\"bar\\",\\"1\\":\\"foo\\"}}"
## JSON library configuration
The default library to encode values (i.e. mostly to escape strings) to JSON is [`Jason`](https://github.com/michalmuskala/jason).
However, you can chose to use [`Poison`](https://github.com/devinus/poison):
iex> msg = %Namespace.Fiz.Foo{a: :BAR}
iex> Protox.json_encode(msg, json_encoder: Poison)
{:ok, ["{", ["\\"a\\"", ":", "\\"BAR\\""], "}"]}
You can also use another library as long as it exports an `encode!` function, which is expected to return objects as maps and `nil`
to represent `null`.
You can easily create a module to wrap a library that would not have this interface (like [`jiffy`](https://github.com/davisp/jiffy)):
defmodule Jiffy do
defdelegate encode!(msg), to: :jiffy, as: :encode
end
TODO: document the json library configuration
## Encoding specifications
See https://developers.google.com/protocol-buffers/docs/proto3#json for the specifications
of the encoding.
"""
@doc since: "1.6.0"
@spec json_encode(struct(), keyword()) :: {:ok, iodata()} | {:error, any()}
def json_encode(msg, opts \\ []) do
msg.__struct__.json_encode(msg, opts)
@doc since: "2.0.0"
@spec json_encode(struct()) :: {:ok, iodata()} | {:error, any()}
def json_encode(msg) do
msg.__struct__.json_encode(msg)
end

@doc """
Throwing version of `json_encode/1`.
"""
@doc since: "1.6.0"
@spec json_encode!(struct(), keyword()) :: iodata() | no_return()
def json_encode!(msg, opts \\ []) do
msg.__struct__.json_encode!(msg, opts)
@doc since: "2.0.0"
@spec json_encode!(struct()) :: iodata() | no_return()
def json_encode!(msg) do
msg.__struct__.json_encode!(msg)
end

# -- Private
Expand Down
35 changes: 17 additions & 18 deletions lib/protox/define_message.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ defmodule Protox.DefineMessage do

def define(messages, opts \\ []) do
keep_unknown_fields = Keyword.get(opts, :keep_unknown_fields, true)
json_library = Keyword.get(opts, :json_library, Jason)

for msg = %Protox.Message{} <- messages do
fields = Enum.sort(msg.fields, &(&1.tag < &2.tag))
Expand All @@ -16,7 +17,7 @@ defmodule Protox.DefineMessage do
unknown_fields_funs = make_unknown_fields_funs(unknown_fields, keep_unknown_fields)
required_fields_fun = make_required_fields_fun(required_fields)
fields_access_funs = make_fields_access_funs(fields)
json_funs = make_json_funs(msg.name)
json_funs = make_json_funs(msg.name, json_library)
default_fun = make_default_funs(fields)
syntax_fun = make_syntax_fun(msg.syntax)
file_options_fun = make_file_options_fun(msg)
Expand Down Expand Up @@ -93,44 +94,42 @@ defmodule Protox.DefineMessage do
end
end

defp make_json_funs(msg_name) do
defp make_json_funs(msg_name, json_library) do
json_library_wrapper = Protox.JsonLibrary.get_wrapper(json_library)

quote do
@spec json_decode(iodata(), keyword()) :: {:ok, struct()} | {:error, any()}
def json_decode(input, opts \\ []) do
@spec json_decode(iodata()) :: {:ok, struct()} | {:error, any()}
def json_decode(input) do
try do
{:ok, json_decode!(input, opts)}
{:ok, json_decode!(input)}
rescue
e in Protox.JsonDecodingError ->
{:error, e}
end
end

@spec json_decode!(iodata(), keyword()) :: struct() | no_return()
def json_decode!(input, opts \\ []) do
json_library_wrapper = Protox.JsonLibrary.get_library(opts, :decode)

@spec json_decode!(iodata()) :: struct() | no_return()
def json_decode!(input) do
Protox.JsonDecode.decode!(
input,
unquote(msg_name),
&json_library_wrapper.decode!(&1)
&unquote(json_library_wrapper).decode!(&1)
)
end

@spec json_encode(struct(), keyword()) :: {:ok, iodata()} | {:error, any()}
def json_encode(msg, opts \\ []) do
@spec json_encode(struct()) :: {:ok, iodata()} | {:error, any()}
def json_encode(msg) do
try do
{:ok, json_encode!(msg, opts)}
{:ok, json_encode!(msg)}
rescue
e in Protox.JsonEncodingError ->
{:error, e}
end
end

@spec json_encode!(struct(), keyword()) :: iodata() | no_return()
def json_encode!(msg, opts \\ []) do
json_library_wrapper = Protox.JsonLibrary.get_library(opts, :encode)

Protox.JsonEncode.encode!(msg, &json_library_wrapper.encode!(&1))
@spec json_encode!(struct()) :: iodata() | no_return()
def json_encode!(msg) do
Protox.JsonEncode.encode!(msg, &unquote(json_library_wrapper).encode!(&1))
end
end
end
Expand Down
15 changes: 15 additions & 0 deletions lib/protox/errors.ex
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,21 @@ defmodule Protox.JsonEncodingError do
end
end

defmodule Protox.JsonLibraryError do
@moduledoc """
This error is thrown when the configured JSON library is not available.
"""

defexception message: ""

@doc false
def new() do
%__MODULE__{
message: "Cannot load JSON library. Please check your project dependencies."
}
end
end

defmodule Protox.RequiredFieldsError do
@moduledoc """
This error is thrown when encoding or decoding a Protobuf 2 message
Expand Down
52 changes: 15 additions & 37 deletions lib/protox/jason.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,23 @@ defmodule Protox.Jason do
@moduledoc false
@behaviour Protox.JsonLibrary

if Code.ensure_loaded?(Jason) do
@impl true
def load(), do: :ok

@impl true
def decode!(iodata) do
try do
Jason.decode!(iodata)
rescue
e in Jason.DecodeError ->
reraise Protox.JsonDecodingError.new(Exception.message(e)), __STACKTRACE__
end
end

@impl true
def encode!(term) do
try do
Jason.encode!(term)
rescue
e in Jason.EncodeError ->
reraise Protox.JsonEncodingError.new(Exception.message(e)), __STACKTRACE__
end
end
else
@impl true
def load(), do: :error

@impl true
def decode!(_iodata) do
raise Protox.JsonDecodingError.new(
"Jason library not loaded. Please check your project dependencies."
)
@impl true
def decode!(iodata) do
try do
Jason.decode!(iodata)
rescue
e in [Jason.DecodeError, Protocol.UndefinedError] ->
reraise Protox.JsonDecodingError.new(Exception.message(e)), __STACKTRACE__
end
end

@impl true
def encode!(_term) do
raise Protox.JsonEncodingError.new(
"Jason library not loaded. Please check your project dependencies."
)
@impl true
def encode!(term) do
try do
Jason.encode!(term)
rescue
e in Jason.EncodeError ->
reraise Protox.JsonEncodingError.new(Exception.message(e)), __STACKTRACE__
end
end
end
22 changes: 5 additions & 17 deletions lib/protox/json_library.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ defmodule Protox.JsonLibrary do
The behaviour to implement when wrapping a JSON library.
"""

@callback load() :: :ok | :error

@doc """
Should wrap any exception of the underlying library in Protox.JsonDecodingError.
"""
Expand All @@ -16,21 +14,11 @@ defmodule Protox.JsonLibrary do
@callback encode!(term()) :: iodata() | no_return()

@doc false
def get_library(opts, decoding_or_encoding) do
json_library_name = Keyword.get(opts, :json_library, Jason)
json_library_wrapper = Module.concat(Protox, json_library_name)

case json_library_wrapper.load() do
:ok ->
json_library_wrapper

:error ->
message = "cannot load JSON library. Please check your project dependencies."

case decoding_or_encoding do
:decode -> raise Protox.JsonDecodingError.new(message)
:encode -> raise Protox.JsonEncodingError.new(message)
end
def get_wrapper(json_library) do
if Code.ensure_loaded?(json_library) do
Module.concat(Protox, json_library)
else
raise Protox.JsonLibraryError.new()
end
end
end
52 changes: 15 additions & 37 deletions lib/protox/poison.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,23 @@ defmodule Protox.Poison do
@moduledoc false
@behaviour Protox.JsonLibrary

if Code.ensure_loaded?(Poison) do
@impl true
def load(), do: :ok

@impl true
def decode!(iodata) do
try do
Poison.decode!(iodata)
rescue
e in [Poison.DecodeError, Poison.ParseError] ->
reraise Protox.JsonDecodingError.new(Exception.message(e)), __STACKTRACE__
end
end

@impl true
def encode!(term) do
try do
Poison.encode!(term)
rescue
e in Poison.EncodeError ->
reraise Protox.JsonEncodingError.new(Exception.message(e)), __STACKTRACE__
end
end
else
@impl true
def load(), do: :error

@impl true
def decode!(_iodata) do
raise Protox.JsonDecodingError.new(
"Poison library not loaded. Please check your project dependencies."
)
@impl true
def decode!(iodata) do
try do
Poison.decode!(iodata)
rescue
e in [Poison.DecodeError, Poison.ParseError] ->
reraise Protox.JsonDecodingError.new(Exception.message(e)), __STACKTRACE__
end
end

@impl true
def encode!(_term) do
raise Protox.JsonEncodingError.new(
"Poison library not loaded. Please check your project dependencies."
)
@impl true
def encode!(term) do
try do
Poison.encode!(term)
rescue
e in Poison.EncodeError ->
reraise Protox.JsonEncodingError.new(Exception.message(e)), __STACKTRACE__
end
end
end
16 changes: 7 additions & 9 deletions test/protox/json_decode_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -685,26 +685,24 @@ defmodule Protox.JsonDecodeTest do
{
:ok,
%{
json: "{\"a\":null, \"b\":\"foo\", \"c\": 33}",
expected: %Sub{a: 0, b: "foo", c: 33}
json: "{\"a\":null, \"b\":\"foo\", \"c\": 33}"
}
}
end

test "Success: jason", %{json: json, expected: expected} do
assert Protox.json_decode!(json, Sub, json_library: Jason) == expected
test "Success: Jason", %{json: json} do
assert Protox.json_decode!(json, WithJason.Sub) == %WithJason.Sub{a: 0, b: "foo", c: 33}
end

test "Success: poison", %{json: json, expected: expected} do
assert Protox.json_decode!(json, Sub, json_library: Poison) == expected
test "Success: Poison", %{json: json} do
assert Protox.json_decode!(json, WithPoison.Sub) == %WithPoison.Sub{a: 0, b: "foo", c: 33}
end

test "Failure: poison", %{} do
test "Failure: Poison", %{} do
assert_raise Protox.JsonDecodingError, fn ->
Protox.json_decode!(
"{\"mapInt32Int32\": 1",
ProtobufTestMessages.Proto3.TestAllTypesProto3,
json_library: Poison
WithPoison.ProtobufTestMessages.Proto3.TestAllTypesProto3
)
end
end
Expand Down
Loading

0 comments on commit 3effd4e

Please sign in to comment.