frame_payments 0.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.
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Frame
4
+ # Configuration management for the Frame SDK.
5
+ #
6
+ # Handles API keys, base URLs, timeouts, and other SDK settings.
7
+ # Typically accessed through the Frame module methods like Frame.api_key.
8
+ class Configuration
9
+ DEFAULT_API_BASE = "https://api.framepayments.com"
10
+ DEFAULT_OPEN_TIMEOUT = 30
11
+ DEFAULT_READ_TIMEOUT = 80
12
+ DEFAULT_VERIFY_SSL_CERTS = true
13
+
14
+ attr_accessor :api_key, :api_base, :open_timeout, :read_timeout, :verify_ssl_certs, :log_level, :logger
15
+
16
+ def self.setup
17
+ new.tap do |config|
18
+ config.api_base = DEFAULT_API_BASE
19
+ config.open_timeout = DEFAULT_OPEN_TIMEOUT
20
+ config.read_timeout = DEFAULT_READ_TIMEOUT
21
+ config.verify_ssl_certs = DEFAULT_VERIFY_SSL_CERTS
22
+ config.log_level = nil
23
+ config.logger = nil
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Frame
4
+ # Base error class for all Frame-related errors
5
+ class Error < StandardError; end
6
+
7
+ # API Error class for handling API responses
8
+ class APIError < Error
9
+ attr_reader :message
10
+ attr_reader :http_status
11
+ attr_reader :http_body
12
+ attr_reader :json_body
13
+ attr_reader :code
14
+
15
+ def initialize(message = nil, http_status: nil, http_body: nil, json_body: nil, code: nil)
16
+ @message = message
17
+ @http_status = http_status
18
+ @http_body = http_body
19
+ @json_body = json_body
20
+ @code = code
21
+ super(message)
22
+ end
23
+
24
+ def to_s
25
+ status_string = @http_status.nil? ? "" : "(Status #{@http_status}) "
26
+ code_string = @code.nil? ? "" : "(Code #{@code}) "
27
+ "#{status_string}#{code_string}#{@message}"
28
+ end
29
+ end
30
+
31
+ # Authentication error
32
+ class AuthenticationError < APIError; end
33
+
34
+ # Invalid request error
35
+ class InvalidRequestError < APIError; end
36
+
37
+ # API connection error
38
+ class APIConnectionError < APIError; end
39
+
40
+ # Rate limit error
41
+ class RateLimitError < APIError; end
42
+
43
+ # Resource not found error
44
+ class ResourceNotFoundError < APIError; end
45
+
46
+ # Invalid parameters error
47
+ class InvalidParameterError < APIError; end
48
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Frame
4
+ # HTTP client for making requests to the Frame Payments API.
5
+ #
6
+ # Handles connection management, request execution, and response processing.
7
+ # Automatically handles authentication, error parsing, and response formatting.
8
+ class FrameClient
9
+ attr_accessor :conn, :config
10
+
11
+ @default_client_mutex = Mutex.new
12
+
13
+ def self.active_client
14
+ Thread.current[:frame_client] || default_client
15
+ end
16
+
17
+ def self.default_client
18
+ return @default_client if @default_client
19
+
20
+ @default_client_mutex.synchronize do
21
+ @default_client ||= FrameClient.new(
22
+ api_key: Frame.api_key,
23
+ api_base: Frame.api_base,
24
+ open_timeout: Frame.open_timeout,
25
+ read_timeout: Frame.read_timeout,
26
+ verify_ssl_certs: Frame.verify_ssl_certs,
27
+ logger: Frame.logger,
28
+ log_level: Frame.log_level
29
+ )
30
+ end
31
+ end
32
+
33
+ def initialize(api_key: nil, api_base: nil, open_timeout: nil, read_timeout: nil, verify_ssl_certs: nil, logger: nil, log_level: nil)
34
+ @config = {
35
+ api_key: api_key || Frame.api_key,
36
+ api_base: api_base || Frame.api_base,
37
+ open_timeout: open_timeout || Frame.open_timeout,
38
+ read_timeout: read_timeout || Frame.read_timeout,
39
+ verify_ssl_certs: verify_ssl_certs.nil? ? Frame.verify_ssl_certs : verify_ssl_certs,
40
+ logger: logger || Frame.logger,
41
+ log_level: log_level || Frame.log_level
42
+ }
43
+
44
+ @conn = create_connection
45
+ end
46
+
47
+ def request(method, path, params = {}, opts = {})
48
+ response = execute_request(method, path, params, opts)
49
+ process_response(response)
50
+ rescue Faraday::ConnectionFailed => e
51
+ raise APIConnectionError.new("Connection failed: #{e.message}")
52
+ rescue Faraday::TimeoutError => e
53
+ raise APIConnectionError.new("Request timed out: #{e.message}")
54
+ rescue Faraday::ClientError => e
55
+ raise APIConnectionError.new("Client error: #{e.message}")
56
+ end
57
+
58
+ private
59
+
60
+ def create_connection
61
+ Faraday.new(url: @config[:api_base]) do |faraday|
62
+ faraday.request :json
63
+ faraday.response :json, content_type: /\bjson$/
64
+ faraday.adapter Faraday.default_adapter
65
+
66
+ # SSL verification setting
67
+ faraday.ssl.verify = @config[:verify_ssl_certs]
68
+
69
+ faraday.options.timeout = @config[:read_timeout]
70
+ faraday.options.open_timeout = @config[:open_timeout]
71
+ end
72
+ end
73
+
74
+ def execute_request(method, path, params, opts)
75
+ unless @config[:api_key]
76
+ raise AuthenticationError.new(
77
+ "API key is required. Set Frame.api_key before making requests.",
78
+ http_status: nil
79
+ )
80
+ end
81
+
82
+ headers = {
83
+ "Authorization" => "Bearer #{@config[:api_key]}",
84
+ "Content-Type" => "application/json",
85
+ "User-Agent" => "FrameRuby/#{Frame::VERSION}"
86
+ }
87
+
88
+ log_request(method, path, params, headers) if should_log?
89
+
90
+ response = case method.to_s.downcase.to_sym
91
+ when :get
92
+ @conn.get(path) do |req|
93
+ req.params = params
94
+ req.headers = headers
95
+ end
96
+ when :post
97
+ @conn.post(path) do |req|
98
+ req.body = params.to_json
99
+ req.headers = headers
100
+ end
101
+ when :patch
102
+ @conn.patch(path) do |req|
103
+ req.body = params.to_json
104
+ req.headers = headers
105
+ end
106
+ when :delete
107
+ @conn.delete(path) do |req|
108
+ req.params = params
109
+ req.headers = headers
110
+ end
111
+ else
112
+ raise APIConnectionError.new("Unrecognized HTTP method: #{method}")
113
+ end
114
+
115
+ log_response(response) if should_log?
116
+
117
+ response
118
+ end
119
+
120
+ def process_response(response)
121
+ case response.status
122
+ when 200, 201, 202
123
+ parsed_response = Util.symbolize_names(response.body)
124
+ when 204
125
+ parsed_response = {}
126
+ when 400, 404
127
+ error = Util.symbolize_names(response.body)
128
+ raise InvalidRequestError.new(
129
+ error[:error],
130
+ http_status: response.status,
131
+ http_body: response.body,
132
+ json_body: error
133
+ )
134
+ when 401
135
+ error = Util.symbolize_names(response.body)
136
+ raise AuthenticationError.new(
137
+ error[:error],
138
+ http_status: response.status,
139
+ http_body: response.body,
140
+ json_body: error
141
+ )
142
+ when 429
143
+ error = Util.symbolize_names(response.body)
144
+ raise RateLimitError.new(
145
+ error[:error],
146
+ http_status: response.status,
147
+ http_body: response.body,
148
+ json_body: error
149
+ )
150
+ else
151
+ error = Util.symbolize_names(response.body)
152
+ raise APIError.new(
153
+ error[:error] || "Unknown error",
154
+ http_status: response.status,
155
+ http_body: response.body,
156
+ json_body: error
157
+ )
158
+ end
159
+
160
+ parsed_response
161
+ end
162
+
163
+ def should_log?
164
+ @config[:logger] && @config[:log_level]
165
+ end
166
+
167
+ def log_request(method, path, params, headers)
168
+ return unless should_log?
169
+
170
+ sanitized_headers = headers.dup
171
+ sanitized_headers["Authorization"] = "Bearer [REDACTED]" if sanitized_headers["Authorization"]
172
+
173
+ sanitized_params = sanitize_sensitive_data(params)
174
+
175
+ @config[:logger].public_send(@config[:log_level], "[Frame] #{method.to_s.upcase} #{path}")
176
+ @config[:logger].public_send(@config[:log_level], "[Frame] Headers: #{sanitized_headers.inspect}")
177
+ @config[:logger].public_send(@config[:log_level], "[Frame] Params: #{sanitized_params.inspect}") unless sanitized_params.empty?
178
+ end
179
+
180
+ def log_response(response)
181
+ return unless should_log?
182
+
183
+ @config[:logger].public_send(@config[:log_level], "[Frame] Response: #{response.status} #{response.reason_phrase}")
184
+ if response.body&.is_a?(Hash)
185
+ sanitized_body = sanitize_sensitive_data(response.body)
186
+ @config[:logger].public_send(@config[:log_level], "[Frame] Body: #{sanitized_body.inspect}")
187
+ end
188
+ end
189
+
190
+ def sanitize_sensitive_data(data)
191
+ return data unless data.is_a?(Hash)
192
+
193
+ sensitive_keys = %w[api_key secret key password card_number cvc cvv number]
194
+ data.each_with_object({}) do |(key, value), sanitized|
195
+ key_str = key.to_s.downcase
196
+ sanitized[key] = if sensitive_keys.any? { |sensitive| key_str.include?(sensitive) }
197
+ "[REDACTED]"
198
+ elsif value.is_a?(Hash)
199
+ sanitize_sensitive_data(value)
200
+ elsif value.is_a?(Array)
201
+ value.map { |v| v.is_a?(Hash) ? sanitize_sensitive_data(v) : v }
202
+ else
203
+ value
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Frame
4
+ # Base object class for all Frame API responses.
5
+ #
6
+ # FrameObject provides dynamic attribute access and JSON serialization
7
+ # for API responses. All API resources inherit from this class.
8
+ #
9
+ # Attributes are dynamically created based on the API response, allowing
10
+ # for flexible access to all fields:
11
+ #
12
+ # customer.name # => "John Doe"
13
+ # customer["name"] # => "John Doe"
14
+ # customer[:name] # => "John Doe"
15
+ #
16
+ class FrameObject
17
+ include Enumerable
18
+
19
+ attr_reader :id, :original_values
20
+
21
+ def initialize(id = nil, opts = {})
22
+ @id = id
23
+ @values = {}
24
+ @original_values = {}
25
+ @unsaved_values = Set.new
26
+ end
27
+
28
+ def self.construct_from(values, opts = {})
29
+ obj = new(values[:id])
30
+ obj.initialize_from(values, opts)
31
+ obj
32
+ end
33
+
34
+ def initialize_from(values, opts = {})
35
+ @original_values = values.dup
36
+ @values = values.dup
37
+
38
+ # Make sure all keys are symbols
39
+ @values.keys.each do |k|
40
+ @values[k.to_sym] = @values.delete(k) unless k.is_a?(Symbol)
41
+ end
42
+
43
+ # Add accessors for all keys
44
+ remove_accessors(@values.keys)
45
+ add_accessors(@values.keys)
46
+
47
+ self
48
+ end
49
+
50
+ def update_attributes(values)
51
+ values.each do |k, v|
52
+ @values[k] = Util.convert_to_frame_object(v)
53
+ end
54
+ end
55
+
56
+ def [](key)
57
+ @values[key.to_sym]
58
+ end
59
+
60
+ def []=(key, value)
61
+ send(:"#{key}=", value)
62
+ end
63
+
64
+ def keys
65
+ @values.keys
66
+ end
67
+
68
+ def values
69
+ @values.values
70
+ end
71
+
72
+ def to_s(*_args)
73
+ JSON.pretty_generate(@values)
74
+ end
75
+
76
+ def inspect
77
+ id_string = @id.nil? ? "" : " id=#{@id}"
78
+ "#<#{self.class}:0x#{object_id.to_s(16)}#{id_string}> JSON: " + JSON.pretty_generate(@values)
79
+ end
80
+
81
+ def to_hash
82
+ @values.each_with_object({}) do |(key, value), hash|
83
+ hash[key] = case value
84
+ when FrameObject
85
+ value.to_hash
86
+ when Array
87
+ value.map { |v| v.respond_to?(:to_hash) ? v.to_hash : v }
88
+ else
89
+ value
90
+ end
91
+ end
92
+ end
93
+
94
+ def each(&blk)
95
+ @values.each(&blk)
96
+ end
97
+
98
+ def serialize_params(obj)
99
+ params = {}
100
+
101
+ obj.instance_variable_get(:@values).each do |key, value|
102
+ params[key] = if value.is_a?(FrameObject)
103
+ value.serialize_params(value)
104
+ elsif value.is_a?(Array)
105
+ value.map { |v| v.is_a?(FrameObject) ? v.serialize_params(v) : v }
106
+ else
107
+ value
108
+ end
109
+ end
110
+
111
+ params
112
+ end
113
+
114
+ protected
115
+
116
+ def metaclass
117
+ class << self; self; end
118
+ end
119
+
120
+ def remove_accessors(keys)
121
+ # Skip keys that should be ignored when adding/removing accessors
122
+ ignored_keys = [:id, :data]
123
+
124
+ metaclass.instance_eval do
125
+ keys.each do |k|
126
+ # Skip certain keys that have special handling
127
+ next if ignored_keys.include?(k.to_sym)
128
+
129
+ # Remove reader method if it exists
130
+ remove_method(k.to_sym) if method_defined?(k.to_sym)
131
+
132
+ # Remove writer method if it exists
133
+ remove_method(:"#{k}=") if method_defined?(:"#{k}=")
134
+ end
135
+ end
136
+ end
137
+
138
+ def add_accessors(keys)
139
+ # Skip keys that should be ignored when adding/removing accessors
140
+ ignored_keys = [:id, :data]
141
+
142
+ metaclass.instance_eval do
143
+ keys.each do |k|
144
+ # Skip certain keys that have special handling
145
+ next if ignored_keys.include?(k.to_sym)
146
+
147
+ define_method(k) { @values[k] }
148
+ define_method(:"#{k}=") do |v|
149
+ @values[k] = v
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Frame
4
+ class ListObject < FrameObject
5
+ include Enumerable
6
+ include Frame::APIOperations::Request
7
+
8
+ attr_accessor :filters
9
+ attr_reader :resource_url, :data
10
+
11
+ def initialize(data = {}, opts = {})
12
+ super(nil, opts)
13
+ @data = data[:data] || []
14
+ @filters = {}
15
+ @resource_url = opts[:resource_url]
16
+ @has_more = data[:meta] && data[:meta][:has_more]
17
+ @page = data[:meta] && data[:meta][:page] || 1
18
+
19
+ # Extract per_page from URL if available
20
+ @per_page = if data.dig(:meta, :url)&.include?("per_page=")
21
+ begin
22
+ data[:meta][:url].match(/per_page=(\d+)/)[1].to_i
23
+ rescue
24
+ 10
25
+ end
26
+ else
27
+ 10
28
+ end
29
+ end
30
+
31
+ def self.construct_from(values, opts = {})
32
+ data = values || {}
33
+
34
+ # Initialize from the values - excluding the :data key to avoid accessor conflicts
35
+ # We'll handle data manually since it's declared as an attribute
36
+ obj = new(data, opts)
37
+
38
+ # Store original values except data
39
+ values_without_data = values.dup
40
+ data_array = values_without_data.delete(:data)
41
+
42
+ # Initialize from values without data first
43
+ obj.initialize_from(values_without_data, opts)
44
+
45
+ # Then process the data array
46
+ if data_array&.is_a?(Array)
47
+ converted_data = data_array.map { |item| Util.convert_to_frame_object(item, opts) }
48
+ obj.instance_variable_set(:@data, converted_data)
49
+ end
50
+
51
+ obj
52
+ end
53
+
54
+ def self.empty_list(opts = {})
55
+ construct_from({data: [], meta: {has_more: false, page: 1}}, opts)
56
+ end
57
+
58
+ def [](index)
59
+ @data[index]
60
+ end
61
+
62
+ def each(&blk)
63
+ @data.each(&blk)
64
+ end
65
+
66
+ def empty?
67
+ @data.empty?
68
+ end
69
+
70
+ def first
71
+ @data.first
72
+ end
73
+
74
+ def last
75
+ @data.last
76
+ end
77
+
78
+ def retrieve(id, opts = {})
79
+ resource_class = object_class_for_data
80
+ return resource_class.retrieve(id, opts) if resource_class
81
+ nil
82
+ end
83
+
84
+ def next_page(params = {}, opts = {})
85
+ return self.class.empty_list(opts) unless has_more?
86
+
87
+ params = filters.merge(params || {})
88
+ next_page_num = @page + 1 if @page
89
+ params[:page] = next_page_num
90
+ params[:per_page] = @per_page if @per_page
91
+
92
+ # Get the resource URL - try all possible fallbacks
93
+ url = resource_url ||
94
+ (self.class.respond_to?(:resource_url) ? self.class.resource_url : nil) ||
95
+ "/v1/customers" # Default if we can't determine it
96
+
97
+ response = request(:get, url, params, opts)
98
+ result = Util.convert_to_frame_object(response, opts)
99
+
100
+ # Update this object's state with the next page's data
101
+ if result && !result.empty?
102
+ @page = next_page_num
103
+ @data = result.data
104
+ @has_more = result.has_more?
105
+
106
+ self
107
+ else
108
+ result
109
+ end
110
+ end
111
+
112
+ def has_more?
113
+ !!@has_more
114
+ end
115
+
116
+ def total_count
117
+ @data.size
118
+ end
119
+
120
+ def to_hash
121
+ {
122
+ data: @data.map { |i| i.is_a?(FrameObject) ? i.to_hash : i },
123
+ meta: {
124
+ has_more: has_more?,
125
+ page: @page,
126
+ per_page: @per_page
127
+ }
128
+ }
129
+ end
130
+
131
+ def inspect
132
+ meta_info = "#<#{self.class.name}:0x#{object_id.to_s(16)} @page=#{@page} @per_page=#{@per_page} @has_more=#{@has_more} items=#{@data.size}>"
133
+
134
+ # If data is empty, just return meta info
135
+ return meta_info if @data.empty?
136
+
137
+ # Format each item in the data array
138
+ data_strings = @data.map do |item|
139
+ if item.is_a?(FrameObject) && item.respond_to?(:id) && item.id
140
+ obj_name = item.class.name.split("::").last
141
+ " #<#{obj_name}:#{item.id} #{item.inspect}>"
142
+ else
143
+ " #{item.inspect}"
144
+ end
145
+ end
146
+
147
+ "#{meta_info}\ndata=[\n#{data_strings.join(",\n")}\n]"
148
+ end
149
+
150
+ private
151
+
152
+ def object_class_for_data
153
+ return nil if @data.empty?
154
+
155
+ # Get first item's object type, whether it's already a FrameObject or still a Hash
156
+ first_item = @data.first
157
+ object_name = if first_item.is_a?(FrameObject)
158
+ first_item[:object]
159
+ elsif first_item.is_a?(Hash)
160
+ first_item[:object]
161
+ end
162
+
163
+ Util.object_classes_to_constants[object_name] if object_name
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Frame
4
+ class ChargeIntent < APIResource
5
+ extend Frame::APIOperations::Create
6
+ extend Frame::APIOperations::List
7
+ include Frame::APIOperations::Save
8
+
9
+ OBJECT_NAME = "charge_intent"
10
+
11
+ def self.object_name
12
+ OBJECT_NAME
13
+ end
14
+
15
+ def self.create(params = {}, opts = {})
16
+ request_object(
17
+ :post,
18
+ "/v1/charge_intents",
19
+ params,
20
+ opts
21
+ )
22
+ end
23
+
24
+ def self.list(params = {}, opts = {})
25
+ request_object(
26
+ :get,
27
+ "/v1/charge_intents",
28
+ params,
29
+ opts
30
+ )
31
+ end
32
+
33
+ def self.retrieve(id, opts = {})
34
+ id = Util.normalize_id(id)
35
+ request_object(
36
+ :get,
37
+ "/v1/charge_intents/#{CGI.escape(id)}",
38
+ {},
39
+ opts
40
+ )
41
+ end
42
+
43
+ def authorize(params = {}, opts = {})
44
+ request_object(
45
+ :post,
46
+ "/v1/charge_intents/#{CGI.escape(self["id"])}/authorize",
47
+ params,
48
+ opts
49
+ )
50
+ end
51
+
52
+ def self.authorize(id, params = {}, opts = {})
53
+ request_object(
54
+ :post,
55
+ "/v1/charge_intents/#{CGI.escape(id)}/authorize",
56
+ params,
57
+ opts
58
+ )
59
+ end
60
+
61
+ def capture(params = {}, opts = {})
62
+ request_object(
63
+ :post,
64
+ "/v1/charge_intents/#{CGI.escape(self["id"])}/capture",
65
+ params,
66
+ opts
67
+ )
68
+ end
69
+
70
+ def self.capture(id, params = {}, opts = {})
71
+ request_object(
72
+ :post,
73
+ "/v1/charge_intents/#{CGI.escape(id)}/capture",
74
+ params,
75
+ opts
76
+ )
77
+ end
78
+
79
+ def cancel(params = {}, opts = {})
80
+ request_object(
81
+ :post,
82
+ "/v1/charge_intents/#{CGI.escape(self["id"])}/cancel",
83
+ params,
84
+ opts
85
+ )
86
+ end
87
+
88
+ def self.cancel(id, params = {}, opts = {})
89
+ request_object(
90
+ :post,
91
+ "/v1/charge_intents/#{CGI.escape(id)}/cancel",
92
+ params,
93
+ opts
94
+ )
95
+ end
96
+
97
+ def save(params = {}, opts = {})
98
+ values = serialize_params(self).merge(params)
99
+
100
+ if values.empty?
101
+ return self
102
+ end
103
+
104
+ updated = request_object(
105
+ :patch,
106
+ "/v1/charge_intents/#{CGI.escape(self["id"])}",
107
+ values,
108
+ opts
109
+ )
110
+
111
+ initialize_from(updated)
112
+ self
113
+ end
114
+ end
115
+ end