Current section

Files

Jump to
spawn lib sidecar grpc code_generator.ex
Raw

lib/sidecar/grpc/code_generator.ex

defmodule Sidecar.GRPC.CodeGenerator do
@moduledoc """
Module for generating gRPC code from Protobuf files.
This module provides functionality to compile Protobuf files and generate gRPC-related code.
It supports both gRPC and HTTP transcoding code generation.
## Usage
1. Call `compile_protos/1` to generate gRPC code from Protobuf files.
2. Ensure that Protobuf files are placed in the specified directory (`priv/protos` by default).
"""
require Logger
alias Actors.Config.PersistentTermConfig, as: Config
alias Spawn.Actors.ActorViewOption
alias Protobuf.Protoc.Generator.Util
alias Mix.Tasks.Protobuf.Generate
alias Spawn.Utils.AnySerializer
@doc """
Compiles Protobuf files and generates gRPC-related code.
### Options:
- `:protos_path` - Include path of protobufs to generate (default: "priv/protos").
- `:output_path` - Output path for generated code (default: "priv/protos/modules").
- `:http_transcoding_enabled` - Enable HTTP transcoding (default: true).
### Example:
```elixir
Sidecar.Grpc.CodeGenerator.compile_protos(output_path: "custom_output", http_transcoding_enabled: false)
"""
def compile_protos(opts \\ []) do
Logger.debug("Compiling ActorHost Protocol Buffers...")
include_path = Keyword.get(opts, :protos_path, Config.get(:grpc_include_protos_path))
output_path = Keyword.get(opts, :output_path, Config.get(:grpc_compiled_modules_path))
transcoding_enabled? =
Keyword.get(opts, :http_transcoding_enabled, Config.get(:grpc_http_transcoding_enabled))
{grpc_generator_plugin, handler_generator_plugin} =
if transcoding_enabled? do
{Sidecar.GRPC.Generators.GRPCWithCustomOptions,
Sidecar.GRPC.Generators.HandlerTranscodingGenerator}
else
{ProtobufGenerate.Plugins.GRPC, Sidecar.GRPC.Generators.HandlerGenerator}
end
include_files_path = list_files_with_full_path_by_extensions(include_path, ".proto")
invoker_helper =
if Code.ensure_loaded?(SpawnSdk) do
["--plugin=Sidecar.GRPC.Generators.ActorInvoker"]
else
[]
end
spawn_protos_dir = Application.app_dir(:spawn, "priv/protos")
if Enum.count(include_files_path) > 0 do
protoc_options =
[
"--include-path=#{include_path}",
"--include-path=#{spawn_protos_dir}",
"--generate-descriptors=true",
"--one-file-per-module",
"--output-path=#{output_path}",
"--plugin=#{grpc_generator_plugin}",
"--plugin=#{handler_generator_plugin}",
"--plugin=Sidecar.GRPC.Generators.GeneratorAccumulator"
] ++
invoker_helper ++ include_files_path
_ = Generate.run(protoc_options)
:ok
else
{:ok, :nothing_to_compile}
end
end
def after_compile_hook do
svcs = :persistent_term.get(:proto_file_descriptors, [])
current_services = :persistent_term.get(:grpc_services, [])
dispatchers =
current_services
|> Enum.reduce("", fn svc, acc -> "#{svc}.ActorDispatcher,\n#{acc}" end)
reflections =
current_services
|> Enum.reduce("", fn svc, acc -> "#{svc}.Service,\n#{acc}" end)
compile_proxy_endpoint(dispatchers)
compile_reflections(reflections)
put_actor_definition_settings(svcs)
:persistent_term.erase(:proto_file_descriptors)
:persistent_term.erase(:grpc_services)
:ok
end
defp compile_reflections(reflections) do
Code.compile_string("""
defmodule Sidecar.GRPC.Reflection.Server do
defmodule V1 do
use GrpcReflection.Server, version: :v1, services: [
#{reflections}
]
end
defmodule V1Alpha do
use GrpcReflection.Server, version: :v1alpha, services: [
#{reflections}
]
end
end
""")
end
defp compile_proxy_endpoint(endpoints) do
Code.compile_string("""
defmodule Sidecar.GRPC.ProxyEndpoint do
use GRPC.Endpoint
intercept(GRPC.Server.Interceptors.Logger)
services = [
#{endpoints}
]
services =
[
Sidecar.GRPC.Reflection.Server.V1,
Sidecar.GRPC.Reflection.Server.V1Alpha,
Spawn.Actors.Healthcheck.HealthCheckActor.ActorDispatcher
] ++ services
run(services)
end
""")
end
defp put_actor_definition_settings(svcs) do
Enum.each(svcs, fn svc ->
options = svc.options || %{}
option_extensions = Map.get(options, :__pb_extensions__, %{})
actor_opts = Map.get(option_extensions, {Spawn.Actors.PbExtension, :actor})
if not is_nil(actor_opts) do
if actor_opts.kind == :PROJECTION do
# put all projections in a persistent term list
projections = :persistent_term.get("projections", [])
:persistent_term.put("projections", [svc.name | projections])
end
:persistent_term.put("actor-#{svc.name}", actor_opts)
end
Enum.each(svc.method, fn method ->
method_options = method.options || %{}
method_option_extensions = Map.get(method_options, :__pb_extensions__, %{})
case Map.get(method_option_extensions, {Spawn.Actors.PbExtension, :view}) do
%ActorViewOption{} = option ->
output_type = AnySerializer.normalize_package_name(method.output_type)
input_type = AnySerializer.normalize_package_name(method.input_type)
descriptor = apply(output_type, :descriptor, [])
field = descriptor.field |> Enum.find(fn field -> field.name == option.map_to end)
type_name = AnySerializer.normalize_package_name(field.type_name)
# let the proxy know that there is a
:persistent_term.put("view-#{svc.name}-#{method.name}", %{
query: option.query,
view_name: method.name,
query_result_type: type_name,
map_to: option.map_to,
page_size: option.page_size,
output_type: output_type,
input_type: input_type
})
:ok
_ ->
nil
end
end)
end)
end
@doc """
Loads Elixir modules from the specified directory with a '.pb.ex' file extension.
## Parameters
- `opts` (KeywordList): Options for loading modules.
- `:output_path` (String): Path to the directory containing the modules. Default is "#{File.cwd!()}/priv/protos/modules".
## Returns
A list of strings, where each string represents the content of an Elixir module file.
## Examples
```elixir
opts = [output_path: "/path/to/modules"]
modules = load_modules(opts)
IO.inspect(modules)
This example loads Elixir modules from the specified directory "/path/to/modules" and prints the content of each module.
"""
def load_modules(opts \\ []) do
path = Keyword.get(opts, :output_path, Config.get(:grpc_compiled_modules_path))
user_defined_modules_files = list_files_with_full_path_by_extensions(path, ".pb.ex")
modules = Enum.map(user_defined_modules_files, fn full_path -> File.read!(full_path) end)
Logger.info("Found #{length(modules)} ActorHost Contract Modules to load...")
{:ok, modules}
end
@doc """
Compiles an Elixir module generated from Protobuf files and returns the compiled code.
### Parameters:
- `module` - The String of Elixir module code generated from Protobuf files.
### Returns:
The compiled Elixir code.
### Example:
```elixir
module_code = Sidecar.Grpc.CodeGenerator.compile_modules(generated_code)
Raises:
Raises an error if there are issues during the compilation process.
"""
def compile_modules(modules) when is_list(modules) do
Logger.debug("Compiling ActorHost contract modules...")
Enum.each(modules, &compile_modules/1)
end
def compile_modules(module), do: do_compile(module)
defp do_compile(module) do
Code.compile_string(module)
rescue
error in UndefinedFunctionError ->
Logger.error("Error in Module definition. Make sure the service name is correct")
raise error
error ->
Logger.error("Error during Service compilation phase #{inspect(error)}")
end
defp list_files_with_full_path_by_extensions(directory, extension) do
case ls_r(directory) do
[] ->
Logger.warning("Not found any protos on #{inspect(directory)}")
[]
files ->
files
# Exclude files that contains google folder
|> Enum.reject(&String.contains?(&1, "google/"))
|> Enum.filter(&String.ends_with?(&1, extension))
|> Enum.sort()
end
end
def ls_r(path \\ ".") do
cond do
File.regular?(path) ->
[path]
File.dir?(path) ->
File.ls!(path)
|> Enum.map(&Path.join(path, &1))
|> Enum.map(&ls_r/1)
|> Enum.concat()
true ->
[]
end
end
end