otto 2.5.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 +65 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +5 -5
- 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/router.rb +67 -10
- data/lib/otto/core/uri_generator.rb +36 -2
- data/lib/otto/env_keys.rb +11 -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 +27 -0
- 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 +100 -5
- data/lib/otto/security/csp/policy.rb +135 -3
- 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 +9 -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,71 @@ 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
|
+
|
|
10
75
|
.. _changelog-2.5.0:
|
|
11
76
|
|
|
12
77
|
2.5.0 — 2026-07-02
|
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
|
|
@@ -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
|
|
@@ -100,6 +100,98 @@ class Otto
|
|
|
100
100
|
@mcp_server.enable!(mcp_options)
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
+
# Validate and freeze the lambda handler registry supplied at construction
|
|
104
|
+
# (issue #41, AC#3). Security: only pre-registered callables are accepted;
|
|
105
|
+
# nothing from route files reaches here, so no eval / dynamic code (AC#8).
|
|
106
|
+
def configure_lambda_handlers(opts)
|
|
107
|
+
@option[:lambda_handlers] = validate_lambda_handlers!(opts[:lambda_handlers])
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @raise [ArgumentError] naming the offending handler on any invalid entry
|
|
111
|
+
# @return [Hash] frozen registry ({}.freeze when none supplied)
|
|
112
|
+
#
|
|
113
|
+
# Keys are normalized to Strings so lookups from +&name+ routes (whose
|
|
114
|
+
# target is always a String parsed from the route file) resolve regardless
|
|
115
|
+
# of whether the caller registered handlers under Symbol or String keys.
|
|
116
|
+
# A fresh Hash is built and frozen so the caller's input object is never
|
|
117
|
+
# mutated in place.
|
|
118
|
+
def validate_lambda_handlers!(handlers)
|
|
119
|
+
return {}.freeze if handlers.nil?
|
|
120
|
+
|
|
121
|
+
unless handlers.is_a?(Hash)
|
|
122
|
+
raise ArgumentError,
|
|
123
|
+
"Otto :lambda_handlers must be a Hash of name => callable, got #{handlers.class}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
registry = {}
|
|
127
|
+
|
|
128
|
+
handlers.each do |name, handler|
|
|
129
|
+
key = name.to_s
|
|
130
|
+
if key.strip.empty?
|
|
131
|
+
raise ArgumentError,
|
|
132
|
+
"Lambda handler name #{name.inspect} is blank " \
|
|
133
|
+
'(expected a non-empty name matching the &handler_name route target)'
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
if registry.key?(key)
|
|
137
|
+
raise ArgumentError,
|
|
138
|
+
"Lambda handler name #{key.inspect} is registered more than once " \
|
|
139
|
+
'(String and Symbol keys collide once normalized to a String)'
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
unless handler.respond_to?(:call)
|
|
143
|
+
raise ArgumentError,
|
|
144
|
+
"Lambda handler '#{key}' is not callable (expected an object " \
|
|
145
|
+
"responding to #call, got #{handler.class})"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
unless lambda_handler_accepts_three?(handler)
|
|
149
|
+
raise ArgumentError,
|
|
150
|
+
"Lambda handler '#{key}' has invalid arity " \
|
|
151
|
+
'(must accept 3 arguments: req, res, extra_params)'
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
registry[key] = handler
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
registry.freeze
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# True if +handler+ can be invoked with exactly three positional arguments.
|
|
161
|
+
#
|
|
162
|
+
# Reflects on the callable's #parameters rather than #arity so that:
|
|
163
|
+
# * non-Proc/Method callables (a plain object with #call) are supported
|
|
164
|
+
# without ever calling #arity, which they need not define (BUG A); and
|
|
165
|
+
# * optional-arg forms that cannot actually take 3 positional args are
|
|
166
|
+
# rejected instead of blanket-accepted by a negative arity (BUG B) --
|
|
167
|
+
# e.g. ->(a=1){} (accepts 0..1) and ->(a,b=1){} (accepts 1..2).
|
|
168
|
+
#
|
|
169
|
+
# Accepts req/opt/rest combinations that admit 3 positionals; rejects
|
|
170
|
+
# anything requiring more than 3 or unable to reach 3.
|
|
171
|
+
#
|
|
172
|
+
# @api private
|
|
173
|
+
# @return [Boolean]
|
|
174
|
+
def lambda_handler_accepts_three?(handler)
|
|
175
|
+
callable =
|
|
176
|
+
if handler.is_a?(Proc) || handler.is_a?(Method)
|
|
177
|
+
handler
|
|
178
|
+
else
|
|
179
|
+
handler.method(:call)
|
|
180
|
+
end
|
|
181
|
+
params = callable.parameters
|
|
182
|
+
required = params.count { |(type, _)| type == :req }
|
|
183
|
+
optional = params.count { |(type, _)| type == :opt }
|
|
184
|
+
has_rest = params.any? { |(type, _)| type == :rest }
|
|
185
|
+
|
|
186
|
+
return false if required > 3
|
|
187
|
+
|
|
188
|
+
has_rest || (required + optional) >= 3
|
|
189
|
+
rescue NameError, NoMethodError
|
|
190
|
+
# A pathological callable whose #method(:call) reflection blows up still
|
|
191
|
+
# yields a clean, handler-named ArgumentError from the caller.
|
|
192
|
+
false
|
|
193
|
+
end
|
|
194
|
+
|
|
103
195
|
# Configure locale settings for the application
|
|
104
196
|
#
|
|
105
197
|
# @param available_locales [Hash] Hash of available locales (e.g., { 'en' => 'English', 'es' => 'Spanish' })
|
|
@@ -190,8 +282,18 @@ class Otto
|
|
|
190
282
|
# Deep freeze route structures (prevent modification of nested hashes/arrays)
|
|
191
283
|
deep_freeze_value(@routes) if @routes
|
|
192
284
|
deep_freeze_value(@routes_literal) if @routes_literal
|
|
193
|
-
|
|
285
|
+
# @routes_static is intentionally NOT deep-frozen: its :GET entry is a
|
|
286
|
+
# Concurrent::Map that lazy static-file discovery writes into at
|
|
287
|
+
# request time (Core::Router#handle_request, Core::FileSafety#add_static_path),
|
|
288
|
+
# after this method has already run. Deep-freezing it would turn the
|
|
289
|
+
# first request for any as-yet-uncached static file into a
|
|
290
|
+
# FrozenError / 500 in production (issue #185). The outer hash is
|
|
291
|
+
# still shallow-frozen so its verb-key structure (currently just
|
|
292
|
+
# :GET) can't be altered post-freeze, while the Concurrent::Map value
|
|
293
|
+
# stays writable.
|
|
294
|
+
@routes_static.freeze if @routes_static && !@routes_static.frozen?
|
|
194
295
|
deep_freeze_value(@route_definitions) if @route_definitions
|
|
296
|
+
deep_freeze_value(@routes_by_definition) if @routes_by_definition
|
|
195
297
|
|
|
196
298
|
@configuration_frozen = true
|
|
197
299
|
|