zai_payment 1.0.2 → 1.2.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 +4 -4
- data/CHANGELOG.md +81 -1
- data/IMPLEMENTATION.md +201 -0
- data/README.md +81 -13
- data/docs/ARCHITECTURE.md +232 -0
- data/docs/AUTHENTICATION.md +647 -0
- data/docs/README.md +81 -0
- data/docs/WEBHOOKS.md +417 -0
- data/docs/WEBHOOK_SECURITY_QUICKSTART.md +141 -0
- data/docs/WEBHOOK_SIGNATURE.md +244 -0
- data/examples/webhooks.md +635 -0
- data/lib/zai_payment/client.rb +116 -0
- data/lib/zai_payment/config.rb +2 -0
- data/lib/zai_payment/errors.rb +19 -0
- data/lib/zai_payment/resources/webhook.rb +331 -0
- data/lib/zai_payment/response.rb +77 -0
- data/lib/zai_payment/version.rb +1 -1
- data/lib/zai_payment.rb +10 -0
- metadata +40 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
|
|
5
|
+
module ZaiPayment
|
|
6
|
+
# Base API client that handles HTTP requests to Zai API
|
|
7
|
+
class Client
|
|
8
|
+
attr_reader :config, :token_provider
|
|
9
|
+
|
|
10
|
+
def initialize(config: nil, token_provider: nil)
|
|
11
|
+
@config = config || ZaiPayment.config
|
|
12
|
+
@token_provider = token_provider || ZaiPayment.auth
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Perform a GET request
|
|
16
|
+
#
|
|
17
|
+
# @param path [String] the API endpoint path
|
|
18
|
+
# @param params [Hash] query parameters
|
|
19
|
+
# @return [Response] the API response
|
|
20
|
+
def get(path, params: {})
|
|
21
|
+
request(:get, path, params: params)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Perform a POST request
|
|
25
|
+
#
|
|
26
|
+
# @param path [String] the API endpoint path
|
|
27
|
+
# @param body [Hash] request body
|
|
28
|
+
# @return [Response] the API response
|
|
29
|
+
def post(path, body: {})
|
|
30
|
+
request(:post, path, body: body)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Perform a PATCH request
|
|
34
|
+
#
|
|
35
|
+
# @param path [String] the API endpoint path
|
|
36
|
+
# @param body [Hash] request body
|
|
37
|
+
# @return [Response] the API response
|
|
38
|
+
def patch(path, body: {})
|
|
39
|
+
request(:patch, path, body: body)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Perform a DELETE request
|
|
43
|
+
#
|
|
44
|
+
# @param path [String] the API endpoint path
|
|
45
|
+
# @return [Response] the API response
|
|
46
|
+
def delete(path)
|
|
47
|
+
request(:delete, path)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def request(method, path, params: {}, body: {})
|
|
53
|
+
response = connection.public_send(method) do |req|
|
|
54
|
+
req.url path
|
|
55
|
+
req.params = params if params.any?
|
|
56
|
+
req.body = body if body.any?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
Response.new(response)
|
|
60
|
+
rescue Faraday::Error => e
|
|
61
|
+
handle_faraday_error(e)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def connection
|
|
65
|
+
@connection ||= build_connection
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def build_connection
|
|
69
|
+
Faraday.new do |faraday|
|
|
70
|
+
configure_connection(faraday)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def configure_connection(faraday)
|
|
75
|
+
faraday.url_prefix = base_url
|
|
76
|
+
apply_headers(faraday)
|
|
77
|
+
apply_middleware(faraday)
|
|
78
|
+
apply_timeouts(faraday)
|
|
79
|
+
faraday.adapter Faraday.default_adapter
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def apply_headers(faraday)
|
|
83
|
+
faraday.headers['Authorization'] = token_provider.bearer_token
|
|
84
|
+
faraday.headers['Content-Type'] = 'application/json'
|
|
85
|
+
faraday.headers['Accept'] = 'application/json'
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def apply_middleware(faraday)
|
|
89
|
+
faraday.request :json
|
|
90
|
+
faraday.response :json, content_type: /\bjson$/
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def apply_timeouts(faraday)
|
|
94
|
+
faraday.options.timeout = config.timeout if config.timeout
|
|
95
|
+
faraday.options.open_timeout = config.open_timeout if config.open_timeout
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def base_url
|
|
99
|
+
# Webhooks API uses va_base endpoint
|
|
100
|
+
config.endpoints[:va_base]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def handle_faraday_error(error)
|
|
104
|
+
case error
|
|
105
|
+
when Faraday::TimeoutError
|
|
106
|
+
raise Errors::TimeoutError, "Request timed out: #{error.message}"
|
|
107
|
+
when Faraday::ConnectionFailed
|
|
108
|
+
raise Errors::ConnectionError, "Connection failed: #{error.message}"
|
|
109
|
+
when Faraday::ClientError
|
|
110
|
+
raise Errors::ApiError, "Client error: #{error.message}"
|
|
111
|
+
else
|
|
112
|
+
raise Errors::ApiError, "Request failed: #{error.message}"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
data/lib/zai_payment/config.rb
CHANGED
data/lib/zai_payment/errors.rb
CHANGED
|
@@ -2,8 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
module ZaiPayment
|
|
4
4
|
module Errors
|
|
5
|
+
# Base error class
|
|
5
6
|
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Authentication errors
|
|
6
9
|
class AuthError < Error; end
|
|
10
|
+
|
|
11
|
+
# Configuration errors
|
|
7
12
|
class ConfigurationError < Error; end
|
|
13
|
+
|
|
14
|
+
# API errors
|
|
15
|
+
class ApiError < Error; end
|
|
16
|
+
class BadRequestError < ApiError; end
|
|
17
|
+
class UnauthorizedError < ApiError; end
|
|
18
|
+
class ForbiddenError < ApiError; end
|
|
19
|
+
class NotFoundError < ApiError; end
|
|
20
|
+
class ValidationError < ApiError; end
|
|
21
|
+
class RateLimitError < ApiError; end
|
|
22
|
+
class ServerError < ApiError; end
|
|
23
|
+
|
|
24
|
+
# Network errors
|
|
25
|
+
class TimeoutError < Error; end
|
|
26
|
+
class ConnectionError < Error; end
|
|
8
27
|
end
|
|
9
28
|
end
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'base64'
|
|
5
|
+
|
|
6
|
+
module ZaiPayment
|
|
7
|
+
module Resources
|
|
8
|
+
# Webhook resource for managing Zai webhooks
|
|
9
|
+
#
|
|
10
|
+
# @see https://developer.hellozai.com/reference/getallwebhooks
|
|
11
|
+
class Webhook
|
|
12
|
+
attr_reader :client
|
|
13
|
+
|
|
14
|
+
def initialize(client: nil)
|
|
15
|
+
@client = client || Client.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# List all webhooks
|
|
19
|
+
#
|
|
20
|
+
# @param limit [Integer] number of records to return (default: 10)
|
|
21
|
+
# @param offset [Integer] number of records to skip (default: 0)
|
|
22
|
+
# @return [Response] the API response containing webhooks array
|
|
23
|
+
#
|
|
24
|
+
# @example
|
|
25
|
+
# webhooks = ZaiPayment::Resources::Webhook.new
|
|
26
|
+
# response = webhooks.list
|
|
27
|
+
# response.data # => [{"id" => "...", "url" => "..."}, ...]
|
|
28
|
+
#
|
|
29
|
+
# @see https://developer.hellozai.com/reference/getallwebhooks
|
|
30
|
+
def list(limit: 10, offset: 0)
|
|
31
|
+
params = {
|
|
32
|
+
limit: limit,
|
|
33
|
+
offset: offset
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
client.get('/webhooks', params: params)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Get a specific webhook by ID
|
|
40
|
+
#
|
|
41
|
+
# @param webhook_id [String] the webhook ID
|
|
42
|
+
# @return [Response] the API response containing webhook details
|
|
43
|
+
#
|
|
44
|
+
# @example
|
|
45
|
+
# webhooks = ZaiPayment::Resources::Webhook.new
|
|
46
|
+
# response = webhooks.show("webhook_id")
|
|
47
|
+
# response.data # => {"id" => "webhook_id", "url" => "...", ...}
|
|
48
|
+
#
|
|
49
|
+
# @see https://developer.hellozai.com/reference/getwebhookbyid
|
|
50
|
+
def show(webhook_id)
|
|
51
|
+
validate_id!(webhook_id, 'webhook_id')
|
|
52
|
+
client.get("/webhooks/#{webhook_id}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Create a new webhook
|
|
56
|
+
#
|
|
57
|
+
# @param url [String] the webhook URL to receive notifications
|
|
58
|
+
# @param object_type [String] the type of object to watch (e.g., 'transactions', 'items')
|
|
59
|
+
# @param enabled [Boolean] whether the webhook is enabled (default: true)
|
|
60
|
+
# @param description [String] optional description of the webhook
|
|
61
|
+
# @return [Response] the API response containing created webhook
|
|
62
|
+
#
|
|
63
|
+
# @example
|
|
64
|
+
# webhooks = ZaiPayment::Resources::Webhook.new
|
|
65
|
+
# response = webhooks.create(
|
|
66
|
+
# url: "https://example.com/webhooks",
|
|
67
|
+
# object_type: "transactions",
|
|
68
|
+
# enabled: true
|
|
69
|
+
# )
|
|
70
|
+
#
|
|
71
|
+
# @see https://developer.hellozai.com/reference/createwebhook
|
|
72
|
+
def create(url: nil, object_type: nil, enabled: true, description: nil)
|
|
73
|
+
validate_presence!(url, 'url')
|
|
74
|
+
validate_presence!(object_type, 'object_type')
|
|
75
|
+
validate_url!(url)
|
|
76
|
+
|
|
77
|
+
body = {
|
|
78
|
+
url: url,
|
|
79
|
+
object_type: object_type,
|
|
80
|
+
enabled: enabled
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
body[:description] = description if description
|
|
84
|
+
|
|
85
|
+
client.post('/webhooks', body: body)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Update an existing webhook
|
|
89
|
+
#
|
|
90
|
+
# @param webhook_id [String] the webhook ID
|
|
91
|
+
# @param url [String] optional new webhook URL
|
|
92
|
+
# @param object_type [String] optional new object type
|
|
93
|
+
# @param enabled [Boolean] optional enabled status
|
|
94
|
+
# @param description [String] optional description
|
|
95
|
+
# @return [Response] the API response containing updated webhook
|
|
96
|
+
#
|
|
97
|
+
# @example
|
|
98
|
+
# webhooks = ZaiPayment::Resources::Webhook.new
|
|
99
|
+
# response = webhooks.update(
|
|
100
|
+
# "webhook_id",
|
|
101
|
+
# enabled: false
|
|
102
|
+
# )
|
|
103
|
+
#
|
|
104
|
+
# @see https://developer.hellozai.com/reference/updatewebhook
|
|
105
|
+
def update(webhook_id, url: nil, object_type: nil, enabled: nil, description: nil)
|
|
106
|
+
validate_id!(webhook_id, 'webhook_id')
|
|
107
|
+
|
|
108
|
+
body = {}
|
|
109
|
+
body[:url] = url if url
|
|
110
|
+
body[:object_type] = object_type if object_type
|
|
111
|
+
body[:enabled] = enabled unless enabled.nil?
|
|
112
|
+
body[:description] = description if description
|
|
113
|
+
|
|
114
|
+
validate_url!(url) if url
|
|
115
|
+
|
|
116
|
+
raise Errors::ValidationError, 'At least one attribute must be provided for update' if body.empty?
|
|
117
|
+
|
|
118
|
+
client.patch("/webhooks/#{webhook_id}", body: body)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Delete a webhook
|
|
122
|
+
#
|
|
123
|
+
# @param webhook_id [String] the webhook ID
|
|
124
|
+
# @return [Response] the API response
|
|
125
|
+
#
|
|
126
|
+
# @example
|
|
127
|
+
# webhooks = ZaiPayment::Resources::Webhook.new
|
|
128
|
+
# response = webhooks.delete("webhook_id")
|
|
129
|
+
#
|
|
130
|
+
# @see https://developer.hellozai.com/reference/deletewebhook
|
|
131
|
+
def delete(webhook_id)
|
|
132
|
+
validate_id!(webhook_id, 'webhook_id')
|
|
133
|
+
client.delete("/webhooks/#{webhook_id}")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Create a secret key for webhook signature verification
|
|
137
|
+
#
|
|
138
|
+
# @param secret_key [String] the secret key to use for HMAC signature generation
|
|
139
|
+
# Must be ASCII characters and at least 32 bytes in size
|
|
140
|
+
# @return [Response] the API response
|
|
141
|
+
#
|
|
142
|
+
# @example
|
|
143
|
+
# webhooks = ZaiPayment::Resources::Webhook.new
|
|
144
|
+
# secret_key = SecureRandom.alphanumeric(32)
|
|
145
|
+
# response = webhooks.create_secret_key(secret_key: secret_key)
|
|
146
|
+
#
|
|
147
|
+
# @see https://developer.hellozai.com/reference/createsecretkey
|
|
148
|
+
# @see https://developer.hellozai.com/docs/verify-webhook-signatures
|
|
149
|
+
def create_secret_key(secret_key:)
|
|
150
|
+
validate_presence!(secret_key, 'secret_key')
|
|
151
|
+
validate_secret_key!(secret_key)
|
|
152
|
+
|
|
153
|
+
body = { secret_key: secret_key }
|
|
154
|
+
client.post('/webhooks/secret_key', body: body)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Verify webhook signature
|
|
158
|
+
#
|
|
159
|
+
# This method verifies that a webhook request came from Zai by validating
|
|
160
|
+
# the HMAC SHA256 signature in the Webhooks-signature header.
|
|
161
|
+
#
|
|
162
|
+
# @param payload [String] the raw request body (JSON string)
|
|
163
|
+
# @param signature_header [String] the Webhooks-signature header value
|
|
164
|
+
# @param secret_key [String] your secret key used for signature generation
|
|
165
|
+
# @param tolerance [Integer] maximum age of webhook in seconds (default: 300 = 5 minutes)
|
|
166
|
+
# @return [Boolean] true if signature is valid and within tolerance
|
|
167
|
+
# @raise [Errors::ValidationError] if signature is invalid or timestamp is outside tolerance
|
|
168
|
+
#
|
|
169
|
+
# @example
|
|
170
|
+
# # In your webhook endpoint (e.g., Rails controller)
|
|
171
|
+
# def webhook
|
|
172
|
+
# payload = request.body.read
|
|
173
|
+
# signature_header = request.headers['Webhooks-signature']
|
|
174
|
+
# secret_key = ENV['ZAI_WEBHOOK_SECRET']
|
|
175
|
+
#
|
|
176
|
+
# if ZaiPayment.webhooks.verify_signature(
|
|
177
|
+
# payload: payload,
|
|
178
|
+
# signature_header: signature_header,
|
|
179
|
+
# secret_key: secret_key
|
|
180
|
+
# )
|
|
181
|
+
# # Process webhook
|
|
182
|
+
# render json: { status: 'success' }
|
|
183
|
+
# else
|
|
184
|
+
# render json: { error: 'Invalid signature' }, status: :unauthorized
|
|
185
|
+
# end
|
|
186
|
+
# end
|
|
187
|
+
#
|
|
188
|
+
# @see https://developer.hellozai.com/docs/verify-webhook-signatures
|
|
189
|
+
def verify_signature(payload:, signature_header:, secret_key:, tolerance: 300)
|
|
190
|
+
validate_presence!(payload, 'payload')
|
|
191
|
+
validate_presence!(signature_header, 'signature_header')
|
|
192
|
+
validate_presence!(secret_key, 'secret_key')
|
|
193
|
+
|
|
194
|
+
# Extract timestamp and signature from header
|
|
195
|
+
timestamp, signatures = parse_signature_header(signature_header)
|
|
196
|
+
|
|
197
|
+
# Verify timestamp is within tolerance (prevent replay attacks)
|
|
198
|
+
verify_timestamp!(timestamp, tolerance)
|
|
199
|
+
|
|
200
|
+
# Generate expected signature
|
|
201
|
+
expected_signature = generate_signature(payload, secret_key, timestamp)
|
|
202
|
+
|
|
203
|
+
# Compare signatures using constant-time comparison
|
|
204
|
+
signatures.any? { |sig| secure_compare(expected_signature, sig) }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Generate a signature for webhook verification
|
|
208
|
+
#
|
|
209
|
+
# This is a utility method that can be used for testing or generating
|
|
210
|
+
# signatures for webhook simulation.
|
|
211
|
+
#
|
|
212
|
+
# @param payload [String] the request body (JSON string)
|
|
213
|
+
# @param secret_key [String] the secret key
|
|
214
|
+
# @param timestamp [Integer] the Unix timestamp (defaults to current time)
|
|
215
|
+
# @return [String] the base64url-encoded HMAC SHA256 signature
|
|
216
|
+
#
|
|
217
|
+
# @example
|
|
218
|
+
# webhooks = ZaiPayment::Resources::Webhook.new
|
|
219
|
+
# signature = webhooks.generate_signature(
|
|
220
|
+
# '{"event": "status_updated"}',
|
|
221
|
+
# 'my_secret_key'
|
|
222
|
+
# )
|
|
223
|
+
#
|
|
224
|
+
# @see https://developer.hellozai.com/docs/verify-webhook-signatures
|
|
225
|
+
def generate_signature(payload, secret_key, timestamp = Time.now.to_i)
|
|
226
|
+
signed_payload = "#{timestamp}.#{payload}"
|
|
227
|
+
digest = OpenSSL::Digest.new('sha256')
|
|
228
|
+
hash = OpenSSL::HMAC.digest(digest, secret_key, signed_payload)
|
|
229
|
+
Base64.urlsafe_encode64(hash, padding: false)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
private
|
|
233
|
+
|
|
234
|
+
def validate_id!(value, field_name)
|
|
235
|
+
return unless value.nil? || value.to_s.strip.empty?
|
|
236
|
+
|
|
237
|
+
raise Errors::ValidationError, "#{field_name} is required and cannot be blank"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def validate_presence!(value, field_name)
|
|
241
|
+
return unless value.nil? || value.to_s.strip.empty?
|
|
242
|
+
|
|
243
|
+
raise Errors::ValidationError, "#{field_name} is required and cannot be blank"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def validate_url!(url)
|
|
247
|
+
uri = URI.parse(url)
|
|
248
|
+
unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
249
|
+
raise Errors::ValidationError, 'url must be a valid HTTP or HTTPS URL'
|
|
250
|
+
end
|
|
251
|
+
rescue URI::InvalidURIError
|
|
252
|
+
raise Errors::ValidationError, 'url must be a valid URL'
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def validate_secret_key!(secret_key)
|
|
256
|
+
# Check if it's ASCII
|
|
257
|
+
raise Errors::ValidationError, 'secret_key must contain only ASCII characters' unless secret_key.ascii_only?
|
|
258
|
+
|
|
259
|
+
# Check minimum length (32 bytes)
|
|
260
|
+
return unless secret_key.bytesize < 32
|
|
261
|
+
|
|
262
|
+
raise Errors::ValidationError, 'secret_key must be at least 32 bytes in size'
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def parse_signature_header(header)
|
|
266
|
+
# Format: "t=1257894000,v=signature1,v=signature2"
|
|
267
|
+
parts = header.split(',').map(&:strip)
|
|
268
|
+
|
|
269
|
+
timestamp, signatures = extract_timestamp_and_signatures(parts)
|
|
270
|
+
|
|
271
|
+
validate_timestamp_presence!(timestamp)
|
|
272
|
+
validate_signatures_presence!(signatures)
|
|
273
|
+
|
|
274
|
+
[timestamp, signatures]
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def extract_timestamp_and_signatures(parts)
|
|
278
|
+
timestamp = nil
|
|
279
|
+
signatures = []
|
|
280
|
+
|
|
281
|
+
parts.each do |part|
|
|
282
|
+
key, value = part.split('=', 2)
|
|
283
|
+
case key
|
|
284
|
+
when 't'
|
|
285
|
+
timestamp = value.to_i
|
|
286
|
+
when 'v'
|
|
287
|
+
signatures << value
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
[timestamp, signatures]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def validate_timestamp_presence!(timestamp)
|
|
295
|
+
return unless timestamp.nil? || timestamp.zero?
|
|
296
|
+
|
|
297
|
+
raise Errors::ValidationError, 'Invalid signature header: missing or invalid timestamp'
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def validate_signatures_presence!(signatures)
|
|
301
|
+
raise Errors::ValidationError, 'Invalid signature header: missing signature' if signatures.empty?
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def verify_timestamp!(timestamp, tolerance)
|
|
305
|
+
current_time = Time.now.to_i
|
|
306
|
+
time_diff = (current_time - timestamp).abs
|
|
307
|
+
|
|
308
|
+
return unless time_diff > tolerance
|
|
309
|
+
|
|
310
|
+
raise Errors::ValidationError,
|
|
311
|
+
"Webhook timestamp is outside tolerance (#{time_diff}s vs #{tolerance}s max). " \
|
|
312
|
+
'This may be a replay attack.'
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Constant-time string comparison to prevent timing attacks
|
|
316
|
+
# Uses OpenSSL's secure_compare if available, otherwise falls back to manual comparison
|
|
317
|
+
def secure_compare(str_a, str_b)
|
|
318
|
+
return false unless str_a.bytesize == str_b.bytesize
|
|
319
|
+
|
|
320
|
+
if defined?(OpenSSL.fixed_length_secure_compare)
|
|
321
|
+
OpenSSL.fixed_length_secure_compare(str_a, str_b)
|
|
322
|
+
else
|
|
323
|
+
# Fallback for older Ruby versions
|
|
324
|
+
result = 0
|
|
325
|
+
str_a.bytes.zip(str_b.bytes) { |x, y| result |= x ^ y }
|
|
326
|
+
result.zero?
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZaiPayment
|
|
4
|
+
# Wrapper for API responses
|
|
5
|
+
class Response
|
|
6
|
+
attr_reader :status, :body, :headers, :raw_response
|
|
7
|
+
|
|
8
|
+
def initialize(faraday_response)
|
|
9
|
+
@raw_response = faraday_response
|
|
10
|
+
@status = faraday_response.status
|
|
11
|
+
@body = faraday_response.body
|
|
12
|
+
@headers = faraday_response.headers
|
|
13
|
+
|
|
14
|
+
check_for_errors!
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Check if the response was successful (2xx status)
|
|
18
|
+
def success?
|
|
19
|
+
(200..299).cover?(status)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Check if the response was a client error (4xx status)
|
|
23
|
+
def client_error?
|
|
24
|
+
(400..499).cover?(status)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Check if the response was a server error (5xx status)
|
|
28
|
+
def server_error?
|
|
29
|
+
(500..599).cover?(status)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Get the data from the response body
|
|
33
|
+
def data
|
|
34
|
+
body.is_a?(Hash) ? body['webhooks'] || body : body
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Get pagination or metadata info
|
|
38
|
+
def meta
|
|
39
|
+
body.is_a?(Hash) ? body['meta'] : nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
ERROR_STATUS_MAP = {
|
|
43
|
+
400 => Errors::BadRequestError,
|
|
44
|
+
401 => Errors::UnauthorizedError,
|
|
45
|
+
403 => Errors::ForbiddenError,
|
|
46
|
+
404 => Errors::NotFoundError,
|
|
47
|
+
422 => Errors::ValidationError,
|
|
48
|
+
429 => Errors::RateLimitError
|
|
49
|
+
}.merge((500..599).to_h { |code| [code, Errors::ServerError] }).freeze
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def check_for_errors!
|
|
54
|
+
return if success?
|
|
55
|
+
|
|
56
|
+
raise_appropriate_error
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def raise_appropriate_error
|
|
60
|
+
error_message = extract_error_message
|
|
61
|
+
error_class = error_class_for_status
|
|
62
|
+
raise error_class, error_message
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def error_class_for_status
|
|
66
|
+
ERROR_STATUS_MAP.fetch(status, Errors::ApiError)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def extract_error_message
|
|
70
|
+
if body.is_a?(Hash)
|
|
71
|
+
body['error'] || body['message'] || body['errors']&.join(', ') || "HTTP #{status}"
|
|
72
|
+
else
|
|
73
|
+
"HTTP #{status}: #{body}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/zai_payment/version.rb
CHANGED
data/lib/zai_payment.rb
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'faraday'
|
|
4
|
+
require 'uri'
|
|
4
5
|
require_relative 'zai_payment/version'
|
|
5
6
|
require_relative 'zai_payment/config'
|
|
6
7
|
require_relative 'zai_payment/errors'
|
|
7
8
|
require_relative 'zai_payment/auth/token_provider'
|
|
8
9
|
require_relative 'zai_payment/auth/token_store'
|
|
9
10
|
require_relative 'zai_payment/auth/token_stores/memory_store'
|
|
11
|
+
require_relative 'zai_payment/client'
|
|
12
|
+
require_relative 'zai_payment/response'
|
|
13
|
+
require_relative 'zai_payment/resources/webhook'
|
|
10
14
|
|
|
11
15
|
module ZaiPayment
|
|
12
16
|
class << self
|
|
@@ -29,5 +33,11 @@ module ZaiPayment
|
|
|
29
33
|
def clear_token! = auth.clear_token
|
|
30
34
|
def token_expiry = auth.token_expiry
|
|
31
35
|
def token_type = auth.token_type
|
|
36
|
+
|
|
37
|
+
# --- Resource accessors ---
|
|
38
|
+
# @return [ZaiPayment::Resources::Webhook] webhook resource instance
|
|
39
|
+
def webhooks
|
|
40
|
+
@webhooks ||= Resources::Webhook.new
|
|
41
|
+
end
|
|
32
42
|
end
|
|
33
43
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: zai_payment
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eddy Jaga
|
|
@@ -9,6 +9,20 @@ bindir: exe
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: base64
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.3.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.3.0
|
|
12
26
|
- !ruby/object:Gem::Dependency
|
|
13
27
|
name: faraday
|
|
14
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -23,6 +37,20 @@ dependencies:
|
|
|
23
37
|
- - "~>"
|
|
24
38
|
- !ruby/object:Gem::Version
|
|
25
39
|
version: '2.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: openssl
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '3.3'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.3'
|
|
26
54
|
description: A Ruby gem for integrating with Zai payment platform APIs.
|
|
27
55
|
email:
|
|
28
56
|
- eddy.jaga@sentia.com.au
|
|
@@ -32,15 +60,26 @@ extra_rdoc_files: []
|
|
|
32
60
|
files:
|
|
33
61
|
- CHANGELOG.md
|
|
34
62
|
- CODE_OF_CONDUCT.md
|
|
63
|
+
- IMPLEMENTATION.md
|
|
35
64
|
- LICENSE.txt
|
|
36
65
|
- README.md
|
|
37
66
|
- Rakefile
|
|
67
|
+
- docs/ARCHITECTURE.md
|
|
68
|
+
- docs/AUTHENTICATION.md
|
|
69
|
+
- docs/README.md
|
|
70
|
+
- docs/WEBHOOKS.md
|
|
71
|
+
- docs/WEBHOOK_SECURITY_QUICKSTART.md
|
|
72
|
+
- docs/WEBHOOK_SIGNATURE.md
|
|
73
|
+
- examples/webhooks.md
|
|
38
74
|
- lib/zai_payment.rb
|
|
39
75
|
- lib/zai_payment/auth/token_provider.rb
|
|
40
76
|
- lib/zai_payment/auth/token_store.rb
|
|
41
77
|
- lib/zai_payment/auth/token_stores/memory_store.rb
|
|
78
|
+
- lib/zai_payment/client.rb
|
|
42
79
|
- lib/zai_payment/config.rb
|
|
43
80
|
- lib/zai_payment/errors.rb
|
|
81
|
+
- lib/zai_payment/resources/webhook.rb
|
|
82
|
+
- lib/zai_payment/response.rb
|
|
44
83
|
- lib/zai_payment/version.rb
|
|
45
84
|
- sig/zai_payment.rbs
|
|
46
85
|
homepage: https://github.com/Sentia/zai-payment
|