Skip to content

Commit

Permalink
Merge pull request #23 from collectiveidea/introduce-generate-option
Browse files Browse the repository at this point in the history
Introduce generate option to customize output
  • Loading branch information
darronschall committed May 17, 2024
2 parents 070b176 + 393768e commit c20aa52
Show file tree
Hide file tree
Showing 21 changed files with 473 additions and 112 deletions.
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.

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

0 comments on commit c20aa52

Please sign in to comment.