diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 189778b..eab9ee2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,13 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install grpcurl + run: | + GRPCURL_VERSION="1.9.3" + curl -sSL https://github.com/fullstorydev/grpcurl/releases/download/v1.9.3/grpcurl_${GRPCURL_VERSION}_linux_x86_64.tar.gz | tar -xvz + mv grpcurl /usr/local/bin/grpcurl + grpcurl --version + - name: Set up Elixir id: beam uses: erlef/setup-beam@v1 @@ -134,6 +141,13 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install grpcurl + run: | + GRPCURL_VERSION="1.9.3" + curl -sSL https://github.com/fullstorydev/grpcurl/releases/download/v1.9.3/grpcurl_${GRPCURL_VERSION}_linux_x86_64.tar.gz | tar -xvz + mv grpcurl /usr/local/bin/grpcurl + grpcurl --version + - name: Set up Elixir id: beam uses: erlef/setup-beam@v1 diff --git a/test/integration/v1_reflection_test.exs b/test/integration/v1_reflection_test.exs index 569d623..3f7d5eb 100644 --- a/test/integration/v1_reflection_test.exs +++ b/test/integration/v1_reflection_test.exs @@ -436,4 +436,48 @@ defmodule GrpcReflection.V1ReflectionTest do {:list_services, ""} ] end + + test "reflection graph is traversable using grpcurl", ctx do + ops = GrpcReflection.TestClient.grpcurl_service(ctx) + + assert ops == [ + {:call, "grpc.reflection.v1.ServerReflection.ServerReflectionInfo"}, + {:call, "grpc.reflection.v1alpha.ServerReflection.ServerReflectionInfo"}, + {:call, "helloworld.Greeter.SayHello"}, + {:call, "testserviceV2.TestService.CallFunction"}, + {:call, "testserviceV3.TestService.CallFunction"}, + {:service, "grpc.reflection.v1.ServerReflection"}, + {:service, "grpc.reflection.v1alpha.ServerReflection"}, + {:service, "helloworld.Greeter"}, + {:service, "testserviceV2.TestService"}, + {:service, "testserviceV3.TestService"}, + {:type, ".google.protobuf.Any"}, + {:type, ".google.protobuf.StringValue"}, + {:type, ".google.protobuf.Timestamp"}, + {:type, ".grpc.reflection.v1.ErrorResponse"}, + {:type, ".grpc.reflection.v1.ExtensionNumberResponse"}, + {:type, ".grpc.reflection.v1.ExtensionRequest"}, + {:type, ".grpc.reflection.v1.FileDescriptorResponse"}, + {:type, ".grpc.reflection.v1.ListServiceResponse"}, + {:type, ".grpc.reflection.v1.ServerReflectionRequest"}, + {:type, ".grpc.reflection.v1.ServerReflectionResponse"}, + {:type, ".grpc.reflection.v1.ServiceResponse"}, + {:type, ".grpc.reflection.v1alpha.ErrorResponse"}, + {:type, ".grpc.reflection.v1alpha.ExtensionNumberResponse"}, + {:type, ".grpc.reflection.v1alpha.ExtensionRequest"}, + {:type, ".grpc.reflection.v1alpha.FileDescriptorResponse"}, + {:type, ".grpc.reflection.v1alpha.ListServiceResponse"}, + {:type, ".grpc.reflection.v1alpha.ServerReflectionRequest"}, + {:type, ".grpc.reflection.v1alpha.ServerReflectionResponse"}, + {:type, ".grpc.reflection.v1alpha.ServiceResponse"}, + {:type, ".helloworld.HelloReply"}, + {:type, ".helloworld.HelloRequest"}, + {:type, ".testserviceV2.Enum"}, + {:type, ".testserviceV2.TestReply"}, + {:type, ".testserviceV2.TestRequest"}, + {:type, ".testserviceV3.Enum"}, + {:type, ".testserviceV3.TestReply"}, + {:type, ".testserviceV3.TestRequest"} + ] + end end diff --git a/test/integration/v1alpha_reflection_test.exs b/test/integration/v1alpha_reflection_test.exs index 5012edf..1997e3c 100644 --- a/test/integration/v1alpha_reflection_test.exs +++ b/test/integration/v1alpha_reflection_test.exs @@ -439,4 +439,48 @@ defmodule GrpcReflection.V1alphaReflectionTest do {:list_services, ""} ] end + + test "reflection graph is traversable using grpcurl", ctx do + ops = GrpcReflection.TestClient.grpcurl_service(ctx) + + assert ops == [ + {:call, "grpc.reflection.v1.ServerReflection.ServerReflectionInfo"}, + {:call, "grpc.reflection.v1alpha.ServerReflection.ServerReflectionInfo"}, + {:call, "helloworld.Greeter.SayHello"}, + {:call, "testserviceV2.TestService.CallFunction"}, + {:call, "testserviceV3.TestService.CallFunction"}, + {:service, "grpc.reflection.v1.ServerReflection"}, + {:service, "grpc.reflection.v1alpha.ServerReflection"}, + {:service, "helloworld.Greeter"}, + {:service, "testserviceV2.TestService"}, + {:service, "testserviceV3.TestService"}, + {:type, ".google.protobuf.Any"}, + {:type, ".google.protobuf.StringValue"}, + {:type, ".google.protobuf.Timestamp"}, + {:type, ".grpc.reflection.v1.ErrorResponse"}, + {:type, ".grpc.reflection.v1.ExtensionNumberResponse"}, + {:type, ".grpc.reflection.v1.ExtensionRequest"}, + {:type, ".grpc.reflection.v1.FileDescriptorResponse"}, + {:type, ".grpc.reflection.v1.ListServiceResponse"}, + {:type, ".grpc.reflection.v1.ServerReflectionRequest"}, + {:type, ".grpc.reflection.v1.ServerReflectionResponse"}, + {:type, ".grpc.reflection.v1.ServiceResponse"}, + {:type, ".grpc.reflection.v1alpha.ErrorResponse"}, + {:type, ".grpc.reflection.v1alpha.ExtensionNumberResponse"}, + {:type, ".grpc.reflection.v1alpha.ExtensionRequest"}, + {:type, ".grpc.reflection.v1alpha.FileDescriptorResponse"}, + {:type, ".grpc.reflection.v1alpha.ListServiceResponse"}, + {:type, ".grpc.reflection.v1alpha.ServerReflectionRequest"}, + {:type, ".grpc.reflection.v1alpha.ServerReflectionResponse"}, + {:type, ".grpc.reflection.v1alpha.ServiceResponse"}, + {:type, ".helloworld.HelloReply"}, + {:type, ".helloworld.HelloRequest"}, + {:type, ".testserviceV2.Enum"}, + {:type, ".testserviceV2.TestReply"}, + {:type, ".testserviceV2.TestRequest"}, + {:type, ".testserviceV3.Enum"}, + {:type, ".testserviceV3.TestReply"}, + {:type, ".testserviceV3.TestRequest"} + ] + end end diff --git a/test/support/client.ex b/test/support/client.ex index 2fe6e76..3b5ea65 100644 --- a/test/support/client.ex +++ b/test/support/client.ex @@ -28,7 +28,7 @@ defmodule GrpcReflection.TestClient do :v1alpha -> GrpcReflection.TestEndpoint.V1AlphaServer.Stub end - %{channel: channel, req: req, stub: stub, version: unquote(version)} + %{channel: channel, req: req, stub: stub, version: unquote(version), host: host} end end end @@ -148,4 +148,109 @@ defmodule GrpcReflection.TestClient do extendee_commands ++ nested_commands end) end + + def grpcurl_service(ctx) do + ctx + |> grpcurl_list_services() + |> Stream.unfold(fn + [] -> + nil + + [{:service, service} | rest] = term -> + commands = grpcurl_describe_service(ctx, service) + {term, commands ++ rest} + + [{:call, call} | rest] = term -> + commands = grpcurl_describe_call(ctx, call) + {term, commands ++ rest} + + [{:type, type} | rest] = term -> + commands = grpcurl_describe_type(ctx, type) + {term, commands ++ rest} + end) + |> Enum.to_list() + |> List.flatten() + |> Enum.uniq() + |> Enum.sort() + end + + defp grpcurl_list_services(%{host: host}) do + {result, 0} = System.cmd("grpcurl", ["-v", "-plaintext", host, "list"]) + + result + |> String.split("\n") + |> Enum.reject(&(&1 == "")) + |> Enum.map(&{:service, &1}) + end + + defp grpcurl_describe_service(%{host: host}, service) do + {result, 0} = System.cmd("grpcurl", ["-v", "-plaintext", host, "list", service]) + + result + |> String.split("\n") + |> Enum.reject(&(&1 == "")) + |> Enum.map(&{:call, &1}) + end + + defp grpcurl_describe_call(%{host: host}, call) do + {result, 0} = System.cmd("grpcurl", ["-v", "-plaintext", host, "describe", call]) + + ~r/\((?: stream)? (?[a-zA-Z0-9.]+) \)/ + |> Regex.scan(result, capture: ["symbol"]) + |> List.flatten() + |> Enum.map(&{:type, &1}) + end + + defp grpcurl_describe_type(%{host: host}, type) do + {result, 0} = System.cmd("grpcurl", ["-v", "-plaintext", host, "describe", type]) + + # we are grabbing the referenced types to we can fetch those too + # but some of them might be defined inside this file + # so we have to identify those and filter them back out + + inline_declared_symbols = + ~r/(?:message (?\w+) {)|(?})|(?{)/ + |> Regex.scan(result, capture: :all_but_first) + |> List.flatten() + |> Enum.reject(&(&1 == "")) + # replace first declaration with base type to get full names + |> then(fn [_base | rest] -> [type | rest] end) + |> remove_matched_parens() + |> Enum.reduce({[], []}, fn token, {path, inline_types} -> + case token do + "}" -> + [_ | path] = path + # End of current message — pop from path + {path, inline_types} + + name -> + path = [name | path] + name = path |> Enum.reverse() |> Enum.join(".") + {path, [name | inline_types]} + end + end) + |> elem(1) + + # now we can grab all the references, then reject the nested declarations + ~r/ (?\.[a-z]+[a-zA-Z0-9.]+) / + |> Regex.scan(result, capture: ["symbol"]) + |> List.flatten() + |> Enum.reject(&Enum.member?(inline_declared_symbols, &1)) + |> Enum.map(&{:type, &1}) + end + + # if we only match `message XYZ {` and `}`, we get extra `}` tokens from things like the + # oneof declarations. If we also match non-message `{` we can eliminate these intermeidate + # paren-blocks, which we do here for our needs + defp remove_matched_parens(["{", "}" | rest]) do + remove_matched_parens(rest) + end + + defp remove_matched_parens([item | rest]) do + [item | remove_matched_parens(rest)] + end + + defp remove_matched_parens(rest) do + rest + end end