tonic-rails 0.1.0

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,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/class/attribute"
4
+ require "active_support/core_ext/hash/keys"
5
+ require "active_support/core_ext/string/inflections"
6
+
7
+ module Tonic
8
+ module Rails
9
+ # Include in a class to define a gRPC service handler.
10
+ #
11
+ # class ProductService
12
+ # include Tonic::Rails::Base
13
+ # rpc_service "shop.ProductService" # every RPC from the descriptor
14
+ #
15
+ # def get_product(request)
16
+ # Shop::Product.new(id: request.id, title: "Widget")
17
+ # end
18
+ # end
19
+ #
20
+ # Handler methods are the snake_case RPC names. A new instance is
21
+ # created for every call, so instance variables never leak between
22
+ # requests.
23
+ #
24
+ # Requests are decoded protobuf messages when `request_format :message`
25
+ # is set (fastest), or string-keyed Hashes by default. Handlers may
26
+ # return either a protobuf message or a Hash; streaming handlers
27
+ # return any Enumerable of them.
28
+ module Base
29
+ def self.included(base)
30
+ base.extend(ClassMethods)
31
+ base.class_attribute :grpc_service_name, instance_writer: false
32
+ base.class_attribute :grpc_rpcs, instance_writer: false, default: {}
33
+ base.class_attribute :grpc_request_format, instance_writer: false, default: :hash
34
+ end
35
+
36
+ module ClassMethods
37
+ # @param name [String] fully-qualified service name, e.g. "shop.ProductService"
38
+ def service(name)
39
+ self.grpc_service_name = name.to_s
40
+ end
41
+
42
+ # How requests are passed to handlers.
43
+ # @param format [Symbol] :hash (string-keyed Hash, the default) or
44
+ # :message (the decoded protobuf message — avoids a conversion)
45
+ def request_format(format)
46
+ unless %i[hash message].include?(format)
47
+ raise ArgumentError, "request_format must be :hash or :message, got #{format.inspect}"
48
+ end
49
+
50
+ self.grpc_request_format = format
51
+ end
52
+
53
+ # Declare one RPC.
54
+ # @param method_name [Symbol, String] RPC name as in the .proto, e.g. :GetProduct
55
+ # @param request_class [Class] protobuf request message class
56
+ # @param response_class [Class] protobuf response message class
57
+ # @param stream [Symbol, nil] :server, :client, :bidi, or nil for unary
58
+ def rpc(method_name, request_class, response_class, stream: nil)
59
+ unless [nil, :server, :client, :bidi].include?(stream)
60
+ raise ArgumentError, "stream must be :server, :client, :bidi, or nil, got #{stream.inspect}"
61
+ end
62
+
63
+ name = method_name.to_s
64
+ self.grpc_rpcs = grpc_rpcs.merge(
65
+ name => {
66
+ request: request_class,
67
+ response: response_class,
68
+ request_stream: %i[client bidi].include?(stream),
69
+ response_stream: %i[server bidi].include?(stream),
70
+ handler: name.underscore.to_sym,
71
+ }.freeze,
72
+ )
73
+ end
74
+
75
+ # Declare the service and every RPC in it from the protobuf
76
+ # descriptor pool. Load the generated `_pb.rb` file first.
77
+ #
78
+ # @param full_name [String] fully-qualified service name
79
+ def rpc_service(full_name)
80
+ descriptor = Google::Protobuf::DescriptorPool.generated_pool.lookup(full_name.to_s)
81
+ unless descriptor.is_a?(Google::Protobuf::ServiceDescriptor)
82
+ raise NotConfiguredError,
83
+ "rpc_service: #{full_name} is not a known protobuf service. " \
84
+ "Require its generated _pb.rb file (protoc --ruby_out) before declaring the handler."
85
+ end
86
+
87
+ service(full_name)
88
+ descriptor.each do |method|
89
+ stream = if method.client_streaming && method.server_streaming
90
+ :bidi
91
+ elsif method.client_streaming
92
+ :client
93
+ elsif method.server_streaming
94
+ :server
95
+ end
96
+ rpc(method.name, method.input_type.msgclass, method.output_type.msgclass, stream: stream)
97
+ end
98
+ end
99
+
100
+ # @api private Called by Server#register.
101
+ def register_on(router)
102
+ raise NotConfiguredError, "#{self} has no service name; call `service` or `rpc_service`" unless grpc_service_name
103
+ raise NotConfiguredError, "#{self} declares no RPCs" if grpc_rpcs.empty?
104
+
105
+ router.add(self)
106
+ end
107
+ end
108
+
109
+ # gRPC request metadata (lower-case header names). Binary (`-bin`)
110
+ # values are base64-encoded, as sent on the wire.
111
+ # @return [Hash{String=>String}]
112
+ attr_reader :grpc_metadata
113
+
114
+ # Milliseconds left before the client's deadline, or nil if the
115
+ # client did not set one.
116
+ # @return [Integer, nil]
117
+ def deadline_ms
118
+ return unless @grpc_deadline_at
119
+
120
+ remaining = ((@grpc_deadline_at - Process.clock_gettime(Process::CLOCK_MONOTONIC)) * 1000).to_i
121
+ remaining.negative? ? 0 : remaining
122
+ end
123
+
124
+ # True when the deadline has passed.
125
+ def deadline_exceeded?
126
+ !deadline_ms.nil? && deadline_ms.zero?
127
+ end
128
+
129
+ # True when the server is shutting down.
130
+ def cancelled?
131
+ Tonic::Rails.cancelled?
132
+ end
133
+
134
+ # @api private
135
+ # Run one RPC. `reader` yields raw request messages (`#next`, then
136
+ # `#error` once exhausted); `sender` accepts raw response messages
137
+ # (`#push`) for server-streaming RPCs.
138
+ # @return [String, nil] the encoded unary response, or nil when
139
+ # messages were streamed through `sender`
140
+ def call_rpc(rpc_name, metadata:, reader:, sender: nil, timeout_ms: nil)
141
+ spec = self.class.grpc_rpcs[rpc_name.to_s]
142
+ raise RpcError.new("method not implemented", code: :unimplemented) unless spec
143
+ raise RpcError.new("method #{rpc_name} not implemented", code: :unimplemented) unless respond_to?(spec[:handler])
144
+
145
+ @grpc_metadata = metadata
146
+ @grpc_deadline_at = timeout_ms && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + (timeout_ms / 1000.0))
147
+
148
+ request = if spec[:request_stream]
149
+ Enumerator.new do |y|
150
+ while (message = read_request(reader, spec))
151
+ y << message
152
+ end
153
+ end
154
+ else
155
+ read_request(reader, spec) || raise(RpcError.new("missing request message", code: :invalid_argument))
156
+ end
157
+
158
+ result = public_send(spec[:handler], request)
159
+
160
+ if spec[:response_stream]
161
+ unless result.respond_to?(:each)
162
+ raise TypeError, "streaming handler #{self.class}##{spec[:handler]} must return an Enumerable, got #{result.class}"
163
+ end
164
+
165
+ result.each { |item| push_response(sender, encode_response(item, spec)) }
166
+ nil
167
+ else
168
+ encode_response(result, spec)
169
+ end
170
+ rescue RpcError
171
+ raise
172
+ rescue StandardError => e
173
+ Tonic::Rails.log_exception(e, "#{self.class}##{rpc_name}")
174
+ raise RpcError.new("internal error", code: :internal)
175
+ end
176
+
177
+ private
178
+
179
+ def read_request(reader, spec)
180
+ bytes = reader.next
181
+ if bytes.nil?
182
+ code, message = reader.error
183
+ raise RpcError.new(message, code: code) if code
184
+
185
+ return
186
+ end
187
+
188
+ message = begin
189
+ spec[:request].decode(bytes)
190
+ rescue Google::Protobuf::ParseError, ArgumentError
191
+ raise RpcError.new("invalid protobuf request", code: :invalid_argument)
192
+ end
193
+ self.class.grpc_request_format == :message ? message : message.to_h.deep_stringify_keys
194
+ end
195
+
196
+ def encode_response(item, spec)
197
+ klass = spec[:response]
198
+ message = case item
199
+ when klass then item
200
+ when Hash then klass.new(item)
201
+ else
202
+ raise TypeError, "#{self.class}##{spec[:handler]} must return a #{klass} or Hash, got #{item.class}"
203
+ end
204
+ klass.encode(message)
205
+ end
206
+
207
+ def push_response(sender, bytes)
208
+ sender.push(bytes)
209
+ rescue IOError
210
+ # The client went away; nothing left to deliver to.
211
+ raise RpcError.new("client cancelled the stream", code: :cancelled)
212
+ end
213
+ end
214
+ end
215
+ end
@@ -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