otto 2.0.1 → 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: 3f3bb450ec316b56ed419765cc0453d9a1b00584a071e77ee03ec14b2deea1f7
4
- data.tar.gz: 5a9f16d21067b4ce3287902c4da6342820414d1f8156470e410ed74ad672faef
3
+ metadata.gz: b4e62574b6cb588f8dd99890758af5689dc23732d7a920c0023627a128a5ab5a
4
+ data.tar.gz: 658e3f7cf3feedf1ee8a120bcb4a5121d2b70c0d5f584b96115ded86d6d2132c
5
5
  SHA512:
6
- metadata.gz: 98313ac7cdf3f6f00fbe2d93422c2f464c45663bb52135c4baeb85a61cd33d90702394ba50284f84099cfce1f6e32bcf6605520fe38434653ded9c762e94ac15
7
- data.tar.gz: b81b1c05ea9258f3d57286411f926959562a78daf377e8fad924de83d376e2fd6d8f342bd175b4b0d135b5e57321775d354c1b8bd06a08d13dae011686e4e4ac
6
+ metadata.gz: f4426d3362c6f2ceb81ca1d8c1dfd55dad7b404218b6a3715394bec29e466b6bf0f09d290bb11fa157eaaa6c2889e702ccac97ba4f5496e947005cc083046332
7
+ data.tar.gz: dca5f99161a47a01514f58a7959062d215b5371f2e8165f57399f77cece4529f57ae4e0cb83224fe5fd93acdd4575dc1b04bbb9682afa82f2ad4e98258b9786d
@@ -22,20 +22,45 @@ jobs:
22
22
  test:
23
23
  timeout-minutes: 10
24
24
  runs-on: ubuntu-24.04
25
- name: "RSpec Tests (Ruby ${{ matrix.ruby }})"
25
+ name: "RSpec Tests (Ruby ${{ matrix.ruby }}, ${{ matrix.lockfile }})"
26
26
  continue-on-error: ${{ matrix.experimental }}
27
27
  strategy:
28
28
  fail-fast: false
29
29
  matrix:
30
+ # Each Ruby runs twice: once against the committed Gemfile.lock
31
+ # (floor of the declared version range, reproducible) and once
32
+ # with the lockfile removed so Bundler resolves fresh inside the
33
+ # gemspec's pessimistic constraints (ceiling, what a downstream
34
+ # user will actually hit). The unlocked cells catch upstream
35
+ # releases that satisfy `~> X.Y` but break Otto at load time -
36
+ # e.g. facets 3.2.0 shipping a self-referential
37
+ # `require_relative 'file/write.rb'` against a file deleted in
38
+ # the same release, the reason 2.0.2 exists.
30
39
  include:
31
40
  - ruby: "3.3"
32
41
  experimental: false
42
+ lockfile: "locked"
33
43
  - ruby: "3.4"
34
44
  experimental: false
45
+ lockfile: "locked"
35
46
  - ruby: "3.5"
36
47
  experimental: true
48
+ lockfile: "locked"
37
49
  - ruby: "4.0"
38
50
  experimental: true
51
+ lockfile: "locked"
52
+ - ruby: "3.3"
53
+ experimental: false
54
+ lockfile: "unlocked"
55
+ - ruby: "3.4"
56
+ experimental: false
57
+ lockfile: "unlocked"
58
+ - ruby: "3.5"
59
+ experimental: true
60
+ lockfile: "unlocked"
61
+ - ruby: "4.0"
62
+ experimental: true
63
+ lockfile: "unlocked"
39
64
 
40
65
  steps:
41
66
  - uses: actions/checkout@v6
@@ -44,7 +69,9 @@ jobs:
44
69
  continue-on-error: ${{ matrix.experimental }}
45
70
  with:
46
71
  ruby-version: ${{ matrix.ruby }}
47
- bundler-cache: ${{ !matrix.experimental }}
72
+ # Bundler cache keys off Gemfile.lock, so only enable it on the
73
+ # locked matrix cells. Unlocked cells need a fresh resolve each run.
74
+ bundler-cache: ${{ !matrix.experimental && matrix.lockfile == 'locked' }}
48
75
 
49
76
  - name: Setup tmate session
50
77
  uses: mxschmitt/action-tmate@c0afd6f790e3a5564914980036ebf83216678101 # v3
