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 +4 -4
- data/.github/workflows/ci.yml +41 -3
- 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 +45 -0
- data/Gemfile +2 -3
- data/Gemfile.lock +18 -19
- 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/helpers/validation.rb +24 -9
- data/lib/otto/route_handlers/factory.rb +17 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +3 -1
- data/otto.gemspec +0 -1
- metadata +2 -15
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
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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@
|
|
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,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 '
|
|
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
|
|
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.
|
|
23
|
-
debug (1.11.
|
|
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 (
|
|
57
|
-
facets (3.1.0)
|
|
55
|
+
erb (6.0.4)
|
|
58
56
|
hana (1.3.7)
|
|
59
|
-
io-console (0.8.
|
|
60
|
-
ipaddr (1.2.
|
|
61
|
-
irb (1.
|
|
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.
|
|
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.
|
|
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 (
|
|
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.
|
|
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.
|
|
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 (
|
|
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.
|
|
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.
|
|
182
|
-
stringio (3.
|
|
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.
|
|
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
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
53
|
-
clean_name = clean_name.gsub(
|
|
54
|
-
clean_name =
|
|
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
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 = []
|
data/otto.gemspec
CHANGED
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
|
|
@@ -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"
|