axn-webhooks 0.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 +7 -0
- data/CHANGELOG.md +176 -0
- data/DESIGN-NOTES.md +241 -0
- data/LICENSE.txt +21 -0
- data/README.md +1042 -0
- data/lib/axn/webhooks/dispatch.rb +129 -0
- data/lib/axn/webhooks/errors.rb +48 -0
- data/lib/axn/webhooks/handler.rb +15 -0
- data/lib/axn/webhooks/header_value.rb +29 -0
- data/lib/axn/webhooks/inbound/build_request.rb +23 -0
- data/lib/axn/webhooks/inbound/challenge.rb +37 -0
- data/lib/axn/webhooks/inbound/challenge_required.rb +35 -0
- data/lib/axn/webhooks/inbound/dsl.rb +240 -0
- data/lib/axn/webhooks/inbound/endpoint.rb +221 -0
- data/lib/axn/webhooks/inbound/parsers.rb +20 -0
- data/lib/axn/webhooks/inbound/respond_context.rb +17 -0
- data/lib/axn/webhooks/inbound/router.rb +104 -0
- data/lib/axn/webhooks/inbound.rb +124 -0
- data/lib/axn/webhooks/outbound/callable_arity.rb +99 -0
- data/lib/axn/webhooks/outbound/config.rb +442 -0
- data/lib/axn/webhooks/outbound/deliver.rb +425 -0
- data/lib/axn/webhooks/outbound/dsl.rb +121 -0
- data/lib/axn/webhooks/outbound/emit.rb +181 -0
- data/lib/axn/webhooks/outbound/envelope.rb +23 -0
- data/lib/axn/webhooks/outbound/signer.rb +376 -0
- data/lib/axn/webhooks/outbound/subscriber.rb +152 -0
- data/lib/axn/webhooks/outbound/target_policy.rb +135 -0
- data/lib/axn/webhooks/outbound/transport.rb +59 -0
- data/lib/axn/webhooks/outbound.rb +73 -0
- data/lib/axn/webhooks/request.rb +230 -0
- data/lib/axn/webhooks/resolvers.rb +43 -0
- data/lib/axn/webhooks/respond.rb +26 -0
- data/lib/axn/webhooks/response.rb +116 -0
- data/lib/axn/webhooks/signature.rb +268 -0
- data/lib/axn/webhooks/static_respond.rb +22 -0
- data/lib/axn/webhooks/vendor_facet.rb +25 -0
- data/lib/axn/webhooks/verifiers/basic_auth.rb +128 -0
- data/lib/axn/webhooks/verifiers/hmac.rb +58 -0
- data/lib/axn/webhooks/verifiers/standard_webhooks.rb +129 -0
- data/lib/axn/webhooks/verifiers.rb +50 -0
- data/lib/axn/webhooks/verify.rb +106 -0
- data/lib/axn/webhooks/version.rb +7 -0
- data/lib/axn/webhooks.rb +61 -0
- data/lib/axn-webhooks.rb +3 -0
- metadata +128 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Webhooks
|
|
5
|
+
# Routes a verified request to its handler Axn. Built as an Axn so every loud failure
|
|
6
|
+
# (missing handler, unmatched key, parse error, handler crash, or an async enqueue with no
|
|
7
|
+
# adapter configured) lands in axn's exception bucket — reported once via on_exception,
|
|
8
|
+
# returned as a formatted result — and a handler business `fail!` stays a quiet failure.
|
|
9
|
+
class Dispatch
|
|
10
|
+
include Axn
|
|
11
|
+
include Axn::Webhooks::VendorFacet
|
|
12
|
+
|
|
13
|
+
expects :request, type: Axn::Webhooks::Request, sensitive: true
|
|
14
|
+
expects :router
|
|
15
|
+
expects :parse
|
|
16
|
+
expects :mode, default: :auto
|
|
17
|
+
expects :respond_declared, type: :boolean, default: false
|
|
18
|
+
exposes :handler_result, allow_nil: true
|
|
19
|
+
exposes :retry_later, type: :boolean, default: false
|
|
20
|
+
exposes :retry_after, allow_nil: true
|
|
21
|
+
error "Webhook dispatch failed"
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
event = parse_event
|
|
25
|
+
resolution = router.resolve(event)
|
|
26
|
+
return done!("acknowledged") if resolution == :ack
|
|
27
|
+
|
|
28
|
+
handler_class, args, route_async = resolution
|
|
29
|
+
return dispatch_async(handler_class, args) if async?(handler_class, route_async)
|
|
30
|
+
|
|
31
|
+
expose handler_result: nil
|
|
32
|
+
expose handler_result: handler_class.call!(**args)
|
|
33
|
+
rescue Axn::Webhooks::RetryLater => e
|
|
34
|
+
# Rescued for the whole method, not just the handler call: a `parse:` proc that does I/O needs
|
|
35
|
+
# the same "come back later" escape hatch, now that everything else it raises is terminal (see
|
|
36
|
+
# #parse_event) — and a RetryLater from a `with:` extractor or an `otherwise:` callable means
|
|
37
|
+
# the same thing wherever it's raised. handler_result is left unexposed (allow_nil) whenever
|
|
38
|
+
# the deferral happens before the handler ran.
|
|
39
|
+
expose retry_later: true
|
|
40
|
+
expose retry_after: e.retry_after
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# The parse step is terminal by contract: a body that can't become an event won't parse on a
|
|
46
|
+
# redelivery either, so anything raised here is wrapped as UnparseableBody (PRO-3143) — still a
|
|
47
|
+
# reported exception outcome, but one Inbound::Endpoint maps to `unparseable_status` rather than
|
|
48
|
+
# a 500 that invites the vendor to send it again forever. The original error is preserved as the
|
|
49
|
+
# wrapper's `cause` (`raise` inside a rescue sets it). Wrapping the whole step, rather than
|
|
50
|
+
# allowlisting JSON::ParserError, is what makes this format-agnostic: a custom XML/form/protobuf
|
|
51
|
+
# `parse:` proc gets the terminal outcome without having to know about this gem's error classes.
|
|
52
|
+
#
|
|
53
|
+
# Two pass-throughs: an UnparseableBody the proc raised itself (already the right answer — don't
|
|
54
|
+
# double-wrap and bury its message), and a RetryLater (handled by #call's rescue above).
|
|
55
|
+
def parse_event
|
|
56
|
+
reads_before = request.params_reads
|
|
57
|
+
event = parse.call(request)
|
|
58
|
+
|
|
59
|
+
# `Request#params` fails SOFT (returns {}) because it is reachable before verification,
|
|
60
|
+
# where a raise would let an unauthenticated sender turn a 401 into a reported 500. That
|
|
61
|
+
# softness must not survive into here: this runs only after verification, and a verified
|
|
62
|
+
# request whose form body did not parse has to be reported as UnparseableBody and mapped to
|
|
63
|
+
# `unparseable_status` — not silently dispatched with an empty event (Codex review).
|
|
64
|
+
#
|
|
65
|
+
# Gated on reads made BY THIS PARSE CALL — not on whether params were ever read — so the
|
|
66
|
+
# failure surfaces only for a parse that actually depended on them. A JSON `parse:` never
|
|
67
|
+
# reads params, so a hostile query string appended to a validly-signed request (the
|
|
68
|
+
# signature covers the body, not the query) cannot downgrade it. Scoping matters: a custom
|
|
69
|
+
# verifier or a `challenge_required` predicate may have read params first, and a lifetime
|
|
70
|
+
# flag would have counted that and reopened exactly this hole (Codex review).
|
|
71
|
+
raise request.params_error if request.params_error && request.params_reads > reads_before
|
|
72
|
+
|
|
73
|
+
event
|
|
74
|
+
rescue Axn::Webhooks::RetryLater, Axn::Webhooks::UnparseableBody
|
|
75
|
+
raise
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
raise Axn::Webhooks::UnparseableBody, "#{e.class}: #{e.message}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Resolve sync vs async per resolved route (Decision D extended, PRO-2952),
|
|
81
|
+
# most-specific wins: (1) the route's own async: flag; (2) an explicit endpoint
|
|
82
|
+
# mode:; (3) a declared respond forces sync (Decision D default); (4) :auto —
|
|
83
|
+
# async when an adapter is configured for THIS handler, else sync.
|
|
84
|
+
def async?(handler_class, route_async)
|
|
85
|
+
return route_async unless route_async.nil?
|
|
86
|
+
return true if mode == :async
|
|
87
|
+
return false if mode == :sync
|
|
88
|
+
return false if respond_declared
|
|
89
|
+
|
|
90
|
+
async_adapter_configured?(handler_class)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Presence check ONLY — decides async-vs-sync, never asks which adapter.
|
|
94
|
+
# A handler's own explicit setting always wins over the global default — mirrors axn's own
|
|
95
|
+
# call_async semantics, where _async_adapter only falls back to the global default when nil;
|
|
96
|
+
# an explicit `false` (opted out) is sticky and never falls back. So an explicitly-disabled
|
|
97
|
+
# handler (_async_adapter == false) is correctly treated as "not configured" even when a
|
|
98
|
+
# truthy global default is set, not silently overridden by it.
|
|
99
|
+
def async_adapter_configured?(handler_class)
|
|
100
|
+
if handler_class.respond_to?(:_async_adapter) && !handler_class._async_adapter.nil?
|
|
101
|
+
return !!handler_class._async_adapter # explicit per-handler setting (incl. `async false`) always wins
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
Axn.config.default_async?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Delegates entirely to axn's own async interface; no handler_result (nothing ran
|
|
108
|
+
# synchronously). Guarded so an unconfigured OR explicitly-disabled handler never reaches
|
|
109
|
+
# call_async, which would raise a ScriptError (NotImplementedError) that escapes the Dispatch
|
|
110
|
+
# axn boundary entirely (the boundary only rescues StandardError). Raising our own StandardError
|
|
111
|
+
# here instead keeps the failure inside the boundary as a clean, reported exception outcome.
|
|
112
|
+
#
|
|
113
|
+
# Only handlers that expose _async_adapter (real Axn handlers) are second-guessed here — that's
|
|
114
|
+
# the only case where axn's own call_async can raise the escaping NotImplementedError. A handler
|
|
115
|
+
# class that doesn't respond to _async_adapter isn't going through axn's async machinery at all
|
|
116
|
+
# (e.g. a plain object providing its own call_async), so there's nothing to guard against.
|
|
117
|
+
def dispatch_async(handler_class, args)
|
|
118
|
+
if handler_class.respond_to?(:_async_adapter) && !async_adapter_configured?(handler_class)
|
|
119
|
+
raise Axn::Webhooks::Error,
|
|
120
|
+
"dispatch mode: :async requires an axn async adapter, but none is configured for " \
|
|
121
|
+
"#{handler_class} (add `async :sidekiq`/`async :active_job` to the handler, or set a global default)"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
handler_class.call_async(**args)
|
|
125
|
+
done!("enqueued")
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Webhooks
|
|
5
|
+
# Rooted in core's public-error boundary (PRO-2997). `Axn::Error` is a marker MODULE, not a base
|
|
6
|
+
# class — `rescue` matches it by `is_a?` — so the tag costs this hierarchy no ancestry: `Error`
|
|
7
|
+
# stays a plain `StandardError`, and a consuming app's `rescue Axn::Error` catches webhook errors
|
|
8
|
+
# alongside core's. The tag is inherited, so `RetryLater` below is covered automatically.
|
|
9
|
+
class Error < StandardError
|
|
10
|
+
include Axn::Error
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Raised by a handler (via Axn::Webhooks.retry_later!) to ask the sender to redeliver later —
|
|
14
|
+
# mapped to 503 + Retry-After by the inbound endpoint. Distinct from a crash (a reported 500):
|
|
15
|
+
# a deliberate, un-paged "come back later".
|
|
16
|
+
class RetryLater < Error
|
|
17
|
+
attr_reader :retry_after
|
|
18
|
+
|
|
19
|
+
def initialize(message = "retry later", retry_after: nil)
|
|
20
|
+
@retry_after = retry_after
|
|
21
|
+
super(message)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Raised by `Dispatch` when the parse step can't turn a verified request's body into an event —
|
|
26
|
+
# wrapping whatever the parser raised (the original stays reachable as `cause`), or raised directly
|
|
27
|
+
# by a custom `parse:` proc that knows its own format is malformed. Terminal by construction: a
|
|
28
|
+
# redelivery of the same bytes will never parse either, so `Inbound::Endpoint` maps it to the
|
|
29
|
+
# configured `unparseable_status` (a 2xx by default) instead of a retry-inviting 500 (PRO-3143).
|
|
30
|
+
# Deliberately NOT in any `fails_on` — it stays an axn exception outcome, so `on_exception` still
|
|
31
|
+
# reports that a vendor is sending garbage. Report, then ack.
|
|
32
|
+
class UnparseableBody < Error; end
|
|
33
|
+
|
|
34
|
+
# Raised by Outbound::TargetPolicy when a resolved subscriber row (a static `to:` entry OR a
|
|
35
|
+
# runtime `subscribers`/`to:` lambda's return value) fails shape/host validation — a non-String
|
|
36
|
+
# URL, a non-http(s) scheme, a missing host, an unknown Hash key, or a host the declared
|
|
37
|
+
# `allowed_hosts`/`allow_url` policy rejects. A RUNTIME condition (a DB-backed store can produce
|
|
38
|
+
# a bad row at any time, not just at boot), so it's `Axn::Webhooks::Error`-rooted rather than the
|
|
39
|
+
# plain `ArgumentError` a pure `outbound` block declaration mistake raises — see the error-class
|
|
40
|
+
# split documented above `Outbound::Config#validate_event!`. `Config#resolve_targets` rescues
|
|
41
|
+
# this per-row so one malformed subscriber can't discard the rest of a fan-out.
|
|
42
|
+
class InvalidTarget < Error; end
|
|
43
|
+
|
|
44
|
+
def self.retry_later!(after: nil)
|
|
45
|
+
raise RetryLater.new(retry_after: after)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Webhooks
|
|
5
|
+
# Include in a webhook handler to get `Axn` plus the retry_later! contract: a RetryLater
|
|
6
|
+
# raised by the handler is treated as a FAILURE (not a reported exception), so asking the
|
|
7
|
+
# sender to redeliver never pages. Dispatch still maps it to 503 (+ Retry-After).
|
|
8
|
+
module Handler
|
|
9
|
+
def self.included(base)
|
|
10
|
+
base.include(Axn)
|
|
11
|
+
base.fails_on(Axn::Webhooks::RetryLater)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Webhooks
|
|
5
|
+
# THE rule for what may appear in an HTTP header value, shared by the outbound request path
|
|
6
|
+
# (Outbound::Deliver#add_custom_header) and the inbound response path (Response#initialize).
|
|
7
|
+
#
|
|
8
|
+
# Deliberately one shared home rather than a copy per side: the outbound half already enforced
|
|
9
|
+
# this while the inbound half did not, which is the same "fixed only where it was noticed"
|
|
10
|
+
# pattern that let a secret-handling bug recur four times in this gem.
|
|
11
|
+
module HeaderValue
|
|
12
|
+
# RFC 7230's `field-value` grammar forbids every control byte except HTAB (0x09). CR/LF
|
|
13
|
+
# (0x0D/0x0A) are the response-splitting pair, but any other control byte (NUL, BEL, ...) is
|
|
14
|
+
# equally invalid on the wire and can get a message rejected by a proxy in between.
|
|
15
|
+
FORBIDDEN_BYTES = /[\x00-\x08\x0A-\x1F\x7F]/
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# False for a value carrying a forbidden byte, AND for one whose encoding makes the question
|
|
20
|
+
# unanswerable — an invalid/incompatible encoding raises from String#match?, and a value we
|
|
21
|
+
# cannot inspect must not be trusted onto the wire.
|
|
22
|
+
def safe?(value)
|
|
23
|
+
!value.to_s.match?(FORBIDDEN_BYTES)
|
|
24
|
+
rescue Encoding::CompatibilityError, ArgumentError
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Webhooks
|
|
5
|
+
module Inbound
|
|
6
|
+
# Wraps Request.from_rack in an Axn boundary so a malformed/adversarial env (missing
|
|
7
|
+
# rack.input, etc.) is reported via Axn.config.on_exception and mapped to a clean 500 by
|
|
8
|
+
# Endpoint#call, never an unhandled exception escaping the Rack app.
|
|
9
|
+
class BuildRequest
|
|
10
|
+
include Axn
|
|
11
|
+
include Axn::Webhooks::VendorFacet
|
|
12
|
+
|
|
13
|
+
expects :env, sensitive: true
|
|
14
|
+
exposes :request, type: Axn::Webhooks::Request, sensitive: true
|
|
15
|
+
error "Webhook Rack request parsing failed"
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
expose request: Axn::Webhooks::Request.from_rack(env)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Webhooks
|
|
5
|
+
module Inbound
|
|
6
|
+
# The GET-echo handshake (spec: "### 3. Challenge"). Computes the exact Response: 200 echo,
|
|
7
|
+
# 403 when a guard (e.g. Meta hub.verify_token) rejects, 400 when there's no challenge value
|
|
8
|
+
# — all quiet (no page). A resolver or guard that RAISES is a loud exception (reported, mapped
|
|
9
|
+
# to 500 by Endpoint#challenge_response) — never an unhandled crash. Exposes a typed Response.
|
|
10
|
+
class Challenge
|
|
11
|
+
include Axn
|
|
12
|
+
include Axn::Webhooks::VendorFacet
|
|
13
|
+
|
|
14
|
+
expects :request, type: Axn::Webhooks::Request, sensitive: true
|
|
15
|
+
expects :resolver
|
|
16
|
+
expects :guard, allow_blank: true, default: nil
|
|
17
|
+
exposes :response, type: Axn::Webhooks::Response
|
|
18
|
+
error "Webhook challenge failed"
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
expose response: build_response
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def build_response
|
|
27
|
+
return Response.new(status: 403) if guard && !guard.call(request) # e.g. Meta hub.verify_token mismatch
|
|
28
|
+
|
|
29
|
+
value = resolver.call(request)
|
|
30
|
+
return Response.new(status: 400) if value.nil?
|
|
31
|
+
|
|
32
|
+
Response.text(value.to_s)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Webhooks
|
|
5
|
+
module Inbound
|
|
6
|
+
# The challenge-required precondition, as an Axn — for the same reason the verifier, the
|
|
7
|
+
# `parse:` step and the GET challenge resolver each have one: it is request-dependent code the
|
|
8
|
+
# gem does not own, reading adversarial input. It runs FIRST on the POST path, ahead of every
|
|
9
|
+
# other boundary, so a raise here would otherwise leave Endpoint#call as an unhandled Rack
|
|
10
|
+
# exception rather than a reported one and a controlled response.
|
|
11
|
+
#
|
|
12
|
+
# A crash settles not-ok, which Endpoint reads as "can't tell" and answers by verifying
|
|
13
|
+
# normally — the behaviour from before the precondition existed. Safe by construction: Verify
|
|
14
|
+
# still decides, so a broken predicate can neither dispatch an unauthenticated request nor drop
|
|
15
|
+
# an authenticated one. It costs the telemetry saving until it's fixed, and says so once via
|
|
16
|
+
# on_exception rather than failing silently.
|
|
17
|
+
class ChallengeRequired
|
|
18
|
+
include Axn
|
|
19
|
+
include Axn::Webhooks::VendorFacet
|
|
20
|
+
|
|
21
|
+
expects :request, type: Axn::Webhooks::Request, sensitive: true
|
|
22
|
+
# The verifier's bound `#challenge_required?` method, or a declared block. Sensitive for the
|
|
23
|
+
# same reason Verify's `verifier:` is: a bound method renders its receiver, which for
|
|
24
|
+
# `verify :basic_auth` is the object holding the vendor's credentials.
|
|
25
|
+
expects :predicate, sensitive: true
|
|
26
|
+
exposes :required, allow_blank: true, default: false
|
|
27
|
+
error "Webhook challenge-required check failed"
|
|
28
|
+
|
|
29
|
+
def call
|
|
30
|
+
expose required: !!predicate.call(request)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Webhooks
|
|
5
|
+
module Inbound
|
|
6
|
+
# Receiver for an `inbound` block: captures declarations (Phase 2: `verify`) and
|
|
7
|
+
# exposes request resolvers. Later phases add dispatch/challenge/respond here.
|
|
8
|
+
class DSL
|
|
9
|
+
# Every declaration a child `endpoint` inherits. Deliberately excludes @child_endpoints
|
|
10
|
+
# (nesting is one level deep) and @nested.
|
|
11
|
+
#
|
|
12
|
+
# Also excludes @dispatch_spec, which cannot be inherited because a parent declaring
|
|
13
|
+
# `dispatch` alongside `endpoint` blocks is rejected at registration (see
|
|
14
|
+
# Axn::Webhooks.inbound). Listing it would be inert, and it would advertise a shared-dispatch
|
|
15
|
+
# feature that does not work anyway: `dispatch` captures ONE spec hash, so a child
|
|
16
|
+
# re-declaring it replaces the parent's wholesale — there is no partial override, and a
|
|
17
|
+
# parent dispatch every child copies verbatim leaves the children differing by nothing
|
|
18
|
+
# (Codex review asked for this; declined for that reason plus the migration hazard the guard
|
|
19
|
+
# exists to catch — silently losing Inbound[:vendor] out from under a mounted route).
|
|
20
|
+
INHERITED_IVARS = %i[
|
|
21
|
+
@verify_spec @unauthorized_headers @challenge_required
|
|
22
|
+
@respond_block @static_respond_block @challenge_spec
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
# verify :hmac, **opts | verify :standard_webhooks, **opts | verify { |req| ... }
|
|
26
|
+
def verify(strategy = nil, **opts, &block)
|
|
27
|
+
@verify_spec = { strategy:, opts:, block: }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# unauthorized_headers "WWW-Authenticate" => %(Basic realm="Webhook")
|
|
31
|
+
#
|
|
32
|
+
# Headers to attach to the 401 a verify failure produces. `verify :basic_auth` supplies
|
|
33
|
+
# this itself; declare it only for a custom `verify` block that has to challenge a client
|
|
34
|
+
# into retrying with credentials (see Endpoint#unauthorized_headers).
|
|
35
|
+
def unauthorized_headers(headers)
|
|
36
|
+
@unauthorized_headers = headers
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# challenge_required { |req| req.header("Authorization").to_s.empty? }
|
|
40
|
+
#
|
|
41
|
+
# Declares which requests are not authentication attempts at all, and so get the challenge
|
|
42
|
+
# (401 + `unauthorized_headers`) instead of being run through `verify` and recorded as
|
|
43
|
+
# verify failures. `verify :basic_auth` answers this itself; declare it only for a custom
|
|
44
|
+
# `verify` block that wraps a two-legged scheme the gem can't see through — the shape
|
|
45
|
+
# buyout's Twilio routes use, where the BasicAuth verifier sits inside a block:
|
|
46
|
+
#
|
|
47
|
+
# challenge_required { |req| my_basic_auth.challenge_required?(req) }
|
|
48
|
+
def challenge_required(&block)
|
|
49
|
+
@challenge_required = block
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# dispatch to: "Handler" | dispatch on: ->(e){…}, to: {map}, otherwise:, via: | parse: | mode:
|
|
53
|
+
# `unparseable_status:` overrides Axn::Webhooks.config.unparseable_status for THIS endpoint —
|
|
54
|
+
# it belongs here, next to `parse:`, because it only describes what happens when that parse
|
|
55
|
+
# fails, and because the right value is a fact about one vendor's retry policy (PRO-3143).
|
|
56
|
+
# rubocop:disable-next Naming/MethodParameterName
|
|
57
|
+
def dispatch(to: nil, on: nil, otherwise: nil, via: nil, parse: :json, mode: :auto, unparseable_status: nil)
|
|
58
|
+
@dispatch_spec = { to:, on:, otherwise:, via:, parse:, mode:, unparseable_status: }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# respond { |handler_result| text("...") } — maps a genuine handler success to a
|
|
62
|
+
# Response. Every other outcome (ack, business fail!, verify failure/exception, or a
|
|
63
|
+
# no-dispatch endpoint) always gets the default bare ack (or a declared `static_respond`
|
|
64
|
+
# body — see below), regardless of this declaration — see Endpoint#to_response.
|
|
65
|
+
def respond(&block)
|
|
66
|
+
# Checked BEFORE the discard below: without a block this stored nil after clearing the
|
|
67
|
+
# inherited alternative, booting an endpoint with no renderer at all — an undocumented way
|
|
68
|
+
# to un-declare a parent's renderer, and a typo that silently downgraded responses to bare
|
|
69
|
+
# acks (Codex review).
|
|
70
|
+
raise Axn::Webhooks::Error, "inbound endpoint's `respond` requires a block" unless block
|
|
71
|
+
|
|
72
|
+
# Endpoint rejects having both renderers set, and a child inherits BOTH ivars — so
|
|
73
|
+
# overriding an inherited `static_respond` with a `respond` has to clear it, or the child
|
|
74
|
+
# raises. Clears only an INHERITED one: declaring both in the same block stays an error
|
|
75
|
+
# rather than silently becoming last-one-wins (Codex review).
|
|
76
|
+
discard_inherited(:@static_respond_block)
|
|
77
|
+
@respond_block = block
|
|
78
|
+
claim_ownership(:@respond_block)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# static_respond { text("...") } — a body that does NOT read the handler result (block
|
|
82
|
+
# takes zero args, unlike respond's `|handler_result|`), so it renders on every non-error
|
|
83
|
+
# outcome: sync success, async enqueue, otherwise: :ack, and business fail! — see
|
|
84
|
+
# Endpoint#default_ack. Mutually exclusive with `respond` (Endpoint#initialize raises if
|
|
85
|
+
# both are declared) and never forces sync dispatch (Dispatch#async? never reads it).
|
|
86
|
+
def static_respond(&block)
|
|
87
|
+
raise Axn::Webhooks::Error, "inbound endpoint's `static_respond` requires a block" unless block
|
|
88
|
+
|
|
89
|
+
if block.parameters.any?
|
|
90
|
+
raise Axn::Webhooks::Error,
|
|
91
|
+
"inbound endpoint's static_respond block must take no arguments (it never reads the " \
|
|
92
|
+
"handler's result, unlike respond) — got a parameter; use `respond` instead if you need " \
|
|
93
|
+
"to read the handler's result"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
discard_inherited(:@respond_block) # see `respond` — cross-form override, inherited only
|
|
97
|
+
@static_respond_block = block
|
|
98
|
+
claim_ownership(:@static_respond_block)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# challenge ->(req){ req.params["challenge"] } — Nylas
|
|
102
|
+
# challenge ->(req){ req.params["hub.challenge"] }, if: ->(req){ ... } — Meta
|
|
103
|
+
def challenge(resolver, if: nil)
|
|
104
|
+
# `if:` shadows Ruby's `if` keyword inside this method body — must read it back via
|
|
105
|
+
# binding.local_variable_get, not a bare `if` reference (that's a syntax trap, not a var).
|
|
106
|
+
guard = binding.local_variable_get(:if)
|
|
107
|
+
@challenge_spec = { resolver:, guard: }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def header(name) = Resolvers.header(name)
|
|
111
|
+
def raw_body = Resolvers.raw_body
|
|
112
|
+
def params = Resolvers.params
|
|
113
|
+
def url = Resolvers.url
|
|
114
|
+
|
|
115
|
+
# Dispatch-map sugar: `async("H")` == `{ call: "H", async: true }`; `sync` forces sync.
|
|
116
|
+
# Callable inside a `dispatch to: { … }` map because the `inbound` block is instance_exec'd
|
|
117
|
+
# against this DSL. Extra kwargs (e.g. `with:`) pass through: `async("H", with: ->(e){ … })`.
|
|
118
|
+
# `**opts` is spread FIRST so the fixed mode and the positional handler always win — a
|
|
119
|
+
# splatted shared options hash carrying `:async`/`:call` can never silently flip the mode
|
|
120
|
+
# or retarget the handler (the helper's name is its contract).
|
|
121
|
+
def async(call, **opts) = { **opts, call:, async: true }
|
|
122
|
+
def sync(call, **opts) = { **opts, call:, async: false }
|
|
123
|
+
|
|
124
|
+
# endpoint(:events) { dispatch … } — declares a CHILD endpoint that inherits everything the
|
|
125
|
+
# parent block declared and may override any of it by re-declaring. One `inbound :slack`
|
|
126
|
+
# block with two `endpoint` blocks registers Inbound[:slack_interactivity] and
|
|
127
|
+
# Inbound[:slack_events]; the parent itself registers nothing.
|
|
128
|
+
def endpoint(name, &block)
|
|
129
|
+
raise ArgumentError, "`endpoint #{name.inspect}` requires a block" unless block
|
|
130
|
+
raise ArgumentError, "`endpoint #{name.inspect}` cannot be nested inside another `endpoint` — one level only" if @nested
|
|
131
|
+
|
|
132
|
+
@child_endpoints ||= {}
|
|
133
|
+
raise ArgumentError, "duplicate `endpoint #{name.inspect}` in the same inbound block" if @child_endpoints.key?(name.to_sym)
|
|
134
|
+
|
|
135
|
+
@child_endpoints[name.to_sym] = block
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Drops an ivar this DSL INHERITED from a parent `endpoint` container, leaving one declared
|
|
139
|
+
# in this very block untouched. Backs the mutually-exclusive renderer override above.
|
|
140
|
+
def discard_inherited(ivar)
|
|
141
|
+
return unless @inherited_ivars&.include?(ivar)
|
|
142
|
+
|
|
143
|
+
instance_variable_set(ivar, nil)
|
|
144
|
+
@inherited_ivars.delete(ivar)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Marks an ivar as belonging to THIS block from here on. Without it, a child that
|
|
148
|
+
# re-declared `respond` left `@respond_block` still listed as inherited, so a following
|
|
149
|
+
# `static_respond` discarded the child's OWN block and the pair was accepted — exactly the
|
|
150
|
+
# same-block conflict the discard rule is supposed to keep raising (Codex review).
|
|
151
|
+
def claim_ownership(ivar) = @inherited_ivars&.delete(ivar)
|
|
152
|
+
|
|
153
|
+
# Internal: declared child endpoints, name => block. Empty for a plain `inbound` block.
|
|
154
|
+
def __children__ = @child_endpoints || {}
|
|
155
|
+
|
|
156
|
+
# Internal: whether `dispatch` was declared directly on THIS DSL. Read instead of
|
|
157
|
+
# `__dispatch__` so the parent-with-children check doesn't build a Router just to ask.
|
|
158
|
+
def __dispatch_declared? = !@dispatch_spec.nil?
|
|
159
|
+
|
|
160
|
+
# Internal: a fresh DSL seeded with this one's captured declarations, with `block` evaluated
|
|
161
|
+
# against it — so a child inherits everything and overrides by re-declaring.
|
|
162
|
+
#
|
|
163
|
+
# Copies the ivars rather than re-`instance_exec`ing the parent block per child (the obvious
|
|
164
|
+
# alternative): replaying the parent block would re-run any side effects in it, and would
|
|
165
|
+
# re-enter `endpoint` recursively, registering each child once per sibling.
|
|
166
|
+
def __child_dsl__(block)
|
|
167
|
+
child = self.class.new
|
|
168
|
+
inherited = INHERITED_IVARS.select { |ivar| instance_variable_defined?(ivar) }
|
|
169
|
+
inherited.each { |ivar| child.instance_variable_set(ivar, instance_variable_get(ivar)) }
|
|
170
|
+
# Recorded so `respond`/`static_respond` can tell an inherited value from one declared in
|
|
171
|
+
# the child's own block, and clear only the former.
|
|
172
|
+
child.instance_variable_set(:@inherited_ivars, inherited.dup)
|
|
173
|
+
child.instance_variable_set(:@nested, true)
|
|
174
|
+
child.instance_exec(&block)
|
|
175
|
+
child
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Internal: build the verifier callable from the captured declaration.
|
|
179
|
+
# For challenge-only endpoints (no dispatch, no verify declared), return a no-op verifier
|
|
180
|
+
# that always succeeds — a challenge-only endpoint just handshakes the GET and 200-acks any
|
|
181
|
+
# POST, so there's no unverified processing to guard against.
|
|
182
|
+
# `verify` is REQUIRED whenever `dispatch` is declared — dispatching an unverified webhook
|
|
183
|
+
# would run the handler on an unauthenticated request.
|
|
184
|
+
def __verifier__
|
|
185
|
+
unless @verify_spec
|
|
186
|
+
# Nothing declared at all: bare endpoint, always an error.
|
|
187
|
+
raise Axn::Webhooks::Error, "inbound endpoint declared no `verify`" if @dispatch_spec.nil? && @challenge_spec.nil?
|
|
188
|
+
|
|
189
|
+
# `dispatch` without `verify` is unsafe regardless of whether `challenge` is also present.
|
|
190
|
+
if @dispatch_spec
|
|
191
|
+
raise Axn::Webhooks::Error,
|
|
192
|
+
"inbound endpoint with `dispatch` must declare `verify` — dispatching an unverified webhook is unsafe"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Challenge-only endpoint (no dispatch): return a no-op verifier.
|
|
196
|
+
return ->(_request) { true }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
raise Axn::Webhooks::Error, "inbound endpoint `verify` needs a strategy or a block" if @verify_spec[:strategy].nil? && @verify_spec[:block].nil?
|
|
200
|
+
|
|
201
|
+
Verifiers.build(**@verify_spec)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Internal: build the { router:, parse:, mode: } dispatch config, or nil if none declared.
|
|
205
|
+
def __dispatch__
|
|
206
|
+
return nil unless @dispatch_spec
|
|
207
|
+
|
|
208
|
+
spec = @dispatch_spec
|
|
209
|
+
unless %i[auto sync async].include?(spec[:mode])
|
|
210
|
+
raise Axn::Webhooks::Error, "dispatch mode: must be :sync, :async, or :auto (got #{spec[:mode].inspect})"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
unless spec[:unparseable_status].nil? || Response.valid_status?(spec[:unparseable_status])
|
|
214
|
+
raise Axn::Webhooks::Error,
|
|
215
|
+
"dispatch unparseable_status: must be an Integer HTTP status between 200 and 599 " \
|
|
216
|
+
"(got #{spec[:unparseable_status].inspect})"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
router = Router.new(to: spec[:to], on: spec[:on], otherwise: spec[:otherwise], via: spec[:via])
|
|
220
|
+
{ router:, parse: Parsers.build(spec[:parse]), mode: spec[:mode], unparseable_status: spec[:unparseable_status] }
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Internal: the captured respond block, or nil if none declared.
|
|
224
|
+
def __respond__ = @respond_block
|
|
225
|
+
|
|
226
|
+
# Internal: the captured static_respond block, or nil if none declared.
|
|
227
|
+
def __static_respond__ = @static_respond_block
|
|
228
|
+
|
|
229
|
+
# Internal: the captured { resolver:, guard: } challenge declaration, or nil if none.
|
|
230
|
+
def __challenge__ = @challenge_spec
|
|
231
|
+
|
|
232
|
+
# Internal: the declared 401 headers, or nil to let the verifier speak for itself.
|
|
233
|
+
def __unauthorized_headers__ = @unauthorized_headers
|
|
234
|
+
|
|
235
|
+
# Internal: the declared challenge-required predicate, or nil to ask the verifier.
|
|
236
|
+
def __challenge_required__ = @challenge_required
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|