Module: Jamm::Webhook

Defined in:
lib/jamm/webhook.rb

Class Method Summary collapse

Class Method Details

.array_inner_type(type) ⇒ Object

Extract T from an Array<T> openapi type, or nil when not an array type.



126
127
128
129
# File 'lib/jamm/webhook.rb', line 126

def self.array_inner_type(type)
  match = type.to_s.match(/\AArray<(.+)>\z/)
  match && match[1]
end

.build(klass, attributes) ⇒ Object

Build a generated model from a webhook payload while normalizing the quirks of the webhook wire format. Applied to every model, so charges, contracts, user-account and refund messages all benefit:

1. Forward-compat: the Jamm backend can add new fields to webhook
 payloads at any time. The generated model `initialize` raises
 ArgumentError on any key outside `attribute_map`, so unknown keys are
 dropped first. Known keys are snake_case, matching `attribute_map`.
2. Numeric enums: the backend serializes webhook payloads with Go's
 `json.Marshal` (not protojson), so every enum field (status,
 api_source, ...) arrives as its integer value, while the generated
 enums are string-based. Each integer is mapped back to the enum string
 so it matches the values returned by the REST API.
3. Nested models: the generated `initialize` assigns nested objects
 verbatim (the `_deserialize` coercion only runs from `build_from_hash`,
 which expects camelCase keys the webhook does not use). So a nested
 field like `refund.error` would stay a raw Hash and `error.code` would
 raise NoMethodError. We coerce nested model fields (and arrays of them)
 recursively so the typed accessors work.


66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/jamm/webhook.rb', line 66

def self.build(klass, attributes)
  return nil if attributes.nil?

  known = klass.attribute_map
  types = klass.openapi_types
  filtered = attributes.each_with_object({}) do |(key, value), acc|
    sym = key.to_sym
    next unless known.key?(sym)

    acc[sym] = coerce(types[sym], value)
  end

  klass.new(filtered)
end

.coerce(type, value) ⇒ Object

Coerce a raw webhook value into the shape the generated model expects, based on the field's openapi type: numeric enums become their string constant, nested models become typed instances, and Array<T> elements are coerced by T. Anything else passes through untouched.



100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/jamm/webhook.rb', line 100

def self.coerce(type, value)
  return value if value.nil?

  inner = array_inner_type(type)
  return coerce_array(inner, value) unless inner.nil?

  klass = openapi_const(type)
  return value if klass.nil?

  if klass.respond_to?(:all_vars)
    resolve_enum(klass, value)
  elsif klass.respond_to?(:openapi_types) && value.is_a?(Hash)
    build(klass, value)
  else
    value
  end
end

.coerce_array(inner_type, value) ⇒ Object

Coerce each element of an Array<T> field by its inner type T.



119
120
121
122
123
# File 'lib/jamm/webhook.rb', line 119

def self.coerce_array(inner_type, value)
  return value unless value.is_a?(Array)

  value.map { |element| coerce(inner_type, element) }
end

.deep_symbolize_keys(value) ⇒ Object

Recursively convert Hash keys to symbols so parsing is robust regardless of how the caller decoded the webhook JSON.



159
160
161
162
163
164
165
166
167
168
# File 'lib/jamm/webhook.rb', line 159

def self.deep_symbolize_keys(value)
  case value
  when Hash
    value.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = deep_symbolize_keys(v) }
  when Array
    value.map { |v| deep_symbolize_keys(v) }
  else
    value
  end
end

.extract_raw_content(raw_body) ⇒ Object

Extracts the top-level content value substring from a raw webhook body verbatim, without decoding or re-serializing it, so the exact signed bytes are recovered.

Raises:

  • (ArgumentError)


211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
# File 'lib/jamm/webhook.rb', line 211

