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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +52 -0
- data/Cargo.lock +1242 -0
- data/Cargo.toml +11 -0
- data/LICENSE +21 -0
- data/README.md +391 -0
- data/ext/tonic_rails_native/Cargo.toml +44 -0
- data/ext/tonic_rails_native/extconf.rb +9 -0
- data/ext/tonic_rails_native/src/body.rs +289 -0
- data/ext/tonic_rails_native/src/dispatch.rs +250 -0
- data/ext/tonic_rails_native/src/framing.rs +349 -0
- data/ext/tonic_rails_native/src/gvl.rs +473 -0
- data/ext/tonic_rails_native/src/lib.rs +90 -0
- data/ext/tonic_rails_native/src/net.rs +146 -0
- data/ext/tonic_rails_native/src/server.rs +507 -0
- data/lib/tonic/rails/base.rb +215 -0
- data/lib/tonic/rails/configuration.rb +149 -0
- data/lib/tonic/rails/railtie.rb +56 -0
- data/lib/tonic/rails/reflection.rb +50 -0
- data/lib/tonic/rails/router.rb +71 -0
- data/lib/tonic/rails/rpc_error.rb +65 -0
- data/lib/tonic/rails/server.rb +224 -0
- data/lib/tonic/rails/tasks/tonic-rails.rake +36 -0
- data/lib/tonic/rails/testing.rb +85 -0
- data/lib/tonic/rails/version.rb +7 -0
- data/lib/tonic-rails.rb +91 -0
- metadata +121 -0
|
@@ -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
|