Skip to content
Closed
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
16 changes: 16 additions & 0 deletions lib/elixirpb.pb.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,25 @@ defmodule Elixirpb.FileOptions do
field :module_prefix, 1, optional: true, type: :string
end

defmodule Elixirpb.MessageOptions do
@moduledoc false
use Protobuf, syntax: :proto2

@type t :: %__MODULE__{
typespec: String.t()
}
defstruct [:typespec]

field :typespec, 1, optional: true, type: :string
end

defmodule Elixirpb.PbExtension do
@moduledoc false
use Protobuf, syntax: :proto2

extend Google.Protobuf.FileOptions, :file, 1047, optional: true, type: Elixirpb.FileOptions

extend Google.Protobuf.MessageOptions, :message, 1047,
optional: true,
type: Elixirpb.MessageOptions
end
9 changes: 7 additions & 2 deletions lib/protobuf/builder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ defmodule Protobuf.Builder do
v =
if f_props.embedded? do
if f_props.repeated? do
Enum.map(v, fn i -> f_props.type.new(i) end)
Enum.map(v, &protobuf_or_term(&1, f_props.type))
else
f_props.type.new(v)
protobuf_or_term(v, f_props.type)
end
else
v
Expand All @@ -86,4 +86,9 @@ defmodule Protobuf.Builder do
end
end)
end

defp protobuf_or_term(value, type),
do: if(encodable?(value), do: value, else: type.new(value))

defp encodable?(v), do: Protobuf.Encodable.impl_for(v) != Protobuf.Encodable.Any
end
33 changes: 33 additions & 0 deletions lib/protobuf/decodable.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
defprotocol Protobuf.Decodable do
@moduledoc """
Defines the contract for transformations after decode a message.

Implementing this protocol is useful to translate protobuf structs to Elixir
terms.

## Examples

defimpl Protobuf.Decodable, for: MyApp.Protobuf.Date do
def to_elixir(%MyApp.Protobuf.Date{year: year, month: month, day: day}) do
{:ok, date} = Date.new(year, month, day)
date
end
end

# later in a decoded message
proto_message.birthday
~D[1988-10-29]
"""
@fallback_to_any true

@doc """
This function will be called after decode the protobuf message binary. The
returning value will be used in place of current `term` struct.
"""
@spec to_elixir(t) :: any
def to_elixir(term)
end

defimpl Protobuf.Decodable, for: Any do
def to_elixir(term), do: term
end
8 changes: 7 additions & 1 deletion lib/protobuf/decoder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ defmodule Protobuf.Decoder do
@moduledoc false
import Protobuf.WireTypes
import Bitwise, only: [bsl: 2, bsr: 2, band: 2]

alias Protobuf.Decodable

require Logger

@max_bits 64
Expand All @@ -14,7 +17,10 @@ defmodule Protobuf.Decoder do
kvs = raw_decode_key(data, [])
%{repeated_fields: repeated_fields} = msg_props = module.__message_props__()
struct = build_struct(kvs, msg_props, module.new())
reverse_repeated(struct, repeated_fields)

struct
|> Decodable.to_elixir()
|> reverse_repeated(repeated_fields)
end

@doc false
Expand Down
34 changes: 34 additions & 0 deletions lib/protobuf/encodable.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
defprotocol Protobuf.Encodable do
@moduledoc """
Defines the contract for Elixir terms transformations before encode a message.

Implementing this protocol is useful to translate Elixir terms to protobuf
structs, works in combination with `Protobuf.Decodable`.

## Examples

defimpl Protobuf.Encodable, for: Date do
def to_protobuf(%Date{year: year, month: month, day: day}, MyApp.Protobuf.Date) do
MyApp.Protobuf.Date.new(year: year, month: month, day: day)
end
end

# later, you can use Elixir terms in your fields and those will be
# converted to protobuf structs before binary encoding
%{protobuf_message | birthday: ~D[1988-10-29]}

"""
@fallback_to_any true

@doc """
This function will invoked before encode a term and only if encoding target is
a protobuf message. The returning value will be used in place of current
Elixir `term` struct.
"""
@spec to_protobuf(t, module) :: struct
def to_protobuf(term, target_protobuf_module)
end

