Skip to content
Merged
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
6 changes: 5 additions & 1 deletion lib/grpc_reflection.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ defmodule GrpcReflection do
@moduledoc """
Reflection support for the grpc-elixir package

To use these servers, all protos must be compiled with the `gen_descriptors=true` option, as that is the source of truth for the reflection service.
Protos compiled with `gen_descriptors=true` provide the richest reflection output.
If that option is omitted, this library synthesizes descriptors from runtime module
metadata (`__message_props__`, `__rpc_calls__`). The synthesized path produces
equivalent output for standard gRPC reflection clients; only proto2 extensions and
custom proto options are unavailable without `gen_descriptors=true`.

To turn on reflection in your application, do the following:

Expand Down
38 changes: 33 additions & 5 deletions lib/grpc_reflection/service/builder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ defmodule GrpcReflection.Service.Builder do
end

defp trace_message_refs(state, parent_symbol, module) do
case module.descriptor() do
case get_descriptor(module) do
%{field: fields} ->
trace_message_fields(state, parent_symbol, module, fields)

Expand All @@ -79,7 +79,7 @@ defmodule GrpcReflection.Service.Builder do
end

defp trace_message_fields(state, parent_symbol, module, fields) do
nested_types = Util.get_nested_types(parent_symbol, module.descriptor())
nested_types = Util.get_nested_types(parent_symbol, get_descriptor(module))

module.__message_props__().field_props
|> Map.values()
Expand Down Expand Up @@ -176,9 +176,37 @@ defmodule GrpcReflection.Service.Builder do
# generate descriptors. Use this to potentially unwrap the service proto when dealing
# with descriptors that could come from a service module.
defp get_descriptor(module) do
case module.descriptor() do
%FileDescriptorProto{service: [proto]} -> proto
proto -> proto
cond do
not Code.ensure_loaded?(module) ->
raise "Module #{inspect(module)} is not loaded"

function_exported?(module, :descriptor, 0) ->
case module.descriptor() do
%FileDescriptorProto{service: [proto]} -> proto
proto -> proto
end

true ->
synthesize_descriptor(module)
end
end

defp synthesize_descriptor(module) do
cond do
function_exported?(module, :__rpc_calls__, 0) ->
GrpcReflection.Service.Builder.Synthesizer.service_descriptor(module)

function_exported?(module, :mapping, 0) ->
GrpcReflection.Service.Builder.Synthesizer.enum_descriptor(module)

function_exported?(module, :__message_props__, 0) ->
GrpcReflection.Service.Builder.Synthesizer.message_descriptor(module)

true ->
# unreachable in practice: validate_services/1 only admits modules that export
# __rpc_calls__/0, __message_props__/0, or descriptor/0, so a module reaching
# synthesize_descriptor/1 without any of those three is impossible.
raise "Module #{inspect(module)} exports neither descriptor/0, __rpc_calls__/0, nor __message_props__/0 — cannot synthesize a descriptor"
end
end
end
20 changes: 12 additions & 8 deletions lib/grpc_reflection/service/builder/extensions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,20 @@ defmodule GrpcReflection.Service.Builder.Extensions do
alias GrpcReflection.Service.State

def add_extensions(state, symbol, module) do
extension_file = symbol <> "Extension.proto"
if Code.ensure_loaded?(module) and function_exported?(module, :descriptor, 0) do
extension_file = symbol <> "Extension.proto"

case process_extensions(module, symbol, extension_file, module.descriptor()) do
{:ok, {extension_numbers, extension_payload}} ->
state
|> State.add_file(extension_payload)
|> State.add_extensions(%{symbol => extension_numbers})
case process_extensions(module, symbol, extension_file, module.descriptor()) do
{:ok, {extension_numbers, extension_payload}} ->
state
|> State.add_file(extension_payload)
|> State.add_extensions(%{symbol => extension_numbers})

:ignore ->
state
:ignore ->
state
end
else
state
end
end

Expand Down
168 changes: 168 additions & 0 deletions lib/grpc_reflection/service/builder/synthesizer.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
defmodule GrpcReflection.Service.Builder.Synthesizer do
@moduledoc false

# Builds a FieldDescriptorProto from a Protobuf.FieldProps.
# Note: oneof_index is NOT set here for proto3_optional fields — those synthetic
# oneof indices are assigned in message_descriptor/1 where all fields are visible.
# syntax must be passed explicitly when building from message context; proto3 repeated
# scalars have packed?: true in FieldProps but must NOT set options.packed (implicit).
def field_descriptor_from_props(%Protobuf.FieldProps{} = props, syntax \\ :proto3) do
{type, type_name} = resolve_type(props)

%Google.Protobuf.FieldDescriptorProto{
name: props.name,
number: props.fnum,
json_name: props.json_name,
type: type,
type_name: type_name,
label: resolve_label(props),
default_value: encode_default(props.default),
oneof_index: props.oneof,
options: field_options(props, syntax),
proto3_optional: props.proto3_optional?,
extendee: nil
}
end

def message_descriptor(module) do
props = module.__message_props__()

fields =
props.ordered_tags
|> Enum.map(&props.field_props[&1])
|> Enum.map(&field_descriptor_from_props(&1, props.syntax))
|> assign_proto3_optional_oneof_indices(length(props.oneof))

real_oneof_decls =
Enum.map(props.oneof, fn {name, _tag} ->
%Google.Protobuf.OneofDescriptorProto{name: Atom.to_string(name)}
end)

synthetic_oneof_decls =
fields
|> Enum.filter(& &1.proto3_optional)
|> Enum.map(fn f ->
%Google.Protobuf.OneofDescriptorProto{name: "_#{f.name}"}
end)

extension_ranges =
(props.extension_range || [])
|> Enum.map(fn {start, stop} ->
%Google.Protobuf.DescriptorProto.ExtensionRange{start: start, end: stop}
end)

short_name = module.full_name() |> String.split(".") |> List.last()

%Google.Protobuf.DescriptorProto{
name: short_name,
field: fields,
oneof_decl: real_oneof_decls ++ synthetic_oneof_decls,
extension_range: extension_ranges,
# nested_type is always [] in synthesized descriptors. All message types — including
# map-entry types and any inline nested message definitions in the original .proto —
# are discovered via field traversal in builder.ex and emitted as separate files.
# This differs from protoc output (which embeds nested types in the parent descriptor)
# but is handled correctly by standard reflection clients.
nested_type: [],
enum_type: []
}
end

def enum_descriptor(module) do
short_name = module.full_name() |> String.split(".") |> List.last()

# mapping() is a map so has no intrinsic order; __message_props__.ordered_tags is
# numerically sorted. Neither preserves proto declaration order, so we sort by value.
# This differs from protoc output for enums with non-monotonic declaration order
# (e.g. negative sentinel values declared after positive ones), but reflection
# clients look up enum values by number, not position, so this is safe in practice.
values =
module.__message_props__().ordered_tags
|> Enum.map(fn tag ->
props = module.__message_props__().field_props[tag]
%Google.Protobuf.EnumValueDescriptorProto{name: props.name, number: props.fnum}
end)

%Google.Protobuf.EnumDescriptorProto{name: short_name, value: values}
end

def service_descriptor(module) do
service_name = module.__meta__(:name) |> String.split(".") |> List.last()

methods =
module.__rpc_calls__()
|> Enum.map(fn
{method, {req, req_stream}, {resp, resp_stream}} ->
build_method_descriptor(method, req, req_stream, resp, resp_stream)

{method, {req, req_stream}, {resp, resp_stream}, _opts} ->
build_method_descriptor(method, req, req_stream, resp, resp_stream)
end)

%Google.Protobuf.ServiceDescriptorProto{name: service_name, method: methods}
end

defp resolve_label(%Protobuf.FieldProps{required?: true}), do: :LABEL_REQUIRED
defp resolve_label(%Protobuf.FieldProps{repeated?: true}), do: :LABEL_REPEATED
defp resolve_label(%Protobuf.FieldProps{map?: true}), do: :LABEL_REPEATED
defp resolve_label(_), do: :LABEL_OPTIONAL

defp resolve_type(%Protobuf.FieldProps{enum?: true, type: {:enum, mod}}) do
{:TYPE_ENUM, "." <> mod.full_name()}
end

defp resolve_type(%Protobuf.FieldProps{embedded?: true, type: mod}) when is_atom(mod) do
{:TYPE_MESSAGE, "." <> mod.full_name()}
end

defp resolve_type(%Protobuf.FieldProps{type: type}) do
{:"TYPE_#{type |> Atom.to_string() |> String.upcase()}", nil}
end

# proto3 repeated scalars are implicitly packed — reflection clients infer this from
# syntax, so options.packed must not be set (matches real protoc output).
defp field_options(props, syntax) do
packed = props.packed? == true and syntax == :proto2
deprecated = props.deprecated? == true

case {packed, deprecated} do
{false, false} ->
nil

{packed, deprecated} ->
%Google.Protobuf.FieldOptions{packed: packed || nil, deprecated: deprecated || nil}
end
end

defp encode_default(nil), do: nil
defp encode_default(v) when is_binary(v), do: v
defp encode_default(v) when is_boolean(v), do: to_string(v)
defp encode_default(v) when is_integer(v), do: Integer.to_string(v)
defp encode_default(v) when is_float(v), do: Float.to_string(v)
defp encode_default(v) when is_atom(v), do: Atom.to_string(v)

# proto3_optional fields have no oneof in __message_props__ (the runtime doesn't need
# the synthetic oneof) but the wire format and reflection clients expect oneof_index to
# point at a synthetic "_fieldname" oneof entry appended after any real oneofs.
defp assign_proto3_optional_oneof_indices(fields, real_oneof_count) do
fields
|> Enum.map_reduce(0, fn field, counter ->
if field.proto3_optional do
{%{field | oneof_index: real_oneof_count + counter}, counter + 1}
else
{field, counter}
end
end)
|> elem(0)
end

defp build_method_descriptor(method, req, req_stream, resp, resp_stream) do
%Google.Protobuf.MethodDescriptorProto{
name: Atom.to_string(method),
input_type: "." <> req.full_name(),
output_type: "." <> resp.full_name(),
client_streaming: req_stream,
server_streaming: resp_stream
}
end
end
7 changes: 5 additions & 2 deletions lib/grpc_reflection/service/builder/util.ex
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,11 @@ defmodule GrpcReflection.Service.Builder.Util do
def validate_services(services) do
invalid_services =
Enum.reject(services, fn service_mod ->
is_binary(service_mod.__meta__(:name)) and
is_struct(service_mod.descriptor())
Code.ensure_loaded?(service_mod) and
function_exported?(service_mod, :__meta__, 1) and
is_binary(service_mod.__meta__(:name)) and
(function_exported?(service_mod, :descriptor, 0) or
function_exported?(service_mod, :__rpc_calls__, 0))
end)

case invalid_services do
Expand Down
32 changes: 19 additions & 13 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ defmodule GrpcReflection.MixProject do
~r/^PackageB\./,
~r/^NestedEnumConflict\./,
~r/^RecursiveMessage\./,
~r/^NoDescriptor\./,
GrpcReflection.TestEndpoint,
GrpcReflection.TestEndpoint.Endpoint
]
Expand Down Expand Up @@ -74,16 +75,15 @@ defmodule GrpcReflection.MixProject do
]
end

defp build_protos(_argv) do
options =
Enum.join(
[
"gen_descriptors=true",
"plugins=grpc"
],
","
)
@protoc_opts "gen_descriptors=true,plugins=grpc"
@protoc_opts_no_descriptor "package_prefix=NoDescriptor,plugins=grpc"
# Protos that set (elixirpb.file).module_prefix must be skipped here: that option
# overrides package_prefix entirely, so the no-descriptor pass would emit modules with
# the same name as the descriptor pass, causing a compile-time conflict.
# Add any proto that uses the elixirpb.file module_prefix option to this list.
@skip_no_descriptor ["custom_prefix_service.proto"]

defp build_protos(_argv) do
# compile reflection protos
Enum.each(
[
Expand All @@ -92,19 +92,25 @@ defmodule GrpcReflection.MixProject do
],
fn reflection_proto ->
Mix.shell().cmd(
"protoc --elixir_out=#{options}:./lib/proto --proto_path=priv/protos/ #{reflection_proto}"
"protoc --elixir_out=#{@protoc_opts}:./lib/proto --proto_path=priv/protos/ #{reflection_proto}"
)
end
)

# compile test protos
# compile test protos — once with descriptors, once without
"./priv/protos"
|> File.ls!()
|> Enum.filter(&Regex.match?(~r/.*.proto$/, &1))
|> Enum.each(fn reflection_proto ->
|> Enum.each(fn proto ->
Mix.shell().cmd(
"protoc --elixir_out=#{options}:./test/support/protos -I priv/protos/ -I deps/protobuf/src #{reflection_proto}"
"protoc --elixir_out=#{@protoc_opts}:./test/support/protos -I priv/protos/ -I deps/protobuf/src #{proto}"
)

unless proto in @skip_no_descriptor do
Mix.shell().cmd(
"protoc --elixir_out=#{@protoc_opts_no_descriptor}:./test/support/protos/no_descriptor -I priv/protos/ -I deps/protobuf/src #{proto}"
)
end
end)
end

Expand Down
23 changes: 23 additions & 0 deletions priv/protos/deprecated_fields.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
syntax = "proto3";

package deprecated_fields;

option go_package = "deprecated_fields";

// A message with a mix of active and deprecated fields.
message DeprecatedRequest {
string active_field = 1;
string legacy_field = 2 [deprecated = true];
int32 active_id = 3;
int32 old_id = 4 [deprecated = true];
}

message DeprecatedResponse {
bool success = 1;
string result = 2;
string old_result = 3 [deprecated = true];
}

service DeprecatedFieldsService {
rpc Process(DeprecatedRequest) returns (DeprecatedResponse);
}
3 changes: 3 additions & 0 deletions priv/protos/proto2_features.proto
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ message Proto2Request {
// Repeated Any in proto2
repeated google.protobuf.Any any_values = 15;

// Packed repeated field (proto2 requires explicit [packed=true])
repeated int32 packed_ints = 16 [packed=true];

// Extensions range
extensions 100 to 199;
}
Expand Down
Loading
Loading