tonic-rails 0.1.0-aarch64-linux-gnu

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Tonic
6
+ module Rails
7
+ class Configuration
8
+ # Address the gRPC server binds to ("host:port"). Default "0.0.0.0:50051".
9
+ attr_accessor :bind_address
10
+
11
+ # Maximum request and response message size in bytes (also caps
12
+ # decompressed payloads). Default 4 MiB.
13
+ attr_accessor :max_message_size
14
+
15
+ # Start the gRPC server in a background thread when `rails server`
16
+ # boots. Prefer a dedicated process (`bin/rails tonic_rails:serve`)
17
+ # in production. Default false.
18
+ attr_accessor :auto_start
19
+
20
+ attr_accessor :logger
21
+
22
+ # Tokio worker threads for the transport. nil = number of CPUs.
23
+ attr_accessor :worker_threads
24
+
25
+ # Ruby threads running handlers. Match your ActiveRecord pool size
26
+ # (RAILS_MAX_THREADS). Default 4.
27
+ attr_accessor :dispatch_threads
28
+
29
+ # RPCs that may wait for a free dispatch thread. Beyond this the
30
+ # server answers RESOURCE_EXHAUSTED immediately. Default 128.
31
+ attr_accessor :dispatch_queue_size
32
+
33
+ # PEM certificate and private key. Set both to enable TLS.
34
+ attr_accessor :tls_cert_path, :tls_key_path
35
+
36
+ # PEM CA bundle. When set, clients must present a certificate
37
+ # signed by it (mutual TLS).
38
+ attr_accessor :tls_client_ca_path
39
+
40
+ # Seconds to wait for in-flight RPCs during shutdown. Default 10.
41
+ attr_accessor :drain_timeout
42
+
43
+ # Concurrent TCP connections; extra connections are refused.
44
+ # 0 = unlimited. Default 64.
45
+ attr_accessor :max_connections
46
+
47
+ # Concurrent HTTP/2 streams per connection. 0 = unlimited. Default 32.
48
+ attr_accessor :concurrency_limit_per_connection
49
+
50
+ # Log every request, not just errors, to stderr. Default false.
51
+ attr_accessor :log_requests
52
+
53
+ # Callables wrapping every RPC, outermost first:
54
+ #
55
+ # ->(path, metadata, next_call) { next_call.call }
56
+ #
57
+ # Raise RpcError to reject the call.
58
+ attr_accessor :interceptors
59
+
60
+ # gzip responses larger than 1 KiB for clients that accept it.
61
+ # Compressed requests are always accepted. Default false.
62
+ attr_accessor :compression
63
+
64
+ # Serve grpc.reflection.v1 (for grpcurl and friends). Exposes your
65
+ # API schema; keep it off in production. Default false.
66
+ attr_accessor :reflection
67
+
68
+ # HTTP/2 PING interval and ack timeout, in seconds. Defaults 15 / 5.
69
+ attr_accessor :keepalive_interval, :keepalive_timeout
70
+
71
+ # Look handler classes up again on each request so code reloading
72
+ # works. The Railtie enables this when Rails reloading is on.
73
+ attr_accessor :reload_handlers
74
+
75
+ def initialize
76
+ @bind_address = "0.0.0.0:50051"
77
+ @max_message_size = 4 * 1024 * 1024
78
+ @auto_start = false
79
+ @logger = Logger.new($stdout)
80
+ @worker_threads = nil
81
+ @dispatch_threads = 4
82
+ @dispatch_queue_size = 128
83
+ @tls_cert_path = nil
84
+ @tls_key_path = nil
85
+ @tls_client_ca_path = nil
86
+ @drain_timeout = 10
87
+ @max_connections = 64
88
+ @concurrency_limit_per_connection = 32
89
+ @log_requests = false
90
+ @interceptors = []
91
+ @compression = false
92
+ @reflection = false
93
+ @keepalive_interval = 15
94
+ @keepalive_timeout = 5
95
+ @reload_handlers = false
96
+ end
97
+
98
+ def tls?
99
+ !tls_cert_path.nil? && !tls_key_path.nil?
100
+ end
101
+
102
+ # @raise [ArgumentError] describing the first invalid setting
103
+ def validate!
104
+ positive_integer!(:dispatch_threads)
105
+ positive_integer!(:dispatch_queue_size)
106
+ positive_integer!(:max_message_size)
107
+ non_negative_integer!(:max_connections)
108
+ non_negative_integer!(:concurrency_limit_per_connection)
109
+ positive_integer!(:worker_threads) unless worker_threads.nil?
110
+ %i[drain_timeout keepalive_interval keepalive_timeout].each { |name| non_negative_number!(name) }
111
+
112
+ raise ArgumentError, "max_message_size must be at most 4 GiB" if max_message_size > 0xFFFF_FFFF
113
+ raise ArgumentError, "tls_cert_path and tls_key_path must be set together" if tls_cert_path.nil? != tls_key_path.nil?
114
+ raise ArgumentError, "tls_client_ca_path requires tls_cert_path and tls_key_path" if tls_client_ca_path && !tls?
115
+ unless interceptors.respond_to?(:each) && interceptors.all? { |i| i.respond_to?(:call) }
116
+ raise ArgumentError, "interceptors must be an Array of callables"
117
+ end
118
+
119
+ self
120
+ end
121
+
122
+ private
123
+
124
+ def positive_integer!(name)
125
+ value = public_send(name)
126
+ return if value.is_a?(Integer) && value.positive?
127
+
128
+ raise ArgumentError,
129
+ "#{name} must be a positive Integer, got #{value.inspect}"
130
+ end
131
+
132
+ def non_negative_integer!(name)
133
+ value = public_send(name)
134
+ return if value.is_a?(Integer) && !value.negative?
135
+
136
+ raise ArgumentError,
137
+ "#{name} must be a non-negative Integer, got #{value.inspect}"
138
+ end
139
+
140
+ def non_negative_number!(name)
141
+ value = public_send(name)
142
+ return if value.is_a?(Numeric) && !value.negative?
143
+
144
+ raise ArgumentError,
145
+ "#{name} must be a non-negative number, got #{value.inspect}"
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tonic
4
+ module Rails
5
+ # Rails integration:
6
+ # - `config.tonic_rails.*` settings are copied onto Tonic::Rails.configuration
7
+ # (an initializer calling Tonic::Rails.configure can still override them)
8
+ # - the Rails logger is used by default
9
+ # - handler classes are looked up per request when code reloading is on
10
+ # - `auto_start` runs the server inside `rails server`
11
+ # - rake tasks: tonic_rails:serve, tonic_rails:routes, tonic_rails:compile_protos
12
+ class Railtie < ::Rails::Railtie
13
+ config.tonic_rails = ::ActiveSupport::OrderedOptions.new
14
+
15
+ initializer "tonic_rails.configuration" do |app|
16
+ configuration = Tonic::Rails.configuration
17
+ configuration.logger = ::Rails.logger if ::Rails.logger
18
+ configuration.reload_handlers = Railtie.reloading_enabled?(app)
19
+
20
+ app.config.tonic_rails.each do |key, value|
21
+ setter = "#{key}="
22
+ raise ArgumentError, "unknown tonic-rails setting config.tonic_rails.#{key}" unless configuration.respond_to?(setter)
23
+
24
+ configuration.public_send(setter, value)
25
+ end
26
+ end
27
+
28
+ config.after_initialize do
29
+ configuration = Tonic::Rails.configuration
30
+ # Only inside an actual web server process — not consoles, rake
31
+ # tasks, or test runs. Forking servers (Puma cluster mode) must
32
+ # instead start the server per worker, e.g. in `on_worker_boot`.
33
+ if configuration.auto_start && defined?(::Rails::Server)
34
+ server = Tonic::Rails.server
35
+ thread = Thread.new do
36
+ Thread.current.name = "tonic-rails"
37
+ server.start(install_signal_handlers: false)
38
+ end
39
+ thread.report_on_exception = true
40
+ at_exit do
41
+ server.stop
42
+ thread.join(configuration.drain_timeout.to_f + 5)
43
+ end
44
+ end
45
+ end
46
+
47
+ def self.reloading_enabled?(app)
48
+ app.config.respond_to?(:enable_reloading) ? app.config.enable_reloading : !app.config.cache_classes
49
+ end
50
+
51
+ rake_tasks do
52
+ load File.expand_path("tasks/tonic-rails.rake", __dir__)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "google/protobuf/descriptor_pb"
4
+
5
+ module Tonic
6
+ module Rails
7
+ # Builds the protobuf file descriptors served by gRPC reflection from
8
+ # the descriptors already loaded in Ruby (the generated `_pb.rb` files).
9
+ module Reflection
10
+ module_function
11
+
12
+ # @param service_names [Array<String>] fully-qualified service names
13
+ # @return [Array<String>] serialized google.protobuf.FileDescriptorProto,
14
+ # covering each service's file and every file its messages reference
15
+ def file_descriptors(service_names)
16
+ pool = Google::Protobuf::DescriptorPool.generated_pool
17
+ files = {}
18
+ seen = Set.new
19
+ pending = []
20
+
21
+ service_names.each do |name|
22
+ service = pool.lookup(name)
23
+ next unless service.is_a?(Google::Protobuf::ServiceDescriptor)
24
+
25
+ add_file(files, service.file_descriptor)
26
+ service.each { |method| pending << method.input_type << method.output_type }
27
+ end
28
+
29
+ until pending.empty?
30
+ descriptor = pending.pop
31
+ next unless seen.add?(descriptor.name)
32
+
33
+ add_file(files, descriptor.file_descriptor)
34
+ next unless descriptor.is_a?(Google::Protobuf::Descriptor)
35
+
36
+ descriptor.each do |field|
37
+ subtype = field.subtype
38
+ pending << subtype if subtype
39
+ end
40
+ end
41
+
42
+ files.values.map { |fd| Google::Protobuf::FileDescriptorProto.encode(fd.to_proto) }
43
+ end
44
+
45
+ def add_file(files, file_descriptor)
46
+ files[file_descriptor.name] ||= file_descriptor
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tonic
4
+ module Rails
5
+ # Maps gRPC method paths ("/package.Service/Method") to handler classes.
6
+ class Router
7
+ Route = Struct.new(:service_class, :rpc_name)
8
+
9
+ def initialize
10
+ @routes = {}
11
+ @services = {}
12
+ end
13
+
14
+ # Register (or re-register, e.g. after a code reload) every RPC of
15
+ # a handler class.
16
+ # @param service_class [Class] a class including Tonic::Rails::Base
17
+ def add(service_class)
18
+ name = service_class.grpc_service_name
19
+ @routes.delete_if { |_, route| route.service_class.grpc_service_name == name }
20
+ @services[name] = service_class
21
+ service_class.grpc_rpcs.each_key do |rpc_name|
22
+ @routes["/#{name}/#{rpc_name}"] = Route.new(service_class, rpc_name)
23
+ end
24
+ self
25
+ end
26
+
27
+ # @param path [String] gRPC method path
28
+ # @param metadata [Hash{String=>String}]
29
+ # @param call [Hash] :reader, :sender and optional :timeout_ms from the transport
30
+ # @return [String, nil] encoded unary response, or nil for streamed responses
31
+ def dispatch(path, metadata, call)
32
+ route = @routes[path]
33
+ raise RpcError.new("unknown method #{path}", code: :unimplemented) unless route
34
+
35
+ resolve(route.service_class).new.call_rpc(
36
+ route.rpc_name,
37
+ metadata: metadata,
38
+ reader: call[:reader],
39
+ sender: call[:sender],
40
+ timeout_ms: call[:timeout_ms],
41
+ )
42
+ end
43
+
44
+ # @return [Array<String>] registered gRPC method paths
45
+ def paths
46
+ @routes.keys
47
+ end
48
+
49
+ # @return [Array<String>] registered fully-qualified service names
50
+ def service_names
51
+ @services.keys
52
+ end
53
+
54
+ def empty?
55
+ @routes.empty?
56
+ end
57
+
58
+ private
59
+
60
+ # With code reloading on, look the class up again so edits to
61
+ # handler bodies take effect without a restart.
62
+ def resolve(service_class)
63
+ return service_class unless Tonic::Rails.configuration.reload_handlers
64
+
65
+ Object.const_get(service_class.name)
66
+ rescue NameError
67
+ service_class
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tonic
4
+ module Rails
5
+ # Raise from a handler to end the RPC with a gRPC status.
6
+ #
7
+ # raise Tonic::Rails::RpcError.new("product not found", code: :not_found)
8
+ #
9
+ # The message is sent to the client, so keep internal details out of
10
+ # it. Any other exception becomes INTERNAL with a generic message
11
+ # and is logged server-side.
12
+ class RpcError < Error
13
+ CODES = {
14
+ ok: 0,
15
+ cancelled: 1,
16
+ unknown: 2,
17
+ invalid_argument: 3,
18
+ deadline_exceeded: 4,
19
+ not_found: 5,
20
+ already_exists: 6,
21
+ permission_denied: 7,
22
+ resource_exhausted: 8,
23
+ failed_precondition: 9,
24
+ aborted: 10,
25
+ out_of_range: 11,
26
+ unimplemented: 12,
27
+ internal: 13,
28
+ unavailable: 14,
29
+ data_loss: 15,
30
+ unauthenticated: 16,
31
+ }.freeze
32
+
33
+ CODE_NAMES = CODES.invert.freeze
34
+
35
+ # @return [Integer] numeric gRPC status code
36
+ attr_reader :code
37
+
38
+ # @param message [String] status message sent to the client
39
+ # @param code [Symbol, Integer] gRPC status code (see CODES)
40
+ def initialize(message = nil, code: :unknown)
41
+ @code = self.class.normalize_code(code)
42
+ super(message || code_name.to_s)
43
+ end
44
+
45
+ # @return [Symbol] e.g. :not_found
46
+ def code_name
47
+ CODE_NAMES.fetch(@code)
48
+ end
49
+
50
+ # @api private
51
+ def self.normalize_code(code)
52
+ case code
53
+ when Symbol, String
54
+ CODES.fetch(code.to_sym) { raise ArgumentError, "unknown gRPC status code: #{code.inspect}" }
55
+ when Integer
56
+ raise ArgumentError, "unknown gRPC status code: #{code}" unless CODE_NAMES.key?(code)
57
+
58
+ code
59
+ else
60
+ raise ArgumentError, "gRPC status code must be a Symbol or Integer, got #{code.class}"
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tonic
4
+ module Rails
5
+ # Runs the gRPC server: a tonic transport in Rust plus a pool of Ruby
6
+ # dispatch threads.
7
+ #
8
+ # server = Tonic::Rails::Server.new
9
+ # server.register(ProductService)
10
+ # server.start # blocks until SIGTERM / SIGINT or #stop
11
+ #
12
+ # Shutdown is graceful: health checks flip to NOT_SERVING, new
13
+ # connections are refused, in-flight RPCs get `drain_timeout` seconds
14
+ # to finish, then everything is torn down.
15
+ class Server
16
+ attr_reader :router, :configuration
17
+
18
+ def initialize(configuration: Tonic::Rails.configuration)
19
+ @configuration = configuration
20
+ @router = Router.new
21
+ @native = nil
22
+ @state = :stopped
23
+ @stop_reader = nil
24
+ @stop_writer = nil
25
+ @stop_requested = false
26
+ @lock = Mutex.new
27
+ end
28
+
29
+ # @param service_class [Class, String] handler class (or its name)
30
+ def register(service_class)
31
+ service_class = Object.const_get(service_class) if service_class.is_a?(String)
32
+ service_class.register_on(@router)
33
+ self
34
+ end
35
+
36
+ # Start serving and block until stopped.
37
+ # @param install_signal_handlers [Boolean] trap TERM and INT to stop.
38
+ # Pass false when embedding in a process that owns its signals.
39
+ # @yield once the server is accepting connections
40
+ def start(install_signal_handlers: true)
41
+ @lock.synchronize do
42
+ raise Error, "server is already running" unless @state == :stopped
43
+
44
+ @state = :starting
45
+ @stop_requested = false
46
+ end
47
+
48
+ begin
49
+ raise Error, "no services registered" if @router.empty?
50
+
51
+ @configuration.validate!
52
+ Tonic::Rails.native_extension!
53
+ Tonic::Rails._set_shutting_down(false)
54
+ @stop_reader, @stop_writer = IO.pipe
55
+ stop if @stop_requested # requested while we were starting up
56
+ @native = build_native_server
57
+ @native.start_server
58
+ @state = :running
59
+
60
+ previous_handlers = install_signal_handlers ? trap_signals : {}
61
+ workers = Array.new(@configuration.dispatch_threads) { |i| spawn_worker(i) }
62
+ log_startup
63
+ yield self if block_given?
64
+
65
+ wait_for_stop(workers)
66
+ shutdown(workers)
67
+ ensure
68
+ previous_handlers&.each { |signal, handler| Signal.trap(signal, handler || "DEFAULT") }
69
+ @native&.stop_server
70
+ @native = nil
71
+ [@stop_reader, @stop_writer].each { |io| io.close if io && !io.closed? }
72
+ @stop_reader = @stop_writer = nil
73
+ Tonic::Rails._set_shutting_down(false)
74
+ @state = :stopped
75
+ end
76
+ end
77
+
78
+ # Request a graceful stop. Safe to call from signal handlers and
79
+ # other threads; returns immediately.
80
+ def stop
81
+ @stop_requested = true
82
+ @stop_writer&.write_nonblock(".", exception: false)
83
+ rescue IOError
84
+ # Already stopped.
85
+ end
86
+
87
+ def running?
88
+ @state == :running
89
+ end
90
+
91
+ # @return [Hash{Symbol=>Integer}] request and connection counters
92
+ def stats
93
+ native = @native
94
+ return native.stats if native
95
+
96
+ { total: 0, successful: 0, errors: 0, cancelled: 0, deadline_exceeded: 0,
97
+ resource_exhausted: 0, unimplemented: 0, active_connections: 0, queued: 0 }
98
+ end
99
+
100
+ private
101
+
102
+ def build_native_server
103
+ config = @configuration
104
+ native = TonicRails::Native::Server.new(config.bind_address, config.max_message_size)
105
+ native.on_request(build_callback)
106
+ native.set_routes(@router.paths)
107
+ if config.tls?
108
+ native.set_tls(config.tls_cert_path, config.tls_key_path)
109
+ native.set_tls_client_ca(config.tls_client_ca_path) if config.tls_client_ca_path
110
+ end
111
+ native.set_max_connections(config.max_connections)
112
+ native.set_concurrency_limit_per_connection(config.concurrency_limit_per_connection)
113
+ native.set_worker_threads(config.worker_threads || 0)
114
+ native.set_keepalive(config.keepalive_interval.to_f, config.keepalive_timeout.to_f)
115
+ native.set_dispatch_queue_size(config.dispatch_queue_size)
116
+ native.set_log_requests(config.log_requests ? true : false)
117
+ native.set_compression(config.compression ? true : false)
118
+ native.set_reflection(config.reflection ? true : false)
119
+ native.set_reflection_descriptors(Reflection.file_descriptors(@router.service_names)) if config.reflection
120
+ native
121
+ end
122
+
123
+ # The proc the transport calls for every RPC:
124
+ # (path, metadata, call) → encoded response String, or nil if streamed
125
+ def build_callback
126
+ router = @router
127
+ call = ->(path, metadata, transport) { router.dispatch(path, metadata, transport) }
128
+
129
+ call = @configuration.interceptors.reverse.reduce(call) do |inner, interceptor|
130
+ lambda do |path, metadata, transport|
131
+ interceptor.call(path, metadata, -> { inner.call(path, metadata, transport) })
132
+ end
133
+ end
134
+
135
+ guarded = lambda do |path, metadata, transport|
136
+ call.call(path, metadata, transport)
137
+ rescue RpcError
138
+ raise
139
+ rescue StandardError => e
140
+ Tonic::Rails.log_exception(e, path)
141
+ raise RpcError.new("internal error", code: :internal)
142
+ end
143
+
144
+ wrap_with_rails_executor(guarded)
145
+ end
146
+
147
+ # Each RPC runs inside the Rails executor (or reloader, when code
148
+ # reloading is on): ActiveRecord connections are returned, query
149
+ # caches and CurrentAttributes are reset — the same isolation Rack
150
+ # requests and Active Job get.
151
+ def wrap_with_rails_executor(callback)
152
+ return callback unless defined?(::Rails.application) && ::Rails.application
153
+
154
+ app = ::Rails.application
155
+ wrapper = @configuration.reload_handlers ? app.reloader : app.executor
156
+ ->(path, metadata, transport) { wrapper.wrap { callback.call(path, metadata, transport) } }
157
+ end
158
+
159
+ def spawn_worker(index)
160
+ native = @native
161
+ Thread.new do
162
+ Thread.current.name = "tonic-rails-dispatch-#{index}"
163
+ native.serve_worker
164
+ rescue Exception => e # rubocop:disable Lint/RescueException
165
+ Tonic::Rails.log_exception(e, "dispatch thread #{index}")
166
+ stop
167
+ raise
168
+ end
169
+ end
170
+
171
+ def trap_signals
172
+ %w[TERM INT].to_h { |signal| [signal, Signal.trap(signal) { stop }] }
173
+ end
174
+
175
+ def wait_for_stop(workers)
176
+ loop do
177
+ break if @stop_reader.wait_readable(1)
178
+
179
+ dead = workers.reject(&:alive?)
180
+ unless dead.empty?
181
+ @configuration.logger&.error("tonic-rails: #{dead.size} dispatch thread(s) died; shutting down")
182
+ break
183
+ end
184
+ end
185
+ end
186
+
187
+ def shutdown(workers)
188
+ logger = @configuration.logger
189
+ @state = :stopping
190
+ Tonic::Rails._set_shutting_down(true)
191
+ logger&.info("tonic-rails: shutting down, draining for up to #{@configuration.drain_timeout}s")
192
+
193
+ @native.begin_shutdown
194
+ unless @native.wait_for_drain(@configuration.drain_timeout.to_f)
195
+ logger&.warn("tonic-rails: drain timeout expired; closing remaining connections")
196
+ end
197
+ @native.stop_workers
198
+ workers.each do |worker|
199
+ worker.join
200
+ rescue Exception # rubocop:disable Lint/RescueException
201
+ # Already logged by the worker.
202
+ end
203
+ logger&.info("tonic-rails: stopped")
204
+ end
205
+
206
+ def log_startup
207
+ config = @configuration
208
+ logger = config.logger
209
+ return unless logger
210
+
211
+ if config.tls?
212
+ logger.info("tonic-rails: TLS enabled#{' (mutual TLS)' if config.tls_client_ca_path}")
213
+ else
214
+ logger.warn("tonic-rails: TLS is not configured; gRPC traffic is plaintext")
215
+ end
216
+ logger.info("tonic-rails: reflection enabled") if config.reflection
217
+ logger.info(
218
+ "tonic-rails: serving #{@router.service_names.join(', ')} on #{config.bind_address} " \
219
+ "(#{config.dispatch_threads} dispatch threads)",
220
+ )
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ namespace :tonic_rails do
6
+ desc "Start the gRPC server (boots the Rails app with its registered services)"
7
+ task serve: :environment do
8
+ # Eager-load so handlers registered via config.to_prepare and
9
+ # autoloaded constants are in place, as they would be in production.
10
+ Rails.application.eager_load! unless Rails.application.config.eager_load
11
+ Tonic::Rails.server.start
12
+ end
13
+
14
+ desc "List registered gRPC methods"
15
+ task routes: :environment do
16
+ router = Tonic::Rails.server.router
17
+ if router.empty?
18
+ puts "No gRPC services registered."
19
+ else
20
+ router.paths.sort.each { |path| puts path }
21
+ end
22
+ end
23
+
24
+ desc "Generate Ruby protobuf classes (PROTO_DIR=app/protos RUBY_OUT=lib/grpc; requires protoc)"
25
+ task :compile_protos do
26
+ proto_dir = ENV.fetch("PROTO_DIR", "app/protos")
27
+ ruby_out = ENV.fetch("RUBY_OUT", "lib/grpc")
28
+ protos = Dir.glob(File.join(proto_dir, "**", "*.proto"))
29
+ abort "No .proto files found in #{proto_dir}/" if protos.empty?
30
+ abort "protoc not found on PATH. Install the Protocol Buffers compiler." unless system("protoc", "--version", out: File::NULL)
31
+
32
+ FileUtils.mkdir_p(ruby_out)
33
+ sh "protoc", "--ruby_out=#{ruby_out}", "-I#{proto_dir}", *protos
34
+ puts "Compiled #{protos.size} proto file(s) to #{ruby_out}/"
35
+ end
36
+ end