Class: Axn::Webhooks::Request

Inherits:
Object
  • Object
show all
Defined in:
lib/axn/webhooks/request.rb

Overview

A Rails-agnostic view of an inbound webhook request. Verifiers and dispatchers read only from this object, so the same pipeline works behind a Rack mount, a controller, or a plain test constructor. Header lookup is case-insensitive.

Defined Under Namespace

Classes: ParamsResult

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(raw_body:, headers: {}, params: {}, url: nil, http_method: "POST", params_error: nil) ⇒ Request

Returns a new instance of Request.



13
14
15
16
17
18
19
20
21
22
23
# File 'lib/axn/webhooks/request.rb', line 13

def initialize(raw_body:, headers: {}, params: {}, url: nil, http_method: "POST", params_error: nil)
  @raw_body = raw_body.frozen? ? raw_body : raw_body.dup.freeze
  @headers = (headers || {}).each_with_object({}) { |(k, v), h| h[k.to_s.downcase] = v }
  @params = (params || {}).dup.freeze
  # The failure `extract_params` swallowed, if any — see #params. Kept so the POST-verification
  # parse step can still see it, without it ever reaching the pre-verification path.
  @params_error = params_error
  @params_reads = 0
  @url = url
  @http_method = http_method.to_s.upcase
end

Instance Attribute Details

#http_methodObject (readonly)

Returns the value of attribute http_method.



25
26
27
# File 'lib/axn/webhooks/request.rb', line 25

def http_method
  @http_method
end

#params_errorObject (readonly)

The exception raised while parsing the query/form params, or nil. Public so Dispatch can surface it AFTER verification (see #params).



29
30
31
# File 'lib/axn/webhooks/request.rb', line 29

def params_error
  @params_error
end

#params_readsObject (readonly)

How many times #params has been read. A COUNT, not a flag, so a caller can scope the question to a window rather than the request's whole lifetime — Dispatch compares it either side of the parse call. A lifetime flag was wrong: a custom verifier or a challenge_required predicate legitimately reads params BEFORE the parse step, and counting that read re-opened the downgrade the gate exists to prevent (Codex review).



36
37
38
# File 'lib/axn/webhooks/request.rb', line 36

def params_reads
  @params_reads
end

#raw_bodyObject (readonly)

Returns the value of attribute raw_body.



25
26
27
# File 'lib/axn/webhooks/request.rb', line 25

def raw_body
  @raw_body
end

#urlObject (readonly)

Returns the value of attribute url.



25
26
27
# File 'lib/axn/webhooks/request.rb', line 25

def url
  @url
end

Class Method Details

.from_rack(env) ⇒ Object

Build a Request from a Rack env, capturing the exact pristine body bytes — this (not a controller's already-parsed params) is why the spec chose a Rack mount over a controller concern (see "## Packaging" in the design spec).

rack.input is OPTIONAL under Rack 3 (it was mandatory in Rack 2), so a bodyless request may omit the key entirely — Rack::MockRequest.env_for does exactly that, which is what a Rails integration/request spec builds. Treat a missing input as an empty body rather than a malformed env: the GET challenge handshake (Nylas, Meta) is bodyless by definition, so fetching here would 500 the very handshake challenge exists to serve.

We rewind BEFORE reading, not only after. Under Rack 3, Rack::Request#POST no longer rewinds rack.input after parsing a form-urlencoded body — and Rails' default middleware stack runs Rack::MethodOverride (which calls #POST looking for _method) ahead of the router. So by the time a mounted endpoint runs, the input of every form-encoded POST is already at EOF and reads as "". That silently empties raw_body AND params for exactly the vendors that post forms (Twilio, Slack), breaking dispatch and signature verification alike.



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/axn/webhooks/request.rb', line 81

def self.from_rack(env)
  input = env["rack.input"]
  rewind(input)
  raw_body = input&.read || ""
  rewind(input) # courtesy for anything downstream of us

  content_type = env["CONTENT_TYPE"]
  params_result = extract_params(env, raw_body, content_type)
  new(
    raw_body:,
    headers: extract_headers(env),
    params: params_result.value,
    params_error: params_result.error,
    url: extract_url(env),
    http_method: env["REQUEST_METHOD"],
  )
end

Instance Method Details

#header(name) ⇒ Object



47
48
49
# File 'lib/axn/webhooks/request.rb', line 47

def header(name)
  @headers[name.to_s.downcase]
end

#inspectObject

raw_body and headers are attacker-controlled webhook payloads of unknown sensitivity (bank account numbers, API credentials, mailing addresses have all shown up in the wild) — never render them. This is the one place that matters: axn's auto-logging, exception reports, and any other caller that inspects a Request all go through #inspect.



55
56
57
# File 'lib/axn/webhooks/request.rb', line 55

def inspect
  "#<#{self.class.name} #{http_method} #{url} raw_body=[REDACTED] (#{raw_body.bytesize}b) headers=[REDACTED]>"
end

#paramsObject

Always a Hash, never raises — this is reachable BEFORE verification (a custom verifier or a challenge_required predicate may read it), where a raise would let an unauthenticated sender turn a 401 into a reported 500. The swallowed failure is not lost: it is kept on #params_error and re-raised by the parse step, which runs only after verification.



42
43
44
45
# File 'lib/axn/webhooks/request.rb', line 42

def params
  @params_reads += 1
  @params
end

#pretty_print(printer) ⇒ Object

pp/PP does not call #inspect by default (Kernel#pretty_print walks instance variables directly), so without this override pp request would leak the same fields #inspect redacts.



61
62
63
# File 'lib/axn/webhooks/request.rb', line 61

def pretty_print(printer)
  printer.text(inspect)
end