otto 2.0.2 → 2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fabc1e8d6f7eef1d982b1f298fd0fe68382417aedd4b250ded28e5c7ba39a588
4
- data.tar.gz: 02f4509daad92151895100c75339d82e3f8b76106dbcaa04b2db34f7b4d39976
3
+ metadata.gz: b4e62574b6cb588f8dd99890758af5689dc23732d7a920c0023627a128a5ab5a
4
+ data.tar.gz: 658e3f7cf3feedf1ee8a120bcb4a5121d2b70c0d5f584b96115ded86d6d2132c
5
5
  SHA512:
6
- metadata.gz: 2c5f1958418f70a429bfc2379f7407387ca4a1a6cdd129160ff6bf3e416f33a637c0d14049bd97ab0c7abce02ab5b11eb943aff330b5c1c0425d55c387ff3351
7
- data.tar.gz: '08e5ec5c3278adeced0dd3c1cffb7452129a12ad742c381b29a29bd569be4d013a67a660ad111bfe51631672e21d96cf2c0f39c69ab595758aa0e7e1d562ed32'
6
+ metadata.gz: f4426d3362c6f2ceb81ca1d8c1dfd55dad7b404218b6a3715394bec29e466b6bf0f09d290bb11fa157eaaa6c2889e702ccac97ba4f5496e947005cc083046332
7
+ data.tar.gz: dca5f99161a47a01514f58a7959062d215b5371f2e8165f57399f77cece4529f57ae4e0cb83224fe5fd93acdd4575dc1b04bbb9682afa82f2ad4e98258b9786d
@@ -54,7 +54,7 @@ jobs:
54
54
  - name: Remove claude-review label
55
55
  # Remove label whether success or failure - prevents getting stuck
56
56
  if: always() && github.event.action != 'opened'
57
- uses: actions/github-script@v8
57
+ uses: actions/github-script@v9
58
58
  with:
59
59
  script: |
