otto 2.4.0 → 2.6.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/ruby-lint.yml +1 -1
- data/.github/workflows/yardoc.yml +1 -1
- data/CHANGELOG.rst +135 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +5 -5
- data/README.md +54 -0
- data/examples/advanced_routes/README.md +49 -0
- data/examples/advanced_routes/config.rb +15 -2
- data/examples/advanced_routes/routes +12 -0
- data/examples/lambda_handlers/README.md +128 -0
- data/examples/lambda_handlers/config.ru +26 -0
- data/examples/lambda_handlers/handlers.rb +75 -0
- data/examples/lambda_handlers/routes +28 -0
- data/lib/otto/core/configuration.rb +103 -1
- data/lib/otto/core/middleware_stack.rb +1 -0
- data/lib/otto/core/router.rb +67 -10
- data/lib/otto/core/uri_generator.rb +36 -2
- data/lib/otto/env_keys.rb +21 -0
- data/lib/otto/errors.rb +7 -0
- data/lib/otto/mcp/route_parser.rb +15 -4
- data/lib/otto/privacy/config.rb +37 -1
- data/lib/otto/privacy/core.rb +18 -1
- data/lib/otto/privacy/redacted_fingerprint.rb +4 -20
- data/lib/otto/privacy/user_agent_privacy.rb +64 -0
- data/lib/otto/privacy.rb +1 -0
- data/lib/otto/request.rb +41 -0
- data/lib/otto/response.rb +80 -43
- data/lib/otto/route.rb +103 -41
- data/lib/otto/route_definition.rb +56 -6
- data/lib/otto/route_handlers/base.rb +4 -0
- data/lib/otto/route_handlers/factory.rb +15 -0
- data/lib/otto/route_handlers/lambda.rb +47 -32
- data/lib/otto/security/config.rb +137 -84
- data/lib/otto/security/configurator.rb +16 -0
- data/lib/otto/security/core.rb +32 -0
- data/lib/otto/security/csp/emit_middleware.rb +91 -0
- data/lib/otto/security/csp/nonce.rb +78 -0
- data/lib/otto/security/csp/policy.rb +273 -0
- data/lib/otto/security/csp/writer.rb +197 -0
- data/lib/otto/security/csp.rb +20 -8
- data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
- data/lib/otto/security/csrf_validation.rb +75 -0
- data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +34 -6
- data/lib/otto/security.rb +1 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +26 -2
- metadata +13 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: de513e2f41fa9d2400937ac2ed577878bd124d5f760558bd12635bf8ad6f296a
|
|
4
|
+
data.tar.gz: 4a69f3741d3a6dbbe5a7387e2504446ce690927be12ccf90fa814db1a893d13d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 77ee1dda2b8b84e3848fb72fd737b88b6d6efca57cd0d4effe909451a310aee51ac207a3152848ebc838df52bd02c3153cb818e1bafdc1000282a374be965375
|
|
7
|
+
data.tar.gz: 8622e8974f115eee90940a157ea93180f2e4aaa05295f5362ca24f068b359b7d2a1396c42483e4d4fa37b9e8fbee7056460230eb68227033134eda09e5e4d8cc
|
data/CHANGELOG.rst
CHANGED
|
@@ -7,6 +7,141 @@ 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.6.0:
|
|
11
|
+
|
|
12
|
+
2.6.0 — 2026-07-10
|
|
13
|
+
==================
|
|
14
|
+
|
|
15
|
+
Added
|
|
16
|
+
-----
|
|
17
|
+
|
|
18
|
+
- ``Otto::Privacy::UserAgentPrivacy.anonymize(ua, max_length:)`` — public
|
|
19
|
+
User-Agent anonymization, mirroring ``Otto::Privacy::IPPrivacy``. Strips
|
|
20
|
+
build/version identifiers while preserving browser/OS family; idempotent.
|
|
21
|
+
(delano/otto#194)
|
|
22
|
+
|
|
23
|
+
- Stable-keyed IP correlation hash: ``req.ip_correlation_hash`` /
|
|
24
|
+
``env['otto.privacy.correlation_hash']``, keyed via
|
|
25
|
+
``configure_ip_privacy(correlation_secret:)`` so the same host correlates
|
|
26
|
+
across days without exposing the raw IP. (#192)
|
|
27
|
+
|
|
28
|
+
- CSP nonce policies can now be customized per-directive via
|
|
29
|
+
``enable_csp_with_nonce!(directives:)`` and ``#csp_directive_overrides=`` /
|
|
30
|
+
``#merge_csp_directives``, not just ``report-uri``/``report-to``. (#201)
|
|
31
|
+
|
|
32
|
+
Changed
|
|
33
|
+
-------
|
|
34
|
+
|
|
35
|
+
- ``RedactedFingerprint#anonymize_user_agent`` now delegates to
|
|
36
|
+
``UserAgentPrivacy.anonymize``; behavior is unchanged. (delano/otto#194)
|
|
37
|
+
|
|
38
|
+
- Default CSP ``worker-src`` now emits ``'self' blob:`` instead of
|
|
39
|
+
``'self' data:``. Restore the old value with
|
|
40
|
+
``directives: { worker_src: "'self' data: blob:" }``. (#201)
|
|
41
|
+
|
|
42
|
+
Fixed
|
|
43
|
+
-----
|
|
44
|
+
|
|
45
|
+
- ``Otto#uri`` no longer corrupts when the same handler is mounted at
|
|
46
|
+
multiple paths — all routes per definition are kept, and ``uri()`` matches
|
|
47
|
+
on the params given. (#190)
|
|
48
|
+
|
|
49
|
+
- ``Route#call`` no longer builds a duplicate, discarded request/response
|
|
50
|
+
pair when a route handler factory is present. (#189)
|
|
51
|
+
|
|
52
|
+
- Dynamic static-file serving no longer raises ``FrozenError`` in production
|
|
53
|
+
on the first request for an uncached asset. (delano/otto#185)
|
|
54
|
+
|
|
55
|
+
Security
|
|
56
|
+
--------
|
|
57
|
+
|
|
58
|
+
- Route loading now fails closed on malformed input: unparseable options
|
|
59
|
+
warn, and malformed or mismatched-case ``auth``/``role``/``csrf`` tokens
|
|
60
|
+
raise ``Otto::RouteDefinitionError`` at load instead of silently serving
|
|
61
|
+
an unprotected route. (#191)
|
|
62
|
+
|
|
63
|
+
- ``klass.otto`` is no longer a shared mutable class accessor — it's now
|
|
64
|
+
fiber-local, preventing concurrent requests across ``Otto`` instances from
|
|
65
|
+
racing and observing the wrong security config. (#188)
|
|
66
|
+
|
|
67
|
+
- Dynamic-route and static-file dispatch now normalize request paths the
|
|
68
|
+
same way literal routing does, closing a trailing-slash and
|
|
69
|
+
invalid-UTF-8 divergence. (#187)
|
|
70
|
+
|
|
71
|
+
- ``csrf=exempt`` is now actually enforced at the handler layer; previously
|
|
72
|
+
it was a silent no-op. **Behavior change**: ``CSRFMiddleware`` used
|
|
73
|
+
standalone no longer blocks unsafe requests on its own. (#186)
|
|
74
|
+
|
|
75
|
+
.. _changelog-2.5.0:
|
|
76
|
+
|
|
77
|
+
2.5.0 — 2026-07-02
|
|
78
|
+
==================
|
|
79
|
+
|
|
80
|
+
Added
|
|
81
|
+
-----
|
|
82
|
+
|
|
83
|
+
- ``Otto::Security::CSP::Writer.apply(headers, nonce, config:, mode:,
|
|
84
|
+
development_mode:)`` — the single structural apply core for nonce-based CSP
|
|
85
|
+
emission. Writes are in-place and key-scoped (case-variant keys are corrected
|
|
86
|
+
to Rack 3's lowercase in the caller's hash; a frozen headers hash fails loud).
|
|
87
|
+
Returns a ``Result`` (``applied?``, ``policy``, ``skip_reason`` of
|
|
88
|
+
``:disabled`` / ``:blank_nonce`` / ``:non_html`` / ``:existing_csp``). Named
|
|
89
|
+
modes ``:override`` (deliberate, replaces) and ``:backstop`` (passive,
|
|
90
|
+
defers). (delano/otto#180)
|
|
91
|
+
|
|
92
|
+
- Framework-owned lazy nonce: ``Otto::Request#csp_nonce`` /
|
|
93
|
+
``Otto::Security::CSP.nonce(env)`` generate on first access and memoize into
|
|
94
|
+
``env['otto.nonce']`` (registered as ``Otto::EnvKeys::NONCE``), so views and
|
|
95
|
+
the header read one value. Configurable env key via
|
|
96
|
+
``Otto::Security::Config#csp_nonce_key`` for apps with an existing convention.
|
|
97
|
+
|
|
98
|
+
- ``Otto::Security::CSP::EmitMiddleware`` and ``Otto#enable_csp_emission!`` — a
|
|
99
|
+
passive backstop that emits a nonce CSP for HTML responses whose request
|
|
100
|
+
consumed a nonce (emit-if-consumed default), never clobbering an existing
|
|
101
|
+
policy. Optional ``eager:`` mode and a per-request ``development_mode:``
|
|
102
|
+
callable.
|
|
103
|
+
|
|
104
|
+
- ``Otto::Response#apply_csp(nonce, mode: :override)`` — the one emission helper,
|
|
105
|
+
routed through the apply core.
|
|
106
|
+
|
|
107
|
+
- ``Otto::Security::CSP::Policy`` — CSP policy building (directive sets,
|
|
108
|
+
report-uri/report-to assembly) extracted from ``Otto::Security::Config`` into
|
|
109
|
+
its own home beside the parser and middlewares; ``Config`` delegates with
|
|
110
|
+
byte-identical output.
|
|
111
|
+
|
|
112
|
+
Deprecated
|
|
113
|
+
----------
|
|
114
|
+
|
|
115
|
+
- ``Otto::Response#send_csp_headers`` — use ``#apply_csp`` or
|
|
116
|
+
``#enable_csp_emission!``. Retained as a thin shim over the apply core (logs a
|
|
117
|
+
one-time ``Otto.logger`` deprecation notice).
|
|
118
|
+
|
|
119
|
+
Fixed
|
|
120
|
+
-----
|
|
121
|
+
|
|
122
|
+
- ``#send_csp_headers`` no longer emits a broken ``script-src 'nonce-'`` for a
|
|
123
|
+
blank/nil nonce (it skips) and no longer emits a CSP for non-HTML responses —
|
|
124
|
+
both via the shared apply core. Its bare ``warn`` to stderr when overwriting an
|
|
125
|
+
existing CSP is also gone: replacement is deliberate in ``:override`` mode, and
|
|
126
|
+
the shim instead logs a one-time deprecation notice through ``Otto.logger``.
|
|
127
|
+
|
|
128
|
+
Security
|
|
129
|
+
--------
|
|
130
|
+
|
|
131
|
+
- Nonce-CSP emission now detects and normalizes CSP / Content-Type headers
|
|
132
|
+
case-insensitively, so a canonical-/mixed-cased header from a downstream layer
|
|
133
|
+
is recognized (and the CSP key rewritten to lowercase) rather than silently
|
|
134
|
+
duplicated — de-duplicating the hand-rolled, case-sensitive guards adopters
|
|
135
|
+
previously re-implemented at each raw-tuple boundary. (delano/otto#180)
|
|
136
|
+
|
|
137
|
+
AI Assistance
|
|
138
|
+
-------------
|
|
139
|
+
|
|
140
|
+
- The nonce-CSP emission redesign — the ``Writer`` apply core, the
|
|
141
|
+
framework-owned lazy nonce, the ``EmitMiddleware`` backstop, and the
|
|
142
|
+
``Policy`` extraction — was designed and implemented with AI assistance.
|
|
143
|
+
(delano/otto#180)
|
|
144
|
+
|
|
10
145
|
.. _changelog-2.4.0:
|
|
11
146
|
|
|
12
147
|
2.4.0 — 2026-07-01
|
data/Gemfile
CHANGED
|
@@ -28,7 +28,7 @@ group :development do
|
|
|
28
28
|
gem 'debug'
|
|
29
29
|
gem 'rackup' # Used to boot examples/ apps; not needed by specs
|
|
30
30
|
gem 'rake', '~> 13.4', require: false # Provides `rake release` for release-gem.yml
|
|
31
|
-
gem 'rubocop', '~> 1.88.
|
|
31
|
+
gem 'rubocop', '~> 1.88.1', require: false
|
|
32
32
|
gem 'rubocop-performance', require: false
|
|
33
33
|
gem 'rubocop-rspec', require: false
|
|
34
34
|
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.
|
|
4
|
+
otto (2.6.0)
|
|
5
5
|
concurrent-ruby (~> 1.3, < 2.0)
|
|
6
6
|
logger (~> 1, < 2.0)
|
|
7
7
|
loofah (~> 2.20)
|
|
@@ -59,7 +59,7 @@ GEM
|
|
|
59
59
|
prism (>= 1.3.0)
|
|
60
60
|
rdoc (>= 4.0.0)
|
|
61
61
|
reline (>= 0.4.2)
|
|
62
|
-
json (2.
|
|
62
|
+
json (2.20.0)
|
|
63
63
|
json_schemer (2.5.0)
|
|
64
64
|
bigdecimal
|
|
65
65
|
hana (~> 1.3)
|
|
@@ -67,7 +67,7 @@ GEM
|
|
|
67
67
|
simpleidn (~> 0.2)
|
|
68
68
|
kramdown (2.5.2)
|
|
69
69
|
rexml (>= 3.4.4)
|
|
70
|
-
language_server-protocol (3.17.0.
|
|
70
|
+
language_server-protocol (3.17.0.6)
|
|
71
71
|
lint_roller (1.1.0)
|
|
72
72
|
logger (1.7.0)
|
|
73
73
|
loofah (2.25.1)
|
|
@@ -147,7 +147,7 @@ GEM
|
|
|
147
147
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
148
148
|
rspec-support (~> 3.13.0)
|
|
149
149
|
rspec-support (3.13.7)
|
|
150
|
-
rubocop (1.88.
|
|
150
|
+
rubocop (1.88.1)
|
|
151
151
|
json (~> 2.3)
|
|
152
152
|
language_server-protocol (~> 3.17.0.2)
|
|
153
153
|
lint_roller (~> 1.1.0)
|
|
@@ -225,7 +225,7 @@ DEPENDENCIES
|
|
|
225
225
|
rake (~> 13.4)
|
|
226
226
|
reek (~> 6.5)
|
|
227
227
|
rspec (~> 3.13)
|
|
228
|
-
rubocop (~> 1.88.
|
|
228
|
+
rubocop (~> 1.88.1)
|
|
229
229
|
rubocop-performance
|
|
230
230
|
rubocop-rspec
|
|
231
231
|
rubocop-thread_safety
|
data/README.md
CHANGED
|
@@ -84,6 +84,60 @@ app = Otto.new("./routes", {
|
|
|
84
84
|
|
|
85
85
|
Security features include CSRF protection, input validation, security headers, and trusted proxy configuration.
|
|
86
86
|
|
|
87
|
+
### Content Security Policy (nonce-based emission)
|
|
88
|
+
|
|
89
|
+
Otto owns the nonce lifecycle so the header and your views can never drift. A
|
|
90
|
+
request-scoped nonce is minted lazily on first access and memoized in the env;
|
|
91
|
+
your views read it to stamp `<script>`/`<link>` tags, and the framework reads
|
|
92
|
+
the *same* value to emit the `script-src 'nonce-…'` header.
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
app = Otto.new("./routes")
|
|
96
|
+
app.enable_csp_with_nonce! # turn on nonce-based CSP
|
|
97
|
+
app.enable_csp_emission! # mount the backstop that writes the header
|
|
98
|
+
|
|
99
|
+
# In a view/handler:
|
|
100
|
+
def show(req, res)
|
|
101
|
+
res['content-type'] = 'text/html; charset=utf-8'
|
|
102
|
+
res.write(%(<script nonce="#{req.csp_nonce}">/* inline */</script>))
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`enable_csp_emission!` mounts `Otto::Security::CSP::EmitMiddleware`, a passive
|
|
107
|
+
**backstop**:
|
|
108
|
+
|
|
109
|
+
- **Emit-if-consumed** (default): it emits a policy only for a response whose
|
|
110
|
+
request actually consumed a nonce (a view called `req.csp_nonce`). A nonce-only
|
|
111
|
+
`script-src` on an HTML page that never stamped the nonce would block every
|
|
112
|
+
script, so "CSP responses whose request consumed a nonce" is the only safe
|
|
113
|
+
blanket default. Pass `eager: true` to mint-and-emit for every eligible HTML
|
|
114
|
+
response (see the caveat in the middleware docs).
|
|
115
|
+
- **Never clobbers**: it defers to any CSP a route already set.
|
|
116
|
+
- **HTML only**, and inert unless `enable_csp_with_nonce!` is on.
|
|
117
|
+
- `development_mode:` accepts a per-request callable, e.g.
|
|
118
|
+
`->(env) { ENV['RACK_ENV'] == 'development' }`, to switch directive sets.
|
|
119
|
+
|
|
120
|
+
To set a policy explicitly from a handler instead, use the one emission helper —
|
|
121
|
+
it routes through the same apply core:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
res['content-type'] = 'text/html; charset=utf-8'
|
|
125
|
+
result = res.apply_csp(req.csp_nonce) # mode: :override by default
|
|
126
|
+
result.applied? # => true
|
|
127
|
+
result.skip_reason # => nil (or :disabled / :blank_nonce / :non_html / :existing_csp)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Apps with an existing nonce env-key convention can point the accessor at it with
|
|
131
|
+
`app.security_config.csp_nonce_key = 'onetime.nonce'` — the views and the header
|
|
132
|
+
still share one value.
|
|
133
|
+
|
|
134
|
+
> [!NOTE]
|
|
135
|
+
> `res.send_csp_headers(content_type, nonce)` is **deprecated** in favour of
|
|
136
|
+
> `res.apply_csp` / `enable_csp_emission!`. It remains as a thin shim over the
|
|
137
|
+
> same apply core (so its old quirks — a broken `'nonce-'` on a blank nonce, a
|
|
138
|
+
> CSP on non-HTML responses, a `warn` to stderr — are now fixed) and logs a
|
|
139
|
+
> one-time deprecation notice.
|
|
140
|
+
|
|
87
141
|
### CSP Violation Reporting
|
|
88
142
|
|
|
89
143
|
Otto can both emit Content-Security-Policy headers and receive the violation
|
|
@@ -58,6 +58,55 @@ Add arbitrary key-value pairs for flexible routing:
|
|
|
58
58
|
GET /admin AdminPanel#dashboard role=admin
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
### Lambda / Inline Route Handlers (Issue #41)
|
|
62
|
+
Route to a proc that you **pre-register** by name, using the `&` prefix:
|
|
63
|
+
```
|
|
64
|
+
GET /ping &health_check response=json
|
|
65
|
+
POST /webhook &receive_webhook response=json csrf=exempt
|
|
66
|
+
GET /go &to_dashboard response=redirect
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The `&name` token is a plain string key looked up (O(1)) in a registry you
|
|
70
|
+
supply at construction — the entire token after `&` is the key (dots, `#`, and
|
|
71
|
+
`::` are inert). Register the procs when you build Otto:
|
|
72
|
+
```ruby
|
|
73
|
+
otto = Otto.new('routes', lambda_handlers: {
|
|
74
|
+
'health_check' => ->(req, res, extra_params) {
|
|
75
|
+
{ status: 'ok', at: Time.now.to_i } # response=json serializes this Hash
|
|
76
|
+
},
|
|
77
|
+
'receive_webhook' => ->(req, res, extra_params) {
|
|
78
|
+
{ received: true }
|
|
79
|
+
},
|
|
80
|
+
'to_dashboard' => ->(req, res, extra_params) {
|
|
81
|
+
'/dashboard' # response=redirect uses this path
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The handler contract:
|
|
87
|
+
|
|
88
|
+
- Each proc is called with **`(req, res, extra_params)`** — `extra_params` is the
|
|
89
|
+
hash of path captures (e.g. `:id` from `/users/:id`).
|
|
90
|
+
- The proc must accept 3 arguments (fixed arity `3`, or a splat/optional form).
|
|
91
|
+
An invalid arity raises `ArgumentError` at construction.
|
|
92
|
+
- **All response types work** exactly as for controller routes:
|
|
93
|
+
`response=json` (serializes a returned Hash), `response=view` (`to_s` as HTML),
|
|
94
|
+
`response=redirect` (returned String is the `Location`), `response=auto`
|
|
95
|
+
(content negotiation). With the default response type the proc must write to
|
|
96
|
+
`res` directly, just like the other handler kinds.
|
|
97
|
+
- **Route options apply**: `csrf=exempt` (parse/expose parity with controllers),
|
|
98
|
+
`auth=`, `role=`, and custom path params all flow through unchanged.
|
|
99
|
+
|
|
100
|
+
Security guarantee (the point of this feature): route files never carry code.
|
|
101
|
+
`&name` is only ever a name; there is **no `eval` and no dynamic constant
|
|
102
|
+
loading**. A route naming an unregistered handler fails with a clear
|
|
103
|
+
`ArgumentError` ("Lambda handler '...' is not registered or not callable")
|
|
104
|
+
instead of executing anything. The registered procs are, of course, trusted
|
|
105
|
+
code that you wrote.
|
|
106
|
+
|
|
107
|
+
Note: `csrf=exempt` is parse-and-expose parity with controller routes — the CSRF
|
|
108
|
+
middleware does not enforce it for any handler kind.
|
|
109
|
+
|
|
61
110
|
## How to Run
|
|
62
111
|
|
|
63
112
|
### Using rackup (recommended)
|
|
@@ -4,8 +4,21 @@ require 'rack'
|
|
|
4
4
|
require_relative '../../lib/otto'
|
|
5
5
|
require_relative 'app'
|
|
6
6
|
|
|
7
|
-
# Simple Otto configuration demonstrating advanced routes syntax
|
|
8
|
-
|
|
7
|
+
# Simple Otto configuration demonstrating advanced routes syntax.
|
|
8
|
+
#
|
|
9
|
+
# Lambda / inline route handlers (issue #41): procs are pre-registered by name
|
|
10
|
+
# and referenced from the routes file with the '&name' prefix. Lookup is O(1)
|
|
11
|
+
# by exact string — no eval, no dynamic code from the route file.
|
|
12
|
+
otto = Otto.new('routes', lambda_handlers: {
|
|
13
|
+
# GET /ping &health_check response=json
|
|
14
|
+
'health_check' => ->(_req, _res, _extra_params) { { status: 'ok', at: Time.now.to_i } },
|
|
15
|
+
|
|
16
|
+
# POST /hooks/receive &receive_webhook response=json csrf=exempt
|
|
17
|
+
'receive_webhook' => ->(req, _res, _extra_params) { { received: true, method: req.request_method } },
|
|
18
|
+
|
|
19
|
+
# GET /go/dashboard &to_dashboard response=redirect (returned String is the Location)
|
|
20
|
+
'to_dashboard' => ->(_req, _res, _extra_params) { '/dashboard' },
|
|
21
|
+
})
|
|
9
22
|
|
|
10
23
|
# Enable basic security features to demonstrate CSRF functionality
|
|
11
24
|
otto.enable_csrf_protection!
|
|
@@ -93,6 +93,18 @@ GET /logic/nested/feature Nested::Feature::Logic
|
|
|
93
93
|
POST /logic/complex/handler Complex::Business::Handler response=json
|
|
94
94
|
PUT /logic/system/config System::Config::Manager response=json csrf=exempt
|
|
95
95
|
|
|
96
|
+
# ========================================
|
|
97
|
+
# LAMBDA / INLINE ROUTE HANDLERS (Issue #41)
|
|
98
|
+
# ========================================
|
|
99
|
+
|
|
100
|
+
# The '&name' prefix references a proc pre-registered via
|
|
101
|
+
# Otto.new('routes', lambda_handlers: { 'name' => ->(req,res,extra){ ... } })
|
|
102
|
+
# Lookup is O(1) by exact string name — no eval, no dynamic constants.
|
|
103
|
+
# An unregistered name fails with a clear error, never executes code.
|
|
104
|
+
GET /ping &health_check response=json
|
|
105
|
+
POST /hooks/receive &receive_webhook response=json csrf=exempt
|
|
106
|
+
GET /go/dashboard &to_dashboard response=redirect
|
|
107
|
+
|
|
96
108
|
# ========================================
|
|
97
109
|
# NAMESPACED CLASS ROUTES
|
|
98
110
|
# ========================================
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Otto - Lambda / Inline Route Handlers
|
|
2
|
+
|
|
3
|
+
This example demonstrates Otto's fourth route-handler kind (issue #41):
|
|
4
|
+
**lambda handlers**. A lambda handler is a plain proc, pre-registered by name,
|
|
5
|
+
that a route can target with an `&` prefix.
|
|
6
|
+
|
|
7
|
+
## What You'll Learn
|
|
8
|
+
|
|
9
|
+
- Registering lambda handlers at `Otto.new` construction time
|
|
10
|
+
- The `&handler_name` route syntax
|
|
11
|
+
- Returning strings, Hashes (JSON), and mutating the response directly
|
|
12
|
+
- Applying route options (`response=json`, `csrf=exempt`) to lambdas
|
|
13
|
+
- Why this is safe: no `eval`, no dynamic code from route files
|
|
14
|
+
|
|
15
|
+
## The Four Handler Kinds
|
|
16
|
+
|
|
17
|
+
| Route target | Kind | Resolves to |
|
|
18
|
+
|-----------------|-------------|-------------------------|
|
|
19
|
+
| `App.index` | `:class` | class method |
|
|
20
|
+
| `App#index` | `:instance` | instance method |
|
|
21
|
+
| `BareClass` | `:logic` | Logic object |
|
|
22
|
+
| `&health_check` | `:lambda` | pre-registered proc |
|
|
23
|
+
|
|
24
|
+
## Registration
|
|
25
|
+
|
|
26
|
+
Lambdas are supplied to Otto as a `name => callable` Hash. Otto validates each
|
|
27
|
+
entry (must respond to `#call`, must accept 3 arguments) and freezes the
|
|
28
|
+
registry:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
require_relative '../../lib/otto'
|
|
32
|
+
require_relative 'handlers'
|
|
33
|
+
|
|
34
|
+
app = Otto.new('routes', {
|
|
35
|
+
lambda_handlers: LambdaHandlers::REGISTRY,
|
|
36
|
+
})
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Each handler receives `(req, res, extra_params)`:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
HEALTH_CHECK = lambda do |req, res, extra_params|
|
|
43
|
+
res.headers['content-type'] = 'text/plain; charset=utf-8'
|
|
44
|
+
res.body = 'OK'
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Route Syntax
|
|
49
|
+
|
|
50
|
+
Prefix the target with `&` and give the registered name. Everything after the
|
|
51
|
+
`&` is the exact registry key. The target comes first; route options follow it:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
GET /ping &health_check
|
|
55
|
+
GET /status &status response=json
|
|
56
|
+
GET /greet/:name &greet response=json
|
|
57
|
+
POST /webhook &webhook csrf=exempt response=json
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Response Types
|
|
61
|
+
|
|
62
|
+
Lambdas participate in Otto's normal response-type dispatch, the same way a
|
|
63
|
+
class/instance/logic handler does:
|
|
64
|
+
|
|
65
|
+
- **Default** (no `response=`): the handler mutates `res` directly — set
|
|
66
|
+
`res.status` / `res.headers` / `res.body`. The return value is ignored, so
|
|
67
|
+
writing `res.body` is what produces output.
|
|
68
|
+
- **`response=json`**: the returned Hash is serialized as JSON and the JSON
|
|
69
|
+
content-type is set for you.
|
|
70
|
+
|
|
71
|
+
## Route Options
|
|
72
|
+
|
|
73
|
+
Route options apply to lambdas just like any other handler:
|
|
74
|
+
|
|
75
|
+
- `response=json` — serialize the returned Hash as JSON.
|
|
76
|
+
- `csrf=exempt` — parsed and exposed on the route definition (intended to mark
|
|
77
|
+
the webhook, which external callers reach without a browser token). Note:
|
|
78
|
+
`CSRFMiddleware` does not yet consult per-route options, so this option is
|
|
79
|
+
currently recorded but not enforced — see issue #186.
|
|
80
|
+
- `auth=` / `role=` — authentication and authorization (when `auth_config`
|
|
81
|
+
is configured on the Otto instance).
|
|
82
|
+
|
|
83
|
+
## Security
|
|
84
|
+
|
|
85
|
+
The `&` syntax performs an O(1) lookup of a **pre-registered** proc by name.
|
|
86
|
+
Route files never contain Ruby code and are never `eval`'d. A route naming a
|
|
87
|
+
handler that was not registered raises a clear error instead of executing
|
|
88
|
+
anything.
|
|
89
|
+
|
|
90
|
+
## How to Run
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
cd examples/lambda_handlers
|
|
94
|
+
rackup config.ru -p 10780
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Then, from another terminal:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
curl localhost:10780/ping
|
|
101
|
+
# OK
|
|
102
|
+
|
|
103
|
+
curl localhost:10780/status
|
|
104
|
+
# {"service":"otto-lambda-demo","status":"healthy","time":"..."}
|
|
105
|
+
|
|
106
|
+
curl localhost:10780/greet/otto
|
|
107
|
+
# {"greeting":"Hello, otto!"}
|
|
108
|
+
|
|
109
|
+
curl -X POST --data 'hello' localhost:10780/webhook
|
|
110
|
+
# {"received":true,"bytes":5}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## File Structure
|
|
114
|
+
|
|
115
|
+
- `README.md`: This file.
|
|
116
|
+
- `handlers.rb`: Defines the lambda procs and the `REGISTRY` Hash.
|
|
117
|
+
- `routes`: Maps URLs to lambdas using the `&` syntax.
|
|
118
|
+
- `config.ru`: Rack config that registers the lambdas with `Otto.new`.
|
|
119
|
+
|
|
120
|
+
## Next Steps
|
|
121
|
+
|
|
122
|
+
- Explore [Advanced Routes](../advanced_routes/) for class/instance/logic
|
|
123
|
+
handlers and response-type negotiation.
|
|
124
|
+
- See [Security Features](../security_features/) for CSRF and input validation.
|
|
125
|
+
|
|
126
|
+
## Further Reading
|
|
127
|
+
|
|
128
|
+
- [docs/ADVANCED_ROUTES.txt](../../docs/ADVANCED_ROUTES.txt)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# examples/lambda_handlers/config.ru
|
|
2
|
+
#
|
|
3
|
+
# Usage:
|
|
4
|
+
#
|
|
5
|
+
# $ rackup config.ru -p 10780
|
|
6
|
+
#
|
|
7
|
+
# then, in another terminal:
|
|
8
|
+
#
|
|
9
|
+
# $ curl localhost:10780/ping
|
|
10
|
+
# $ curl localhost:10780/status
|
|
11
|
+
# $ curl localhost:10780/greet/otto
|
|
12
|
+
# $ curl -X POST --data 'hello' localhost:10780/webhook
|
|
13
|
+
|
|
14
|
+
require_relative '../../lib/otto'
|
|
15
|
+
require_relative 'handlers'
|
|
16
|
+
|
|
17
|
+
# Register the lambda handlers at construction time. Only names present in
|
|
18
|
+
# this Hash can be referenced from the routes file's '&' syntax. Otto
|
|
19
|
+
# validates every entry here (must respond to #call, arity of 3) and freezes
|
|
20
|
+
# the registry — a route naming an unknown handler fails loudly rather than
|
|
21
|
+
# executing anything.
|
|
22
|
+
app = Otto.new('routes', {
|
|
23
|
+
lambda_handlers: LambdaHandlers::REGISTRY,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
run app
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# examples/lambda_handlers/handlers.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# Pre-registered lambda route handlers (Otto issue #41).
|
|
6
|
+
#
|
|
7
|
+
# A lambda handler is any object responding to #call that accepts exactly
|
|
8
|
+
# three arguments: (req, res, extra_params).
|
|
9
|
+
#
|
|
10
|
+
# * req - the Rack::Request for this request
|
|
11
|
+
# * res - the Rack::Response to populate
|
|
12
|
+
# * extra_params - a Hash of route/path params merged by Otto
|
|
13
|
+
#
|
|
14
|
+
# These procs are handed to Otto at construction time via the
|
|
15
|
+
# `lambda_handlers:` option (see config.ru). Each is looked up O(1) by name
|
|
16
|
+
# from the route file's `&handler_name` syntax. Nothing in the route file is
|
|
17
|
+
# ever eval'd — only names present in this registry can be invoked, and an
|
|
18
|
+
# unknown name fails loudly instead of executing arbitrary code.
|
|
19
|
+
#
|
|
20
|
+
# How the response is produced depends on the route's `response=` option,
|
|
21
|
+
# exactly like the class/instance/logic handler kinds:
|
|
22
|
+
#
|
|
23
|
+
# * default (no `response=`) -> the handler mutates `res` directly
|
|
24
|
+
# (status/headers/body); the return value is ignored.
|
|
25
|
+
# * `response=json` -> the returned Hash is serialized to JSON.
|
|
26
|
+
module LambdaHandlers
|
|
27
|
+
# A minimal string responder. With no `response=` option the default response
|
|
28
|
+
# handler leaves the body alone, so (like a class handler) we write it here.
|
|
29
|
+
HEALTH_CHECK = lambda do |_req, res, _extra|
|
|
30
|
+
res.headers['content-type'] = 'text/plain; charset=utf-8'
|
|
31
|
+
res.body = 'OK'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A Hash responder. Pair it with `response=json` on the route and Otto's
|
|
35
|
+
# JSON response handler serializes the Hash and sets the JSON content-type.
|
|
36
|
+
STATUS = lambda do |_req, _res, _extra|
|
|
37
|
+
{
|
|
38
|
+
service: 'otto-lambda-demo',
|
|
39
|
+
status: 'healthy',
|
|
40
|
+
time: Time.now.utc.iso8601,
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A handler that reads merged params. Path/query params arrive in
|
|
45
|
+
# `extra_params`; request params are available through `req`.
|
|
46
|
+
GREET = lambda do |req, _res, extra|
|
|
47
|
+
name = extra['name'] || req.params['name'] || 'world'
|
|
48
|
+
{ greeting: "Hello, #{name}!" }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A webhook-style POST handler. The matching route declares `csrf=exempt`,
|
|
52
|
+
# intended to let external callers (which cannot present a CSRF token) reach
|
|
53
|
+
# it. Note: CSRFMiddleware does not yet honor per-route csrf=exempt (issue
|
|
54
|
+
# #186), so the option is currently advisory. When enforcement lands, exempt
|
|
55
|
+
# CSRF only for endpoints authenticated another way (signature header, shared
|
|
56
|
+
# secret, etc.).
|
|
57
|
+
WEBHOOK = lambda do |req, _res, _extra|
|
|
58
|
+
# Rewind first: upstream middleware may already have read the input stream.
|
|
59
|
+
req.body.rewind if req.body.respond_to?(:rewind)
|
|
60
|
+
payload = req.body.read.to_s
|
|
61
|
+
# NOTE: this route uses `response=json`, so Otto's JSON response handler
|
|
62
|
+
# owns the status (200) and content-type. Setting them here would be
|
|
63
|
+
# overridden, so we only return the Hash to be serialized.
|
|
64
|
+
{ received: true, bytes: payload.bytesize }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The registry passed to Otto.new(lambda_handlers: ...). Keys are the names
|
|
68
|
+
# referenced after '&' in the routes file.
|
|
69
|
+
REGISTRY = {
|
|
70
|
+
'health_check' => HEALTH_CHECK,
|
|
71
|
+
'status' => STATUS,
|
|
72
|
+
'greet' => GREET,
|
|
73
|
+
'webhook' => WEBHOOK,
|
|
74
|
+
}.freeze
|
|
75
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# examples/lambda_handlers/routes
|
|
2
|
+
|
|
3
|
+
# OTTO - LAMBDA / INLINE ROUTE HANDLERS (issue #41)
|
|
4
|
+
#
|
|
5
|
+
# A route target prefixed with '&' names a pre-registered lambda handler
|
|
6
|
+
# instead of a class/method. The name after '&' is looked up O(1) from the
|
|
7
|
+
# lambda_handlers registry supplied to Otto.new -- never eval'd.
|
|
8
|
+
#
|
|
9
|
+
# The target comes first; route options follow it.
|
|
10
|
+
#
|
|
11
|
+
# Compare the four handler kinds:
|
|
12
|
+
# App.index -> :class (class method)
|
|
13
|
+
# App#index -> :instance (instance method)
|
|
14
|
+
# BareClass -> :logic (Logic object)
|
|
15
|
+
# &health_check -> :lambda (pre-registered proc) <-- this file
|
|
16
|
+
|
|
17
|
+
# Plain-text string responder (default response handler).
|
|
18
|
+
GET /ping &health_check
|
|
19
|
+
|
|
20
|
+
# Hash responder serialized as JSON.
|
|
21
|
+
GET /status &status response=json
|
|
22
|
+
|
|
23
|
+
# Reads a path param, returned as JSON.
|
|
24
|
+
GET /greet/:name &greet response=json
|
|
25
|
+
|
|
26
|
+
# Webhook receiver: marked csrf=exempt (option is parsed/exposed but not yet
|
|
27
|
+
# enforced by CSRFMiddleware -- see issue #186).
|
|
28
|
+
POST /webhook &webhook csrf=exempt response=json
|