tonic-rails 0.1.0-x86_64-linux-gnu → 0.3.0-x86_64-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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -2
  3. data/README.md +204 -13
  4. data/lib/generators/tonic_rails/install/install_generator.rb +45 -0
  5. data/lib/generators/tonic_rails/install/templates/tonic_rails.rb.tt +21 -0
  6. data/lib/generators/tonic_rails/service/service_generator.rb +110 -0
  7. data/lib/generators/tonic_rails/service/templates/handler.rb.tt +38 -0
  8. data/lib/generators/tonic_rails/service/templates/service.proto.tt +16 -0
  9. data/lib/generators/tonic_rails/service/templates/service_spec.rb.tt +17 -0
  10. data/lib/generators/tonic_rails/service/templates/service_test.rb.tt +14 -0
  11. data/lib/puma/plugin/tonic_rails.rb +116 -0
  12. data/lib/tonic/rails/3.3/tonic_rails_native.so +0 -0
  13. data/lib/tonic/rails/3.4/tonic_rails_native.so +0 -0
  14. data/lib/tonic/rails/4.0/tonic_rails_native.so +0 -0
  15. data/lib/tonic/rails/base.rb +169 -23
  16. data/lib/tonic/rails/configuration.rb +27 -4
  17. data/lib/tonic/rails/health.rb +105 -0
  18. data/lib/tonic/rails/instrumentation.rb +78 -0
  19. data/lib/tonic/rails/metadata.rb +50 -0
  20. data/lib/tonic/rails/peer.rb +60 -0
  21. data/lib/tonic/rails/railtie.rb +2 -2
  22. data/lib/tonic/rails/router.rb +1 -0
  23. data/lib/tonic/rails/rpc_error.rb +73 -6
  24. data/lib/tonic/rails/server.rb +82 -12
  25. data/lib/tonic/rails/tasks/tonic-rails.rake +13 -1
  26. data/lib/tonic/rails/testing/errors.rb +47 -0
  27. data/lib/tonic/rails/testing/minitest.rb +44 -0
  28. data/lib/tonic/rails/testing/rspec.rb +53 -0
  29. data/lib/tonic/rails/testing.rb +35 -4
  30. data/lib/tonic/rails/version.rb +1 -1
  31. data/lib/tonic-rails.rb +4 -0
  32. metadata +16 -1
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tonic
4
+ module Rails
5
+ # The client on the other end of an RPC, available in handlers as
6
+ # `peer`:
7
+ #
8
+ # peer.address # => "10.0.4.17:51234"
9
+ # peer.ip # => "10.0.4.17"
10
+ # peer.certificate # => OpenSSL::X509::Certificate, with mutual TLS
11
+ # peer.certificate&.subject.to_s # => "/CN=billing-service"
12
+ class Peer
13
+ # @return [String, nil] "ip:port" ("[ipv6]:port" for IPv6)
14
+ attr_reader :address
15
+
16
+ # @param address [String, nil]
17
+ # @param certificates [Array<String>] DER-encoded chain, leaf first
18
+ def initialize(address = nil, certificates = [])
19
+ @address = address
20
+ @certificate_ders = certificates
21
+ end
22
+
23
+ # @return [String, nil]
24
+ def ip
25
+ return unless address
26
+
27
+ host = address.rpartition(":").first
28
+ host.delete_prefix("[").delete_suffix("]")
29
+ end
30
+
31
+ # @return [Integer, nil]
32
+ def port
33
+ address && Integer(address.rpartition(":").last, exception: false)
34
+ end
35
+
36
+ # The client certificate presented over mutual TLS, or nil.
37
+ # @return [OpenSSL::X509::Certificate, nil]
38
+ def certificate
39
+ certificate_chain.first
40
+ end
41
+
42
+ # @return [Array<OpenSSL::X509::Certificate>] leaf first
43
+ def certificate_chain
44
+ @certificate_chain ||= begin
45
+ require "openssl"
46
+ @certificate_ders.map { |der| OpenSSL::X509::Certificate.new(der) }
47
+ end
48
+ end
49
+
50
+ # True when the client authenticated with a certificate.
51
+ def authenticated?
52
+ !@certificate_ders.empty?
53
+ end
54
+
55
+ def inspect
56
+ "#<#{self.class.name} #{address || 'unknown'}#{' (certificate)' if authenticated?}>"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -28,8 +28,8 @@ module Tonic
28
28
  config.after_initialize do
