gritz-core 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 +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +28 -0
- data/lib/gritz/call.rb +27 -0
- data/lib/gritz/cli.rb +86 -0
- data/lib/gritz/configuration.rb +196 -0
- data/lib/gritz/configuration_error.rb +7 -0
- data/lib/gritz/context.rb +50 -0
- data/lib/gritz/controller.rb +139 -0
- data/lib/gritz/core/version.rb +7 -0
- data/lib/gritz/core.rb +19 -0
- data/lib/gritz/dispatcher.rb +30 -0
- data/lib/gritz/dsl.rb +47 -0
- data/lib/gritz/errors.rb +47 -0
- data/lib/gritz/method_descriptor.rb +35 -0
- data/lib/gritz/middleware/context.rb +17 -0
- data/lib/gritz/middleware/exception_mapper.rb +41 -0
- data/lib/gritz/middleware/logging.rb +32 -0
- data/lib/gritz/middleware/request_id.rb +27 -0
- data/lib/gritz/middleware/stack.rb +62 -0
- data/lib/gritz/router.rb +42 -0
- data/lib/gritz/testing/in_memory_call.rb +39 -0
- data/lib/gritz/testing/minitest.rb +19 -0
- data/lib/gritz/testing/rpc_helper.rb +29 -0
- data/lib/gritz/testing/rspec.rb +41 -0
- metadata +150 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9d69bbe23da7b5ec3c973bdbe3a2c2c1d1596b6632695f446ae6fb242d0d1f94
|
|
4
|
+
data.tar.gz: 89929d525c1709e164057aa7d684317db596734395371cf942019832cf7189bd
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c4c61c0a88eedb466c3f556f9cd9861468f496b0ba6303be58133d58c6761d8d6fa6a1a9c4b51e7dd6bec168b8207aa8b82562d096f0ead5f8745e351a67c790
|
|
7
|
+
data.tar.gz: 5e7d9a717648f45c0f3774f3de35511608d6b6fa7fc5fc6f6be61fbf4d7b874c1cfafdd348b9441e0f37a528a30d341f3eddfa45aa50760255a42f7b644a58cf
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Gritz Core
|
|
2
|
+
|
|
3
|
+
Transport-independent controllers, routing, middleware, configuration and network-free testing for Ruby gRPC applications. Requires CRuby 3.3 or later. This gem has no grpc dependency and never loads a transport by itself.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
require "gritz/core"
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Use [gritz](https://github.com/gritzrpc/gritz) for the default framework and executable, or combine this gem with [gritz-native](https://github.com/gritzrpc/gritz-native) for the official grpc gem adapter. The Fiber adapter `gritz-async` is planned.
|
|
10
|
+
|
|
11
|
+
Controller tests can load `gritz/testing/rspec` or `gritz/testing/minitest` and run without a network or the grpc gem. See the [controller examples](https://github.com/gritzrpc/gritz#controllers) and [configuration guide](docs/guides/configuration.md).
|
|
12
|
+
|
|
13
|
+
## Development
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
git clone https://github.com/gritzrpc/gritz-core.git
|
|
17
|
+
cd gritz-core
|
|
18
|
+
bundle install
|
|
19
|
+
COVERAGE=1 bundle exec rake
|
|
20
|
+
bundle exec rubocop
|
|
21
|
+
bundle exec rake build
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Tests, lint and packaging run independently in this repository. See [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md) and the [release guide](docs/guides/releasing.md).
|
|
25
|
+
|
|
26
|
+
## License
|
|
27
|
+
|
|
28
|
+
[MIT](LICENSE.txt).
|
data/lib/gritz/call.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gritz
|
|
4
|
+
# Contract implemented by transports and in-memory RPC calls.
|
|
5
|
+
# @api public
|
|
6
|
+
module Call
|
|
7
|
+
def method_descriptor = raise NotImplementedError
|
|
8
|
+
def metadata = raise NotImplementedError
|
|
9
|
+
def deadline = nil
|
|
10
|
+
def peer = nil
|
|
11
|
+
def peer_identity = nil
|
|
12
|
+
def cancelled? = false
|
|
13
|
+
def read = raise NotImplementedError
|
|
14
|
+
def write(message) = raise NotImplementedError
|
|
15
|
+
def send_initial_metadata(metadata = {}) = raise NotImplementedError
|
|
16
|
+
def merge_initial_metadata(metadata = {}) = send_initial_metadata(metadata)
|
|
17
|
+
def trailing_metadata = raise NotImplementedError
|
|
18
|
+
|
|
19
|
+
def each_message
|
|
20
|
+
return enum_for(__method__) unless block_given?
|
|
21
|
+
|
|
22
|
+
while (message = read)
|
|
23
|
+
yield message
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/gritz/cli.rb
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "logger"
|
|
5
|
+
|
|
6
|
+
module Gritz
|
|
7
|
+
# Command line entry point for the single-process server and route listing.
|
|
8
|
+
# @api public
|
|
9
|
+
class CLI
|
|
10
|
+
def initialize(stdout: $stdout, stderr: $stderr, env: ENV)
|
|
11
|
+
@stdout = stdout
|
|
12
|
+
@stderr = stderr
|
|
13
|
+
@env = env
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run(argv)
|
|
17
|
+
args = argv.dup
|
|
18
|
+
path = nil
|
|
19
|
+
overrides = {}
|
|
20
|
+
parser = OptionParser.new do |options|
|
|
21
|
+
options.banner = "Usage: gritz [start|routes] [-C config/gritz.rb] [options]"
|
|
22
|
+
options.on("-C", "--config PATH", "Configuration file") { |value| path = value }
|
|
23
|
+
options.on("--workers N", Integer) { |value| overrides[:workers] = value }
|
|
24
|
+
options.on("--threads N", Integer) { |value| overrides[:threads] = value }
|
|
25
|
+
options.on("--bind ADDRESS") { |value| overrides[:bind] = value }
|
|
26
|
+
options.on("--strict-routes") { overrides[:strict_routes] = true }
|
|
27
|
+
options.on("-v", "--version") {
|
|
28
|
+
@stdout.puts(Core::VERSION)
|
|
29
|
+
return 0
|
|
30
|
+
}
|
|
31
|
+
options.on("-h", "--help") {
|
|
32
|
+
@stdout.puts(options)
|
|
33
|
+
return 0
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
parser.parse!(args)
|
|
37
|
+
command = args.shift || "start"
|
|
38
|
+
raise ConfigurationError, "Unknown command #{command}" unless %w[start routes].include?(command)
|
|
39
|
+
raise ConfigurationError, "Unexpected argument #{args.first}" unless args.empty?
|
|
40
|
+
|
|
41
|
+
path ||= "config/gritz.rb" if File.file?("config/gritz.rb")
|
|
42
|
+
config = Configuration.load(path: path, env: @env, overrides: overrides)
|
|
43
|
+
config.validate_single_process! if command == "start"
|
|
44
|
+
config.preload! if config.preload_app?
|
|
45
|
+
logger = Logger.new(@stdout)
|
|
46
|
+
logger.formatter = ->(_severity, _time, _progname, message) { "#{message}\n" }
|
|
47
|
+
router = Router.new(controllers: config.controllers, strict: config.strict_routes, logger: logger)
|
|
48
|
+
if command == "routes"
|
|
49
|
+
router.routes.each_value do |route|
|
|
50
|
+
@stdout.puts "#{route.full_name} #{route.kind} #{route.controller}##{route.action}"
|
|
51
|
+
end
|
|
52
|
+
else
|
|
53
|
+
start(config, router, logger)
|
|
54
|
+
end
|
|
55
|
+
0
|
|
56
|
+
rescue ConfigurationError, OptionParser::ParseError, ArgumentError, Errno::ENOENT, SyntaxError, LoadError => e
|
|
57
|
+
@stderr.puts "gritz: #{e.message}"
|
|
58
|
+
1
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def start(config, router, logger)
|
|
64
|
+
@stdout.sync = true if @stdout.respond_to?(:sync=)
|
|
65
|
+
# Load the adapter only when starting; route inspection stays transport-independent.
|
|
66
|
+
require "gritz/native"
|
|
67
|
+
dispatcher = Dispatcher.new(router: router, middleware: config.middleware, logger: logger)
|
|
68
|
+
adapter = Transport::Native.new(config: config, dispatcher: dispatcher, logger: logger)
|
|
69
|
+
signals = []
|
|
70
|
+
previous = %w[TERM INT QUIT].to_h { |name| [name, Signal.trap(name) { signals << name }] }
|
|
71
|
+
config.run_hooks(:on_worker_boot, 0)
|
|
72
|
+
port = adapter.bind(config.bind)
|
|
73
|
+
adapter.start
|
|
74
|
+
logger.info("Gritz #{Core::VERSION} listening on #{config.bind.sub(/:\d+\z/, ":#{port}")}")
|
|
75
|
+
while signals.empty?
|
|
76
|
+
sleep 0.05
|
|
77
|
+
raise ConfigurationError, "gRPC server stopped unexpectedly" unless adapter.running?
|
|
78
|
+
end
|
|
79
|
+
signals.shift == "QUIT" ? adapter.kill : adapter.stop(deadline: Time.now + config.shutdown_timeout)
|
|
80
|
+
ensure
|
|
81
|
+
previous&.each { |name, handler| Signal.trap(name, handler) }
|
|
82
|
+
adapter&.kill if adapter&.running?
|
|
83
|
+
config.run_hooks(:on_worker_shutdown, 0)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Gritz
|
|
6
|
+
# Startup settings. Process-related settings are reserved for the supervisor.
|
|
7
|
+
# @api public
|
|
8
|
+
class Configuration
|
|
9
|
+
DEFAULTS = {
|
|
10
|
+
workers: 0, threads: 16, max_waiting_requests: 64,
|
|
11
|
+
transport: :native, listener_strategy: :reuseport,
|
|
12
|
+
bind: "0.0.0.0:50051", admin_bind: "127.0.0.1:9090",
|
|
13
|
+
fork_mode: :clean, fork_guard: :raise, strict_routes: false,
|
|
14
|
+
drain_delay: 5.0, shutdown_timeout: 25.0, worker_boot_timeout: 60.0,
|
|
15
|
+
worker_timeout: 30.0, status_interval: 1.0, min_ready_workers: 1,
|
|
16
|
+
phased_restart_surge: 1, max_connection_age: 300.0,
|
|
17
|
+
max_connection_age_grace: 30.0, keepalive_time: 60.0,
|
|
18
|
+
keepalive_permit_without_calls: true,
|
|
19
|
+
max_receive_message_size: 4 * 1024 * 1024,
|
|
20
|
+
max_send_message_size: 4 * 1024 * 1024, max_metadata_size: 8192,
|
|
21
|
+
metrics_backend: :pipe, log_format: :json, worker_recycle: {}, tls: {}
|
|
22
|
+
}.freeze
|
|
23
|
+
ENUMS = {
|
|
24
|
+
transport: %i[native async], listener_strategy: %i[reuseport inherited_fd port_per_worker],
|
|
25
|
+
fork_mode: %i[clean grpc_fork_support], fork_guard: %i[raise warn off],
|
|
26
|
+
metrics_backend: %i[pipe otlp mmap], log_format: %i[json logfmt]
|
|
27
|
+
}.freeze
|
|
28
|
+
HOOKS = %i[before_fork on_worker_boot on_worker_shutdown].freeze
|
|
29
|
+
|
|
30
|
+
attr_accessor(*DEFAULTS.keys, :controllers, :middleware, :preload_app)
|
|
31
|
+
attr_reader :health_checks
|
|
32
|
+
|
|
33
|
+
def initialize
|
|
34
|
+
DEFAULTS.each { |name, value| public_send("#{name}=", value.dup) }
|
|
35
|
+
@controllers = []
|
|
36
|
+
@middleware = Middleware::Stack.default
|
|
37
|
+
@hooks = HOOKS.to_h { |name| [name, []] }
|
|
38
|
+
@health_checks = {}
|
|
39
|
+
@preload_app = false
|
|
40
|
+
@preloaders = []
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# CLI overrides > environment > configuration file > defaults.
|
|
44
|
+
def self.load(path: nil, env: ENV, overrides: {})
|
|
45
|
+
config = new
|
|
46
|
+
DSL.new(config).evaluate(path) if path
|
|
47
|
+
env.each do |key, value|
|
|
48
|
+
next unless key.start_with?("GRITZ_")
|
|
49
|
+
|
|
50
|
+
name = key.delete_prefix("GRITZ_").downcase.to_sym
|
|
51
|
+
raise ConfigurationError, "Unknown environment setting #{key}" unless DEFAULTS.key?(name)
|
|
52
|
+
next if overrides.key?(name)
|
|
53
|
+
|
|
54
|
+
config.public_send("#{name}=", parse_environment(name, value))
|
|
55
|
+
end
|
|
56
|
+
overrides.each { |name, value| config.public_send("#{name}=", value) }
|
|
57
|
+
config.validate!
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.parse_environment(name, value)
|
|
61
|
+
case DEFAULTS.fetch(name)
|
|
62
|
+
when Integer then Integer(value, 10)
|
|
63
|
+
when Float then Float(value)
|
|
64
|
+
when Symbol then value.to_sym
|
|
65
|
+
when Hash then JSON.parse(value, symbolize_names: true)
|
|
66
|
+
when true, false
|
|
67
|
+
return true if %w[true 1].include?(value)
|
|
68
|
+
return false if %w[false 0].include?(value)
|
|
69
|
+
|
|
70
|
+
raise ArgumentError, "expected true or false"
|
|
71
|
+
else value
|
|
72
|
+
end
|
|
73
|
+
rescue ArgumentError, TypeError, JSON::ParserError => e
|
|
74
|
+
raise ConfigurationError, "Invalid GRITZ_#{name.to_s.upcase}: #{e.message}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validate!
|
|
78
|
+
DEFAULTS.each do |name, default|
|
|
79
|
+
value = public_send(name)
|
|
80
|
+
valid = case default
|
|
81
|
+
when Integer then value.is_a?(Integer) && value.between?(name == :workers ? 0 : 1, 2_147_483_647)
|
|
82
|
+
when Float then value.is_a?(Numeric) && value.real? && value.finite? && value.between?(0, 2_147_483.647)
|
|
83
|
+
when true, false then [true, false].include?(value)
|
|
84
|
+
else value.is_a?(default.class)
|
|
85
|
+
end
|
|
86
|
+
raise ConfigurationError, "Invalid #{name}: #{value.inspect}" unless valid
|
|
87
|
+
end
|
|
88
|
+
%i[shutdown_timeout worker_boot_timeout worker_timeout status_interval].each do |name|
|
|
89
|
+
raise ConfigurationError, "#{name} must be positive" unless public_send(name).positive?
|
|
90
|
+
end
|
|
91
|
+
ENUMS.each do |name, allowed|
|
|
92
|
+
raise ConfigurationError, "#{name} must be one of #{allowed.join(', ')}" unless allowed.include?(public_send(name))
|
|
93
|
+
end
|
|
94
|
+
validate_addresses!
|
|
95
|
+
validate_recycle!
|
|
96
|
+
validate_tls!
|
|
97
|
+
raise ConfigurationError, "controllers must be an Array of classes" unless controllers.is_a?(Array) && controllers.all?(Class)
|
|
98
|
+
raise ConfigurationError, "middleware must be a Stack" unless middleware.is_a?(Middleware::Stack)
|
|
99
|
+
raise ConfigurationError, "preload_app must be boolean" unless [true, false].include?(preload_app)
|
|
100
|
+
|
|
101
|
+
self
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def preload_app? = preload_app
|
|
105
|
+
|
|
106
|
+
# Fail before allocating transport resources for unsupported release features.
|
|
107
|
+
def validate_single_process!
|
|
108
|
+
validate!
|
|
109
|
+
raise ConfigurationError, "v0.1 supports workers 0; the supervisor is planned for v0.2" unless workers.zero?
|
|
110
|
+
raise ConfigurationError, "v0.1 supports transport :native" unless transport == :native
|
|
111
|
+
raise ConfigurationError, "v0.1 supports listener_strategy :reuseport" unless listener_strategy == :reuseport
|
|
112
|
+
raise ConfigurationError, "TLS is planned for v0.3" unless tls.empty?
|
|
113
|
+
raise ConfigurationError, "worker_recycle requires the supervisor" unless worker_recycle.empty?
|
|
114
|
+
raise ConfigurationError, "Health checks are planned for v0.3" unless health_checks.empty?
|
|
115
|
+
raise ConfigurationError, "v0.1 supports log_format :json" unless log_format == :json
|
|
116
|
+
raise ConfigurationError, "v0.1 reserves metrics_backend :pipe; metrics export is planned for v0.3" unless metrics_backend == :pipe
|
|
117
|
+
raise ConfigurationError, "grpc_fork_support requires the supervisor" unless fork_mode == :clean
|
|
118
|
+
raise ConfigurationError, "at least one controller must be registered" if controllers.empty?
|
|
119
|
+
|
|
120
|
+
self
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def add_preloader(&block)
|
|
124
|
+
@preload_app = true
|
|
125
|
+
@preloaders << block if block
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def preload!
|
|
129
|
+
@preloaders.each(&:call)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def add_hook(name, &block)
|
|
133
|
+
raise ConfigurationError, "#{name} requires a block" unless block
|
|
134
|
+
|
|
135
|
+
@hooks.fetch(name) << block
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def run_hooks(name, *args)
|
|
139
|
+
@hooks.fetch(name).each { |hook| hook.call(*args) }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def server_args
|
|
143
|
+
{
|
|
144
|
+
"grpc.so_reuseport" => listener_strategy == :reuseport ? 1 : 0,
|
|
145
|
+
"grpc.max_connection_age_ms" => (max_connection_age * 1000).to_i,
|
|
146
|
+
"grpc.max_connection_age_grace_ms" => (max_connection_age_grace * 1000).to_i,
|
|
147
|
+
"grpc.keepalive_time_ms" => (keepalive_time * 1000).to_i,
|
|
148
|
+
"grpc.keepalive_permit_without_calls" => keepalive_permit_without_calls ? 1 : 0,
|
|
149
|
+
"grpc.max_receive_message_length" => max_receive_message_size,
|
|
150
|
+
"grpc.max_send_message_length" => max_send_message_size,
|
|
151
|
+
"grpc.max_metadata_size" => max_metadata_size
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def validate_addresses!
|
|
158
|
+
%i[bind admin_bind].each do |name|
|
|
159
|
+
match = /\A(?:\[[^\]\s]+\]|[^:\s]+):(\d+)\z/.match(public_send(name))
|
|
160
|
+
raise ConfigurationError, "#{name} must be a host:port address" unless match && match[1].to_i <= 65_535
|
|
161
|
+
end
|
|
162
|
+
if listener_strategy == :reuseport && workers > 1 && bind.end_with?(":0")
|
|
163
|
+
raise ConfigurationError, "reuseport with multiple workers cannot use port 0"
|
|
164
|
+
end
|
|
165
|
+
if transport == :native && listener_strategy == :inherited_fd
|
|
166
|
+
raise ConfigurationError, "native does not support inherited_fd"
|
|
167
|
+
end
|
|
168
|
+
return unless fork_mode == :grpc_fork_support && !RUBY_PLATFORM.include?("linux")
|
|
169
|
+
|
|
170
|
+
raise ConfigurationError, "grpc_fork_support requires Linux"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def validate_recycle!
|
|
174
|
+
worker_recycle.each do |name, value|
|
|
175
|
+
valid = if name == :jitter
|
|
176
|
+
value.is_a?(Numeric) && value.real? && value.finite? && value.between?(0, 1)
|
|
177
|
+
elsif %i[max_requests max_rss_mb max_pss_mb max_lifetime].include?(name)
|
|
178
|
+
value.is_a?(Numeric) && value.real? && value.finite? && value.positive? && (name != :max_requests || value.is_a?(Integer))
|
|
179
|
+
end
|
|
180
|
+
raise ConfigurationError, "Invalid worker_recycle #{name}" unless valid
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def validate_tls!
|
|
185
|
+
return if tls.empty?
|
|
186
|
+
|
|
187
|
+
unless %i[cert key].all? { |name| tls[name].is_a?(String) && File.readable?(tls[name]) }
|
|
188
|
+
raise ConfigurationError, "tls requires readable cert and key files"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
tls.each do |name, path|
|
|
192
|
+
raise ConfigurationError, "Invalid tls #{name}" unless %i[cert key client_ca].include?(name) && path.is_a?(String) && File.readable?(path)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gritz
|
|
4
|
+
# Request state inherited by child fibers and threads using Ruby Fiber storage.
|
|
5
|
+
# @api public
|
|
6
|
+
class Context
|
|
7
|
+
attr_reader :call, :logger, :store
|
|
8
|
+
attr_accessor :request_id, :trace_context
|
|
9
|
+
|
|
10
|
+
def initialize(call:, logger:)
|
|
11
|
+
@call = call
|
|
12
|
+
@logger = logger
|
|
13
|
+
@store = {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.current = Fiber[:gritz_context]
|
|
17
|
+
|
|
18
|
+
def self.with(context)
|
|
19
|
+
previous = current
|
|
20
|
+
Fiber[:gritz_context] = context
|
|
21
|
+
yield
|
|
22
|
+
ensure
|
|
23
|
+
Fiber[:gritz_context] = previous
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def metadata = call.metadata
|
|
27
|
+
def deadline = call.deadline
|
|
28
|
+
def method = call.method_descriptor
|
|
29
|
+
def peer = call.peer
|
|
30
|
+
def peer_identity = call.peer_identity
|
|
31
|
+
def cancelled? = call.cancelled?
|
|
32
|
+
def remaining = deadline && [deadline - Time.now, 0.0].max
|
|
33
|
+
|
|
34
|
+
def check_deadline!
|
|
35
|
+
raise Errors::DeadlineExceeded, "deadline exceeded" if deadline && remaining.zero?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def check_cancelled!
|
|
39
|
+
raise Errors::Cancelled, "request cancelled" if cancelled?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Keep serialization failures within the request's exception mapper.
|
|
43
|
+
# @api private
|
|
44
|
+
def validate_response!(message)
|
|
45
|
+
raise TypeError, "RPC response must be #{method.output_type}, got #{message.class}" unless message.is_a?(method.output_type)
|
|
46
|
+
|
|
47
|
+
message
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gritz
|
|
4
|
+
# One instance is created per RPC; application actions use request and stream.
|
|
5
|
+
# @api public
|
|
6
|
+
class Controller
|
|
7
|
+
class << self
|
|
8
|
+
def bind(service)
|
|
9
|
+
raise ArgumentError, "service must expose service_name and rpc_descs" unless service.respond_to?(:service_name) && service.respond_to?(:rpc_descs)
|
|
10
|
+
|
|
11
|
+
@service_class = service
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def service_class = @service_class || (superclass.service_class if superclass.respond_to?(:service_class))
|
|
15
|
+
|
|
16
|
+
def action_defined?(action)
|
|
17
|
+
public_method_defined?(action) && ancestors.take_while { |ancestor| ancestor != Controller }.include?(instance_method(action).owner)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
%i[before around after].each do |kind|
|
|
21
|
+
define_method("#{kind}_action") do |method = nil, only: nil, except: nil, &block|
|
|
22
|
+
callback = method || block
|
|
23
|
+
raise ArgumentError, "callback required" unless callback
|
|
24
|
+
|
|
25
|
+
(@filters ||= []) << [kind, callback, Array(only).map(&:to_sym), Array(except).map(&:to_sym)]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def filters = (superclass.respond_to?(:filters) ? superclass.filters : []) + (@filters || [])
|
|
30
|
+
|
|
31
|
+
def rescue_from(*errors, with: nil, &block)
|
|
32
|
+
handler = with || block
|
|
33
|
+
raise ArgumentError, "handler required" unless handler
|
|
34
|
+
|
|
35
|
+
(@rescue_handlers ||= []).concat(errors.map { |error| [error, handler] })
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def rescue_handlers = (superclass.respond_to?(:rescue_handlers) ? superclass.rescue_handlers : []) + (@rescue_handlers || [])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
attr_reader :context, :request, :stream
|
|
42
|
+
|
|
43
|
+
def initialize(context:)
|
|
44
|
+
@context = context
|
|
45
|
+
@request = Request.new(context.call, context)
|
|
46
|
+
@stream = Stream.new(context.call, context)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def process(action)
|
|
50
|
+
raise Errors::Unimplemented, "unimplemented RPC action: #{action}" unless self.class.action_defined?(action)
|
|
51
|
+
|
|
52
|
+
applicable = self.class.filters.select do |_, _, only, except|
|
|
53
|
+
(only.empty? || only.include?(action)) && !except.include?(action)
|
|
54
|
+
end
|
|
55
|
+
endpoint = lambda do
|
|
56
|
+
applicable.each { |kind, callback, _, _| invoke_callback(callback) if kind == :before }
|
|
57
|
+
result = public_send(action)
|
|
58
|
+
applicable.each { |kind, callback, _, _| invoke_callback(callback) if kind == :after }
|
|
59
|
+
result
|
|
60
|
+
end
|
|
61
|
+
applicable.select { |kind,| kind == :around }.reverse_each do |_, callback, _, _|
|
|
62
|
+
inner = endpoint
|
|
63
|
+
endpoint = lambda do
|
|
64
|
+
called = false
|
|
65
|
+
result = nil
|
|
66
|
+
callback_result = invoke_callback(callback, lambda {
|
|
67
|
+
called = true
|
|
68
|
+
result = inner.call
|
|
69
|
+
})
|
|
70
|
+
called ? result : callback_result
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
endpoint.call
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
handler = self.class.rescue_handlers.reverse.find { |klass, _| e.is_a?(klass) }
|
|
76
|
+
raise unless handler
|
|
77
|
+
|
|
78
|
+
invoke_callback(handler.last, e)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def fail!(code, message = nil, details: [], metadata: {})
|
|
82
|
+
raise Errors.for_code(code).new(message, details:, metadata:)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def invoke_callback(callback, argument = nil)
|
|
88
|
+
return argument ? instance_exec(argument, &callback) : instance_exec(&callback) if callback.respond_to?(:call)
|
|
89
|
+
return __send__(callback, &argument) if argument.is_a?(Proc)
|
|
90
|
+
|
|
91
|
+
argument ? __send__(callback, argument) : __send__(callback)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @api public
|
|
95
|
+
class Request
|
|
96
|
+
def initialize(call, context)
|
|
97
|
+
@call = call
|
|
98
|
+
@context = context
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def message
|
|
102
|
+
@context.check_deadline!
|
|
103
|
+
@context.check_cancelled!
|
|
104
|
+
return @message if defined?(@message)
|
|
105
|
+
|
|
106
|
+
@message = @call.read
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def each_message
|
|
110
|
+
return enum_for(__method__) unless block_given?
|
|
111
|
+
|
|
112
|
+
@call.each_message do |message|
|
|
113
|
+
@context.check_deadline!
|
|
114
|
+
@context.check_cancelled!
|
|
115
|
+
yield message
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
alias messages each_message
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @api public
|
|
123
|
+
class Stream
|
|
124
|
+
def initialize(call, context)
|
|
125
|
+
@call = call
|
|
126
|
+
@context = context
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def write(message)
|
|
130
|
+
raise TypeError, "RPC method does not stream responses" unless @context.method.server_streaming?
|
|
131
|
+
|
|
132
|
+
@context.check_deadline!
|
|
133
|
+
@context.check_cancelled!
|
|
134
|
+
@context.validate_response!(message)
|
|
135
|
+
@call.write(message)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
data/lib/gritz/core.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zeitwerk"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "core/version"
|
|
6
|
+
|
|
7
|
+
module Gritz
|
|
8
|
+
module Core
|
|
9
|
+
# Core loading deliberately does not require grpc or initialize transports.
|
|
10
|
+
# @api private
|
|
11
|
+
LOADER = Zeitwerk::Loader.new
|
|
12
|
+
LOADER.tag = "gritz-core"
|
|
13
|
+
LOADER.inflector.inflect("dsl" => "DSL", "cli" => "CLI")
|
|
14
|
+
LOADER.push_dir(__dir__, namespace: Gritz)
|
|
15
|
+
LOADER.ignore(__FILE__, File.join(__dir__, "errors.rb"), File.join(__dir__, "core/version.rb"))
|
|
16
|
+
LOADER.do_not_eager_load(File.join(__dir__, "testing/rspec.rb"), File.join(__dir__, "testing/minitest.rb"))
|
|
17
|
+
LOADER.setup
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module Gritz
|
|
6
|
+
# Routes one Call through the configured middleware and a fresh controller.
|
|
7
|
+
# @api public
|
|
8
|
+
class Dispatcher
|
|
9
|
+
attr_reader :router
|
|
10
|
+
|
|
11
|
+
def initialize(router:, middleware: Middleware::Stack.default, logger: Logger.new($stdout))
|
|
12
|
+
@router = router
|
|
13
|
+
@logger = logger
|
|
14
|
+
endpoint = lambda do |context|
|
|
15
|
+
context.check_deadline!
|
|
16
|
+
context.check_cancelled!
|
|
17
|
+
descriptor = router.resolve(context.method.full_name)
|
|
18
|
+
result = descriptor.controller.new(context:).process(descriptor.action)
|
|
19
|
+
context.check_deadline!
|
|
20
|
+
context.check_cancelled!
|
|
21
|
+
descriptor.server_streaming? ? result : context.validate_response!(result)
|
|
22
|
+
end
|
|
23
|
+
@app = middleware ? middleware.build(endpoint) : endpoint
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call(call)
|
|
27
|
+
@app.call(Context.new(call:, logger: @logger))
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/gritz/dsl.rb
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gritz
|
|
4
|
+
# Puma-style configuration file evaluator.
|
|
5
|
+
# @api public
|
|
6
|
+
class DSL
|
|
7
|
+
def initialize(config)
|
|
8
|
+
@config = config
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def evaluate(path)
|
|
12
|
+
instance_eval(File.read(path), File.expand_path(path), 1)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
Configuration::DEFAULTS.each_key do |name|
|
|
16
|
+
define_method(name) { |value| @config.public_send("#{name}=", value) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
Configuration::HOOKS.each do |name|
|
|
20
|
+
define_method(name) { |&block| @config.add_hook(name, &block) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def preload_app!(&)
|
|
24
|
+
@config.add_preloader(&)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def register_controller(*controllers)
|
|
28
|
+
@config.controllers.concat(controllers)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def middleware
|
|
32
|
+
yield @config.middleware
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def health_check(name, &block)
|
|
36
|
+
raise ConfigurationError, "health_check requires a block" unless block
|
|
37
|
+
|
|
38
|
+
@config.health_checks[name] = block
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def method_missing(name, *_args)
|
|
42
|
+
raise ConfigurationError, "Unknown configuration setting #{name}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def respond_to_missing?(_name, _include_private = false) = false
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/gritz/errors.rb
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gritz
|
|
4
|
+
# Transport-independent status and rich error details.
|
|
5
|
+
# @api public
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
attr_reader :details, :metadata
|
|
8
|
+
|
|
9
|
+
def initialize(message = nil, details: [], metadata: {})
|
|
10
|
+
super(message || code.to_s.tr("_", " "))
|
|
11
|
+
@details = details
|
|
12
|
+
@metadata = metadata
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def code = self.class::CODE
|
|
16
|
+
def grpc_code = self.class::GRPC_CODE
|
|
17
|
+
|
|
18
|
+
CODE = :unknown
|
|
19
|
+
GRPC_CODE = 2
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# gRPC's canonical codes, without a dependency on the grpc gem.
|
|
23
|
+
# @api public
|
|
24
|
+
module Errors
|
|
25
|
+
CODES = %i[ok cancelled unknown invalid_argument deadline_exceeded not_found already_exists permission_denied
|
|
26
|
+
resource_exhausted failed_precondition aborted out_of_range unimplemented internal unavailable data_loss unauthenticated].freeze
|
|
27
|
+
|
|
28
|
+
CODES.each_with_index do |code, number|
|
|
29
|
+
klass = Class.new(Error)
|
|
30
|
+
klass.const_set(:CODE, code)
|
|
31
|
+
klass.const_set(:GRPC_CODE, number)
|
|
32
|
+
const_set(code.to_s.split("_").map(&:capitalize).join, klass)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.for_code(code)
|
|
36
|
+
name = case code
|
|
37
|
+
when Integer then CODES[code] if code >= 0
|
|
38
|
+
when String, Symbol then code.to_sym
|
|
39
|
+
end
|
|
40
|
+
raise ArgumentError, "unknown RPC status: #{code.inspect}" unless CODES.include?(name)
|
|
41
|
+
|
|
42
|
+
const_get(name.to_s.split("_").map(&:capitalize).join, false)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
DeadlineExceeded = Errors::DeadlineExceeded
|
|
47
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gritz
|
|
4
|
+
# Immutable route and protobuf type information for one RPC.
|
|
5
|
+
# @api public
|
|
6
|
+
class MethodDescriptor
|
|
7
|
+
attr_reader :service, :name, :input_type, :output_type, :controller, :service_class, :action
|
|
8
|
+
|
|
9
|
+
def initialize(service:, name:, input_type:, output_type:, client_streaming: false, server_streaming: false,
|
|
10
|
+
controller: nil, service_class: nil)
|
|
11
|
+
@service = service.to_s
|
|
12
|
+
@name = name.to_s
|
|
13
|
+
@input_type = input_type
|
|
14
|
+
@output_type = output_type
|
|
15
|
+
@client_streaming = client_streaming
|
|
16
|
+
@server_streaming = server_streaming
|
|
17
|
+
@controller = controller
|
|
18
|
+
@service_class = service_class
|
|
19
|
+
@action = @name.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.to_sym
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def full_name = "/#{service}/#{name}"
|
|
24
|
+
def client_streaming? = @client_streaming
|
|
25
|
+
def server_streaming? = @server_streaming
|
|
26
|
+
|
|
27
|
+
def kind
|
|
28
|
+
return :bidi if client_streaming? && server_streaming?
|
|
29
|
+
return :client_streaming if client_streaming?
|
|
30
|
+
return :server_streaming if server_streaming?
|
|
31
|
+
|
|
32
|
+
:unary
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gritz
|
|
4
|
+
module Middleware
|
|
5
|
+
# Sets request-local state for the entire lifetime of an RPC.
|
|
6
|
+
# @api public
|
|
7
|
+
class Context
|
|
8
|
+
def initialize(app)
|
|
9
|
+
@app = app
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(context)
|
|
13
|
+
Gritz::Context.with(context) { @app.call(context) }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Gritz
|
|
7
|
+
module Middleware
|
|
8
|
+
# Converts application exceptions to safe RPC errors.
|
|
9
|
+
# @api public
|
|
10
|
+
class ExceptionMapper
|
|
11
|
+
def initialize(app, mappings: {}, expose_errors: false)
|
|
12
|
+
@app = app
|
|
13
|
+
@mappings = mappings
|
|
14
|
+
@expose_errors = expose_errors
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(context)
|
|
18
|
+
@app.call(context)
|
|
19
|
+
rescue Gritz::Error
|
|
20
|
+
raise
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
mapping = @mappings.find { |klass, _| e.is_a?(klass) }&.last
|
|
23
|
+
if mapping
|
|
24
|
+
raise(mapping.respond_to?(:call) ? mapping.call(e) : Errors.for_code(mapping).new(e.message))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
case e.class.name
|
|
28
|
+
when "ActiveRecord::RecordNotFound"
|
|
29
|
+
raise Errors::NotFound, "record not found"
|
|
30
|
+
when "ActiveRecord::RecordInvalid"
|
|
31
|
+
raise Errors::InvalidArgument, "invalid record"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
error_id = SecureRandom.uuid
|
|
35
|
+
context.logger.error(JSON.generate(error_id:, request_id: context.request_id, error: e.class.name,
|
|
36
|
+
message: e.message, backtrace: e.backtrace))
|
|
37
|
+
raise Errors::Internal.new(@expose_errors ? e.message : "internal error (#{error_id})", metadata: { "error-id" => error_id })
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Gritz
|
|
6
|
+
module Middleware
|
|
7
|
+
# Logs one structured completion row per RPC, including streaming failures.
|
|
8
|
+
# @api public
|
|
9
|
+
class Logging
|
|
10
|
+
def initialize(app)
|
|
11
|
+
@app = app
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(context)
|
|
15
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
16
|
+
code = :ok
|
|
17
|
+
@app.call(context)
|
|
18
|
+
rescue Gritz::Error => e
|
|
19
|
+
code = e.code
|
|
20
|
+
raise
|
|
21
|
+
rescue StandardError
|
|
22
|
+
code = :internal
|
|
23
|
+
raise
|
|
24
|
+
ensure
|
|
25
|
+
duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
|
|
26
|
+
context.logger.info(JSON.generate(request_id: context.request_id, service: context.method.service,
|
|
27
|
+
method: context.method.name, code:, duration_ms: duration.round(3),
|
|
28
|
+
peer: context.peer, pid: Process.pid))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Gritz
|
|
6
|
+
module Middleware
|
|
7
|
+
# Assigns and echoes a correlation ID for each RPC.
|
|
8
|
+
# @api public
|
|
9
|
+
class RequestId
|
|
10
|
+
def initialize(app)
|
|
11
|
+
@app = app
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(context)
|
|
15
|
+
incoming = Array(context.metadata["x-request-id"]).first
|
|
16
|
+
context.request_id = incoming.is_a?(String) && incoming.match?(/\A[\x21-\x7e]{1,128}\z/) ? incoming : SecureRandom.uuid
|
|
17
|
+
metadata = { "x-request-id" => context.request_id }
|
|
18
|
+
if context.call.respond_to?(:merge_initial_metadata)
|
|
19
|
+
context.call.merge_initial_metadata(metadata)
|
|
20
|
+
else
|
|
21
|
+
context.call.send_initial_metadata(metadata)
|
|
22
|
+
end
|
|
23
|
+
@app.call(context)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gritz
|
|
4
|
+
module Middleware
|
|
5
|
+
# Mutable middleware configuration; build creates an immutable call chain.
|
|
6
|
+
# @api public
|
|
7
|
+
class Stack
|
|
8
|
+
Entry = Struct.new(:middleware, :options, :block)
|
|
9
|
+
attr_reader :entries
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@entries = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize_copy(other)
|
|
16
|
+
super
|
|
17
|
+
@entries = other.entries.dup
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.default
|
|
21
|
+
new.use(RequestId).use(Context).use(Logging).use(ExceptionMapper)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def use(middleware, **options, &block)
|
|
25
|
+
entries << Entry.new(middleware, options, block)
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def insert_before(target, middleware, **options, &block)
|
|
30
|
+
entries.insert(index_of(target), Entry.new(middleware, options, block))
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def insert_after(target, middleware, **options, &block)
|
|
35
|
+
entries.insert(index_of(target) + 1, Entry.new(middleware, options, block))
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def swap(target, middleware, **options, &block)
|
|
40
|
+
entries[index_of(target)] = Entry.new(middleware, options, block)
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def delete(middleware)
|
|
45
|
+
entries.reject! { |entry| entry.middleware == middleware }
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build(app)
|
|
50
|
+
entries.reverse_each.reduce(app) do |endpoint, entry|
|
|
51
|
+
entry.middleware.new(endpoint, **entry.options, &entry.block)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def index_of(middleware)
|
|
58
|
+
entries.index { |entry| entry.middleware == middleware } || raise(ArgumentError, "middleware not found: #{middleware}")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/gritz/router.rb
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gritz
|
|
4
|
+
# Resolves generated service descriptors to controller actions at boot.
|
|
5
|
+
# @api public
|
|
6
|
+
class Router
|
|
7
|
+
attr_reader :routes
|
|
8
|
+
|
|
9
|
+
def initialize(controllers:, strict: false, logger: nil)
|
|
10
|
+
@routes = {}
|
|
11
|
+
services = {}
|
|
12
|
+
missing = []
|
|
13
|
+
controllers.each do |controller|
|
|
14
|
+
service = controller.service_class
|
|
15
|
+
raise ArgumentError, "#{controller} has no bound service" unless service
|
|
16
|
+
raise ArgumentError, "service #{service.service_name} is already bound" if services.key?(service.service_name)
|
|
17
|
+
|
|
18
|
+
services[service.service_name] = controller
|
|
19
|
+
service.rpc_descs.each do |name, rpc|
|
|
20
|
+
input_stream = rpc.client_streamer? || rpc.bidi_streamer?
|
|
21
|
+
output_stream = rpc.server_streamer? || rpc.bidi_streamer?
|
|
22
|
+
route = MethodDescriptor.new(service: service.service_name, name:, input_type: input_stream ? rpc.input.type : rpc.input,
|
|
23
|
+
output_type: output_stream ? rpc.output.type : rpc.output, client_streaming: input_stream,
|
|
24
|
+
server_streaming: output_stream, controller:, service_class: service)
|
|
25
|
+
@routes[route.full_name] = route
|
|
26
|
+
missing << "#{controller}##{route.action}" unless controller.action_defined?(route.action)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
unless missing.empty?
|
|
30
|
+
message = "unimplemented RPC actions: #{missing.join(', ')}"
|
|
31
|
+
raise ArgumentError, message if strict
|
|
32
|
+
|
|
33
|
+
logger&.warn(message)
|
|
34
|
+
end
|
|
35
|
+
@routes.freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def resolve(full_name)
|
|
39
|
+
routes.fetch(full_name) { raise Errors::Unimplemented, "unknown RPC: #{full_name}" }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gritz
|
|
4
|
+
module Testing
|
|
5
|
+
# Runs all RPC shapes without sockets or grpc initialization.
|
|
6
|
+
# @api public
|
|
7
|
+
class InMemoryCall
|
|
8
|
+
include Gritz::Call
|
|
9
|
+
|
|
10
|
+
attr_reader :method_descriptor, :metadata, :deadline, :peer, :peer_identity,
|
|
11
|
+
:responses, :initial_metadata, :trailing_metadata
|
|
12
|
+
|
|
13
|
+
def initialize(method_descriptor:, messages:, metadata: {}, deadline: nil, peer: nil, peer_identity: nil)
|
|
14
|
+
@method_descriptor = method_descriptor
|
|
15
|
+
@messages = messages.to_enum
|
|
16
|
+
@metadata = metadata
|
|
17
|
+
@deadline = deadline
|
|
18
|
+
@peer = peer
|
|
19
|
+
@peer_identity = peer_identity
|
|
20
|
+
@responses = []
|
|
21
|
+
@initial_metadata = {}
|
|
22
|
+
@trailing_metadata = {}
|
|
23
|
+
@cancelled = false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def read
|
|
27
|
+
@messages.next
|
|
28
|
+
rescue StopIteration
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def write(message) = @responses << message
|
|
33
|
+
def send_initial_metadata(metadata = {}) = @initial_metadata.merge!(metadata)
|
|
34
|
+
alias merge_initial_metadata send_initial_metadata
|
|
35
|
+
def cancelled? = @cancelled
|
|
36
|
+
def cancel! = @cancelled = true
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../core"
|
|
4
|
+
|
|
5
|
+
module Gritz
|
|
6
|
+
module Testing
|
|
7
|
+
# Include in a Minitest test and pass controller: to rpc.
|
|
8
|
+
# @api public
|
|
9
|
+
module Minitest
|
|
10
|
+
include RpcHelper
|
|
11
|
+
|
|
12
|
+
def assert_rpc_error(code, message = nil, &)
|
|
13
|
+
error = assert_raises(Gritz::Error, *[message].compact, &)
|
|
14
|
+
assert_equal(code.to_sym, error.code, message)
|
|
15
|
+
error
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module Gritz
|
|
6
|
+
module Testing
|
|
7
|
+
# Controller tests exercise the real dispatcher without opening a socket.
|
|
8
|
+
# @api public
|
|
9
|
+
module RpcHelper
|
|
10
|
+
attr_reader :last_rpc_call
|
|
11
|
+
|
|
12
|
+
def rpc(action, messages, controller: nil, metadata: {}, deadline: nil, peer: nil, peer_identity: nil,
|
|
13
|
+
middleware: true, logger: Logger.new(File::NULL))
|
|
14
|
+
controller ||= described_class if respond_to?(:described_class)
|
|
15
|
+
raise ArgumentError, "pass controller: or use an RSpec controller example group" unless controller
|
|
16
|
+
|
|
17
|
+
router = Router.new(controllers: [controller], logger:)
|
|
18
|
+
descriptor = router.routes.values.find { |route| route.action == action.to_sym || route.name == action.to_s }
|
|
19
|
+
raise ArgumentError, "unknown RPC action: #{action}" unless descriptor
|
|
20
|
+
|
|
21
|
+
input = descriptor.client_streaming? ? messages : [messages]
|
|
22
|
+
@last_rpc_call = InMemoryCall.new(method_descriptor: descriptor, messages: input, metadata:, deadline:, peer:, peer_identity:)
|
|
23
|
+
stack = middleware == true ? Middleware::Stack.default : middleware
|
|
24
|
+
result = Dispatcher.new(router:, middleware: stack, logger:).call(@last_rpc_call)
|
|
25
|
+
descriptor.server_streaming? ? @last_rpc_call.responses : result
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/core"
|
|
4
|
+
require "rspec/expectations"
|
|
5
|
+
require_relative "../core"
|
|
6
|
+
|
|
7
|
+
module Gritz
|
|
8
|
+
module Testing
|
|
9
|
+
# Requiring gritz/testing/rspec enables type: :rpc controller tests.
|
|
10
|
+
# @api public
|
|
11
|
+
module Rspec
|
|
12
|
+
def self.install
|
|
13
|
+
::RSpec.configure { |config| config.include RpcHelper, type: :rpc }
|
|
14
|
+
::RSpec::Matchers.define :raise_rpc_error do |code|
|
|
15
|
+
supports_block_expectations
|
|
16
|
+
|
|
17
|
+
match do |block|
|
|
18
|
+
@error = nil
|
|
19
|
+
begin
|
|
20
|
+
block.call
|
|
21
|
+
false
|
|
22
|
+
rescue Gritz::Error => e
|
|
23
|
+
@error = e
|
|
24
|
+
e.code == code.to_sym
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
failure_message do
|
|
29
|
+
"expected RPC error #{code}, got #{@error ? @error.code : 'no error'}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
failure_message_when_negated do
|
|
33
|
+
"expected no RPC error #{code}, got #{@error.code}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Gritz::Testing::Rspec.install
|
metadata
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: gritz-core
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yudai Takada
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: google-protobuf
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '4.33'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '5'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '4.33'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '5'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: json
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '2.7'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '3'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '2.7'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '3'
|
|
52
|
+
- !ruby/object:Gem::Dependency
|
|
53
|
+
name: logger
|
|
54
|
+
requirement: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '1.6'
|
|
59
|
+
- - "<"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '2'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '1.6'
|
|
69
|
+
- - "<"
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: '2'
|
|
72
|
+
- !ruby/object:Gem::Dependency
|
|
73
|
+
name: zeitwerk
|
|
74
|
+
requirement: !ruby/object:Gem::Requirement
|
|
75
|
+
requirements:
|
|
76
|
+
- - ">="
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: '2.7'
|
|
79
|
+
- - "<"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '3'
|
|
82
|
+
type: :runtime
|
|
83
|
+
prerelease: false
|
|
84
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '2.7'
|
|
89
|
+
- - "<"
|
|
90
|
+
- !ruby/object:Gem::Version
|
|
91
|
+
version: '3'
|
|
92
|
+
description: Transport-independent controllers, routing, middleware, configuration
|
|
93
|
+
and testing for Gritz.
|
|
94
|
+
email:
|
|
95
|
+
- t.yudai92@gmail.com
|
|
96
|
+
executables: []
|
|
97
|
+
extensions: []
|
|
98
|
+
extra_rdoc_files: []
|
|
99
|
+
files:
|
|
100
|
+
- CHANGELOG.md
|
|
101
|
+
- LICENSE.txt
|
|
102
|
+
- README.md
|
|
103
|
+
- lib/gritz/call.rb
|
|
104
|
+
- lib/gritz/cli.rb
|
|
105
|
+
- lib/gritz/configuration.rb
|
|
106
|
+
- lib/gritz/configuration_error.rb
|
|
107
|
+
- lib/gritz/context.rb
|
|
108
|
+
- lib/gritz/controller.rb
|
|
109
|
+
- lib/gritz/core.rb
|
|
110
|
+
- lib/gritz/core/version.rb
|
|
111
|
+
- lib/gritz/dispatcher.rb
|
|
112
|
+
- lib/gritz/dsl.rb
|
|
113
|
+
- lib/gritz/errors.rb
|
|
114
|
+
- lib/gritz/method_descriptor.rb
|
|
115
|
+
- lib/gritz/middleware/context.rb
|
|
116
|
+
- lib/gritz/middleware/exception_mapper.rb
|
|
117
|
+
- lib/gritz/middleware/logging.rb
|
|
118
|
+
- lib/gritz/middleware/request_id.rb
|
|
119
|
+
- lib/gritz/middleware/stack.rb
|
|
120
|
+
- lib/gritz/router.rb
|
|
121
|
+
- lib/gritz/testing/in_memory_call.rb
|
|
122
|
+
- lib/gritz/testing/minitest.rb
|
|
123
|
+
- lib/gritz/testing/rpc_helper.rb
|
|
124
|
+
- lib/gritz/testing/rspec.rb
|
|
125
|
+
homepage: https://github.com/gritzrpc/gritz-core
|
|
126
|
+
licenses:
|
|
127
|
+
- MIT
|
|
128
|
+
metadata:
|
|
129
|
+
allowed_push_host: https://rubygems.org
|
|
130
|
+
source_code_uri: https://github.com/gritzrpc/gritz-core
|
|
131
|
+
changelog_uri: https://github.com/gritzrpc/gritz-core/blob/main/CHANGELOG.md
|
|
132
|
+
rubygems_mfa_required: 'true'
|
|
133
|
+
rdoc_options: []
|
|
134
|
+
require_paths:
|
|
135
|
+
- lib
|
|
136
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
137
|
+
requirements:
|
|
138
|
+
- - ">="
|
|
139
|
+
- !ruby/object:Gem::Version
|
|
140
|
+
version: '3.3'
|
|
141
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
142
|
+
requirements:
|
|
143
|
+
- - ">="
|
|
144
|
+
- !ruby/object:Gem::Version
|
|
145
|
+
version: '0'
|
|
146
|
+
requirements: []
|
|
147
|
+
rubygems_version: 4.0.16
|
|
148
|
+
specification_version: 4
|
|
149
|
+
summary: Transport-independent controllers and middleware for Gritz
|
|
150
|
+
test_files: []
|