@@ -56,12 +83,23 @@ jobs:
56
83
  continue-on-error: ${{ matrix.experimental }}
57
84
  env:
58
85
  EXPERIMENTAL: ${{ matrix.experimental }}
86
+ LOCKFILE: ${{ matrix.lockfile }}
59
87
  run: |
60
88
  bundle config path vendor/bundle
89
+ # Enable the optional :development, :test group (json_schemer,
90
+ # rack-attack) that specs depend on. `bundle install --with` was
91
+ # removed in Bundler 2.7, which ships with Ruby 4.0.
92
+ bundle config set --local with 'development test'
61
93
  if [ "$EXPERIMENTAL" = "true" ]; then
62
94
  bundle config set --local force_ruby_platform true
63
95
  fi
64
- bundle install --jobs 4 --retry 3 --with test
96
+ if [ "$LOCKFILE" = "unlocked" ]; then
97
+ # Drop the committed lockfile so Bundler resolves fresh inside
98
+ # the gemspec's pessimistic constraints. This surfaces the gap
99
+ # between "version range we declare" and "version range we test."
100
+ rm -f Gemfile.lock
101
+ fi
102
+ bundle install --jobs 4 --retry 3
65
103
 
66
104
  - name: Verify setup
67
105
  run: |
@@ -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,51 @@ 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
+
35
+ .. _changelog-2.0.2:
36
+
37
+ 2.0.2 — 2026-04-15
38
+ ==================
39
+
40
+ - Load failure under facets 3.2.0. ``Otto::Security::ValidationHelpers`` no
41
+ longer requires ``facets/file``, whose aggregator in 3.2.0 does
42
+ ``require_relative 'file/write.rb'`` against a file deleted in the same
43
+ release. The one function Otto borrowed from facets — ``File.sanitize`` —
44
+ is now inlined as a private method on the helper module (with credit in
45
+ the source comment), and the ``facets`` runtime dependency is removed
46
+ from the gemspec entirely. Applications depending on facets directly are
47
+ unaffected.
48
+
49
+ - CI now runs the RSpec suite twice for each Ruby in the matrix: once
50
+ against the committed ``Gemfile.lock`` and once with the lockfile removed
51
+ so Bundler resolves fresh inside the gemspec's pessimistic constraints.
52
+ The unlocked cells catch upstream releases that satisfy ``~> X.Y`` but
53
+ break Otto at load time.
54
+
10
55
  .. _changelog-2.0.1:
11
56
 
12
57
  2.0.1 — 2026-04-15
data/Gemfile CHANGED
@@ -9,8 +9,6 @@ source 'https://rubygems.org'
9
9
 
10
10
  gemspec
11
11
 
12
- gem 'rackup'
13
-
14
12
  group :test do
15
13
  gem 'rack-test'
16
14
  gem 'rspec', '~> 3.13'
@@ -28,7 +26,8 @@ end
28
26
  group :development do
29
27
  gem 'benchmark'
30
28
  gem 'debug'
31
- gem 'rubocop', '~> 1.81.7', require: false
29
+ gem 'rackup' # Used to boot examples/ apps; not needed by specs
30
+ gem 'rubocop', '~> 1.86.2', require: false
32
31
  gem 'rubocop-performance', require: false
33
32
  gem 'rubocop-rspec', require: false
34
33
  gem 'rubocop-thread_safety', require: false
data/Gemfile.lock CHANGED
@@ -1,9 +1,8 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- otto (2.0.1)
4
+ otto (2.1.0)
5
5
  concurrent-ruby (~> 1.3, < 2.0)
6
- facets (~> 3.1)
7
6
  ipaddr (~> 1, < 2.0)
8
7
  logger (~> 1, < 2.0)
9
8
  loofah (~> 2.20)
@@ -19,8 +18,8 @@ GEM
19
18
  bigdecimal (4.1.1)
20
19
  concurrent-ruby (1.3.6)
21
20
  crass (1.0.6)
22
- date (3.4.1)
23
- debug (1.11.0)
21
+ date (3.5.1)
22
+ debug (1.11.1)
24
23
  irb (~> 1.10)
25
24
  reline (>= 0.3.8)
26
25
  diff-lcs (1.6.2)
@@ -53,16 +52,16 @@ GEM
53
52
  dry-inflector (~> 1.0)
54
53
  dry-logic (~> 1.4)
55
54
  zeitwerk (~> 2.6)
