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 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/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release.
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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gritz
4
+ # Invalid startup configuration.
5
+ # @api public
6
+ class ConfigurationError < StandardError; end
7
+ 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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gritz
4
+ module Core
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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
@@ -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
@@ -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: []