defimpl Protobuf.Encodable, for: Any do
def to_protobuf(term, _target_protobuf_module), do: term
end
6 changes: 4 additions & 2 deletions lib/protobuf/encoder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ defmodule Protobuf.Encoder do
import Protobuf.WireTypes
import Bitwise, only: [bsr: 2, band: 2, bsl: 2, bor: 2]

alias Protobuf.{MessageProps, FieldProps}
alias Protobuf.{Encodable, MessageProps, FieldProps}

@spec encode(atom, map | struct, keyword) :: iodata
def encode(mod, msg, opts) do
Expand Down Expand Up @@ -111,7 +111,9 @@ defmodule Protobuf.Encoder do
) do
repeated = is_repeated || is_map

repeated_or_not(val, repeated, fn v ->
val
|> Encodable.to_protobuf(type)
|> repeated_or_not(repeated, fn v ->
v = if is_map, do: struct(prop.type, %{key: elem(v, 0), value: elem(v, 1)}), else: v
# so that oneof {:atom, v} can be encoded
encoded = encode(type, v, [])
Expand Down
41 changes: 33 additions & 8 deletions lib/protobuf/protoc/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -105,26 +105,39 @@ defmodule Protobuf.Protoc.CLI do
new_ctx = append_ns(ctx, name)

types
|> update_types(ctx, name)
|> update_types(ctx, desc)
|> find_types_in_proto(new_ctx, desc.enum_type)
|> find_types_in_proto(new_ctx, desc.nested_type)
end

defp find_types_in_proto(types, ctx, %Google.Protobuf.EnumDescriptorProto{name: name}) do
update_types(types, ctx, name)
defp find_types_in_proto(types, ctx, desc) do
update_types(types, ctx, desc)
end

defp append_ns(%{namespace: ns} = ctx, name) do
new_ns = ns ++ [name]
Map.put(ctx, :namespace, new_ns)
end

defp update_types(types, %{namespace: ns, package: pkg, module_prefix: prefix}, name) do
type_name =
join_names(prefix || pkg, ns, name)
|> Protobuf.Protoc.Generator.Util.normalize_type_name()
defp update_types(types, %{namespace: ns, package: pkg, module_prefix: prefix}, desc) do
name = desc.name
module_name = gen_module_name(prefix, pkg, ns, name)

Map.put(types, "." <> join_names(pkg, ns, name), %{type_name: type_name})
typespec =
desc.options
|> get_msg_options()
|> Map.get(:typespec)

Map.put(types, "." <> join_names(pkg, ns, name), %{
type_name: module_name,
typespec: typespec
})
end

defp gen_module_name(prefix, pkg, ns, name) do
(prefix || pkg)
|> join_names(ns, name)
|> Protobuf.Protoc.Generator.Util.normalize_type_name()
end

defp join_names(pkg, ns, name) do
Expand All @@ -134,4 +147,16 @@ defmodule Protobuf.Protoc.CLI do
|> Enum.filter(&(&1 && &1 != ""))
|> Enum.join(".")
end

defp get_msg_options(nil), do: %{}

defp get_msg_options(options) do
case Google.Protobuf.MessageOptions.get_extension(options, Elixirpb.PbExtension, :message) do
nil ->
%{}

opts ->
opts
end
end
end
26 changes: 21 additions & 5 deletions lib/protobuf/protoc/generator/message.ex
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,21 @@ defmodule Protobuf.Protoc.Generator.Message do
"%{#{k_type} => #{v_type}}"
end

defp fmt_type(%{label: "repeated", type_enum: type_enum, type: type}) do
"[#{type_to_spec(type_enum, type, true)}]"
defp fmt_type(%{label: "repeated", type_enum: type_enum, type: type, typespec: typespec}) do
"[#{typespec || type_to_spec(type_enum, type, true)}]"
end

defp fmt_type(%{type_enum: type_enum, type: type}) do
"#{type_to_spec(type_enum, type)}"
defp fmt_type(%{type_enum: type_enum, type: type, typespec: typespec}) do
cond do
type_enum == :TYPE_MESSAGE and typespec ->
typespec <> " | nil"

typespec ->
typespec

true ->
"#{type_to_spec(type_enum, type)}"
end
end

defp type_to_spec(enum, type, repeated \\ false)
Expand Down Expand Up @@ -188,12 +197,14 @@ defmodule Protobuf.Protoc.Generator.Message do
opts_str = if opts_str == "", do: "", else: ", " <> opts_str

type = field_type_name(ctx, f)
typespec = field_typespec(ctx, f)

%{
name: f.name,
number: f.number,
label: label_name(f.label),
type: type,
typespec: typespec,
type_enum: f.type,
opts: opts,
opts_str: opts_str,
Expand All @@ -212,12 +223,17 @@ defmodule Protobuf.Protoc.Generator.Message do
type = TypeUtil.from_enum(f.type)

if f.type_name && (type == :enum || type == :message) do
Util.type_from_type_name(ctx, f.type_name)
Util.get_metadata_from_type_name(ctx, f.type_name)[:type_name]
else
":#{type}"
end
end

defp field_typespec(_ctx, %{type_name: nil} = _field), do: nil

defp field_typespec(ctx, %{type_name: type_name} = _field),
do: Util.get_metadata_from_type_name(ctx, type_name)[:typespec]

# Map of protobuf are actually nested(one level) messages
defp nested_maps(ctx, desc) do
full_name = Util.join_name([ctx.package | ctx.namespace] ++ [desc.name])
Expand Down
10 changes: 8 additions & 2 deletions lib/protobuf/protoc/generator/util.ex
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,20 @@ defmodule Protobuf.Protoc.Generator.Util do
|> Enum.join(", ")
end

def type_from_type_name(ctx, type_name) do
def module_from_type_name(ctx, type_name),
do: get_metadata_from_type_name(ctx, type_name)[:module_name]

def type_from_type_name(ctx, type_name),
do: get_metadata_from_type_name(ctx, type_name)[:type_name]

def get_metadata_from_type_name(ctx, type_name) do
# The doc says there's a situation where type_name begins without a `.`, but I never got that.
# Handle that later.
metadata =
ctx.dep_type_mapping[type_name] ||
raise "There's something wrong to get #{type_name}'s type, please contact with the lib author."

metadata[:type_name]
metadata
end

def normalize_type_name(name) do
Expand Down
29 changes: 29 additions & 0 deletions src/elixirpb.proto
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,35 @@ message FileOptions {
optional string module_prefix = 1;
}

// Message level options
//
// For example,
// option (elixirpb.message).typespec = "Date.t";
message MessageOptions {
// Specify a typespec that will used when a message reference this as field.
// For example, let's say you have in your message:
//
// package MyApp.Protobuf;
//
// message Date {
// option (elixirpb.message).typespec = "Date.t";
// int iso_days = 1;
// }
//
// message User {
// Date birthday = 1;
// }
//
// Then in `MyApp.Protobuf.User`, the type notation will for `birthday` will
// be `Date.t()` and not `MyApp.Protobuf.Date.t()`. This is useulf when
// combined with `Protobuf.Encodable` and `Protobuf.Decodable` mechanism.
optional string typespec = 1;
}

extend google.protobuf.FileOptions {
optional FileOptions file = 1047;
}

extend google.protobuf.MessageOptions {
optional MessageOptions message = 1047;
}
4 changes: 4 additions & 0 deletions test/protobuf/decoder_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,8 @@ defmodule Protobuf.DecoderTest do
assert Decoder.decode(<<18, 0, 24, 0>>, TestMsg.Oneof) ==
TestMsg.Oneof.new(first: {:b, ""}, second: {:c, 0})
end

test "transforms to elixir representation after decode the message" do
assert Decoder.decode(<<8, 132, 171, 44>>, TestMsg.DateFoo) == ~D[1988-10-29]
end
end
12 changes: 12 additions & 0 deletions test/protobuf/encoder_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,16 @@ defmodule Protobuf.EncoderTest do
msg = TestMsg.Bar2.new(a: 0, b: 1)
assert Encoder.encode(msg) == <<8, 0, 16, 1>>
end

test "transforms from Elixir term to protobuf before encode a message" do
msg = %TestMsg.FooWithDate{date: ~D[1988-10-29]}

assert Encoder.encode(msg) == <<10, 4, 8, 132, 171, 44>>
end

test "transforms from Elixir term to protobuf before encode a message using new function" do
msg = TestMsg.FooWithDate.new(date: ~D[1988-10-29])

assert Encoder.encode(msg) == <<10, 4, 8, 132, 171, 44>>
end
end
Loading