Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce generate option to customize output #23

Merged
merged 8 commits into from
May 17, 2024
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## [Unreleased]

- Add `generate=<service|client|both>` option to customize generated output - [#23](https://github.com/collectiveidea/protoc-gen-twirp_ruby/pull/23)
- Add `skip-empty` option to prevent generating empty scaffolding for proto files without services - [#21](https://github.com/collectiveidea/protoc-gen-twirp_ruby/pull/21)
- Refactor code generator to improve internal readability - [#12](https://github.com/collectiveidea/protoc-gen-twirp_ruby/pull/12), [#13](https://github.com/collectiveidea/protoc-gen-twirp_ruby/pull/13), [#22](https://github.com/collectiveidea/protoc-gen-twirp_ruby/pull/22)
- Remove unnecessary extra files from packaged gem - [#11](https://github.com/collectiveidea/protoc-gen-twirp_ruby/pull/11)
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ that might affect migration include:

* Generated output code is in [standardrb style](https://github.com/standardrb/standard).
* Generated service and client class names are improved for well-named protobuf services. See [#6](https://github.com/collectiveidea/protoc-gen-twirp_ruby/pull/6).
* Supports options: `skip-empty`
* Supports various protoc command line [configuration options](https://github.com/collectiveidea/protoc-gen-twirp_ruby?tab=readme-ov-file#options).

## Usage

Expand All @@ -70,7 +70,13 @@ protoc --proto_path=. --ruby_out=. --twirp_ruby_out=. ./path/to/service.proto
The plugin supports the following options to configure code generation. Pass options by
specifying `--twirp_ruby_opt=<option>` on the `protoc` command line.

* `skip-empty`: Avoid generating a `_twirp.rb` for a `.proto` with no service definitions.
* `skip-empty`: Avoid generating a `_twirp.rb` for a `.proto` with no service definitions. By default, a `_twirp.rb`
file is generated for every proto file listed on the command line, even if the file is empty scaffolding.
* `generate=<service|client|both>`: Customize generated output to include generated services, clients, or both.
* `generate=service` - only generate `::Twirp::Service` subclass(es).
* `generate=client` - only generate `::Twirp::Client` subclass(es).
* `generate=both` - generate both services and clients. This is the default option to preserve
backwards compatibility.

## Development

Expand Down
105 changes: 81 additions & 24 deletions lib/twirp/protoc_plugin/code_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ module ProtocPlugin
class CodeGenerator
# @param proto_file [Google::Protobuf::FileDescriptorProto]
# @param relative_ruby_protobuf [String] e.g. "example_rb.pb"
# @param options [Hash{Symbol => Boolean}]
# @param options [Hash{Symbol => Boolean, Symbol}]
# * :skip_empty [Boolean] indicating whether generation should skip creating a twirp file
# for proto files that contain no services. Default false.
# for proto files that contain no services.
# * :generate [Symbol] one of: :service, :client, or :both.
def initialize(proto_file, relative_ruby_protobuf, options)
@proto_file = proto_file
@relative_ruby_protobuf = relative_ruby_protobuf
Expand Down Expand Up @@ -49,33 +50,22 @@ def generate
end

@proto_file.service.each_with_index do |service, index| # service: <Google::Protobuf::ServiceDescriptorProto>
# Add newline between service definitions when multiple are generated
# Add newline between definitions when multiple are generated
output << "\n" if index > 0

service_name = service.name
service_class_name = service.service_class_name

# Generate service class
output << line("class #{service_class_name} < ::Twirp::Service", indent_level)
output << line(" package \"#{@proto_file.package}\"", indent_level) unless @proto_file.package.to_s.empty?
output << line(" service \"#{service_name}\"", indent_level)
service["method"].each do |method| # method: <Google::Protobuf::MethodDescriptorProto>
input_type = convert_to_ruby_type(method.input_type, current_module)
output_type = convert_to_ruby_type(method.output_type, current_module)
ruby_method_name = method.name.snake_case

output << line(" rpc :#{method.name}, #{input_type}, #{output_type}, ruby_method: :#{ruby_method_name}", indent_level)
if %i[service both].include?(@options[:generate])
generate_service_class(output, indent_level, service, @proto_file.package, current_module)
end
output << line("end", indent_level)

# Generate client class
if @options[:generate] == :both
# Space between generated service and client when generating both
output << "\n"

client_class_name = service.client_class_name

output << "\n"
output << line("class #{client_class_name} < ::Twirp::Client", indent_level)
output << line(" client_for #{service_class_name}", indent_level)
output << line("end", indent_level)
generate_client_class_for_service(output, indent_level, service)
elsif @options[:generate] == :client
# When generating only the client, we can't use the `client_for` DSL.
generate_client_class_standalone(output, indent_level, service, @proto_file.package, current_module)
end
end

modules.each do |_|
Expand All @@ -98,6 +88,73 @@ def line(input, indent_level = 0)
"#{" " * indent_level}#{input}\n"
end

# Generates a Twirp::Service subclass for the given service class, adding the
# string to the output.
#
# @param output [#<<] the output to append the generated service code to
# @param indent_level [Integer] the number of double spaces to indent the generated code by
# @param service [Google::Protobuf::ServiceDescriptorProto]
# @param package [String, nil] the optional package of the proto file that contains the service, e.g. "example.hello_world"
# @param current_module [String, nil] the optional name of the containing module, e.g. "::Example::HelloWorld"
# @return [void]
def generate_service_class(output, indent_level, service, package, current_module)
service_name = service.name
service_class_name = service.service_class_name

# Generate service class
output << line("class #{service_class_name} < ::Twirp::Service", indent_level)
output << line(" package \"#{package}\"", indent_level) unless package.to_s.empty?
output << line(" service \"#{service_name}\"", indent_level)
service["method"].each do |method| # method: <Google::Protobuf::MethodDescriptorProto>
input_type = convert_to_ruby_type(method.input_type, current_module)
output_type = convert_to_ruby_type(method.output_type, current_module)
ruby_method_name = method.name.snake_case

output << line(" rpc :#{method.name}, #{input_type}, #{output_type}, ruby_method: :#{ruby_method_name}", indent_level)
end
output << line("end", indent_level)
end

# Generates a Twirp::Client subclass for the given service class, adding the
# string to the output.
#
# @param output [#<<] the output to append the generated service code to
# @param indent_level [Integer] the number of double spaces to indent the generated code by
# @param service [Google::Protobuf::ServiceDescriptorProto]
# @return [void]
def generate_client_class_for_service(output, indent_level, service)
output << line("class #{service.client_class_name} < ::Twirp::Client", indent_level)
output << line(" client_for #{service.service_class_name}", indent_level)
output << line("end", indent_level)
end

# Generates a Twirp::Client subclass standalone, without using the `client_for` DSL because
# there is no corresponding service to reference.
#
# This essentially in-lines the `client_for` logic from
# https://github.com/arthurnn/twirp-ruby/blob/v1.11.0/lib/twirp/client.rb#L31
#
# @param output [#<<] the output to append the generated service code to
# @param indent_level [Integer] the number of double spaces to indent the generated code by
# @param service [Google::Protobuf::ServiceDescriptorProto]
# @param package [String, nil] the optional package of the proto file that contains the service, e.g. "example.hello_world"
# @param current_module [String, nil] the optional name of the containing module, e.g. "::Example::HelloWorld"
# @return [void]
def generate_client_class_standalone(output, indent_level, service, package, current_module)
output << line("class #{service.client_class_name} < ::Twirp::Client", indent_level)
output << line(" package \"#{package}\"", indent_level) unless package.to_s.empty?
output << line(" service \"#{service.name}\"", indent_level)
service["method"].each do |method| # method: <Google::Protobuf::MethodDescriptorProto>
input_type = convert_to_ruby_type(method.input_type, current_module)
output_type = convert_to_ruby_type(method.output_type, current_module)
ruby_method_name = method.name.snake_case

# TRICKY: The service `rpc` DSL accepts a method symbol, but the client `rpc` DSL expects a string.
output << line(" rpc \"#{method.name}\", #{input_type}, #{output_type}, ruby_method: :#{ruby_method_name}", indent_level)
end
output << line("end", indent_level)
end

# Converts either a package string like ".some.example.api" or a namespaced
# message like "google.protobuf.Empty" to an Array of Strings that can be
# used as Ruby constants (when joined with "::").
Expand Down
17 changes: 15 additions & 2 deletions lib/twirp/protoc_plugin/process.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,16 @@ def process(input)
# array format, e.g. "some-flag,key1=value1".
#
# The only valid parameter is currently the optional "skip-empty" flag.
# @return [Hash{Symbol => Boolean}]
# @return [Hash{Symbol => Boolean, Symbol}]
# * :skip_empty [Boolean] indicating whether generation should skip creating a twirp file
# for proto files that contain no services. Default false.
# * :generate [Symbol] one of: :service, :client, or :both. Default :both.
# @raise [ArgumentError] when a required parameter is missing, a parameter value is invalid, or
# an unrecognized parameter is present on the command line
def extract_options(params)
opts = {
skip_empty: false
skip_empty: false,
generate: :both
}

# Process the options passed to the plugin from `protoc`.
Expand All @@ -62,6 +64,17 @@ def extract_options(params)
raise ArgumentError, "Unexpected value passed to skip-empty flag: #{value}"
end
opts[:skip_empty] = true
elsif key == "generate"
if value.nil? || value.empty?
raise ArgumentError, "Unexpected missing value for generate option. Please supply one of: service, client, both."
end

value_as_symbol = value&.to_sym
unless %i[service client both].include?(value_as_symbol)
raise ArgumentError, "The generate value must be one of: service, client, both. Unexpectedly received: #{value}"
end

opts[:generate] = value_as_symbol
else
raise ArgumentError, "Invalid option: #{key}"
end
Expand Down
49 changes: 49 additions & 0 deletions spec/fixtures/complex_example/api.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
syntax = "proto3";

// This is a contrived example that demonstrates:
//
// 1. Code generation for multiple services, each with multiple rpcs
// 2. Using imported types for request and response types
// 3. Services proto in a completely different package
//
// This file does _not_ demonstrate best practices.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤣


package api;

import "common/rpc/status.proto";
import "common/type/color.proto";
import "common/type/time_of_day.proto";

service GreetService {
rpc SayHello(HelloRequest) returns (HelloResponse);
rpc SayGoodbye(GoodbyeRequest) returns (GoodbyeResponse);
rpc ChangeColor(.common.type.Color) returns (ChangeColorResponse);
}

service StatusService {
rpc GetStatus(StatusRequest) returns (.common.rpc.Status);
rpc GetTimeOfDay(TimeOfDayRequest) returns (common.type.TimeOfDay);
}

message HelloRequest {
string name = 1;
}

message HelloResponse {
string response = 1;
.common.type.Color favorite_color = 2;
}

message GoodbyeRequest {
string name = 1;
}

message GoodbyeResponse {
string response = 1;
}

message ChangeColorResponse {}

message StatusRequest {}

message TimeOfDayRequest {}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
11 changes: 11 additions & 0 deletions spec/fixtures/complex_example/common/rpc/status.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
syntax = "proto3";

package common.rpc;

import "google/protobuf/any.proto";

message Status {
int32 code = 1;
string message = 2;
repeated google.protobuf.Any details = 3;
}
10 changes: 10 additions & 0 deletions spec/fixtures/complex_example/common/type/color.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
syntax = "proto3";

package common.type;

message Color {
float red = 1;
float green = 2;
float blue = 3;
float alpha = 4;
}
13 changes: 13 additions & 0 deletions spec/fixtures/complex_example/common/type/time_of_day.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
syntax = "proto3";

package common.type;

message TimeOfDay {
enum Meridiem {
UNKNOWN = 0;
ANTE = 1;
POST = 2;
}

Meridiem am_or_pm = 1;
}
5 changes: 0 additions & 5 deletions spec/fixtures/import_type_retention/example.proto

This file was deleted.

Binary file not shown.
Binary file not shown.

This file was deleted.

Binary file not shown.
Binary file not shown.
2 changes: 1 addition & 1 deletion spec/twirp/protoc_plugin/code_generator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# the `process_spec.rb`. But, in order to get to the private helper methods
# to test, we have to stand up an instance that we can send messages to.
let(:proto_file_descriptor) { Google::Protobuf::FileDescriptorProto.new }
let(:options) { {skip_empty: false} }
let(:options) { {skip_empty: false, generate: :both} }
let(:code_generator) { Twirp::ProtocPlugin::CodeGenerator.new(proto_file_descriptor, "example_rb.pb", options) }

describe "#split_to_constants" do
Expand Down
Loading