56
- erb (5.1.1)
57
- facets (3.1.0)
55
+ erb (6.0.4)
58
56
  hana (1.3.7)
59
- io-console (0.8.1)
60
- ipaddr (1.2.8)
61
- irb (1.15.2)
57
+ io-console (0.8.2)
58
+ ipaddr (1.2.9)
59
+ irb (1.18.0)
62
60
  pp (>= 0.6.0)
61
+ prism (>= 1.3.0)
63
62
  rdoc (>= 4.0.0)
64
63
  reline (>= 0.4.2)
65
- json (2.19.3)
64
+ json (2.19.5)
66
65
  json_schemer (2.5.0)
67
66
  bigdecimal
68
67
  hana (~> 1.3)
@@ -102,7 +101,7 @@ GEM
102
101
  prettier_print (1.2.1)
103
102
  prettyprint (0.2.0)
104
103
  prism (1.9.0)
105
- psych (5.2.6)
104
+ psych (5.3.1)
106
105
  date
107
106
  stringio
108
107
  racc (1.8.1)
@@ -120,7 +119,7 @@ GEM
120
119
  logger
121
120
  prism (>= 1.6.0)
122
121
  tsort
123
- rdoc (6.15.0)
122
+ rdoc (7.2.0)
124
123
  erb
125
124
  psych (>= 4.0.0)
126
125
  tsort
@@ -131,7 +130,7 @@ GEM
131
130
  rainbow (>= 2.0, < 4.0)
132
131
  rexml (~> 3.1)
133
132
  regexp_parser (2.12.0)
134
- reline (0.6.2)
133
+ reline (0.6.3)
135
134
  io-console (~> 0.5)
136
135
  rexml (3.4.4)
137
136
  rspec (3.13.2)
@@ -147,15 +146,15 @@ GEM
147
146
  diff-lcs (>= 1.2.0, < 2.0)
148
147
  rspec-support (~> 3.13.0)
149
148
  rspec-support (3.13.7)
150
- rubocop (1.81.7)
149
+ rubocop (1.86.2)
151
150
  json (~> 2.3)
152
151
  language_server-protocol (~> 3.17.0.2)
153
152
  lint_roller (~> 1.1.0)
154
- parallel (~> 1.10)
153
+ parallel (>= 1.10)
155
154
  parser (>= 3.3.0.2)
156
155
  rainbow (>= 2.2.2, < 4.0)
157
156
  regexp_parser (>= 2.9.3, < 3.0)
158
- rubocop-ast (>= 1.47.1, < 2.0)
157
+ rubocop-ast (>= 1.49.0, < 2.0)
159
158
  ruby-progressbar (~> 1.7)
160
159
  unicode-display_width (>= 2.4.0, < 4.0)
161
160
  rubocop-ast (1.49.1)
@@ -178,8 +177,8 @@ GEM
178
177
  rbs (>= 3, < 5)
179
178
  ruby-progressbar (1.13.0)
180
179
  simpleidn (0.2.3)
181
- stackprof (0.2.27)
182
- stringio (3.1.7)
180
+ stackprof (0.2.28)
181
+ stringio (3.2.0)
183
182
  syntax_tree (6.3.0)
184
183
  prettier_print (>= 1.2.0)
185
184
  tryouts (3.7.1)
@@ -221,7 +220,7 @@ DEPENDENCIES
221
220
  rackup
222
221
  reek (~> 6.5)
223
222
  rspec (~> 3.13)
224
- rubocop (~> 1.81.7)
223
+ rubocop (~> 1.86.2)
225
224
  rubocop-performance
226
225
  rubocop-rspec
227
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
@@ -3,12 +3,20 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  require 'loofah'
6
- require 'facets/file'
7
6
 
8
7
  class Otto
9
8
  module Security
10
9
  # Validation helper methods providing input validation and sanitization
11
10
  module ValidationHelpers
11
+ # Replace filesystem-unsafe characters with an underscore. Borrowed
12
+ # verbatim from facets 3.1.0's `File.sanitize` (lib/core/facets/file/
13
+ # sanitize.rb, credit: George Moschovitis) and inlined here so Otto
14
+ # doesn't take a runtime dep on the whole facets grab-bag for one
15
+ # 12-line function. See commit message for 2.0.2 for context.
16
+ FILENAME_SANITIZE_PATTERN = /[^a-zA-Z0-9.\-+_]/
17
+ FILENAME_DOT_ONLY = /^\.+$/
18
+ private_constant :FILENAME_SANITIZE_PATTERN, :FILENAME_DOT_ONLY
19
+
12
20
  def validate_input(input, max_length: 1000, allow_html: false)
