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,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Webhooks
|
|
5
|
+
module Inbound
|
|
6
|
+
# A registered inbound webhook endpoint. Verifies a request's signature, dispatches
|
|
7
|
+
# the (verified, parsed) event to a handler Axn, and maps the pipeline's outcome to an
|
|
8
|
+
# HTTP Response. Challenge (GET) and Rack mount arrive in a later phase.
|
|
9
|
+
class Endpoint
|
|
10
|
+
def initialize(name:, verifier:, dispatch: nil, respond: nil, static_respond: nil, challenge: nil,
|
|
11
|
+
unauthorized_headers: nil, challenge_required: nil)
|
|
12
|
+
if dispatch && dispatch[:mode] == :async && respond
|
|
13
|
+
raise Axn::Webhooks::Error,
|
|
14
|
+
"inbound endpoint `#{name}` declares a custom `respond` but explicit `dispatch mode: :async` " \
|
|
15
|
+
"can't produce a handler_result for it to read — use `mode: :sync` (or omit mode) or drop the respond block"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
if respond && static_respond
|
|
19
|
+
raise Axn::Webhooks::Error,
|
|
20
|
+
"inbound endpoint `#{name}` declares both `respond` and `static_respond` — declare only one; " \
|
|
21
|
+
"`respond` reads the handler's result, `static_respond` doesn't and renders on every non-error outcome"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@name = name.to_sym
|
|
25
|
+
@verifier = verifier
|
|
26
|
+
@dispatch = dispatch
|
|
27
|
+
@respond = respond
|
|
28
|
+
@static_respond = static_respond
|
|
29
|
+
@challenge = challenge
|
|
30
|
+
@unauthorized_headers = unauthorized_headers
|
|
31
|
+
@challenge_required = challenge_required
|
|
32
|
+
|
|
33
|
+
validate_challenge!
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
attr_reader :name
|
|
37
|
+
|
|
38
|
+
# Headers attached to the 401 a verify failure produces. Empty for the signature
|
|
39
|
+
# strategies — there is nothing for a signing client to be challenged *with* — but
|
|
40
|
+
# mandatory for HTTP Basic auth (RFC 7617), where a client that doesn't authenticate
|
|
41
|
+
# preemptively sends its first request bare and repeats it with credentials only after a
|
|
42
|
+
# 401 carrying `WWW-Authenticate`. Without this the second leg never comes and every
|
|
43
|
+
# request from such a client is dropped, uniformly and silently.
|
|
44
|
+
#
|
|
45
|
+
# An explicit `unauthorized_headers` declaration wins, so a custom `verify` block can
|
|
46
|
+
# supply its own challenge; otherwise the verifier speaks for itself.
|
|
47
|
+
def unauthorized_headers
|
|
48
|
+
return @unauthorized_headers if @unauthorized_headers
|
|
49
|
+
return @verifier.unauthorized_headers if @verifier.respond_to?(:unauthorized_headers)
|
|
50
|
+
|
|
51
|
+
{}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Is this request an authentication attempt at all? When it isn't, there is nothing to
|
|
55
|
+
# verify — it's a protocol precondition, not a failed verification — and #to_response
|
|
56
|
+
# answers with the challenge without invoking Verify (PRO-3148). Under a two-legged scheme
|
|
57
|
+
# like RFC 7617 Basic auth a reactive client sends one such request per *successful*
|
|
58
|
+
# webhook, so recording them as verify failures made the highest-volume outcome on a healthy
|
|
59
|
+
# endpoint a recorded failure, and a cross-vendor verify-failure monitor unusable without
|
|
60
|
+
# knowing which vendors happen to use Basic auth.
|
|
61
|
+
#
|
|
62
|
+
# False unless something says otherwise, so the signature strategies — which have no
|
|
63
|
+
# challenge to offer and no second leg to wait for — are untouched: no predicate means no
|
|
64
|
+
# ChallengeRequired call either, not merely a false answer from one.
|
|
65
|
+
#
|
|
66
|
+
# Note this is NOT the `challenge` declaration (that's the vendor's GET handshake, see
|
|
67
|
+
# #challenge_response). Same word, different protocol: this one is the 401 kind.
|
|
68
|
+
def challenge_required?(request)
|
|
69
|
+
predicate = challenge_predicate
|
|
70
|
+
return false unless predicate
|
|
71
|
+
|
|
72
|
+
# Inside an Axn boundary: the predicate is request-dependent code the gem doesn't own, and
|
|
73
|
+
# it runs ahead of every other boundary on the POST path. A crash settles not-ok and is read
|
|
74
|
+
# as "can't tell" -> verify normally (see ChallengeRequired for why that's the safe answer).
|
|
75
|
+
checked = ChallengeRequired.call(request:, predicate:, vendor: @name)
|
|
76
|
+
checked.ok? && checked.required
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Verify the request's signature. Returns an Axn::Result: ok? when verified,
|
|
80
|
+
# a failure on mismatch, an exception if the verifier raises.
|
|
81
|
+
def verify(request)
|
|
82
|
+
Verify.call(request:, verifier: @verifier, vendor: @name)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Full pipeline: verify, then (if a dispatch is declared and verification passed)
|
|
86
|
+
# parse + route to the handler. Returns the final Axn::Result.
|
|
87
|
+
def handle(request)
|
|
88
|
+
verified = verify(request)
|
|
89
|
+
return verified unless verified.ok? && @dispatch
|
|
90
|
+
|
|
91
|
+
Dispatch.call(request:, router: @dispatch[:router], parse: @dispatch[:parse],
|
|
92
|
+
mode: @dispatch[:mode], respond_declared: !@respond.nil?, vendor: @name)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The staged HTTP outcome mapping (spec: "Respond + staged outcome model"). Verify and
|
|
96
|
+
# dispatch are mapped in separate branches — deliberately NOT a single outcome->status
|
|
97
|
+
# rule, because a verify failure (401) and a handler business fail! (2xx) are both
|
|
98
|
+
# `outcome.failure?` but mean opposite things at the HTTP layer.
|
|
99
|
+
def to_response(request)
|
|
100
|
+
# Ahead of verify, deliberately: a request that isn't an authentication attempt gets the
|
|
101
|
+
# challenge rather than a recorded verify failure (see #challenge_required?). Same 401 on
|
|
102
|
+
# the wire, and it still can't reach a handler — strictly safer than the `done!` that
|
|
103
|
+
# would settle this leg as a *success*.
|
|
104
|
+
return Response.new(status: 401, headers: unauthorized_headers) if challenge_required?(request)
|
|
105
|
+
|
|
106
|
+
verified = verify(request)
|
|
107
|
+
return Response.new(status: 401, headers: unauthorized_headers) unless verified.ok?
|
|
108
|
+
return default_ack unless @dispatch
|
|
109
|
+
|
|
110
|
+
dispatched = Dispatch.call(request:, router: @dispatch[:router], parse: @dispatch[:parse],
|
|
111
|
+
mode: @dispatch[:mode], respond_declared: !@respond.nil?, vendor: @name)
|
|
112
|
+
response_for(dispatched)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The GET branch (spec: the mount owns the whole path, every verb). Testable without a Rack
|
|
116
|
+
# env, mirroring #verify/#handle/#to_response.
|
|
117
|
+
def challenge_response(request)
|
|
118
|
+
return Response.new(status: 405) unless @challenge
|
|
119
|
+
|
|
120
|
+
# The Challenge axn computes the exact Response (200 echo / 403 guard-fail / 400 nil).
|
|
121
|
+
# Only a raising resolver/guard makes it not-ok -> a reported 500.
|
|
122
|
+
result = Challenge.call(request:, resolver: @challenge[:resolver], guard: @challenge[:guard], vendor: @name)
|
|
123
|
+
result.ok? ? result.response : Response.new(status: 500)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The Rack app entry point (spec: mount-first packaging). `Inbound[:vendor]` (this object)
|
|
127
|
+
# is directly `mount`-able in Rails routes.rb or `run`-able in a bare Rack::Builder — the
|
|
128
|
+
# mount owns the whole path and every verb: POST -> #to_response, GET -> #challenge_response,
|
|
129
|
+
# anything else -> 405. Named `call`, deliberately reserved since Phase 3 (see #handle).
|
|
130
|
+
def call(env)
|
|
131
|
+
built = BuildRequest.call(env:, vendor: @name)
|
|
132
|
+
return Response.new(status: 500).to_rack unless built.ok?
|
|
133
|
+
|
|
134
|
+
request = built.request
|
|
135
|
+
response =
|
|
136
|
+
case request.http_method
|
|
137
|
+
when "POST" then to_response(request)
|
|
138
|
+
when "GET" then challenge_response(request)
|
|
139
|
+
else Response.new(status: 405)
|
|
140
|
+
end
|
|
141
|
+
response.to_rack
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
# The declared block, else the verifier's own bound predicate, else nil for "nobody claims a
|
|
147
|
+
# challenge" — the signature strategies and every plain `verify` lambda. Same precedence as
|
|
148
|
+
# #unauthorized_headers: a declaration wins, so a custom block can speak for a verifier the
|
|
149
|
+
# gem can't see through.
|
|
150
|
+
def challenge_predicate
|
|
151
|
+
return @challenge_required if @challenge_required
|
|
152
|
+
return @verifier.method(:challenge_required?) if @verifier.respond_to?(:challenge_required?)
|
|
153
|
+
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# A challenge with nothing in it is the PRO-3146 silent drop: the client is told to retry and
|
|
158
|
+
# never told how, so every request is dropped forever — and now without even a verify failure
|
|
159
|
+
# recorded, since answering the challenge skips Verify. Fails the boot rather than shipping an
|
|
160
|
+
# endpoint that is both broken and invisible.
|
|
161
|
+
#
|
|
162
|
+
# Both halves read the *effective* value, not the declaration: a predicate can arrive from a
|
|
163
|
+
# registered verifier (`Verifiers.register` is public) as easily as from a `challenge_required`
|
|
164
|
+
# block, and either can be paired with a challenge from the other side. So `verify :basic_auth`
|
|
165
|
+
# plus a declared predicate is fine, a declared pair is fine, and only an endpoint that claims
|
|
166
|
+
# a challenge is required without saying what to challenge with lands here.
|
|
167
|
+
def validate_challenge!
|
|
168
|
+
return unless challenge_predicate && unauthorized_headers.empty?
|
|
169
|
+
|
|
170
|
+
raise Axn::Webhooks::Error,
|
|
171
|
+
"inbound endpoint `#{@name}` requires a challenge (`challenge_required`, or a verifier that " \
|
|
172
|
+
"answers `#challenge_required?`) but has no challenge to send — declare `unauthorized_headers`, " \
|
|
173
|
+
"or the challenged client is never told how to retry"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def response_for(dispatched)
|
|
177
|
+
return Response.service_unavailable(retry_after: dispatched.retry_after) if dispatched.retry_later
|
|
178
|
+
return default_ack(status: unparseable_status) if unparseable?(dispatched)
|
|
179
|
+
return Response.new(status: 500) if dispatched.outcome.exception?
|
|
180
|
+
return default_ack if dispatched.outcome.failure? # handler fail! -> quiet ack (or static body)
|
|
181
|
+
return default_ack if dispatched.handler_result.nil? # otherwise: :ack / async enqueue -> ack (or static body)
|
|
182
|
+
return default_ack unless @respond
|
|
183
|
+
|
|
184
|
+
# Run the user's respond block inside the Respond axn so a raise in it (e.g. reading a
|
|
185
|
+
# missing exposure) becomes a reported 500, not an exception escaping the HTTP mapper.
|
|
186
|
+
responded = Respond.call(handler_result: dispatched.handler_result, responder: @respond, vendor: @name)
|
|
187
|
+
responded.ok? ? responded.response : Response.new(status: 500)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# A verified request whose body doesn't parse (PRO-3143). Checked ahead of the generic
|
|
191
|
+
# exception -> 500 branch: it IS an exception outcome (already reported via on_exception, which
|
|
192
|
+
# is how you learn a vendor is sending garbage), but a retry can never fix malformed bytes, so
|
|
193
|
+
# the HTTP answer is terminal instead of an invitation to redeliver forever.
|
|
194
|
+
def unparseable?(dispatched) = dispatched.exception.is_a?(Axn::Webhooks::UnparseableBody)
|
|
195
|
+
|
|
196
|
+
# This endpoint's declaration wins over the global setting; see the setting's own comment for
|
|
197
|
+
# why the default is a 2xx rather than the semantically-tidier 400.
|
|
198
|
+
def unparseable_status = @dispatch[:unparseable_status] || Axn::Webhooks.config.unparseable_status
|
|
199
|
+
|
|
200
|
+
# The bare-ack default, or the declared static_respond body in its place. Every branch
|
|
201
|
+
# above that used to hardcode `Response.ack` (dispatch.failure?, nil handler_result, no
|
|
202
|
+
# respond declared, no dispatch at all) now goes through here — static_respond, unlike
|
|
203
|
+
# respond, has no handler_result to read, so it renders on all of them uniformly.
|
|
204
|
+
#
|
|
205
|
+
# `status:` restamps the rendered response, for the one caller (the unparseable-body row) whose
|
|
206
|
+
# status the gem decides rather than the block. Left nil by every other caller, so a block that
|
|
207
|
+
# picked its own status — `text("queued", status: 202)` — still keeps it on the success rows.
|
|
208
|
+
# A raising/non-Response static_respond block is still a 500 here: an internal error in the
|
|
209
|
+
# endpoint's own body-rendering isn't the vendor's malformed body, and shouldn't be acked as one.
|
|
210
|
+
def default_ack(status: nil)
|
|
211
|
+
return Response.ack(status: status || 200) unless @static_respond
|
|
212
|
+
|
|
213
|
+
responded = StaticRespond.call(responder: @static_respond, vendor: @name)
|
|
214
|
+
return Response.new(status: 500) unless responded.ok?
|
|
215
|
+
|
|
216
|
+
status ? responded.response.with_status(status) : responded.response
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Axn
|
|
6
|
+
module Webhooks
|
|
7
|
+
# Builds the callable that turns a Request into the parsed `event` a dispatcher routes on.
|
|
8
|
+
module Parsers
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def build(option)
|
|
12
|
+
case option
|
|
13
|
+
when nil, :json then ->(request) { JSON.parse(request.raw_body) }
|
|
14
|
+
when Proc then option
|
|
15
|
+
else raise Axn::Webhooks::Error, "unknown parse option #{option.inspect} (use :json or a proc)"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Webhooks
|
|
5
|
+
module Inbound
|
|
6
|
+
# instance_exec context for a `respond` block: exposes `ack`/`text`/`xml`/`json` as bare calls,
|
|
7
|
+
# so a respond proc reads `text("...")` rather than `Axn::Webhooks::Response.text("...")` —
|
|
8
|
+
# mirrors how the `verify` custom block gets `header`/`params`/etc. as bare calls from DSL.
|
|
9
|
+
class RespondContext
|
|
10
|
+
def ack(**) = Response.ack(**)
|
|
11
|
+
def text(body, **) = Response.text(body, **)
|
|
12
|
+
def xml(body, **) = Response.xml(body, **)
|
|
13
|
+
def json(body, **) = Response.json(body, **)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Webhooks
|
|
5
|
+
module Inbound
|
|
6
|
+
# Resolves a parsed webhook event to the handler to invoke. Pure logic (no Axn) —
|
|
7
|
+
# a missing constant or an unmatched key with no `otherwise:` raises, and the
|
|
8
|
+
# Dispatch Axn turns that raise into a reported exception + formatted result.
|
|
9
|
+
class Router
|
|
10
|
+
# rubocop:disable Naming/MethodParameterName
|
|
11
|
+
def initialize(to:, on: nil, otherwise: nil, via: nil)
|
|
12
|
+
# rubocop:enable Naming/MethodParameterName
|
|
13
|
+
raise Axn::Webhooks::Error, "dispatch needs a `to:` target" if to.nil?
|
|
14
|
+
|
|
15
|
+
@to = to
|
|
16
|
+
@on = on
|
|
17
|
+
@otherwise = otherwise
|
|
18
|
+
@via = via
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# → [handler_class, kwargs, route_async] for a matched handler, or :ack.
|
|
22
|
+
def resolve(event)
|
|
23
|
+
return handler_for(@to, event) if @on.nil?
|
|
24
|
+
|
|
25
|
+
key = @on.call(event)
|
|
26
|
+
@to.is_a?(Hash) ? resolve_mapped(key, event) : resolve_by_convention(key, event)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def resolve_mapped(key, event)
|
|
32
|
+
entry = @to.fetch(key) { return unmatched(key, event) }
|
|
33
|
+
handler_for(entry, event)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def resolve_by_convention(key, event)
|
|
37
|
+
transform = @via || method(:default_transform)
|
|
38
|
+
[constantize("#{@to}::#{transform.call(key)}"), { event: }, nil]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def handler_for(entry, event)
|
|
42
|
+
case entry
|
|
43
|
+
when String, Module then [constantize(entry), { event: }, nil]
|
|
44
|
+
when Hash
|
|
45
|
+
[constantize(entry.fetch(:call)), args_for(entry, event), route_async(entry)]
|
|
46
|
+
else
|
|
47
|
+
raise Axn::Webhooks::Error, "invalid dispatch target: #{entry.inspect}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Handler kwargs from a map entry: no `with:` passes the whole event as `event:`, a Symbol
|
|
52
|
+
# passes it under that name instead (for endpoints whose parsed object isn't naturally an
|
|
53
|
+
# "event" — e.g. a Slack interaction `payload`), and a callable projects it to whatever it
|
|
54
|
+
# returns. Non-callables raise here rather than NoMethodError-ing on `.call`.
|
|
55
|
+
def args_for(entry, event)
|
|
56
|
+
return { event: } unless entry.key?(:with)
|
|
57
|
+
|
|
58
|
+
extractor = entry.fetch(:with)
|
|
59
|
+
return { extractor => event } if extractor.is_a?(Symbol)
|
|
60
|
+
|
|
61
|
+
raise Axn::Webhooks::Error, "dispatch entry `with:` must be a Symbol or a callable (got #{extractor.inspect})" unless extractor.respond_to?(:call)
|
|
62
|
+
|
|
63
|
+
extractor.call(event)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def unmatched(key, event)
|
|
67
|
+
case @otherwise
|
|
68
|
+
when :ack then :ack
|
|
69
|
+
when nil then raise Axn::Webhooks::Error, "no handler for webhook event #{key.inspect} (and no `otherwise:`)"
|
|
70
|
+
else
|
|
71
|
+
@otherwise.call(event) # user callable (e.g. alerting); return value ignored
|
|
72
|
+
:ack
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Optional per-route sync/async opt-out from a map entry: true=async, false=sync,
|
|
77
|
+
# absent=nil (no opinion — Dispatch falls through to endpoint mode / respond default).
|
|
78
|
+
def route_async(entry)
|
|
79
|
+
return nil unless entry.key?(:async)
|
|
80
|
+
|
|
81
|
+
value = entry.fetch(:async)
|
|
82
|
+
return value if [true, false, nil].include?(value)
|
|
83
|
+
|
|
84
|
+
raise Axn::Webhooks::Error, "dispatch entry `async:` must be true or false (got #{value.inspect})"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Accepts a class-name String (resolved late) or a Class/Module target. A named Module is
|
|
88
|
+
# re-resolved via const_get on EVERY call, so a class object passed at declaration time stays
|
|
89
|
+
# reload-safe under Rails/Zeitwerk (a captured object would go stale when the constant is
|
|
90
|
+
# reassigned on reload). A target whose name doesn't actually resolve to a constant — a truly
|
|
91
|
+
# anonymous class (name.nil?), or an Axn::Factory product (whose debug name is a String but was
|
|
92
|
+
# never assigned to a constant) — has nothing to re-resolve by, so it's used as-is.
|
|
93
|
+
def constantize(target)
|
|
94
|
+
return Object.const_get(target) unless target.is_a?(Module)
|
|
95
|
+
|
|
96
|
+
name = target.name
|
|
97
|
+
name && Object.const_defined?(name) ? Object.const_get(name) : target
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def default_transform(key) = key.to_s.split(/[._]/).reject(&:empty?).map(&:capitalize).join
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "inbound/dsl"
|
|
4
|
+
require_relative "inbound/endpoint"
|
|
5
|
+
require_relative "inbound/router"
|
|
6
|
+
|
|
7
|
+
module Axn
|
|
8
|
+
module Webhooks
|
|
9
|
+
# Process-global registry of inbound webhook endpoints, populated by
|
|
10
|
+
# `Axn::Webhooks.inbound(:vendor) { ... }` and looked up as `Inbound[:vendor]`.
|
|
11
|
+
module Inbound
|
|
12
|
+
@registry = {}
|
|
13
|
+
# declaration name => the registry keys it owns. Once one `inbound :slack` can produce N
|
|
14
|
+
# endpoints, re-declaring it has to be able to REMOVE keys, not just overwrite them.
|
|
15
|
+
@declared = {}
|
|
16
|
+
# registry key => the declaration that CURRENTLY owns it. Needed because two declarations can
|
|
17
|
+
# generate the same endpoint name, and a stale claim must never delete a live route.
|
|
18
|
+
@owners = {}
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def register(name, endpoint) = @registry[name.to_sym] = endpoint
|
|
22
|
+
def [](name) = @registry.fetch(name.to_sym) { raise KeyError, "no inbound webhook registered for #{name.inspect}" }
|
|
23
|
+
def registered = @registry.keys
|
|
24
|
+
|
|
25
|
+
# Publish everything one `inbound <name>` declaration defines, dropping whatever that same
|
|
26
|
+
# declaration registered last time. Covers every transition — fewer children, more children,
|
|
27
|
+
# nested becoming plain, plain becoming nested — each of which previously left a route
|
|
28
|
+
# mounted with its old verifier and handler (Codex review).
|
|
29
|
+
#
|
|
30
|
+
# Reclaims only the keys this declaration STILL owns. Two declarations can generate the same
|
|
31
|
+
# endpoint name (`inbound :slack` with `endpoint(:events)` vs. a plain `inbound
|
|
32
|
+
# :slack_events`); last writer wins, which predates nesting — but without the ownership
|
|
33
|
+
# check, re-declaring the FIRST one would then delete the second one's live route and not
|
|
34
|
+
# restore it. That deletion is not pre-existing: before this bookkeeping, registration only
|
|
35
|
+
# ever overwrote (Codex review). A takeover warns rather than raising: the collision is
|
|
36
|
+
# recoverable, and the endpoint that loses is named so it can be found.
|
|
37
|
+
def replace_declaration(name, endpoints)
|
|
38
|
+
key = name.to_sym
|
|
39
|
+
|
|
40
|
+
(@declared[key] || []).each do |registered_name|
|
|
41
|
+
next unless @owners[registered_name] == key
|
|
42
|
+
|
|
43
|
+
@registry.delete(registered_name)
|
|
44
|
+
@owners.delete(registered_name)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
endpoints.each do |endpoint_name, endpoint|
|
|
48
|
+
warn_takeover(endpoint_name, key) if @owners.key?(endpoint_name) && @owners[endpoint_name] != key
|
|
49
|
+
@registry[endpoint_name] = endpoint
|
|
50
|
+
@owners[endpoint_name] = key
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
@declared[key] = endpoints.keys
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def reset!
|
|
57
|
+
@registry.clear
|
|
58
|
+
@declared.clear
|
|
59
|
+
@owners.clear
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def warn_takeover(endpoint_name, new_owner)
|
|
65
|
+
Axn.config.logger.warn(
|
|
66
|
+
"[axn-webhooks] inbound endpoint #{endpoint_name.inspect} is now registered by " \
|
|
67
|
+
"`inbound #{new_owner.inspect}`, replacing the one from `inbound #{@owners[endpoint_name].inspect}` — " \
|
|
68
|
+
"two declarations generate the same endpoint name",
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Declare an inbound webhook endpoint. Evaluated at boot (e.g. a Rails initializer)
|
|
75
|
+
# so registration is deterministic, in or out of Rails.
|
|
76
|
+
#
|
|
77
|
+
# With one or more nested `endpoint` blocks, this registers one endpoint per child, named
|
|
78
|
+
# :"#{name}_#{child}", and does NOT register `name` itself — see Inbound::DSL#endpoint.
|
|
79
|
+
def self.inbound(name, &block)
|
|
80
|
+
raise ArgumentError, "Axn::Webhooks.inbound requires a block" unless block
|
|
81
|
+
|
|
82
|
+
dsl = Inbound::DSL.new
|
|
83
|
+
dsl.instance_exec(&block)
|
|
84
|
+
children = dsl.__children__
|
|
85
|
+
return Inbound.replace_declaration(name, { name.to_sym => build_endpoint(name, dsl) }) if children.empty?
|
|
86
|
+
|
|
87
|
+
# A parent with children is a container, not an endpoint. A top-level `dispatch` is what
|
|
88
|
+
# would make it look like one, and registering both it and the children would leave an extra
|
|
89
|
+
# endpoint nobody mounted, silently — so that combination is a declaration mistake, caught at
|
|
90
|
+
# boot. A parent `respond`/`static_respond` is NOT: it renders nothing on its own, and
|
|
91
|
+
# sharing one renderer across a vendor's endpoints is precisely what nesting is for, so it
|
|
92
|
+
# inherits like every other declaration (Codex review).
|
|
93
|
+
if dsl.__dispatch_declared?
|
|
94
|
+
raise ArgumentError,
|
|
95
|
+
"inbound #{name.inspect} declares `endpoint` blocks AND its own `dispatch` — a parent " \
|
|
96
|
+
"with endpoints registers nothing itself; move the dispatch into an endpoint"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Build and validate EVERY child before publishing any: the registry is process-global, so
|
|
100
|
+
# registering as we go left earlier children live when a later one raised — a rescued
|
|
101
|
+
# declaration failure or a reload would mix endpoints from different declarations
|
|
102
|
+
# (Codex review).
|
|
103
|
+
built = children.map { |child, child_block| [:"#{name}_#{child}", build_endpoint(:"#{name}_#{child}", dsl.__child_dsl__(child_block))] }
|
|
104
|
+
Inbound.replace_declaration(name, built.to_h)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Each child is a complete, independently valid endpoint by the time it gets here, so the
|
|
108
|
+
# existing per-endpoint validation (__verifier__'s "declared no `verify`" check, Endpoint's
|
|
109
|
+
# respond/static_respond exclusivity) runs per child, unchanged.
|
|
110
|
+
def self.build_endpoint(name, dsl)
|
|
111
|
+
Inbound::Endpoint.new(
|
|
112
|
+
name:,
|
|
113
|
+
verifier: dsl.__verifier__,
|
|
114
|
+
dispatch: dsl.__dispatch__,
|
|
115
|
+
respond: dsl.__respond__,
|
|
116
|
+
static_respond: dsl.__static_respond__,
|
|
117
|
+
challenge: dsl.__challenge__,
|
|
118
|
+
unauthorized_headers: dsl.__unauthorized_headers__,
|
|
119
|
+
challenge_required: dsl.__challenge_required__,
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
private_class_method :build_endpoint
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Webhooks
|
|
5
|
+
module Outbound
|
|
6
|
+
# Whether `callable.call(*args)` actually works for exactly `count` positional arguments — via
|
|
7
|
+
# `#parameters`, which correctly distinguishes required/optional/rest/keyword params uniformly
|
|
8
|
+
# across a lambda, a non-strict Proc, and a plain object's `#call` Method. `#arity` alone can't
|
|
9
|
+
# tell "one required positional" apart from "one required KEYWORD" (same arity, one raises) or
|
|
10
|
+
# "needs 2+ positional, has a splat" (negative arity, but still too few args at `count == 1`) —
|
|
11
|
+
# both boot-time-valid under a bare arity check, both raising `ArgumentError` on the very first
|
|
12
|
+
# real invocation (Codex P2 findings, on `backoff`, `user_agent`, and the signing `secret`).
|
|
13
|
+
module CallableArity
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def accepts?(callable, count)
|
|
17
|
+
params = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
|
|
18
|
+
return false if params.any? { |(type, _)| type == :keyreq }
|
|
19
|
+
|
|
20
|
+
required = params.count { |(type, _)| type == :req }
|
|
21
|
+
return false if required > count
|
|
22
|
+
|
|
23
|
+
optional = params.count { |(type, _)| type == :opt }
|
|
24
|
+
rest = params.any? { |(type, _)| type == :rest }
|
|
25
|
+
required + optional + (rest ? 1 : 0) >= count
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Which keyword names `callable.call(**kwargs)` actually accepts: `:all` for a callable that
|
|
29
|
+
# double-splats (accepts anything), else the Array of Symbol names it declares (required and
|
|
30
|
+
# optional alike). Used to filter a fixed kwarg set down to what a caller-supplied signing
|
|
31
|
+
# block declares (`CustomSigner`), so a block written against today's `(id:, timestamp:,
|
|
32
|
+
# body:)` contract keeps working byte-for-byte when a widened caller starts also offering
|
|
33
|
+
# `url:`/`subscriber:` — those become a plain ArgumentError from a *filtered* call, not a
|
|
34
|
+
# silent widening the block didn't ask for.
|
|
35
|
+
def accepted_keywords(callable)
|
|
36
|
+
params = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
|
|
37
|
+
return :all if params.any? { |(type, _)| type == :keyrest }
|
|
38
|
+
|
|
39
|
+
params.select { |(type, _)| %i[key keyreq].include?(type) }.map { |(_, name)| name }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Which keyword names `callable.call(**kwargs)` REQUIRES — a strict subset of
|
|
43
|
+
# `accepted_keywords` (excludes optional `:key` params). Used to catch a callable that
|
|
44
|
+
# needs a keyword outside a fixed supplied set (e.g. `sign { |id:, vendor:| … }`, where
|
|
45
|
+
# `vendor:` is never one of the kwargs this gem passes a signer) — the one shape that
|
|
46
|
+
# genuinely fails on every call, as opposed to a callable that simply ignores some/all of
|
|
47
|
+
# what it's offered (which Ruby's own Proc/block semantics already tolerate fine).
|
|
48
|
+
def required_keywords(callable)
|
|
49
|
+
params = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
|
|
50
|
+
params.select { |(type, _)| type == :keyreq }.map { |(_, name)| name }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The ORIGINAL `subscribers`/`to:` resolver dispatch rule, preserved byte-for-byte (Codex P2
|
|
54
|
+
# finding): "pass the event unless the callable's raw arity is EXACTLY zero." Deliberately
|
|
55
|
+
# raw #arity, not #parameters-based: a Proc (non-lambda) with a single OPTIONAL/default
|
|
56
|
+
# param reports arity `0` (a Ruby quirk -- lambda-with-default reports NEGATIVE instead),
|
|
57
|
+
# and that quirk is exactly what a pre-existing `proc { |event = :all| … }` resolver already
|
|
58
|
+
# relied on to keep using its own default. Only made callable-object-safe here (falls back
|
|
59
|
+
# to `Method#arity` via `#call`) -- the dispatch RULE itself is unchanged.
|
|
60
|
+
def zero_arity?(callable)
|
|
61
|
+
raw_arity(callable).zero?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# For a newly-introduced 0-OR-1-arity callable (PRO-3214's per-subscriber `secret`/
|
|
65
|
+
# `headers`): prefer a zero-arg call whenever genuinely possible. Raw arity, not
|
|
66
|
+
# `#parameters`-based `accepts?`: a plain `proc { |subscriber| … }` (NO default) reports its
|
|
67
|
+
# param as `:opt` via `#parameters` -- indistinguishable from a genuine default by that
|
|
68
|
+
# API -- but its raw arity is still the correct POSITIVE `1`, so this is the one signal that
|
|
69
|
+
# tells "has a real default/rest" apart from "merely tolerates a missing arg, Proc-style,
|
|
70
|
+
# but was never given one to default from" (Codex P1 finding: passing `nil` in place of the
|
|
71
|
+
# subscriber for exactly this shape).
|
|
72
|
+
#
|
|
73
|
+
# ONLY arity `0` (a truly empty/all-defaulted signature) or `-1` (Ruby's `-(required + 1)`
|
|
74
|
+
# encoding with `required == 0` -- zero REQUIRED params, any number of optional/rest ones)
|
|
75
|
+
# genuinely means "callable with zero args". A more negative arity still has a required
|
|
76
|
+
# LEADING param: `->(subscriber, cache = nil)` is arity `-2` (required == 1) and raises if
|
|
77
|
+
# actually called with zero args -- `arity <= 0` wrongly matched it too (Codex P1 finding,
|
|
78
|
+
# round 5: passed nothing, so a resolver shaped exactly like this raised on every attempt).
|
|
79
|
+
def prefers_zero_args?(callable)
|
|
80
|
+
[0, -1].include?(raw_arity(callable))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Shared by `zero_arity?`/`prefers_zero_args?`: raw `#arity`, falling back to
|
|
84
|
+
# `Method#arity` via `#call` for a plain callable object with none of its own.
|
|
85
|
+
def raw_arity(callable)
|
|
86
|
+
callable.respond_to?(:arity) ? callable.arity : callable.method(:call).arity
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Whether `callable` declares at least one POSITIONAL parameter (required, optional, or
|
|
90
|
+
# rest) -- used by `Signer::CustomSigner` to detect the historical "single options-hash
|
|
91
|
+
# positional" custom-signer shape (`sign { |options| … }`, no keyword params at all).
|
|
92
|
+
def accepts_positional?(callable)
|
|
93
|
+
params = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
|
|
94
|
+
params.any? { |(type, _)| %i[req opt rest].include?(type) }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|