Module: Jamm::Webhook
- Defined in:
- lib/jamm/webhook.rb
Class Method Summary collapse
-
.array_inner_type(type) ⇒ Object
Extract ‘T` from an `Array<T>` openapi type, or nil when not an array type.
-
.build(klass, attributes) ⇒ Object
Build a generated model from a webhook payload while normalizing the quirks of the webhook wire format.
-
.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`.
-
.coerce_array(inner_type, value) ⇒ Object
Coerce each element of an ‘Array<T>` field by its inner type `T`.
-
.deep_symbolize_keys(value) ⇒ Object
Recursively convert Hash keys to symbols so parsing is robust regardless of how the caller decoded the webhook JSON.
-
.flatten_charge_content(content) ⇒ Object
Refund webhooks (REFUND_SUCCEEDED / REFUND_FAILED) deliver ‘content` as a nested { transaction, refund } wrapper instead of a flat ChargeMessage.
-
.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.
-
.parse(json) ⇒ Object
Parse command is for parsing the received webhook message.
-
.resolve_enum(enum, value) ⇒ Object
Map a numeric enum wire value onto its string enum constant.
-
.secure_compare(a, b) ⇒ Object
Securely compare two strings of equal length.
-
.verify(data:, signature:) ⇒ Object
Verify message.
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.
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 |