13
21
  return input if input.nil?
14
22
 
@@ -42,20 +50,16 @@ class Otto
42
50
  return nil if filename.nil?
43
51
  return 'file' if filename.empty?
44
52
 
45
- # Use Facets File.sanitize for basic filesystem-safe filename
46
- clean_name = File.sanitize(filename.to_s)
53
+ clean_name = basic_filename_sanitize(filename.to_s)
47
54
 
48
- # Handle edge cases and improve on Facets behavior to match test expectations
49
55
  if clean_name.nil? || clean_name.empty?
50
56
  clean_name = 'file'
51
57
  else
52
- # Additional cleanup that Facets doesn't do but our tests expect
53
- clean_name = clean_name.gsub(/_{2,}/, '_') # Collapse multiple underscores
54
- clean_name = clean_name.gsub(/^_+|_+$/, '') # Remove leading/trailing underscores
55
- clean_name = 'file' if clean_name.empty? # Handle case where only underscores remain
58
+ clean_name = clean_name.gsub(/_{2,}/, '_')
59
+ clean_name = clean_name.gsub(/^_+|_+$/, '')
60
+ clean_name = 'file' if clean_name.empty? || clean_name.match?(FILENAME_DOT_ONLY)
56
61
  end
57
62
 
58
- # Ensure reasonable length (255 is filesystem limit, leave some padding)
59
63
  clean_name = clean_name[0..99] if clean_name.length > 100
60
64
 
61
65
  clean_name
@@ -63,6 +67,17 @@ class Otto
63
67
 
64
68
  private
65
69
 
70
+ # Filesystem-safe basename. Port of facets 3.1.0's `File.sanitize`:
71
+ # strip directory components (handling backslashes for IE-uploaded
72
+ # paths), replace anything outside [A-Za-z0-9.\-+_] with '_', and
73
+ # prefix a leading '_' if the whole name is just dots ('.', '..').
74
+ def basic_filename_sanitize(filename)
75
+ name = File.basename(filename.gsub('\\', '/'))
76
+ name = name.gsub(FILENAME_SANITIZE_PATTERN, '_')
77
+ name = "_#{name}" if name.match?(FILENAME_DOT_ONLY)
78
+ name
79
+ end
80
+
66
81
  # Check if content looks like it contains HTML tags or entities
67
82
  def contains_html_like_content?(content)
68
83
  content.match?(/[<>&]/) || content.match?(/&\w+;/)
@@ -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.1'
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 = []
data/otto.gemspec CHANGED
@@ -31,7 +31,6 @@ Gem::Specification.new do |spec|
31
31
  spec.add_dependency 'rexml', '~> 3.4'
32
32
 
33
33
  # Security dependencies
34
- spec.add_dependency 'facets', '~> 3.1'
35
34
  spec.add_dependency 'loofah', '~> 2.20'
36
35
 
37
36
  # Optional MCP dependencies
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.1
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
@@ -117,20 +117,6 @@ dependencies:
117
117
  - - "~>"
118
118
  - !ruby/object:Gem::Version
119
119
  version: '3.4'
120
- - !ruby/object:Gem::Dependency
121
- name: facets
122
- requirement: !ruby/object:Gem::Requirement
123
- requirements:
124
- - - "~>"
125
- - !ruby/object:Gem::Version
126
- version: '3.1'
127
- type: :runtime
128
- prerelease: false
129
- version_requirements: !ruby/object:Gem::Requirement
130
- requirements:
131
- - - "~>"
132
- - !ruby/object:Gem::Version
133
- version: '3.1'
134
120
  - !ruby/object:Gem::Dependency
135
121
  name: loofah
136
122
  requirement: !ruby/object:Gem::Requirement
@@ -156,6 +142,7 @@ files:
156
142
  - ".github/workflows/claude-code-review.yml"
157
143
  - ".github/workflows/claude.yml"
158
144
  - ".github/workflows/code-smells.yml"
145
+ - ".github/workflows/release-gem.yml"
159
146
  - ".gitignore"
160
147
  - ".pre-commit-config.yaml"
161
148
  - ".pre-push-config.yaml"