defmodule Protox do
@moduledoc ~S'''
Use this module to generate the Elixir structs corresponding to a set of protobuf definitions
and to encode/decode instances of these structures.
## Elixit structs generation examples
From a set of files:
defmodule Dummy do
use Protox,
files: [
"./defs/foo.proto",
"./defs/bar.proto",
"./defs/baz/fiz.proto",
]
end
From a string:
defmodule Dummy do
use Protox,
schema: """
syntax = "proto3";
package fiz;
message Baz {
}
message Foo {
map<int32, Baz> b = 2;
}
"""
end
The generated modules respect the package declaration. For instance, in the above example,
both the `Fiz.Baz` and `Fiz.Foo` modules will be generated.
## Encoding/decoding
For the rest of this module documentation, we suppose the following protobuf messages are defined:
defmodule Dummy do
use Protox,
schema: """
syntax = "proto3";
package fiz;
message Baz {
}
enum Enum {
FOO = 0;
BAR = 1;
}
message Foo {
Enum a = 1;
map<int32, Baz> b = 2;
}
""",
namespace: Namespace
use Protox,
schema: """
syntax = "proto3";
message Msg {
map<int32, string> msg_k = 8;
}
"""
use Protox,
schema: """
syntax = "proto3";
message Sub {
int32 a = 1;
}
"""
end
See each function documentation to see how they are used to encode and decode protobuf messages.
'''
defmacro __using__(opts) do
{opts, _} = Code.eval_quoted(opts)
{paths, opts} = get_paths(opts)
{files, opts} = get_files(opts)
{:ok, file_descriptor_set} = Protox.Protoc.run(files, paths)
%{enums: enums, messages: messages} = Protox.Parse.parse(file_descriptor_set, opts)
quote do
unquote(make_external_resources(files))
unquote(Protox.Define.define(enums, messages, opts))
end
end
@doc """
Throwing version of `decode/2`.
"""
@doc since: "1.6.0"
@spec decode!(binary(), atom()) :: struct() | no_return()
def decode!(binary, msg_module) do
msg_module.decode!(binary)
end
@doc """
Decode a binary into a protobuf message.
## Examples
iex> binary = <<66, 7, 8, 1, 18, 3, 102, 111, 111, 66, 7, 8, 2, 18, 3, 98, 97, 114>>
iex> {:ok, msg} = Protox.decode(binary, Msg)
iex> msg
%Msg{msg_k: %{1 => "foo", 2 => "bar"}}
iex> binary = <<66, 7, 8, 1, 18, 3, 102, 111, 66, 7, 8, 2, 18, 3, 98, 97, 114>>
iex> {:error, reason} = Protox.decode(binary, Msg)
iex> reason
%Protox.IllegalTagError{message: "Field with illegal tag 0"}
"""
@doc since: "1.6.0"
@spec decode(binary(), atom()) :: {:ok, struct()} | {:error, any()}
def decode(binary, msg_module) do
msg_module.decode(binary)
end
@doc """
Throwing version of `encode/1`.
"""
@doc since: "1.6.0"
@spec encode!(struct()) :: iodata() | no_return()
def encode!(msg) do
msg.__struct__.encode!(msg)
end
@doc """
Encode a protobuf message into IO data.
## Examples
iex> msg = %Namespace.Fiz.Foo{a: 3, b: %{1 => %Namespace.Fiz.Baz{}}}
iex> {:ok, iodata} = Protox.encode(msg)
iex> :binary.list_to_bin(iodata)
<<8, 3, 18, 4, 8, 1, 18, 0>>
iex> msg = %Namespace.Fiz.Foo{a: "should not be a string"}
iex> {:error, reason} = Protox.encode(msg)
iex> reason
%Protox.EncodingError{field: :a, message: "Could not encode field :a (invalid field value)"}
"""
@doc since: "1.6.0"
@spec encode(struct()) :: {:ok, iodata()} | {:error, any()}
def encode(msg) do
msg.__struct__.encode(msg)
end
@doc """
## Errors
This function returns a tuple `{:error, reason}` if:
- `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
"""
@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)
end
@doc """
Throwing version of `json_decode/2`.
"""
@doc since: "1.6.0"
@spec json_decode!(iodata(), atom(), keyword()) :: iodata() | no_return()
def json_decode!(input, message_module, opts \\ []) do
message_module.json_decode!(input, opts)
end
@doc """
Export a proto3 message to JSON as IO data.
## Errors
This function returns a tuple `{:error, reason}` if:
- `msg` could not be encoded to JSON; `reason` is a `Protox.JsonEncodingError` error
## Examples
iex> msg = %Namespace.Fiz.Foo{a: :BAR}
iex> {:ok, iodata} = Protox.json_encode(msg)
iex> iodata
["{", ["\\"a\\"", ":", "\\"BAR\\""], "}"]
iex> msg = %Sub{a: 42}
iex> {:ok, iodata} = Protox.json_encode(msg)
iex> iodata
["{", ["\\"a\\"", ":", "42"], "}"]
iex> msg = %Msg{msg_k: %{1 => "foo", 2 => "bar"}}
iex> {:ok, iodata} = msg |> Protox.json_encode()
iex> :binary.list_to_bin(iodata)
"{\\"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
## 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)
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)
end
# -- Private
defp get_paths(opts) do
case Keyword.pop(opts, :paths) do
{nil, opts} -> get_path(opts)
{ps, opts} -> {Enum.map(ps, &Path.expand/1), opts}
end
end
defp get_path(opts) do
case Keyword.pop(opts, :path) do
{nil, opts} -> {nil, opts}
{p, opts} -> {[Path.expand(p)], opts}
end
end
defp get_files(opts) do
case Keyword.pop(opts, :schema) do
{<<text::binary>>, opts} ->
filename = "#{Base.encode16(:crypto.hash(:sha, text))}.proto"
filepath = [Mix.Project.build_path(), filename] |> Path.join() |> Path.expand()
File.write!(filepath, text)
{[filepath], opts}
{nil, opts} ->
{files, opts} = Keyword.pop(opts, :files)
{Enum.map(files, &Path.expand/1), opts}
end
end
defp make_external_resources(files) do
Enum.map(files, fn file -> quote(do: @external_resource(unquote(file))) end)
end
end