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 +4 -4
- data/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/code-smells.yml +1 -1
- data/.github/workflows/release-gem.yml +158 -0
- data/CHANGELOG.rst +25 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +18 -17
- data/lib/otto/core/configuration.rb +34 -0
- data/lib/otto/core/lifecycle_hooks.rb +80 -0
- data/lib/otto/core/router.rb +10 -0
- data/lib/otto/route_handlers/factory.rb +17 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +3 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b4e62574b6cb588f8dd99890758af5689dc23732d7a920c0023627a128a5ab5a
|
|
4
|
+
data.tar.gz: 658e3f7cf3feedf1ee8a120bcb4a5121d2b70c0d5f584b96115ded86d6d2132c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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@
|
|
57
|
+
uses: actions/github-script@v9
|
|
58
58
|
with:
|
|
59
59
|
script: |
|
|
60
60
|
try {
|
|
@@ -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.
|
|
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
|
|
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.
|
|
22
|
-
debug (1.11.
|
|
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 (
|
|
55
|
+
erb (6.0.4)
|
|
56
56
|
hana (1.3.7)
|
|
57
|
-
io-console (0.8.
|
|
58
|
-
ipaddr (1.2.
|
|
59
|
-
irb (1.
|
|
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.
|
|
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.
|
|
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 (
|
|
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.
|
|
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.
|
|
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 (
|
|
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.
|
|
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.
|
|
180
|
-
stringio (3.
|
|
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.
|
|
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
|
data/lib/otto/core/router.rb
CHANGED
|
@@ -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
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
|
-
@
|
|
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
|
|
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"
|