def self.extract_raw_content(raw_body)
  i = 0
  n = raw_body.length
  i += 1 while i < n && raw_body[i].match?(/\s/)
  raise ArgumentError, 'Webhook body must be a JSON object' unless raw_body[i] == '{'

  i += 1
  # Scan every top-level key. Duplicate keys are rejected: JSON.parse keeps the LAST
  # occurrence while this returns the FIRST, so a duplicate `content` could otherwise
  # verify one payload and parse another (signature bypass).
  seen = {}
  content = nil
  loop do
    i += 1 while i < n && (raw_body[i].match?(/\s/) || raw_body[i] == ',')
    break if i >= n || raw_body[i] == '}'
    raise ArgumentError, 'Malformed webhook JSON' unless raw_body[i] == '"'

    key_start = i
    i = skip_string(raw_body, i)
    # Decode the key (not a raw slice): otherwise an escaped duplicate such as
    # "content" would evade both the duplicate check and the 'content' match below,
    # while JSON.parse collapses it to `content` and keeps the last value. Normalize a
    # malformed key to ArgumentError to match the rest of this method.
    key = begin
      JSON.parse(raw_body[key_start...i])
    rescue JSON::ParserError
      raise ArgumentError, 'Malformed webhook JSON'
    end
    raise ArgumentError, "Duplicate top-level key in webhook body: #{key}" if seen.key?(key)

    seen[key] = true
    i += 1 while i < n && raw_body[i].match?(/\s/)
    raise ArgumentError, 'Malformed webhook JSON' unless raw_body[i] == ':'

    i += 1
    i += 1 while i < n && raw_body[i].match?(/\s/)
    value_start = i
    i = skip_value(raw_body, i)
    content = raw_body[value_start...i] if key == 'content'
  end
  raise ArgumentError, "Webhook body does not contain 'content' field" if content.nil?

  content
end

.flatten_charge_content(content) ⇒ Object

Refund webhooks (REFUND_SUCCEEDED / REFUND_FAILED) deliver content as a nested { transaction, refund } wrapper instead of a flat ChargeMessage. Flatten it back into a ChargeMessage so callers always receive the same shape.



84
85
86
87
88
89
90
91
92
93
94
# File 'lib/jamm/webhook.rb', line 84

def self.flatten_charge_content(content)
  return content unless content.is_a?(Hash) && content.key?(:transaction)

  refund = content[:refund]
  # Keep `refund` as the raw Hash: `build` coerces it into a typed RefundInfo
  # (and recursively types its nested `error`). Also surface the refund's
  # `rfd-` id on the flat `refund_id` attribute the model documents.
  charge = content[:transaction].merge(refund: refund)
  charge[:refund_id] = refund[:id] if refund.is_a?(Hash) && !refund[:id].nil?
  charge
end

.openapi_const(type) ⇒ Object

Resolve an openapi_types entry (e.g. :ChargeMessageApiSource, :RefundInfo) to its generated class, or nil when the type is a primitive (String, Integer, ...) or otherwise unresolvable.



146
147
148
149
150
151
152
153
154
155
# File 'lib/jamm/webhook.rb', line 146

def self.openapi_const(type)
  return nil if type.nil?

  name = type.to_s
  return nil unless Jamm::OpenAPI.const_defined?(name)

  Jamm::OpenAPI.const_get(name)
rescue NameError
  nil
end

.parse(json) ⇒ Object

Parse command is for parsing the received webhook message. It does not call anything remotely, instead returns the suitable object.



13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/jamm/webhook.rb', line 13

def self.parse(json)
  # Webhook payloads may arrive with string or symbol keys depending on how
  # the caller decoded the JSON (e.g. JSON.parse with or without
  # symbolize_names: true). Normalize to symbols so event-type routing,
  # wrapper flattening, and field lookups are reliable either way.
  json = deep_symbolize_keys(json)

  out = build(Jamm::OpenAPI::MerchantWebhookMessage, json)

  # Route on the coerced event type: the wire value is an enum string today,
  # but `build` also resolves a numeric wire value onto its string constant,
  # so routing survives either serialization.
  case out.event_type
  when Jamm::OpenAPI::EventType::CHARGE_CREATED,
       Jamm::OpenAPI::EventType::CHARGE_UPDATED,
       Jamm::OpenAPI::EventType::REFUND_SUCCEEDED,
       Jamm::OpenAPI::EventType::REFUND_FAILED,
       Jamm::OpenAPI::EventType::CHARGE_SUCCESS,
       Jamm::OpenAPI::EventType::CHARGE_FAIL
    out.content = build(Jamm::OpenAPI::ChargeMessage, flatten_charge_content(json[:content]))
    return out

  when Jamm::OpenAPI::EventType::CONTRACT_ACTIVATED
    out.content = build(Jamm::OpenAPI::ContractMessage, json[:content])
    return out

  when Jamm::OpenAPI::EventType::
    out.content = build(Jamm::OpenAPI::UserAccountMessage, json[:content])
    return out
  end

  raise 'Unknown event type'