29
29
  configuration = Tonic::Rails.configuration
30
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`.
31
+ # tasks, or test runs. With Puma, prefer `plugin :tonic_rails`
32
+ # (lib/puma/plugin/tonic_rails.rb), which also covers cluster mode.
33
33
  if configuration.auto_start && defined?(::Rails::Server)
34
34
  server = Tonic::Rails.server
35
35
  thread = Thread.new do
@@ -38,6 +38,7 @@ module Tonic
38
38
  reader: call[:reader],
39
39
  sender: call[:sender],
40
40
  timeout_ms: call[:timeout_ms],
41
+ peer: Peer.new(call[:peer_address], call[:peer_certificates] || []),
41
42
  )
42
43
  end
43
44
 
@@ -2,13 +2,23 @@
2
2
 
3
3
  module Tonic
4
4
  module Rails
5
- # Raise from a handler to end the RPC with a gRPC status.
5
+ # Raise from a handler (or interceptor) to end the RPC with a gRPC status.
6
6
  #
7
- # raise Tonic::Rails::RpcError.new("product not found", code: :not_found)
7
+ # raise Tonic::Rails::RpcError.new("item not found", code: :not_found)
8
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.
9
+ # Optionally attach trailing metadata and rich error details (protobuf
10
+ # messages such as google.rpc.BadRequest, sent in the standard
11
+ # grpc-status-details-bin trailer as a google.rpc.Status):
12
+ #
13
+ # raise Tonic::Rails::RpcError.new(
14
+ # "invalid item", code: :invalid_argument,
15
+ # metadata: { "x-request-id" => request_id },
16
+ # details: [Google::Rpc::BadRequest.new(field_violations: [...])],
17
+ # )
18
+ #
19
+ # The message, metadata and details go to the client, so keep internal
20
+ # details out of them. Any other exception becomes INTERNAL with a
21
+ # generic message and is logged server-side.
12
22
  class RpcError < Error
13
23
  CODES = {
14
24
  ok: 0,
@@ -35,13 +45,70 @@ module Tonic
35
45
  # @return [Integer] numeric gRPC status code
36
46
  attr_reader :code
37
47
 
48
+ # @return [Hash] trailing metadata sent with the status
49
+ attr_reader :metadata
50
+
51
+ # @return [Array<Google::Protobuf::AbstractMessage>] rich error details
52
+ attr_reader :details
53
+
38
54
  # @param message [String] status message sent to the client
39
55
  # @param code [Symbol, Integer] gRPC status code (see CODES)
40
- def initialize(message = nil, code: :unknown)
56
+ # @param metadata [Hash] trailing metadata (see Tonic::Rails::Metadata)
57
+ # @param details [Array<Google::Protobuf::AbstractMessage>] messages
58
+ # packed into google.rpc.Status.details
59
+ def initialize(message = nil, code: :unknown, metadata: {}, details: [])
41
60
  @code = self.class.normalize_code(code)
61
+ @metadata = metadata
62
+ @details = Array(details)
42
63
  super(message || code_name.to_s)
43
64
  end
44
65
 
66
+ # @api private
67
+ # @return [Hash{String=>String}] trailers to send with this status
68
+ def trailers
69
+ trailers = Metadata.encode(metadata)
70
+ trailers["grpc-status-details-bin"] = [status_proto].pack("m0") unless details.empty?
71
+ trailers
72
+ end
73
+
74
+ # The encoded google.rpc.Status (code = 1, message = 2, details = 3 as
75
+ # google.protobuf.Any), built directly so googleapis protos aren't a
76
+ # dependency.
77
+ def status_proto
78
+ require "google/protobuf/well_known_types"
79
+ buf = (+"").b
80
+ buf << field_key(1, 0) << varint(code)
81
+ buf << length_delimited(2, message.b)
82
+ details.each do |detail|
83
+ any = detail.is_a?(Google::Protobuf::Any) ? detail : Google::Protobuf::Any.pack(detail)
84
+ buf << length_delimited(3, Google::Protobuf::Any.encode(any))
85
+ end
86
+ buf
87
+ end
88
+
89
+ private
90
+
91
+ def field_key(field, wire_type)
92
+ varint((field << 3) | wire_type)
93
+ end
94
+
95
+ def length_delimited(field, bytes)
96
+ field_key(field, 2) + varint(bytes.bytesize) + bytes
97
+ end
98
+
99
+ def varint(value)
100
+ bytes = []
101
+ loop do
102
+ byte = value & 0x7f
103
+ value >>= 7
104
+ bytes << (value.zero? ? byte : byte | 0x80)
105
+ break if value.zero?
106
+ end
107
+ bytes.pack("C*")
108
+ end
109
+
110
+ public
111
+
45
112
  # @return [Symbol] e.g. :not_found
46
113
  def code_name
47
114
  CODE_NAMES.fetch(@code)
@@ -20,9 +20,9 @@ module Tonic
20
20
  @router = Router.new
21
21
  @native = nil
22
22
  @state = :stopped
23
- @stop_reader = nil
24
- @stop_writer = nil
23
+ @stop_signal = nil
25
24
  @stop_requested = false
25
+ @health = Health.new(configuration, @router)
26
26
  @lock = Mutex.new
27
27
  end
28
28
 
@@ -51,25 +51,34 @@ module Tonic
51
51
  @configuration.validate!
52
52
  Tonic::Rails.native_extension!
53
53
  Tonic::Rails._set_shutting_down(false)
54
- @stop_reader, @stop_writer = IO.pipe
54
+ # A Queue rather than a self-pipe: pushing to it is safe from trap
55
+ # handlers, and unlike pipes it can be waited on with a timeout
56
+ # on every platform (Windows select() only handles sockets).
57
+ @stop_signal = Thread::Queue.new
55
58
  stop if @stop_requested # requested while we were starting up
56
59
  @native = build_native_server
57
60
  @native.start_server
61
+ log_pump = spawn_log_pump
62
+ @health.attach(@native)
58
63
  @state = :running
59
64
 
65
+ request_log = subscribe_request_logger
60
66
  previous_handlers = install_signal_handlers ? trap_signals : {}
61
67
  workers = Array.new(@configuration.dispatch_threads) { |i| spawn_worker(i) }
62
68
  log_startup
63
69
  yield self if block_given?
64
70
 
65
71
  wait_for_stop(workers)
72
+ @health.detach
66
73
  shutdown(workers)
67
74
  ensure
75
+ ActiveSupport::Notifications.unsubscribe(request_log) if request_log
68
76
  previous_handlers&.each { |signal, handler| Signal.trap(signal, handler || "DEFAULT") }
77
+ @health.detach
69
78
  @native&.stop_server
79
+ stop_log_pump(log_pump)
70
80
  @native = nil
71
- [@stop_reader, @stop_writer].each { |io| io.close if io && !io.closed? }
72
- @stop_reader = @stop_writer = nil
81
+ @stop_signal = nil
73
82
  Tonic::Rails._set_shutting_down(false)
74
83
  @state = :stopped
75
84
  end
@@ -79,15 +88,32 @@ module Tonic
79
88
  # other threads; returns immediately.
80
89
  def stop
81
90
  @stop_requested = true
82
- @stop_writer&.write_nonblock(".", exception: false)
83
- rescue IOError
84
- # Already stopped.
91
+ @stop_signal&.push(:stop)
85
92
  end
86
93
 
87
94
  def running?
88
95
  @state == :running
89
96
  end
90
97
 
98
+ # Mark the whole server, or the given services (fully-qualified
99
+ # names), SERVING in grpc.health.v1. Takes effect immediately when
100
+ # running, otherwise when the server starts.
101
+ #
102
+ # With `config.health_check`, a service reports SERVING only while the
103
+ # check passes *and* it's marked serving here.
104
+ def serving!(*services)
105
+ @health.mark(services, true)
106
+ self
107
+ end
108
+
109
+ # Mark the whole server, or the given services, NOT_SERVING (e.g. for
110
+ # maintenance), so load balancers and readiness probes route around
111
+ # this instance. Calls are still served.
112
+ def not_serving!(*services)
113
+ @health.mark(services, false)
114
+ self
115
+ end
116
+
91
117
  # @return [Hash{Symbol=>Integer}] request and connection counters
92
118
  def stats
93
119
  native = @native
@@ -113,7 +139,6 @@ module Tonic
113
139
  native.set_worker_threads(config.worker_threads || 0)
114
140
  native.set_keepalive(config.keepalive_interval.to_f, config.keepalive_timeout.to_f)
115
141
  native.set_dispatch_queue_size(config.dispatch_queue_size)
116
- native.set_log_requests(config.log_requests ? true : false)
117
142
  native.set_compression(config.compression ? true : false)
118
143
  native.set_reflection(config.reflection ? true : false)
119
144
  native.set_reflection_descriptors(Reflection.file_descriptors(@router.service_names)) if config.reflection
@@ -133,8 +158,9 @@ module Tonic
133
158
  end
134
159
 
135
160
  guarded = lambda do |path, metadata, transport|
136
- call.call(path, metadata, transport)
137
- rescue RpcError
161
+ Instrumentation.instrument(path, metadata) { call.call(path, metadata, transport) }
162
+ rescue RpcError => e
163
+ attach_error_trailers(transport, e)
138
164
  raise
139
165
  rescue StandardError => e
140
166
  Tonic::Rails.log_exception(e, path)
@@ -144,6 +170,15 @@ module Tonic
144
170
  wrap_with_rails_executor(guarded)
145
171
  end
146
172
 
173
+ # An RpcError's own metadata and rich details travel as trailers.
174
+ def attach_error_trailers(transport, error)
175
+ return if error.metadata.empty? && error.details.empty?
176
+
177
+ transport[:sender]&.add_trailers(error.trailers)
178
+ rescue StandardError => e
179
+ Tonic::Rails.log_exception(e, "RpcError trailers")
180
+ end
181
+
147
182
  # Each RPC runs inside the Rails executor (or reloader, when code
148
183
  # reloading is on): ActiveRecord connections are returned, query
149
184
  # caches and CurrentAttributes are reset — the same isolation Rack
@@ -156,6 +191,41 @@ module Tonic
156
191
  ->(path, metadata, transport) { wrapper.wrap { callback.call(path, metadata, transport) } }
157
192
  end
158
193
 
194
+ def subscribe_request_logger
195
+ return unless @configuration.log_requests && @configuration.logger
196
+
197
+ Instrumentation::RequestLogger.new(@configuration.logger).subscribe
198
+ end
199
+
200
+ # Transport events from the Rust side (rejected calls, expired
201
+ # deadlines, refused connections, faults) can't call Ruby directly;
202
+ # this thread drains them into the configured logger.
203
+ def spawn_log_pump
204
+ native = @native
205
+ logger = @configuration.logger
206
+ @log_pump_running = true
207
+ Thread.new do
208
+ Thread.current.name = "tonic-rails-log"
209
+ write_transport_logs(logger, native.next_log_events(0.25)) while @log_pump_running
210
+ write_transport_logs(logger, native.next_log_events(0)) # whatever shutdown produced
211
+ rescue StandardError => e
212
+ warn("tonic-rails: log thread stopped: #{e.class}: #{e.message}")
213
+ end
214
+ end
215
+
216
+ def stop_log_pump(thread)
217
+ return unless thread
218
+
219
+ @log_pump_running = false
220
+ thread.join(2)
221
+ end
222
+
223
+ def write_transport_logs(logger, events)
224
+ return unless logger
225
+
226
+ events.each { |level, message| logger.public_send(level, "tonic-rails: #{message}") }
227
+ end
228
+
159
229
  def spawn_worker(index)
160
230
  native = @native
161
231
  Thread.new do
@@ -174,7 +244,7 @@ module Tonic
174
244
 
175
245
  def wait_for_stop(workers)
176
246
  loop do
177
- break if @stop_reader.wait_readable(1)
247
+ break if @stop_signal.pop(timeout: 1)
178
248
 
179
249
  dead = workers.reject(&:alive?)
180
250
  unless dead.empty?
@@ -27,7 +27,19 @@ namespace :tonic_rails do
27
27
  ruby_out = ENV.fetch("RUBY_OUT", "lib/grpc")
28
28
  protos = Dir.glob(File.join(proto_dir, "**", "*.proto"))
29
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)
30
+ version = begin
31
+ IO.popen(%w[protoc --version], err: File::NULL, &:read)[/\d+(\.\d+)+/]
32
+ rescue SystemCallError
33
+ nil
34
+ end
35
+ abort "protoc not found on PATH. Install the Protocol Buffers compiler (22.0 or newer)." unless version
36
+ # protoc before 22 emits the Ruby DSL that google-protobuf 4 removed
37
+ # (`undefined method 'build' for DescriptorPool` at load time).
38
+ major = version.split(".").then { |parts| parts.first == "3" ? parts[1].to_i : parts.first.to_i }
39
+ if major < 22
40
+ abort "protoc #{version} is too old for google-protobuf 4: install protoc 22.0 or newer " \
41
+ "(distribution packages are often older; see https://github.com/protocolbuffers/protobuf/releases)."
42
+ end
31
43
 
32
44
  FileUtils.mkdir_p(ruby_out)
33
45
  sh "protoc", "--ruby_out=#{ruby_out}", "-I#{proto_dir}", *protos
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tonic
4
+ module Rails
5
+ module Testing
6
+ # Shared helpers for the RSpec matcher and Minitest assertions.
7
+ # @api private
8
+ module Errors
9
+ module_function
10
+
11
+ # Exceptions that carry a gRPC status: tonic-rails' own, and the
12
+ # official grpc gem client's, when it's loaded.
13
+ def classes
14
+ [Tonic::Rails::RpcError, (GRPC::BadStatus if defined?(GRPC::BadStatus))].compact
15
+ end
16
+
17
+ def status?(error)
18
+ classes.any? { |klass| error.is_a?(klass) }
19
+ end
20
+
21
+ # @return [Integer]
22
+ def code(error)
23
+ error.code
24
+ end
25
+
26
+ # @return [String] the status message the client sees. The grpc
27
+ # gem keeps it in #details (its #message adds the code).
28
+ def message(error)
29
+ text = defined?(GRPC::BadStatus) && error.is_a?(GRPC::BadStatus) ? error.details.to_s : error.message
30
+ text.dup.force_encoding(Encoding::UTF_8)
31
+ end
32
+
33
+ def code_name(code)
34
+ Tonic::Rails::RpcError::CODE_NAMES.fetch(code, code)
35
+ end
36
+
37
+ def message_matches?(expected, actual)
38
+ expected.is_a?(Regexp) ? expected.match?(actual) : expected.to_s == actual
39
+ end
40
+
41
+ def describe(error)
42
+ "#{code_name(code(error)).to_s.upcase} (#{message(error).inspect})"
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tonic/rails/testing"
4
+ require "tonic/rails/testing/errors"
5
+
6
+ module Tonic
7
+ module Rails
8
+ module Testing
9
+ # Minitest assertions for gRPC statuses:
10
+ #
11
+ # class ItemServiceTest < ActiveSupport::TestCase
12
+ # include Tonic::Rails::Testing::Assertions
13
+ #
14
+ # test "missing items" do
15
+ # error = assert_grpc_error(:not_found) { Tonic::Rails::Testing.call(ItemService, :GetItem, { id: 0 }) }
16
+ # assert_match(/0/, error.message)
17
+ # end
18
+ # end
19
+ module Assertions
20
+ # Asserts the block raises a gRPC status (Tonic::Rails::RpcError or
21
+ # GRPC::BadStatus) with the given code and, optionally, message
22
+ # (String or Regexp). Returns the error.
23
+ def assert_grpc_error(code = nil, message = nil, &)
24
+ errors = Testing::Errors
25
+ error = assert_raises(*errors.classes, &)
26
+ if code
27
+ expected = Tonic::Rails::RpcError.normalize_code(code)
28
+ assert_equal(
29
+ errors.code_name(expected), errors.code_name(errors.code(error)),
30
+ "Expected gRPC status #{errors.code_name(expected).to_s.upcase}, got #{errors.describe(error)}"
31
+ )
32
+ end
33
+ if message
34
+ assert(
35
+ errors.message_matches?(message, errors.message(error)),
36
+ "Expected gRPC message #{message.inspect}, got #{errors.message(error).inspect}",
37
+ )
38
+ end
39
+ error
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/expectations"
4
+ require "tonic/rails/testing"
5
+ require "tonic/rails/testing/errors"
6
+
7
+ # Matches a block that ends with a gRPC status, raised as a
8
+ # Tonic::Rails::RpcError (Testing.call) or a GRPC::BadStatus (the official
9
+ # client):
10
+ #
11
+ # expect { Tonic::Rails::Testing.call(ItemService, :GetItem, { id: 0 }) }
12
+ # .to raise_grpc_error(:not_found)
13
+ # expect { stub.get_item(request) }.to raise_grpc_error(:invalid_argument).with_message(/name/)
14
+ # expect { ... }.to raise_grpc_error # any non-OK status
15
+ RSpec::Matchers.define :raise_grpc_error do |expected_code = nil|
16
+ errors = Tonic::Rails::Testing::Errors
17
+
18
+ supports_block_expectations
19
+
20
+ chain(:with_message) { |message| @expected_message = message }
21
+
22
+ match do |block|
23
+ @expected = expected_code && Tonic::Rails::RpcError.normalize_code(expected_code)
24
+ begin
25
+ block.call
26
+ @error = nil
27
+ rescue Exception => e # rubocop:disable Lint/RescueException
28
+ raise unless errors.status?(e)
29
+
30
+ @error = e
31
+ end
32
+ return false unless @error
33
+
34
+ (@expected.nil? || errors.code(@error) == @expected) &&
35
+ (!defined?(@expected_message) || errors.message_matches?(@expected_message, errors.message(@error)))
36
+ end
37
+
38
+ description do
39
+ text = +"raise a gRPC error"
40
+ text << " #{errors.code_name(@expected).to_s.upcase}" if @expected
41
+ text << " with message #{@expected_message.inspect}" if defined?(@expected_message)
42
+ text
43
+ end
44
+
45
+ failure_message do
46
+ actual = @error ? "got #{errors.describe(@error)}" : "nothing was raised"
47
+ "expected block to #{description}, but #{actual}"
48
+ end
49
+
50
+ failure_message_when_negated do
51
+ "expected block not to #{description}, but got #{errors.describe(@error)}"
52
+ end
53
+ end
@@ -28,19 +28,35 @@ module Tonic
28
28
  attr_reader :error
29
29
  end
30
30
 
31
- # Response side of a call: collects encoded messages.
31
+ # Response side of a call: collects encoded messages and metadata.
32
32
  class Sender
33
- attr_reader :messages
33
+ attr_reader :messages, :headers, :trailers
34
34
 
35
35
  def initialize
36
36
  @messages = []
37
+ @headers = {}
38
+ @trailers = {}
37
39
  end
38
40
 
39
41
  def push(bytes)
40
42
  @messages << bytes
41
43
  end
44
+
45
+ def send_headers(headers)
46
+ raise "response headers must be sent before any response message" unless @messages.empty?
47
+
48
+ @headers.merge!(headers)
49
+ end
50
+
51
+ def add_trailers(trailers)
52
+ @trailers.merge!(trailers)
53
+ end
42
54
  end
43
55
 
56
+ # What a call produced: the response (or responses, for streaming),
57
+ # plus the response headers and trailers as they'd go on the wire.
58
+ Result = Struct.new(:response, :headers, :trailers)
59
+
44
60
  module_function
45
61
 
46
62
  # @param service_class [Class] handler class
@@ -49,10 +65,23 @@ module Tonic
49
65
  # request; an Array of them for client- and bidi-streaming RPCs
50
66
  # @param metadata [Hash{String=>String}]
51
67
  # @param timeout_ms [Integer, nil] simulated client deadline
68
+ # @param peer [Tonic::Rails::Peer, nil] simulated client (address, certificates)
52
69
  # @return [Google::Protobuf::AbstractMessage, Array] the response, or
53
70
  # an Array of responses for server- and bidi-streaming RPCs
54
71
  # @raise [RpcError] when the handler ends the call with a status
55
- def call(service_class, rpc_name, request, metadata: {}, timeout_ms: nil)
72
+ def call(service_class, rpc_name, request, metadata: {}, timeout_ms: nil, peer: nil)
73
+ invoke(service_class, rpc_name, request, metadata: metadata, timeout_ms: timeout_ms, peer: peer).response
74
+ end
75
+
76
+ # Like #call, but returns a Result with the response metadata too:
77
+ #
78
+ # result = Tonic::Rails::Testing.invoke(ItemService, :GetItem, { id: 1 })
79
+ # result.response.name # => "Widget"
80
+ # result.headers["x-cache"] # => "hit"
81
+ # result.trailers["x-cost-ms"] # => "3"
82
+ #
83
+ # @return [Result]
84
+ def invoke(service_class, rpc_name, request, metadata: {}, timeout_ms: nil, peer: nil)
56
85
  spec = service_class.grpc_rpcs.fetch(rpc_name.to_s) do
57
86
  raise ArgumentError, "#{service_class} has no RPC #{rpc_name}"
58
87
  end
@@ -67,13 +96,15 @@ module Tonic
67
96
  reader: reader,
68
97
  sender: sender,
69
98
  timeout_ms: timeout_ms,
99
+ peer: peer,
70
100
  )
71
101
 
72
- if spec[:response_stream]
102
+ response = if spec[:response_stream]
73
103
  sender.messages.map { |bytes| spec[:response].decode(bytes) }
74
104
  else
75
105
  spec[:response].decode(result)
76
106
  end
107
+ Result.new(response, sender.headers, sender.trailers)
77
108
  end
78
109
 
79
110
  def encode(klass, message)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Tonic
4
4
  module Rails
5
- VERSION = "0.1.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
data/lib/tonic-rails.rb CHANGED
@@ -10,10 +10,14 @@ module Tonic
10
10
  end
11
11
 
12
12
  require "tonic/rails/version"
13
+ require "tonic/rails/metadata"
14
+ require "tonic/rails/peer"
13
15
  require "tonic/rails/rpc_error"
14
16
  require "tonic/rails/configuration"
15
17
  require "tonic/rails/router"
18
+ require "tonic/rails/instrumentation"
16
19
  require "tonic/rails/base"
20
+ require "tonic/rails/health"
17
21
  require "tonic/rails/reflection"
18
22
  require "tonic/rails/server"
19
23
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tonic-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: x86_64-linux-gnu
6
6
  authors:
7
7
  - Matt Tanous
@@ -54,12 +54,24 @@ files:
54
54
  - CHANGELOG.md
55
55
  - LICENSE
56
56
  - README.md
57
+ - lib/generators/tonic_rails/install/install_generator.rb
58
+ - lib/generators/tonic_rails/install/templates/tonic_rails.rb.tt
59
+ - lib/generators/tonic_rails/service/service_generator.rb
60
+ - lib/generators/tonic_rails/service/templates/handler.rb.tt
61
+ - lib/generators/tonic_rails/service/templates/service.proto.tt
62
+ - lib/generators/tonic_rails/service/templates/service_spec.rb.tt
63
+ - lib/generators/tonic_rails/service/templates/service_test.rb.tt
64
+ - lib/puma/plugin/tonic_rails.rb
57
65
  - lib/tonic-rails.rb
58
66
  - lib/tonic/rails/3.3/tonic_rails_native.so
59
67
  - lib/tonic/rails/3.4/tonic_rails_native.so
60
68
  - lib/tonic/rails/4.0/tonic_rails_native.so
61
69
  - lib/tonic/rails/base.rb
62
70
  - lib/tonic/rails/configuration.rb
71
+ - lib/tonic/rails/health.rb
72
+ - lib/tonic/rails/instrumentation.rb
73
+ - lib/tonic/rails/metadata.rb
74
+ - lib/tonic/rails/peer.rb
63
75
  - lib/tonic/rails/railtie.rb
64
76
  - lib/tonic/rails/reflection.rb
65
77
  - lib/tonic/rails/router.rb
@@ -67,6 +79,9 @@ files:
67
79
  - lib/tonic/rails/server.rb
68
80
  - lib/tonic/rails/tasks/tonic-rails.rake
69
81
  - lib/tonic/rails/testing.rb
82
+ - lib/tonic/rails/testing/errors.rb
83
+ - lib/tonic/rails/testing/minitest.rb
84
+ - lib/tonic/rails/testing/rspec.rb
70
85
  - lib/tonic/rails/version.rb
71
86
  homepage: https://github.com/CodingAnarchy/tonic-rails
72
87
  licenses: