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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby-lint.yml +1 -1
  3. data/.github/workflows/yardoc.yml +1 -1
  4. data/CHANGELOG.rst +135 -0
  5. data/Gemfile +1 -1
  6. data/Gemfile.lock +5 -5
  7. data/README.md +54 -0
  8. data/examples/advanced_routes/README.md +49 -0
  9. data/examples/advanced_routes/config.rb +15 -2
  10. data/examples/advanced_routes/routes +12 -0
  11. data/examples/lambda_handlers/README.md +128 -0
  12. data/examples/lambda_handlers/config.ru +26 -0
  13. data/examples/lambda_handlers/handlers.rb +75 -0
  14. data/examples/lambda_handlers/routes +28 -0
  15. data/lib/otto/core/configuration.rb +103 -1
  16. data/lib/otto/core/middleware_stack.rb +1 -0
  17. data/lib/otto/core/router.rb +67 -10
  18. data/lib/otto/core/uri_generator.rb +36 -2
  19. data/lib/otto/env_keys.rb +21 -0
  20. data/lib/otto/errors.rb +7 -0
  21. data/lib/otto/mcp/route_parser.rb +15 -4
  22. data/lib/otto/privacy/config.rb +37 -1
  23. data/lib/otto/privacy/core.rb +18 -1
  24. data/lib/otto/privacy/redacted_fingerprint.rb +4 -20
  25. data/lib/otto/privacy/user_agent_privacy.rb +64 -0
  26. data/lib/otto/privacy.rb +1 -0
  27. data/lib/otto/request.rb +41 -0
  28. data/lib/otto/response.rb +80 -43
  29. data/lib/otto/route.rb +103 -41
  30. data/lib/otto/route_definition.rb +56 -6
  31. data/lib/otto/route_handlers/base.rb +4 -0
  32. data/lib/otto/route_handlers/factory.rb +15 -0
  33. data/lib/otto/route_handlers/lambda.rb +47 -32
  34. data/lib/otto/security/config.rb +137 -84
  35. data/lib/otto/security/configurator.rb +16 -0
  36. data/lib/otto/security/core.rb +32 -0
  37. data/lib/otto/security/csp/emit_middleware.rb +91 -0
  38. data/lib/otto/security/csp/nonce.rb +78 -0
  39. data/lib/otto/security/csp/policy.rb +273 -0
  40. data/lib/otto/security/csp/writer.rb +197 -0
  41. data/lib/otto/security/csp.rb +20 -8
  42. data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
  43. data/lib/otto/security/csrf_validation.rb +75 -0
  44. data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
  45. data/lib/otto/security/middleware/ip_privacy_middleware.rb +34 -6
  46. data/lib/otto/security.rb +1 -0
  47. data/lib/otto/version.rb +1 -1
  48. data/lib/otto.rb +26 -2
  49. metadata +13 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e50cca678689db3e2447e76a8a1b12f3b709305d4f73908cb06dfe6883adc596
4
- data.tar.gz: a6093388b74ffbd098c222a026c1f1cf439c8039c75a0d55b663a0874669c6ee
3
+ metadata.gz: de513e2f41fa9d2400937ac2ed577878bd124d5f760558bd12635bf8ad6f296a
4
+ data.tar.gz: 4a69f3741d3a6dbbe5a7387e2504446ce690927be12ccf90fa814db1a893d13d
5
5
  SHA512:
6
- metadata.gz: ea5a8acda9a29c09d3a16a050050c2e9b9a1517c7f63e24bb7bc910801a7a07874fff1215550a740cef2c9c3698bc77378083b33f47794a7668b77feff9e6e67
7
- data.tar.gz: 62cfbe824d5d0d9e159424a6d930217f05b8b01de7142108a6a80953207cca9037a3e63d6ae5c16585dda3ff7b8a9b2d070b593f748563c0a5a20ed852add37f
6
+ metadata.gz: 77ee1dda2b8b84e3848fb72fd737b88b6d6efca57cd0d4effe909451a310aee51ac207a3152848ebc838df52bd02c3153cb818e1bafdc1000282a374be965375
7
+ data.tar.gz: 8622e8974f115eee90940a157ea93180f2e4aaa05295f5362ca24f068b359b7d2a1396c42483e4d4fa37b9e8fbee7056460230eb68227033134eda09e5e4d8cc
@@ -41,7 +41,7 @@ jobs:
41
41
 
42
42
  steps:
43
43
  - name: Checkout code
44
- uses: actions/checkout@v6.0.3
44
+ uses: actions/checkout@v7.0.0
45
45
 
46
46
  - uses: ruby/setup-ruby@v1
47
47
  with:
@@ -42,7 +42,7 @@ jobs:
42
42
 
43
43
  steps:
44
44
  - name: Checkout repository
45
- uses: actions/checkout@v6.0.3
45
+ uses: actions/checkout@v7.0.0
46
46
  with:
47
47
  fetch-depth: 0
48
48
 
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.0', require: false
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.0)
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.19.9)
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.5)
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.0)
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.0)
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
- otto = Otto.new('routes')
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