60
60
  try {
@@ -71,7 +71,7 @@ jobs:
71
71
  continue-on-error: true
72
72
 
73
73
  - name: Upload Reek report as artifact
74
- uses: actions/upload-artifact@v6
74
+ uses: actions/upload-artifact@v7
75
75
  if: always()
76
76
  with:
77
77
  name: reek-report
@@ -0,0 +1,158 @@
1
+ # Release Gem Workflow
2
+ #
3
+ # Automatically builds and publishes the otto gem to RubyGems.org when a
4
+ # GitHub release is published with a semantic version tag (vMAJOR.MINOR.PATCH,
5
+ # e.g. v0.6.1, v1.2.3).
6
+ #
7
+ # Follows the canonical RubyGems Trusted Publishing workflow:
8
+ # https://guides.rubygems.org/trusted-publishing/
9
+ #
10
+ # ============================================================================
11
+ # SETUP INSTRUCTIONS (one-time)
12
+ # ============================================================================
13
+ #
14
+ # This workflow uses RubyGems Trusted Publishing (OIDC). No long-lived API
15
+ # key needs to be stored in GitHub.
16
+ #
17
+ # ----------------------------------------------------------------------------
18
+ # 1. Configure the trusted publisher on RubyGems.org
19
+ # ----------------------------------------------------------------------------
20
+ #
21
+ # a. Sign in to https://rubygems.org and enable MFA on your account
22
+ # (required for trusted publishing).
23
+ #
24
+ # b. Open the gem's trusted publisher page (works for both existing gems
25
+ # and the first-ever publish):
26
+ # Existing gem: https://rubygems.org/gems/otto/trusted_publishers
27
+ # First publish: https://rubygems.org/profile/oidc/pending_trusted_publishers/new
28
+ #
29
+ # c. Click "Create" and fill in:
30
+ # Publisher type: GitHub Actions
31
+ # Repository owner: delano
32
+ # Repository name: otto
33
+ # Workflow filename: release-gem.yml
34
+ # Environment: rubygems.org (must match `environment.name` below)
35
+ #
36
+ # d. Save. RubyGems will now accept short-lived OIDC tokens issued by this
37
+ # workflow running in this repository.
38
+ #
39
+ # ----------------------------------------------------------------------------
40
+ # 2. Configure the GitHub environment
41
+ # ----------------------------------------------------------------------------
42
+ #
43
+ # a. In GitHub: Settings -> Environments -> New environment
44
+ # Name: `rubygems.org` (must match `environment.name` below)
45
+ #
46
+ # b. Under "Deployment branches and tags", restrict to tags matching:
47
+ # v*.*.*
48
+ # This prevents the environment (and its OIDC token) from being used
49
+ # from any branch or non-release tag.
50
+ #
51
+ # c. Optional but recommended: add required reviewers to gate publishes
52
+ # behind manual approval.
53
+ #
54
+ # No GitHub secrets are required - OIDC handles authentication.
55
+ #
56
+ # ----------------------------------------------------------------------------
57
+ # 3. Cutting a release
58
+ # ----------------------------------------------------------------------------
59
+ #
60
+ # a. Bump Otto::VERSION in lib/otto/version.rb (semver: MAJOR.MINOR.PATCH).
61
+ # b. Update CHANGELOG.md and commit on main.
62
+ # c. On GitHub, draft a Release with tag `vX.Y.Z` (matching the constant)
63
+ # and publish it. This workflow runs automatically: it verifies the tag
64
+ # matches the gemspec version, builds the gem, and pushes it.
65
+ #
66
+ # ----------------------------------------------------------------------------
67
+ # Fallback: API key (only if Trusted Publishing isn't an option)
68
+ # ----------------------------------------------------------------------------
69
+ #
70
+ # 1. Create an API key at https://rubygems.org/profile/api_keys with scope
71
+ # "Push rubygem" restricted to the `otto` gem.
72
+ # 2. Store it as a repo secret named `RUBYGEMS_API_KEY`.
73
+ # 3. Drop the `id-token: write` permission and `environment:` block, and
74
+ # replace the `rubygems/release-gem` step with:
75
+ #
76
+ # - name: Publish to RubyGems
77
+ # env:
78
+ # GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
79
+ # run: gem push otto-*.gem
80
+ #
81
+ # ----------------------------------------------------------------------------
82
+ # Action provenance (SHA pins)
83
+ # ----------------------------------------------------------------------------
84
+ #
85
+ # All third-party actions below are pinned to a full commit SHA, per GitHub's
86
+ # secure-use guidance for release-critical workflows. The accompanying
87
+ # comment records the tag the SHA was resolved from so renovate/dependabot
88
+ # can keep them current.
89
+ #
90
+ # actions/checkout - official GitHub action
91
+ # ruby/setup-ruby - official Ruby org action (GitHub-verified creator)
92
+ # rubygems/release-gem - official RubyGems action for trusted publishing
93
+ #
94
+ # ============================================================================
95
+
96
+ name: Release Gem
97
+
98
+ on:
99
+ release:
100
+ types: [published]
101
+
102
+ # Coarse default. Each job re-declares the narrowest permissions it needs.
103
+ permissions:
104
+ contents: read
105
+
106
+ # Don't allow two release runs to race - one published tag, one publish.
107
+ concurrency:
108
+ group: release-gem-${{ github.event.release.tag_name }}
109
+ cancel-in-progress: false
110
+
111
+ jobs:
112
+ release:
113
+ name: Build and push gem
114
+ runs-on: ubuntu-latest
115
+ timeout-minutes: 10
116
+
117
+ environment:
118
+ name: rubygems.org
119
+ url: https://rubygems.org/gems/otto
120
+
121
+ permissions:
122
+ contents: write # rubygems/release-gem attaches built .gem to the GitHub release
123
+ id-token: write # OIDC token for RubyGems Trusted Publishing
124
+
125
+ steps:
126
+ - name: Checkout
127
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
128
+ with:
129
+ persist-credentials: false
130
+
131
+ - name: Set up Ruby
132
+ uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
133
+ with:
134
+ bundler-cache: true
135
+ ruby-version: ruby # latest stable Ruby installed by setup-ruby
136
+
137
+ - name: Verify release tag matches Otto::VERSION
138
+ env:
139
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
140
+ run: |
141
+ set -euo pipefail
142
+ case "${RELEASE_TAG}" in
143
+ v[0-9]*.[0-9]*.[0-9]*) ;;
144
+ *)
145
+ echo "Release tag '${RELEASE_TAG}' is not a vMAJOR.MINOR.PATCH semver tag." >&2
146
+ exit 1
147
+ ;;
148
+ esac
149
+ tag_version="${RELEASE_TAG#v}"
150
+ gem_version="$(ruby -r ./lib/otto/version.rb -e 'print Otto::VERSION')"
151
+ if [ "${tag_version}" != "${gem_version}" ]; then
152
+ echo "Release tag ${RELEASE_TAG} (${tag_version}) != Otto::VERSION (${gem_version})." >&2
153
+ exit 1
154
+ fi
155
+ echo "Releasing otto ${gem_version} from tag ${RELEASE_TAG}"
156
+
157
+ - name: Build and push gem to RubyGems
158
+ uses: rubygems/release-gem@6317d8d1f7e28c24d28f6eff169ea854948bd9f7 # v1.2.0
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,31 @@ The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.1.0/>`
7
7
 
8
8
  <!--scriv-insert-here-->
9
9
 
10
+ .. _changelog-2.1.0:
11
+
12
+ 2.1.0 — 2026-05-27
13
+ ==================
14
+
15
+ - Add ``Otto#on_route_matched`` lifecycle hook. Callbacks fire after a
16
+ route matches but before the handler dispatches, with signature
17
+ ``(env, route_definition)``. Mirrors ``on_request_complete`` for
18
+ registration and freezing, but exceptions raised from a callback
19
+ propagate through ``handle_error`` rather than being swallowed, so
20
+ consumers can route custom error classes through
21
+ ``register_error_handler`` for short-circuit gating. Skipped for
22
+ static file routes and the 404 fallback; fires on both literal and
23
+ dynamic matches. Per-instance state, zero overhead when no callbacks
24
+ are registered. (#129)
25
+
26
+ - Add ``Otto#register_handler_wrapper`` API for per-request handler
27
+ composition. Registers factory blocks composed around each route
28
+ handler at request time; wrappers nest outermost-first in
29
+ registration order, with ``RouteAuthWrapper`` preserved as the
30
+ innermost wrapper so consumers see ``env['otto.strategy_result']``.
31
+ ``freeze_configuration!`` now exercises every registered wrapper
32
+ against every loaded route, surfacing ``TypeError`` and factory bugs
33
+ at boot rather than on the first matching request. (#130)
34
+
10
35
  .. _changelog-2.0.2:
11
36
 
12
37
  2.0.2 — 2026-04-15
data/Gemfile CHANGED
@@ -27,7 +27,7 @@ group :development do
27
27
  gem 'benchmark'
28
28
  gem 'debug'
29
29
  gem 'rackup' # Used to boot examples/ apps; not needed by specs
30
- gem 'rubocop', '~> 1.81.7', require: false
30
+ gem 'rubocop', '~> 1.86.2', require: false
31
31
  gem 'rubocop-performance', require: false
32
32
  gem 'rubocop-rspec', require: false
33
33
  gem 'rubocop-thread_safety', require: false
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- otto (2.0.2)
4
+ otto (2.1.0)
5
5
  concurrent-ruby (~> 1.3, < 2.0)
6
6
  ipaddr (~> 1, < 2.0)
7
7
  logger (~> 1, < 2.0)
@@ -18,8 +18,8 @@ GEM
18
18
  bigdecimal (4.1.1)
19
19
  concurrent-ruby (1.3.6)
20
20
  crass (1.0.6)
21
- date (3.4.1)
22
- debug (1.11.0)
21
+ date (3.5.1)
22
+ debug (1.11.1)
23
23
  irb (~> 1.10)
24
24
  reline (>= 0.3.8)
25
25
  diff-lcs (1.6.2)
@@ -52,15 +52,16 @@ GEM
52
52
  dry-inflector (~> 1.0)
53
53
  dry-logic (~> 1.4)
54
54
  zeitwerk (~> 2.6)
55
- erb (5.1.1)
55
+ erb (6.0.4)
56
56
  hana (1.3.7)
57
- io-console (0.8.1)
58
- ipaddr (1.2.8)
59
- irb (1.15.2)
57
+ io-console (0.8.2)
58
+ ipaddr (1.2.9)
59
+ irb (1.18.0)
60
60
  pp (>= 0.6.0)
61
+ prism (>= 1.3.0)
61
62
  rdoc (>= 4.0.0)
62
63
  reline (>= 0.4.2)
63
- json (2.19.3)
64
+ json (2.19.5)
64
65
  json_schemer (2.5.0)
65
66
  bigdecimal
66
67
  hana (~> 1.3)
@@ -100,7 +101,7 @@ GEM
100
101
  prettier_print (1.2.1)
101
102
  prettyprint (0.2.0)
102
103
  prism (1.9.0)
103
- psych (5.2.6)
104
+ psych (5.3.1)
104
105
  date
105
106
  stringio
106
107
  racc (1.8.1)
@@ -118,7 +119,7 @@ GEM
118
119
  logger
119
120
  prism (>= 1.6.0)
120
121
  tsort
121
- rdoc (6.15.0)
122
+ rdoc (7.2.0)
122
123
  erb
123
124
  psych (>= 4.0.0)
124
125
  tsort
@@ -129,7 +130,7 @@ GEM
129
130
  rainbow (>= 2.0, < 4.0)
130
131
  rexml (~> 3.1)
131
132
  regexp_parser (2.12.0)
132
- reline (0.6.2)
133
+ reline (0.6.3)
133
134
  io-console (~> 0.5)
134
135
  rexml (3.4.4)
135
136
  rspec (3.13.2)
@@ -145,15 +146,15 @@ GEM
145
146
  diff-lcs (>= 1.2.0, < 2.0)
146
147
  rspec-support (~> 3.13.0)
147
148
  rspec-support (3.13.7)
148
- rubocop (1.81.7)
149
+ rubocop (1.86.2)
149
150
  json (~> 2.3)
150
151
  language_server-protocol (~> 3.17.0.2)
151
152
  lint_roller (~> 1.1.0)
152
- parallel (~> 1.10)
153
+ parallel (>= 1.10)
153
154
  parser (>= 3.3.0.2)
154
155
  rainbow (>= 2.2.2, < 4.0)
155
156
  regexp_parser (>= 2.9.3, < 3.0)
156
- rubocop-ast (>= 1.47.1, < 2.0)
157
+ rubocop-ast (>= 1.49.0, < 2.0)
157
158
  ruby-progressbar (~> 1.7)
158
159
  unicode-display_width (>= 2.4.0, < 4.0)
159
160
  rubocop-ast (1.49.1)
@@ -176,8 +177,8 @@ GEM
176
177
  rbs (>= 3, < 5)
177
178
  ruby-progressbar (1.13.0)
178
179
  simpleidn (0.2.3)
179
- stackprof (0.2.27)
180
- stringio (3.1.7)
180
+ stackprof (0.2.28)
181
+ stringio (3.2.0)
181
182
  syntax_tree (6.3.0)
182
183
  prettier_print (>= 1.2.0)
183
184
  tryouts (3.7.1)
@@ -219,7 +220,7 @@ DEPENDENCIES
219
220
  rackup
220
221
  reek (~> 6.5)
221
222
  rspec (~> 3.13)
222
- rubocop (~> 1.81.7)
223
+ rubocop (~> 1.86.2)
223
224
  rubocop-performance
224
225
  rubocop-rspec
225
226
  rubocop-thread_safety
@@ -170,6 +170,11 @@ class Otto
170
170
  deep_freeze_value(@auth_config) if @auth_config
171
171
  deep_freeze_value(@option) if @option
172
172
 
173
+ # Validate registered handler-wrapper factories against every loaded
174
+ # route before locking the config. Surfaces TypeError / factory bugs
175
+ # at boot instead of on the first request that happens to match.
176
+ validate_handler_wrappers!
177
+
173
178
  # Deep freeze route structures (prevent modification of nested hashes/arrays)
174
179
  deep_freeze_value(@routes) if @routes
175
180
  deep_freeze_value(@routes_literal) if @routes_literal
@@ -207,6 +212,35 @@ class Otto
207
212
  # Only check the new middleware stack as the single source of truth
208
213
  @middleware&.includes?(middleware_class)
209
214
  end
215
+
216
+ # Walk every loaded route and exercise the registered handler-wrapper
217
+ # factories against a sentinel inner handler. Each factory must return a
218
+ # callable; HandlerFactory.apply_handler_wrappers raises TypeError
219
+ # otherwise. The constructed chain is discarded — this is a fail-fast
220
+ # validation pass, not memoization.
221
+ #
222
+ # Iterates @routes (covers MCP routes added directly) uniquified by
223
+ # identity. No-op if no wrappers are registered or no routes are loaded.
224
+ #
225
+ # @api private
226
+ # @return [void]
227
+ def validate_handler_wrappers!
228
+ return unless @routes && @route_handler_factory
229
+ return if @handler_wrappers.nil? || @handler_wrappers.empty?
230
+
231
+ sentinel = ->(_env, _extra = {}) { [200, {}, []] }
232
+ seen = {}.compare_by_identity
233
+ @routes.each_value do |routes_for_verb|
234
+ routes_for_verb.each do |route|
235
+ next if seen[route]
236
+
237
+ seen[route] = true
238
+ Otto::RouteHandlers::HandlerFactory.apply_handler_wrappers(
239
+ sentinel, route.route_definition, self
240
+ )
241
+ end
242
+ end
243
+ end
210
244
  end
211
245
  end
212
246
  end
@@ -58,6 +58,86 @@ class Otto
58
58
  def request_complete_callbacks
59
59
  @request_complete_callbacks
60
60
  end
61
+
62
+ # Register a callback fired after a route matches but before the handler dispatches.
63
+ #
64
+ # The callback receives two arguments:
65
+ # - env: the Rack environment hash
66
+ # - route_definition: the matched Otto::RouteDefinition
67
+ #
68
+ # Unlike on_request_complete, exceptions raised inside on_route_matched callbacks
69
+ # are NOT swallowed: they propagate to Otto#handle_error so consumers can route
70
+ # custom error classes through register_error_handler.
71
+ #
72
+ # Does not fire for static-file routes or for the 404 fallback route.
73
+ #
74
+ # @example
75
+ # otto.on_route_matched do |env, route_definition|
76
+ # raise MyApp::Maintenance if maintenance? && route_definition.auth_requirement
77
+ # end
78
+ #
79
+ # @yield [env, route_definition] Block to execute after route match
80
+ # @yieldparam env [Hash] The Rack environment
81
+ # @yieldparam route_definition [Otto::RouteDefinition] The matched route definition
82
+ # @return [self] Returns self for method chaining
83
+ # @raise [FrozenError] if called after configuration is frozen
84
+ def on_route_matched(&block)
85
+ ensure_not_frozen!
86
+ @route_matched_callbacks << block if block_given?
87
+ self
88
+ end
89
+
90
+ # Get registered route matched callbacks (for internal use)
91
+ #
92
+ # @api private
93
+ # @return [Array<Proc>] Array of registered callback blocks
94
+ def route_matched_callbacks
95
+ @route_matched_callbacks
96
+ end
97
+
98
+ # Register a wrapper factory that composes around each route handler.
99
+ #
100
+ # The block is invoked once per request, identical to RouteAuthWrapper
101
+ # instantiation, with the route's definition and the inner (already
102
+ # wrapped) handler. It must return either the original inner_handler (to
103
+ # opt out for that route) or a new object responding to :call(env).
104
+ # Wrappers compose outermost-first in registration order:
105
+ #
106
+ # request -> wrapper_1 -> wrapper_2 -> ... -> RouteAuthWrapper -> base_handler
107
+ #
108
+ # RouteAuthWrapper always stays innermost so env['otto.strategy_result'] is set
109
+ # before any consumer wrapper sees the request.
110
+ #
111
+ # Factory blocks are exercised once against every loaded route during
112
+ # freeze_configuration! (see validate_handler_wrappers!) so factories that
113
+ # raise unconditionally or return non-callables surface at boot rather
114
+ # than on the first request that happens to match.
115
+ #
116
+ # @example Gate a route by an option set in the routes file
117
+ # otto.register_handler_wrapper do |route_definition, inner_handler|
118
+ # next inner_handler unless route_definition.option(:scope) == 'canonical'
119
+ # MyApp::CanonicalOnlyWrapper.new(inner_handler, route_definition)
120
+ # end
121
+ #
122
+ # @yield [route_definition, inner_handler] Factory invoked per request
123
+ # @yieldparam route_definition [Otto::RouteDefinition] The route being wrapped
124
+ # @yieldparam inner_handler [#call] The handler to wrap (RouteAuthWrapper or base)
125
+ # @yieldreturn [#call] A callable wrapper, or inner_handler unchanged to skip
126
+ # @return [self] Returns self for method chaining
127
+ # @raise [FrozenError] if called after configuration is frozen
128
+ def register_handler_wrapper(&block)
129
+ ensure_not_frozen!
130
+ @handler_wrappers << block if block_given?
131
+ self
132
+ end
133
+
134
+ # Get registered handler wrapper factories (for internal use)
135
+ #
136
+ # @api private
137
+ # @return [Array<Proc>] Array of registered wrapper factory blocks
138
+ def handler_wrappers
139
+ @handler_wrappers
140
+ end
61
141
  end
62
142
  end
63
143
  end
@@ -110,6 +110,10 @@ class Otto
110
110
  handler: route.route_definition.definition,
111
111
  auth_strategy: route.route_definition.auth_requirement || 'none'
112
112
  ))
113
+ # Fire route matched hooks before dispatch; raises propagate to handle_error
114
+ unless @route_matched_callbacks.empty?
115
+ @route_matched_callbacks.each { |cb| cb.call(env, route.route_definition) }
116
+ end
113
117
  route.call(env)
114
118
  elsif static_route && http_verb == :GET && safe_file?(path_info)
115
119
  Otto.structured_log(:debug, 'Route matched',
@@ -180,6 +184,12 @@ class Otto
180
184
  Otto::LoggingHelpers.request_context(env).merge(
181
185
  fallback_to: '404_route'
182
186
  ))
187
+ else
188
+ # Fire route matched hooks before dispatch; suppressed for 404 fallback.
189
+ # Raises propagate to handle_error so custom error classes can be registered.
190
+ unless @route_matched_callbacks.empty?
191
+ @route_matched_callbacks.each { |cb| cb.call(env, found_route.route_definition) }
192
+ end
183
193
  end
184
194
  found_route.call env, extra_params
185
195
  else
@@ -37,6 +37,23 @@ class Otto
37
37
  )
38
38
  end
39
39
 
40
+ apply_handler_wrappers(handler, route_definition, otto_instance)
41
+ end
42
+
43
+ # Compose registered wrappers outermost-first. Built innermost-out so
44
+ # reverse_each yields a final order of: w1(w2(...(RouteAuthWrapper(base)))).
45
+ def self.apply_handler_wrappers(handler, route_definition, otto_instance)
46
+ wrappers = otto_instance&.handler_wrappers
47
+ return handler if wrappers.nil? || wrappers.empty?
48
+
49
+ wrappers.reverse_each do |factory|
50
+ wrapped = factory.call(route_definition, handler)
51
+ unless wrapped.respond_to?(:call)
52
+ raise TypeError,
53
+ "handler wrapper must return an object responding to :call, got #{wrapped.class}"
54
+ end
55
+ handler = wrapped
56
+ end
40
57
  handler
41
58
  end
42
59
  end
data/lib/otto/version.rb CHANGED
@@ -3,5 +3,5 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
- VERSION = '2.0.2'
6
+ VERSION = '2.1.0'
7
7
  end
data/lib/otto.rb CHANGED
@@ -170,7 +170,9 @@ class Otto
170
170
  @security = Otto::Security::Configurator.new(@security_config, @middleware, @auth_config)
171
171
  @app = nil # Pre-built middleware app (built after initialization)
172
172
  @request_complete_callbacks = [] # Instance-level request completion callbacks
173
- @error_handlers = {} # Registered error handlers for expected errors
173
+ @route_matched_callbacks = [] # Instance-level route matched callbacks
174
+ @handler_wrappers = [] # Instance-level handler wrapper factories
175
+ @error_handlers = {} # Registered error handlers for expected errors
174
176
 
175
177
  # Initialize helper module registries
176
178
  @request_helper_modules = []
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: otto
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.2
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
@@ -142,6 +142,7 @@ files:
142
142
  - ".github/workflows/claude-code-review.yml"
143
143
  - ".github/workflows/claude.yml"
144
144
  - ".github/workflows/code-smells.yml"
145
+ - ".github/workflows/release-gem.yml"
145
146
  - ".gitignore"
146
147
  - ".pre-commit-config.yaml"
147
148
  - ".pre-push-config.yaml"