zai_payment 1.0.1 → 1.1.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.
data/docs/WEBHOOKS.md ADDED
@@ -0,0 +1,157 @@
1
+ # Zai Payment Webhook Implementation
2
+
3
+ ## Overview
4
+ This document provides a summary of the webhook implementation in the zai_payment gem.
5
+
6
+ ## Architecture
7
+
8
+ ### Core Components
9
+
10
+ 1. **Client** (`lib/zai_payment/client.rb`)
11
+ - Base HTTP client for making API requests
12
+ - Handles authentication automatically via TokenProvider
13
+ - Supports GET, POST, PATCH, DELETE methods
14
+ - Manages connection with proper headers and JSON encoding/decoding
15
+
16
+ 2. **Response** (`lib/zai_payment/response.rb`)
17
+ - Wraps Faraday responses
18
+ - Provides convenient methods: `success?`, `client_error?`, `server_error?`
19
+ - Automatically raises appropriate errors based on HTTP status
20
+ - Extracts data and metadata from response body
21
+
22
+ 3. **Webhook Resource** (`lib/zai_payment/resources/webhook.rb`)
23
+ - Implements all CRUD operations for webhooks
24
+ - Full input validation
25
+ - Clean, documented API
26
+
27
+ 4. **Enhanced Error Handling** (`lib/zai_payment/errors.rb`)
28
+ - Specific error classes for different scenarios
29
+ - Makes debugging and error handling easier
30
+
31
+ ## API Methods
32
+
33
+ ### List Webhooks
34
+ ```ruby
35
+ ZaiPayment.webhooks.list(limit: 10, offset: 0)
36
+ ```
37
+ - Returns paginated list of webhooks
38
+ - Response includes `data` (array of webhooks) and `meta` (pagination info)
39
+
40
+ ### Show Webhook
41
+ ```ruby
42
+ ZaiPayment.webhooks.show(webhook_id)
43
+ ```
44
+ - Returns details of a specific webhook
45
+ - Raises `NotFoundError` if webhook doesn't exist
46
+
47
+ ### Create Webhook
48
+ ```ruby
49
+ ZaiPayment.webhooks.create(
50
+ url: 'https://example.com/webhook',
51
+ object_type: 'transactions',
52
+ enabled: true,
53
+ description: 'Optional description'
54
+ )
55
+ ```
56
+ - Validates URL format
57
+ - Validates required fields
58
+ - Returns created webhook with ID
59
+
60
+ ### Update Webhook
61
+ ```ruby
62
+ ZaiPayment.webhooks.update(
63
+ webhook_id,
64
+ url: 'https://example.com/new-webhook',
65
+ enabled: false
66
+ )
67
+ ```
68
+ - All fields are optional
69
+ - Only updates provided fields
70
+ - Validates URL format if URL is provided
71
+
72
+ ### Delete Webhook
73
+ ```ruby
74
+ ZaiPayment.webhooks.delete(webhook_id)
75
+ ```
76
+ - Permanently deletes the webhook
77
+ - Returns 204 No Content on success
78
+
79
+ ## Error Handling
80
+
81
+ The gem provides specific error classes:
82
+
83
+ | Error Class | HTTP Status | Description |
84
+ |------------|-------------|-------------|
85
+ | `ValidationError` | 400, 422 | Invalid input data |
86
+ | `UnauthorizedError` | 401 | Authentication failed |
87
+ | `ForbiddenError` | 403 | Access denied |
88
+ | `NotFoundError` | 404 | Resource not found |
89
+ | `RateLimitError` | 429 | Too many requests |
90
+ | `ServerError` | 5xx | Server-side error |
91
+ | `TimeoutError` | - | Request timeout |
92
+ | `ConnectionError` | - | Connection failed |
93
+
94
+ Example:
95
+ ```ruby
96
+ begin
97
+ response = ZaiPayment.webhooks.create(...)
98
+ rescue ZaiPayment::Errors::ValidationError => e
99
+ puts "Validation failed: #{e.message}"
100
+ rescue ZaiPayment::Errors::UnauthorizedError => e
101
+ puts "Authentication failed: #{e.message}"
102
+ end
103
+ ```
104
+
105
+ ## Best Practices Implemented
106
+
107
+ 1. **Single Responsibility**: Each class has a clear, focused purpose
108
+ 2. **DRY (Don't Repeat Yourself)**: Client and Response classes are reusable
109
+ 3. **Error Handling**: Comprehensive error handling with specific error classes
110
+ 4. **Input Validation**: All inputs are validated before making API calls
111
+ 5. **Documentation**: Inline documentation with examples
112
+ 6. **Testing**: Comprehensive test coverage using RSpec
113
+ 7. **Thread Safety**: TokenProvider uses mutex for thread-safe token refresh
114
+ 8. **Configuration**: Centralized configuration management
115
+ 9. **RESTful Design**: Follows REST principles for resource management
116
+ 10. **Response Wrapping**: Consistent response format across all methods
117
+
118
+ ## Usage Examples
119
+
120
+ See `examples/webhooks.rb` for complete examples including:
121
+ - Basic CRUD operations
122
+ - Pagination
123
+ - Error handling
124
+ - Custom client instances
125
+
126
+ ## Testing
127
+
128
+ Run the webhook tests:
129
+ ```bash
130
+ bundle exec rspec spec/zai_payment/resources/webhook_spec.rb
131
+ ```
132
+
133
+ The test suite covers:
134
+ - All CRUD operations
135
+ - Success and error scenarios
136
+ - Input validation
137
+ - Error handling
138
+ - Edge cases
139
+
140
+ ## Future Enhancements
141
+
142
+ Potential improvements for future versions:
143
+ 1. Webhook job management (list jobs, show job details)
144
+ 2. Webhook signature verification
145
+ 3. Webhook retry logic
146
+ 4. Bulk operations
147
+ 5. Async webhook operations
148
+
149
+ ## API Reference
150
+
151
+ For the official Zai API documentation, see:
152
+ - [List Webhooks](https://developer.hellozai.com/reference/getallwebhooks)
153
+ - [Show Webhook](https://developer.hellozai.com/reference/getwebhookbyid)
154
+ - [Create Webhook](https://developer.hellozai.com/reference/createwebhook)
155
+ - [Update Webhook](https://developer.hellozai.com/reference/updatewebhook)
156
+ - [Delete Webhook](https://developer.hellozai.com/reference/deletewebhookbyid)
157
+
@@ -0,0 +1,146 @@
1
+ # Webhook Examples
2
+
3
+ This file demonstrates how to use the ZaiPayment webhook functionality.
4
+
5
+ ## Setup
6
+
7
+ ```ruby
8
+ require 'zai_payment'
9
+
10
+ # Configure the gem
11
+ ZaiPayment.configure do |config|
12
+ config.environment = :prelive # or :production
13
+ config.client_id = 'your_client_id'
14
+ config.client_secret = 'your_client_secret'
15
+ config.scope = 'your_scope'
16
+ end
17
+ ```
18
+
19
+ ## List Webhooks
20
+
21
+ ```ruby
22
+ # Get all webhooks
23
+ response = ZaiPayment.webhooks.list
24
+ puts response.data # Array of webhooks
25
+ puts response.meta # Pagination metadata
26
+
27
+ # With pagination
28
+ response = ZaiPayment.webhooks.list(limit: 20, offset: 10)
29
+ ```
30
+
31
+ ## Show a Specific Webhook
32
+
33
+ ```ruby
34
+ webhook_id = 'webhook_123'
35
+ response = ZaiPayment.webhooks.show(webhook_id)
36
+
37
+ webhook = response.data
38
+ puts webhook['id']
39
+ puts webhook['url']
40
+ puts webhook['object_type']
41
+ puts webhook['enabled']
42
+ ```
43
+
44
+ ## Create a Webhook
45
+
46
+ ```ruby
47
+ response = ZaiPayment.webhooks.create(
48
+ url: 'https://example.com/webhooks/zai',
49
+ object_type: 'transactions',
50
+ enabled: true,
51
+ description: 'Production webhook for transactions'
52
+ )
53
+
54
+ new_webhook = response.data
55
+ puts "Created webhook with ID: #{new_webhook['id']}"
56
+ ```
57
+
58
+ ## Update a Webhook
59
+
60
+ ```ruby
61
+ webhook_id = 'webhook_123'
62
+
63
+ # Update specific fields
64
+ response = ZaiPayment.webhooks.update(
65
+ webhook_id,
66
+ enabled: false,
67
+ description: 'Temporarily disabled'
68
+ )
69
+
70
+ # Or update multiple fields
71
+ response = ZaiPayment.webhooks.update(
72
+ webhook_id,
73
+ url: 'https://example.com/webhooks/zai-v2',
74
+ object_type: 'items',
75
+ enabled: true
76
+ )
77
+ ```
78
+
79
+ ## Delete a Webhook
80
+
81
+ ```ruby
82
+ webhook_id = 'webhook_123'
83
+ response = ZaiPayment.webhooks.delete(webhook_id)
84
+
85
+ if response.success?
86
+ puts "Webhook deleted successfully"
87
+ end
88
+ ```
89
+
90
+ ## Error Handling
91
+
92
+ ```ruby
93
+ begin
94
+ response = ZaiPayment.webhooks.create(
95
+ url: 'https://example.com/webhook',
96
+ object_type: 'transactions'
97
+ )
98
+ rescue ZaiPayment::Errors::ValidationError => e
99
+ puts "Validation error: #{e.message}"
100
+ rescue ZaiPayment::Errors::UnauthorizedError => e
101
+ puts "Authentication failed: #{e.message}"
102
+ rescue ZaiPayment::Errors::NotFoundError => e
103
+ puts "Resource not found: #{e.message}"
104
+ rescue ZaiPayment::Errors::ApiError => e
105
+ puts "API error: #{e.message}"
106
+ end
107
+ ```
108
+
109
+ ## Using Custom Client Instance
110
+
111
+ If you need more control, you can create your own client instance:
112
+
113
+ ```ruby
114
+ config = ZaiPayment::Config.new
115
+ config.environment = :prelive
116
+ config.client_id = 'your_client_id'
117
+ config.client_secret = 'your_client_secret'
118
+ config.scope = 'your_scope'
119
+
120
+ token_provider = ZaiPayment::Auth::TokenProvider.new(config: config)
121
+ client = ZaiPayment::Client.new(config: config, token_provider: token_provider)
122
+
123
+ webhooks = ZaiPayment::Resources::Webhook.new(client: client)
124
+ response = webhooks.list
125
+ ```
126
+
127
+ ## Response Object
128
+
129
+ All webhook methods return a `ZaiPayment::Response` object with the following methods:
130
+
131
+ ```ruby
132
+ response = ZaiPayment.webhooks.list
133
+
134
+ # Check status
135
+ response.success? # => true/false (2xx status)
136
+ response.client_error? # => true/false (4xx status)
137
+ response.server_error? # => true/false (5xx status)
138
+
139
+ # Access data
140
+ response.data # => Main response data (array or hash)
141
+ response.meta # => Pagination metadata (if available)
142
+ response.body # => Raw response body
143
+ response.headers # => Response headers
144
+ response.status # => HTTP status code
145
+ ```
146
+
@@ -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
@@ -10,6 +10,8 @@ module ZaiPayment
10
10
  @client_id = nil
11
11
  @client_secret = nil
12
12
  @scope = nil
13
+ @timeout = 10
14
+ @open_timeout = 10
13
15
  end
14
16
 
15
17
  def validate!
@@ -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,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZaiPayment
4
+ module Resources
5
+ # Webhook resource for managing Zai webhooks
6
+ #
7
+ # @see https://developer.hellozai.com/reference/getallwebhooks
8
+ class Webhook
9
+ attr_reader :client
10
+
11
+ def initialize(client: nil)
12
+ @client = client || Client.new
13
+ end
14
+
15
+ # List all webhooks
16
+ #
17
+ # @param limit [Integer] number of records to return (default: 10)
18
+ # @param offset [Integer] number of records to skip (default: 0)
19
+ # @return [Response] the API response containing webhooks array
20
+ #
21
+ # @example
22
+ # webhooks = ZaiPayment::Resources::Webhook.new
23
+ # response = webhooks.list
24
+ # response.data # => [{"id" => "...", "url" => "..."}, ...]
25
+ #
26
+ # @see https://developer.hellozai.com/reference/getallwebhooks
27
+ def list(limit: 10, offset: 0)
28
+ params = {
29
+ limit: limit,
30
+ offset: offset
31
+ }
32
+
33
+ client.get('/webhooks', params: params)
34
+ end
35
+
36
+ # Get a specific webhook by ID
37
+ #
38
+ # @param webhook_id [String] the webhook ID
39
+ # @return [Response] the API response containing webhook details
40
+ #
41
+ # @example
42
+ # webhooks = ZaiPayment::Resources::Webhook.new
43
+ # response = webhooks.show("webhook_id")
44
+ # response.data # => {"id" => "webhook_id", "url" => "...", ...}
45
+ #
46
+ # @see https://developer.hellozai.com/reference/getwebhookbyid
47
+ def show(webhook_id)
48
+ validate_id!(webhook_id, 'webhook_id')
49
+ client.get("/webhooks/#{webhook_id}")
50
+ end
51
+
52
+ # Create a new webhook
53
+ #
54
+ # @param url [String] the webhook URL to receive notifications
55
+ # @param object_type [String] the type of object to watch (e.g., 'transactions', 'items')
56
+ # @param enabled [Boolean] whether the webhook is enabled (default: true)
57
+ # @param description [String] optional description of the webhook
58
+ # @return [Response] the API response containing created webhook
59
+ #
60
+ # @example
61
+ # webhooks = ZaiPayment::Resources::Webhook.new
62
+ # response = webhooks.create(
63
+ # url: "https://example.com/webhooks",
64
+ # object_type: "transactions",
65
+ # enabled: true
66
+ # )
67
+ #
68
+ # @see https://developer.hellozai.com/reference/createwebhook
69
+ def create(url: nil, object_type: nil, enabled: true, description: nil)
70
+ validate_presence!(url, 'url')
71
+ validate_presence!(object_type, 'object_type')
72
+ validate_url!(url)
73
+
74
+ body = {
75
+ url: url,
76
+ object_type: object_type,
77
+ enabled: enabled
78
+ }
79
+
80
+ body[:description] = description if description
81
+
82
+ client.post('/webhooks', body: body)
83
+ end
84
+
85
+ # Update an existing webhook
86
+ #
87
+ # @param webhook_id [String] the webhook ID
88
+ # @param url [String] optional new webhook URL
89
+ # @param object_type [String] optional new object type
90
+ # @param enabled [Boolean] optional enabled status
91
+ # @param description [String] optional description
92
+ # @return [Response] the API response containing updated webhook
93
+ #
94
+ # @example
95
+ # webhooks = ZaiPayment::Resources::Webhook.new
96
+ # response = webhooks.update(
97
+ # "webhook_id",
98
+ # enabled: false
99
+ # )
100
+ #
101
+ # @see https://developer.hellozai.com/reference/updatewebhook
102
+ def update(webhook_id, url: nil, object_type: nil, enabled: nil, description: nil)
103
+ validate_id!(webhook_id, 'webhook_id')
104
+
105
+ body = {}
106
+ body[:url] = url if url
107
+ body[:object_type] = object_type if object_type
108
+ body[:enabled] = enabled unless enabled.nil?
109
+ body[:description] = description if description
110
+
111
+ validate_url!(url) if url
112
+
113
+ raise Errors::ValidationError, 'At least one attribute must be provided for update' if body.empty?
114
+
115
+ client.patch("/webhooks/#{webhook_id}", body: body)
116
+ end
117
+
118
+ # Delete a webhook
119
+ #
120
+ # @param webhook_id [String] the webhook ID
121
+ # @return [Response] the API response
122
+ #
123
+ # @example
124
+ # webhooks = ZaiPayment::Resources::Webhook.new
125
+ # response = webhooks.delete("webhook_id")
126
+ #
127
+ # @see https://developer.hellozai.com/reference/deletewebhook
128
+ def delete(webhook_id)
129
+ validate_id!(webhook_id, 'webhook_id')
130
+ client.delete("/webhooks/#{webhook_id}")
131
+ end
132
+
133
+ private
134
+
135
+ def validate_id!(value, field_name)
136
+ return unless value.nil? || value.to_s.strip.empty?
137
+
138
+ raise Errors::ValidationError, "#{field_name} is required and cannot be blank"
139
+ end
140
+
141
+ def validate_presence!(value, field_name)
142
+ return unless value.nil? || value.to_s.strip.empty?
143
+
144
+ raise Errors::ValidationError, "#{field_name} is required and cannot be blank"
145
+ end
146
+
147
+ def validate_url!(url)
148
+ uri = URI.parse(url)
149
+ unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
150
+ raise Errors::ValidationError, 'url must be a valid HTTP or HTTPS URL'
151
+ end
152
+ rescue URI::InvalidURIError
153
+ raise Errors::ValidationError, 'url must be a valid URL'
154
+ end
155
+ end
156
+ end
157
+ 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ZaiPayment
4
- VERSION = '1.0.1'
4
+ VERSION = '1.1.0'
5
5
  end