end

.resolve_enum(enum, value) ⇒ Object

Map a numeric enum wire value onto its string enum constant. A value that is already a string (REST-style) passes through untouched.



133
134
135
136
137
138
139
140
141
# File 'lib/jamm/webhook.rb', line 133

def self.resolve_enum(enum, value)
  return value unless value.is_a?(Integer)

  vars = enum.all_vars
  # Guard the bounds explicitly: Ruby maps negative indices from the end of
  # the array, so any unexpected wire value must fall back to the *_UNSPECIFIED
  # member (index 0) rather than silently selecting the wrong constant.
  value.between?(0, vars.length - 1) ? vars[value] : vars[0]
end

.secure_compare(a, b) ⇒ Object

Securely compare two strings of equal length. This method is a port of ActiveSupport::SecurityUtils.secure_compare which works on non-Rails platforms.



302
303
304
305
306
307
308
309
310
311
312
313
# File 'lib/jamm/webhook.rb', line 302

def self.secure_compare(a, b)
  return false unless a.bytesize == b.bytesize

  # Unpack strings into arrays of bytes
  a_bytes = a.unpack('C*')
  b_bytes = b.unpack('C*')
  result = 0

  # XOR each byte and accumulate the result
  a_bytes.zip(b_bytes) { |x, y| result |= x ^ y }
  result.zero?
end

.skip_string(str, i) ⇒ Object

i points at an opening '"'. Returns the index just past the closing '"'.

Raises:

  • (ArgumentError)


257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
# File 'lib/jamm/webhook.rb', line 257

def self.skip_string(str, i)
  i += 1
  n = str.length
  while i < n
    c = str[i]
    if c == '\\'
      i += 2
      next
    end
    return i + 1 if c == '"'

    i += 1
  end
  raise ArgumentError, 'Unterminated string in webhook JSON'
end

.skip_value(str, i) ⇒ Object

i points at the first char of a JSON value. Returns the index just past it.



274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/jamm/webhook.rb', line 274

def self.skip_value(str, i)
  return skip_string(str, i) if str[i] == '"'

  if str[i] == '{' || str[i] == '['
    depth = 0
    n = str.length
    while i < n
      ch = str[i]
      if ch == '"'
        i = skip_string(str, i)
        next
      elsif ch == '{' || ch == '['
        depth += 1
      elsif ch == '}' || ch == ']'
        depth -= 1
        return i + 1 if depth.zero?
      end
      i += 1
    end
    raise ArgumentError, 'Unterminated object/array in webhook JSON'
  end
  i += 1 while i < str.length && !",}] \t\n\r".include?(str[i])
  i
end

.verify(data:, signature:) ⇒ Object

Verify message. This method will use client secret to verify the message.

Raises:

  • (ArgumentError)


172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/jamm/webhook.rb', line 172

def self.verify(data:, signature:)
  raise ArgumentError, 'data cannot be nil' if data.nil?
  raise ArgumentError, 'signature cannot be nil' if signature.nil?

  # Convert the JSON to a string
  json = JSON.dump(data)

  digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), Jamm.client_secret, json)
  given = "sha256=#{digest}"

  return if secure_compare(given, signature)

  raise Jamm::InvalidSignatureError, 'Digests do not match'
end

.verify_and_parse(raw_body) ⇒ Object

Verify the HMAC signature over the exact received bytes and parse, in one step.

This is the recommended entry point. The backend signs the raw content bytes it transmits, produced by Go's JSON encoder which HTML-escapes & < > as & <

. Re-serializing the parsed content (as verify does via JSON.dump) un-escapes those characters, so its digest no longer matches. This slices the raw content substring out of raw_body verbatim and HMACs that.

Raises:

  • (ArgumentError)


194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/jamm/webhook.rb', line 194

def self.verify_and_parse(raw_body)
  raise ArgumentError, 'raw_body cannot be nil or empty' if raw_body.nil? || raw_body.empty?

  parsed = JSON.parse(raw_body, symbolize_names: true)
  signature = parsed[:signature]
  raise ArgumentError, "Webhook body is missing the 'signature' field" if signature.nil? || signature.empty?

  raw_content = extract_raw_content(raw_body)
  digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), Jamm.client_secret, raw_content)
  given = "sha256=#{digest}"
  raise Jamm::InvalidSignatureError, 'Digests do not match' unless secure_compare(given, signature)

  parse(parsed)
end