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.



123
124
125
126
# File 'lib/jamm/webhook.rb', line 123

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.


63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/jamm/webhook.rb', line 63

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.



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

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`.



116
117
118
119
120
# File 'lib/jamm/webhook.rb', line 116

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.



156
157
158
159
160
161
162
163
164
165
# File 'lib/jamm/webhook.rb', line 156

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

.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.



81
82
83
84
85
86
87
88
89
90
91
# File 'lib/jamm/webhook.rb', line 81

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.



143
144
145
146
147
148
149
150
151
152
# File 'lib/jamm/webhook.rb', line 143

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
# 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)

  case json[: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::USER_ACCOUNT_DELETED
    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.



130
131
132
133
134
135
136
137
138
# File 'lib/jamm/webhook.rb', line 130

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.



187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/jamm/webhook.rb', line 187

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

.verify(data:, signature:) ⇒ Object

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

Raises:

  • (ArgumentError)


169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/jamm/webhook.rb', line 169

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