Skip to content

Commit edef380

Browse files
authored
Add recursive message support (#82)
1 parent 0034ba3 commit edef380

6 files changed

Lines changed: 119 additions & 17 deletions

File tree

lib/grpc_reflection/service/builder.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ defmodule GrpcReflection.Service.Builder do
1414
new_state = process_service(service)
1515
State.merge(state, new_state)
1616
end)
17-
|> State.group_symbols_by_namespace()
17+
|> State.shrink_cycles()
1818

1919
{:ok, tree}
2020
end
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
defmodule GrpcReflection.Service.Cycle do
2+
@moduledoc false
3+
4+
def get_cycles(%GrpcReflection.Service.State{files: files}) do
5+
files
6+
|> Map.values()
7+
|> Enum.reject(&String.ends_with?(&1.name, "Extension.proto"))
8+
|> Map.new(fn file -> {file.name, file.dependency} end)
9+
|> find_cycles()
10+
end
11+
12+
defp find_cycles(graph) do
13+
graph
14+
|> Map.keys()
15+
|> Enum.reduce({[], []}, fn node, {visited, cycles} ->
16+
dfs(node, graph, visited, [], cycles)
17+
end)
18+
|> elem(1)
19+
|> Enum.map(&Enum.sort/1)
20+
|> Enum.sort()
21+
|> Enum.uniq()
22+
end
23+
24+
defp dfs(node, graph, visited, path, cycles) do
25+
cond do
26+
node in path ->
27+
cycle = [node | Enum.take_while(path, &(&1 != node))]
28+
{visited, [cycle | cycles]}
29+
30+
node in visited ->
31+
{visited, cycles}
32+
33+
true ->
34+
{visited, cycles} =
35+
graph
36+
|> Map.get(node, [])
37+
|> Enum.reduce({[node | visited], cycles}, fn neighbor, {v, c} ->
38+
dfs(neighbor, graph, v, [node | path], c)
39+
end)
40+
41+
{visited, cycles}
42+
end
43+
end
44+
end

lib/grpc_reflection/service/state.ex

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,68 @@ defmodule GrpcReflection.Service.State do
116116
end
117117
end
118118

119-
def group_symbols_by_namespace(%__MODULE__{} = state) do
120-
# group symbols by namespace and combine
121-
# IO.inspect(state)
122-
state
119+
def shrink_cycles(%__MODULE__{} = state) do
120+
new_state =
121+
state
122+
|> GrpcReflection.Service.Cycle.get_cycles()
123+
|> Enum.reduce(state, fn filenames, acc ->
124+
files = filenames |> Enum.map(&acc.files[&1]) |> Enum.reject(&is_nil/1)
125+
126+
if length(files) < 2 do
127+
acc
128+
else
129+
update_with_combined(acc, combine_file_descriptors(files), filenames)
130+
end
131+
end)
132+
133+
if new_state == state, do: state, else: shrink_cycles(new_state)
134+
end
135+
136+
defp update_with_combined(state, combined_file, combined_filenames) do
137+
new_files =
138+
state.files
139+
|> Map.drop(combined_filenames)
140+
|> Map.new(fn {filename, descriptor} ->
141+
if Enum.any?(descriptor.dependency, &(&1 in combined_filenames)) do
142+
updated_deps = (descriptor.dependency -- combined_filenames) ++ [combined_file.name]
143+
{filename, %{descriptor | dependency: Enum.uniq(updated_deps)}}
144+
else
145+
{filename, descriptor}
146+
end
147+
end)
148+
|> Map.put(combined_file.name, combined_file)
149+
150+
new_symbols =
151+
Map.new(state.symbols, fn {symbol, filename} ->
152+
if filename in combined_filenames do
153+
{symbol, combined_file.name}
154+
else
155+
{symbol, filename}
156+
end
157+
end)
158+
159+
%{state | files: new_files, symbols: new_symbols}
160+
end
161+
162+
defp combine_file_descriptors(file_descriptors) do
163+
combined_names = Enum.map(file_descriptors, & &1.name)
164+
canonical_name = Enum.min(combined_names)
165+
166+
Enum.reduce(
167+
file_descriptors,
168+
%Google.Protobuf.FileDescriptorProto{name: canonical_name},
169+
fn descriptor, acc ->
170+
%{
171+
acc
172+
| syntax: acc.syntax || descriptor.syntax,
173+
package: acc.package || descriptor.package,
174+
message_type: Enum.uniq(acc.message_type ++ descriptor.message_type),
175+
service: Enum.uniq(acc.service ++ descriptor.service),
176+
enum_type: Enum.uniq(acc.enum_type ++ descriptor.enum_type),
177+
dependency: Enum.uniq(acc.dependency ++ (descriptor.dependency -- combined_names)),
178+
extension: Enum.uniq(acc.extension ++ descriptor.extension)
179+
}
180+
end
181+
)
123182
end
124183
end

test/case/recursive_message_test.exs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,6 @@ defmodule GrpcReflection.Case.RecursiveMessageTest do
33

44
use GrpcCase, service: RecursiveMessage.Service.Service
55

6-
# Recursive message structures cause infinite loops in the builder's graph traversal.
7-
# Tracked for future fix; protos and tests are in place to validate when resolved.
8-
@moduletag :skip
9-
106
versions = ["v1", "v1alpha"]
117

128
for version <- versions do

test/case/well_known_types_test.exs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,13 @@ defmodule GrpcReflection.Case.WellKnownTypesTest do
5757
} = response
5858
end
5959

60-
# Well-known types contain circular references that cause an infinite loop in our
61-
# reflection tree builder, which grpcurl exposes as a stack overflow. Out of scope
62-
# for now; the reflection API itself is verified via the symbol/filename tests above.
60+
test "reflection graph is traversable using grpcurl", ctx do
61+
ops = GrpcReflection.TestClient.grpcurl_service(ctx)
62+
63+
assert {:call, "well_known_types.WellKnownTypesService.ProcessWellKnownTypes"} in ops
64+
assert {:call, "well_known_types.WellKnownTypesService.EmptyMethod"} in ops
65+
assert {:service, "well_known_types.WellKnownTypesService"} in ops
66+
end
6367
end
6468
end
6569
end

test/service/builder_test.exs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,10 @@ defmodule GrpcReflection.Service.BuilderTest do
133133
test "handles a recursive message structure" do
134134
assert {:ok, tree} = Builder.build_reflection_tree([RecursiveMessage.Service.Service])
135135

136-
assert tree.files |> Map.keys() |> Enum.sort() == [
137-
"recursive_message.Reply.proto",
138-
"recursive_message.Request.proto",
139-
"recursive_message.Service.proto"
140-
]
136+
# Request and Reply form a cycle and are merged into one file
137+
file_names = tree.files |> Map.keys() |> Enum.sort()
138+
assert length(file_names) == 2
139+
assert "recursive_message.Service.proto" in file_names
141140

142141
assert tree.symbols |> Map.keys() |> Enum.sort() == [
143142
"recursive_message.Reply",

0 commit comments

Comments
 (0)