whatsapp-cloud-api-ruby 1.0.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,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhatsAppCloudApi
4
+ module Errors
5
+ # Error categories mapped from the JavaScript implementation
6
+ ERROR_CATEGORIES = {
7
+ 'authorization' => :authorization,
8
+ 'permission' => :permission,
9
+ 'parameter' => :parameter,
10
+ 'throttling' => :throttling,
11
+ 'template' => :template,
12
+ 'media' => :media,
13
+ 'phone_registration' => :phone_registration,
14
+ 'integrity' => :integrity,
15
+ 'business_eligibility' => :business_eligibility,
16
+ 'reengagement_window' => :reengagement_window,
17
+ 'waba_config' => :waba_config,
18
+ 'flow' => :flow,
19
+ 'synchronization' => :synchronization,
20
+ 'server' => :server,
21
+ 'unknown' => :unknown
22
+ }.freeze
23
+
24
+ # Error codes and their categories
25
+ ERROR_CODE_CATEGORIES = {
26
+ 0 => :authorization,
27
+ 190 => :authorization,
28
+ 3 => :permission,
29
+ 10 => :permission,
30
+ (200..219) => :permission,
31
+ 4 => :throttling,
32
+ 80007 => :throttling,
33
+ 130429 => :throttling,
34
+ 131048 => :throttling,
35
+ 131056 => :throttling,
36
+ 33 => :parameter,
37
+ 100 => :parameter,
38
+ 130472 => :parameter,
39
+ 131008 => :parameter,
40
+ 131009 => :parameter,
41
+ 131021 => :parameter,
42
+ 131026 => :parameter,
43
+ 131051 => :media,
44
+ 131052 => :media,
45
+ 131053 => :media,
46
+ 131000 => :server,
47
+ 131016 => :server,
48
+ 131057 => :server,
49
+ 133004 => :server,
50
+ 133005 => :server,
51
+ 368 => :integrity,
52
+ 130497 => :integrity,
53
+ 131031 => :integrity,
54
+ 131047 => :reengagement_window,
55
+ 131037 => :waba_config,
56
+ 131042 => :business_eligibility,
57
+ 131045 => :phone_registration,
58
+ 133000 => :phone_registration,
59
+ 133006 => :phone_registration,
60
+ 133008 => :phone_registration,
61
+ 133009 => :phone_registration,
62
+ 133010 => :phone_registration,
63
+ 133015 => :phone_registration,
64
+ 133016 => :phone_registration,
65
+ 132000 => :template,
66
+ 132001 => :template,
67
+ 132005 => :template,
68
+ 132007 => :template,
69
+ 132012 => :template,
70
+ 132015 => :template,
71
+ 132016 => :template,
72
+ 132068 => :flow,
73
+ 132069 => :flow,
74
+ 134011 => :business_eligibility,
75
+ 135000 => :parameter,
76
+ 2593107 => :synchronization,
77
+ 2593108 => :synchronization
78
+ }.freeze
79
+
80
+ # Error codes that should not be retried
81
+ DO_NOT_RETRY_CODES = [131049, 131050, 131047, 368, 130497, 131031].freeze
82
+
83
+ # Error codes that require token refresh
84
+ REFRESH_TOKEN_CODES = [0, 190].freeze
85
+
86
+ class GraphApiError < StandardError
87
+ attr_reader :http_status, :code, :type, :details, :error_subcode,
88
+ :fbtrace_id, :error_data, :category, :retry_hint, :raw_response, :retry_after
89
+
90
+ def initialize(message: nil, http_status:, code: nil, type: nil, details: nil,
91
+ error_subcode: nil, fbtrace_id: nil, error_data: nil,
92
+ category: nil, retry_hint: nil, raw_response: nil, retry_after: nil)
93
+ @http_status = http_status
94
+ @code = code || http_status
95
+ @type = type || 'GraphApiError'
96
+ @details = details
97
+ @error_subcode = error_subcode
98
+ @fbtrace_id = fbtrace_id
99
+ @error_data = error_data
100
+ @retry_after = retry_after
101
+ @category = category || categorize_error_code(@code, @http_status)
102
+ @retry_hint = retry_hint || derive_retry_hint
103
+ @raw_response = raw_response
104
+
105
+ error_message = message || build_error_message
106
+ super(error_message)
107
+ end
108
+
109
+ class << self
110
+ def from_response(response, body = nil, raw_text = nil)
111
+ http_status = response.status
112
+ retry_after_ms = parse_retry_after(response.headers['retry-after'])
113
+
114
+ # Ensure body is a hash for processing
115
+ unless body.is_a?(Hash)
116
+ if body.is_a?(String)
117
+ begin
118
+ body = JSON.parse(body)
119
+ rescue JSON::ParserError
120
+ body = {}
121
+ end
122
+ else
123
+ body = {}
124
+ end
125
+ end
126
+
127
+ # Check for Graph API error envelope
128
+ if body.key?('error')
129
+ error_payload = body['error']
130
+ code = error_payload['code'] || http_status
131
+ type = error_payload['type'] || 'GraphApiError'
132
+ details = error_payload.is_a?(Hash) ? error_payload.dig('error_data', 'details') : nil
133
+
134
+ new(
135
+ message: error_payload['message'],
136
+ http_status: http_status,
137
+ code: code,
138
+ type: type,
139
+ details: details,
140
+ error_subcode: error_payload['error_subcode'],
141
+ fbtrace_id: error_payload['fbtrace_id'],
142
+ error_data: error_payload['error_data'],
143
+ retry_hint: build_retry_hint_with_delay(code, http_status, retry_after_ms),
144
+ raw_response: body
145
+ )
146
+ elsif body.is_a?(Hash) && body.key?('error') && body['error'].is_a?(String)
147
+ # Kapso proxy error format
148
+ category = http_status >= 500 ? :server : categorize_error_code(nil, http_status)
149
+ new(
150
+ message: body['error'],
151
+ http_status: http_status,
152
+ code: http_status,
153
+ category: category,
154
+ retry_hint: build_retry_hint_with_delay(http_status, http_status, retry_after_ms),
155
+ raw_response: body
156
+ )
157
+ else
158
+ # Generic HTTP error
159
+ category = http_status >= 500 ? :server : categorize_error_code(nil, http_status)
160
+ message = build_default_message(http_status, nil, raw_text)
161
+
162
+ new(
163
+ message: message,
164
+ http_status: http_status,
165
+ code: http_status,
166
+ category: category,
167
+ retry_hint: build_retry_hint_with_delay(http_status, http_status, retry_after_ms),
168
+ raw_response: raw_text || body
169
+ )
170
+ end
171
+ end
172
+
173
+ private
174
+
175
+ def parse_retry_after(header)
176
+ return nil unless header
177
+
178
+ # Try parsing as number of seconds
179
+ if header.match?(/^\d+$/)
180
+ header.to_i * 1000
181
+ else
182
+ # Try parsing as HTTP date
183
+ begin
184
+ date = Time.parse(header)
185
+ diff = (date.to_f - Time.now.to_f) * 1000
186
+ diff > 0 ? diff.to_i : 0
187
+ rescue ArgumentError
188
+ nil
189
+ end
190
+ end
191
+ end
192
+
193
+ def categorize_error_code(code, http_status)
194
+ return :authorization if http_status == 401
195
+ return :permission if http_status == 403
196
+ return :parameter if http_status == 404
197
+ return :throttling if http_status == 429
198
+ return :server if http_status >= 500
199
+ return :parameter if http_status >= 400 && http_status < 500
200
+
201
+ if code
202
+ ERROR_CODE_CATEGORIES.each do |key, category|
203
+ if key.is_a?(Range)
204
+ return category if key.include?(code)
205
+ elsif key == code
206
+ return category
207
+ end
208
+ end
209
+
210
+ # Check permission range
211
+ return :permission if code >= 200 && code <= 299
212
+ end
213
+
214
+ :unknown
215
+ end
216
+
217
+ def build_retry_hint_with_delay(code, http_status, retry_after_ms)
218
+ if retry_after_ms
219
+ { action: :retry_after, retry_after_ms: retry_after_ms }
220
+ elsif DO_NOT_RETRY_CODES.include?(code)
221
+ { action: :do_not_retry }
222
+ elsif REFRESH_TOKEN_CODES.include?(code)
223
+ { action: :refresh_token }
224
+ elsif http_status >= 500
225
+ { action: :retry }
226
+ else
227
+ { action: :fix_and_retry }
228
+ end
229
+ end
230
+
231
+ def build_default_message(status, details = nil, raw_text = nil)
232
+ if details
233
+ "Meta API request failed with status #{status}: #{details}"
234
+ elsif raw_text && !raw_text.strip.empty?
235
+ "Meta API request failed with status #{status}: #{raw_text}"
236
+ else
237
+ "Meta API request failed with status #{status}"
238
+ end
239
+ end
240
+ end
241
+
242
+ def auth_error?
243
+ category == :authorization
244
+ end
245
+
246
+ def rate_limit?
247
+ category == :throttling
248
+ end
249
+
250
+ def temporary?
251
+ [:throttling, :server, :synchronization].include?(category) ||
252
+ http_status >= 500 ||
253
+ [1, 2, 17, 341].include?(code)
254
+ end
255
+
256
+ def template_error?
257
+ category == :template
258
+ end
259
+
260
+ def requires_token_refresh?
261
+ category == :authorization || REFRESH_TOKEN_CODES.include?(code)
262
+ end
263
+
264
+ def retryable?
265
+ ![:do_not_retry].include?(retry_hint[:action])
266
+ end
267
+
268
+ def to_h
269
+ {
270
+ name: self.class.name,
271
+ message: message,
272
+ http_status: http_status,
273
+ code: code,
274
+ type: type,
275
+ details: details,
276
+ error_subcode: error_subcode,
277
+ fbtrace_id: fbtrace_id,
278
+ category: category,
279
+ retry_hint: retry_hint,
280
+ raw_response: raw_response
281
+ }
282
+ end
283
+
284
+ private
285
+
286
+ def categorize_error_code(code, http_status)
287
+ self.class.send(:categorize_error_code, code, http_status)
288
+ end
289
+
290
+ def derive_retry_hint
291
+ if DO_NOT_RETRY_CODES.include?(code)
292
+ { action: :do_not_retry }
293
+ elsif REFRESH_TOKEN_CODES.include?(code)
294
+ { action: :refresh_token }
295
+ elsif http_status >= 500
296
+ { action: :retry }
297
+ else
298
+ { action: :fix_and_retry }
299
+ end
300
+ end
301
+
302
+ def build_error_message
303
+ if details
304
+ "Meta API request failed with status #{http_status}: #{details}"
305
+ elsif raw_response.is_a?(String) && !raw_response.strip.empty?
306
+ "Meta API request failed with status #{http_status}: #{raw_response}"
307
+ else
308
+ "Meta API request failed with status #{http_status}"
309
+ end
310
+ end
311
+ end
312
+
313
+ class KapsoProxyRequiredError < StandardError
314
+ attr_reader :feature, :help_url
315
+
316
+ def initialize(feature)
317
+ @feature = feature
318
+ @help_url = 'https://kapso.ai/'
319
+
320
+ message = "#{feature} is only available via the Kapso Proxy. " \
321
+ "Set base_url to https://app.kapso.ai/api/meta and provide kapso_api_key. " \
322
+ "Create a free account at #{help_url}"
323
+ super(message)
324
+ end
325
+ end
326
+
327
+ class ConfigurationError < StandardError; end
328
+ class ValidationError < StandardError; end
329
+ end
330
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhatsAppCloudApi
4
+ module Resources
5
+ class Calls
6
+ def initialize(client)
7
+ @client = client
8
+ end
9
+
10
+ # Initiate a call
11
+ def connect(phone_number_id:, to:, session: nil, biz_opaque_callback_data: nil)
12
+ payload = {
13
+ messaging_product: 'whatsapp',
14
+ to: to,
15
+ action: 'connect'
16
+ }
17
+
18
+ payload[:session] = session if session
19
+ payload[:biz_opaque_callback_data] = biz_opaque_callback_data if biz_opaque_callback_data
20
+
21
+ response = @client.request(:post, "#{phone_number_id}/calls",
22
+ body: payload.to_json, response_type: :json)
23
+ Types::CallConnectResponse.new(response)
24
+ end
25
+
26
+ # Pre-accept a call
27
+ def pre_accept(phone_number_id:, call_id:, session:)
28
+ raise ArgumentError, 'call_id cannot be empty' if call_id.nil? || call_id.strip.empty?
29
+ raise ArgumentError, 'session cannot be nil' if session.nil?
30
+
31
+ payload = {
32
+ messaging_product: 'whatsapp',
33
+ call_id: call_id,
34
+ action: 'pre_accept',
35
+ session: session
36
+ }
37
+
38
+ response = @client.request(:post, "#{phone_number_id}/calls",
39
+ body: payload.to_json, response_type: :json)
40
+ Types::CallActionResponse.new(response)
41
+ end
42
+
43
+ # Accept a call
44
+ def accept(phone_number_id:, call_id:, session:, biz_opaque_callback_data: nil)
45
+ raise ArgumentError, 'call_id cannot be empty' if call_id.nil? || call_id.strip.empty?
46
+ raise ArgumentError, 'session cannot be nil' if session.nil?
47
+
48
+ payload = {
49
+ messaging_product: 'whatsapp',
50
+ call_id: call_id,
51
+ action: 'accept',
52
+ session: session
53
+ }
54
+
55
+ payload[:biz_opaque_callback_data] = biz_opaque_callback_data if biz_opaque_callback_data
56
+
57
+ response = @client.request(:post, "#{phone_number_id}/calls",
58
+ body: payload.to_json, response_type: :json)
59
+ Types::CallActionResponse.new(response)
60
+ end
61
+
62
+ # Reject a call
63
+ def reject(phone_number_id:, call_id:)
64
+ raise ArgumentError, 'call_id cannot be empty' if call_id.nil? || call_id.strip.empty?
65
+
66
+ payload = {
67
+ messaging_product: 'whatsapp',
68
+ call_id: call_id,
69
+ action: 'reject'
70
+ }
71
+
72
+ response = @client.request(:post, "#{phone_number_id}/calls",
73
+ body: payload.to_json, response_type: :json)
74
+ Types::CallActionResponse.new(response)
75
+ end
76
+
77
+ # Terminate a call
78
+ def terminate(phone_number_id:, call_id:)
79
+ raise ArgumentError, 'call_id cannot be empty' if call_id.nil? || call_id.strip.empty?
80
+
81
+ payload = {
82
+ messaging_product: 'whatsapp',
83
+ call_id: call_id,
84
+ action: 'terminate'
85
+ }
86
+
87
+ response = @client.request(:post, "#{phone_number_id}/calls",
88
+ body: payload.to_json, response_type: :json)
89
+ Types::CallActionResponse.new(response)
90
+ end
91
+
92
+ # List calls (Kapso Proxy only)
93
+ def list(phone_number_id:, direction: nil, status: nil, since: nil,
94
+ until_time: nil, call_id: nil, limit: nil, after: nil,
95
+ before: nil, fields: nil)
96
+ assert_kapso_proxy('Call history API')
97
+
98
+ query_params = {
99
+ direction: direction,
100
+ status: status,
101
+ since: since,
102
+ until: until_time,
103
+ call_id: call_id,
104
+ limit: limit,
105
+ after: after,
106
+ before: before,
107
+ fields: fields
108
+ }.compact
109
+
110
+ response = @client.request(:get, "#{phone_number_id}/calls",
111
+ query: query_params, response_type: :json)
112
+ Types::PagedResponse.new(response, Types::CallRecord)
113
+ end
114
+
115
+ # Get call details (Kapso Proxy only)
116
+ def get(phone_number_id:, call_id:, fields: nil)
117
+ assert_kapso_proxy('Call details API')
118
+
119
+ query_params = {}
120
+ query_params[:fields] = fields if fields
121
+
122
+ response = @client.request(:get, "#{phone_number_id}/calls/#{call_id}",
123
+ query: query_params, response_type: :json)
124
+ Types::CallRecord.new(response)
125
+ end
126
+
127
+ # Call permissions management
128
+ class Permissions
129
+ def initialize(client)
130
+ @client = client
131
+ end
132
+
133
+ # Get call permissions
134
+ def get(phone_number_id:, user_wa_id:)
135
+ raise ArgumentError, 'user_wa_id cannot be empty' if user_wa_id.nil? || user_wa_id.strip.empty?
136
+
137
+ query_params = { user_wa_id: user_wa_id }
138
+
139
+ response = @client.request(:get, "#{phone_number_id}/call_permissions",
140
+ query: query_params, response_type: :json)
141
+ response
142
+ end
143
+
144
+ # Update call permissions
145
+ def update(phone_number_id:, user_wa_id:, permission:)
146
+ raise ArgumentError, 'user_wa_id cannot be empty' if user_wa_id.nil? || user_wa_id.strip.empty?
147
+ raise ArgumentError, 'permission cannot be empty' if permission.nil?
148
+
149
+ payload = {
150
+ user_wa_id: user_wa_id,
151
+ permission: permission
152
+ }
153
+
154
+ response = @client.request(:post, "#{phone_number_id}/call_permissions",
155
+ body: payload.to_json, response_type: :json)
156
+ Types::GraphSuccessResponse.new(response)
157
+ end
158
+ end
159
+
160
+ def permissions
161
+ @permissions ||= Permissions.new(@client)
162
+ end
163
+
164
+ private
165
+
166
+ def assert_kapso_proxy(feature)
167
+ unless @client.kapso_proxy?
168
+ raise Errors::KapsoProxyRequiredError.new(feature)
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhatsAppCloudApi
4
+ module Resources
5
+ class Contacts
6
+ def initialize(client)
7
+ @client = client
8
+ end
9
+
10
+ # List contacts (Kapso Proxy only)
11
+ def list(phone_number_id:, customer_id: nil, phone_number: nil,
12
+ profile_name: nil, limit: nil, after: nil, before: nil, fields: nil)
13
+ assert_kapso_proxy('Contacts API')
14
+
15
+ query_params = {
16
+ customer_id: customer_id,
17
+ phone_number: phone_number,
18
+ profile_name: profile_name,
19
+ limit: limit,
20
+ after: after,
21
+ before: before,
22
+ fields: fields
23
+ }.compact
24
+
25
+ response = @client.request(:get, "#{phone_number_id}/contacts",
26
+ query: query_params, response_type: :json)
27
+ Types::PagedResponse.new(response, Types::ContactRecord)
28
+ end
29
+
30
+ # Get contact details (Kapso Proxy only)
31
+ def get(phone_number_id:, wa_id:, fields: nil)
32
+ assert_kapso_proxy('Contacts API')
33
+
34
+ raise ArgumentError, 'wa_id cannot be empty' if wa_id.nil? || wa_id.strip.empty?
35
+
36
+ query_params = {}
37
+ query_params[:fields] = fields if fields
38
+
39
+ response = @client.request(:get, "#{phone_number_id}/contacts/#{wa_id}",
40
+ query: query_params, response_type: :json)
41
+
42
+ # Handle both single object and data envelope responses
43
+ if response.is_a?(Hash) && response.key?('data')
44
+ Types::ContactRecord.new(response['data'])
45
+ else
46
+ Types::ContactRecord.new(response)
47
+ end
48
+ end
49
+
50
+ # Update contact metadata (Kapso Proxy only)
51
+ def update(phone_number_id:, wa_id:, metadata: nil, tags: nil,
52
+ customer_id: nil, notes: nil)
53
+ assert_kapso_proxy('Contacts API')
54
+
55
+ raise ArgumentError, 'wa_id cannot be empty' if wa_id.nil? || wa_id.strip.empty?
56
+
57
+ payload = {}
58
+ payload[:metadata] = metadata if metadata
59
+ payload[:tags] = tags if tags
60
+ payload[:customer_id] = customer_id if customer_id
61
+ payload[:notes] = notes if notes
62
+
63
+ return if payload.empty?
64
+
65
+ response = @client.request(:patch, "#{phone_number_id}/contacts/#{wa_id}",
66
+ body: payload.to_json, response_type: :json)
67
+ Types::GraphSuccessResponse.new(response)
68
+ end
69
+
70
+ # Add tags to contact (Kapso Proxy only)
71
+ def add_tags(phone_number_id:, wa_id:, tags:)
72
+ raise ArgumentError, 'tags cannot be empty' if tags.nil? || tags.empty?
73
+
74
+ current_contact = get(phone_number_id: phone_number_id, wa_id: wa_id)
75
+ existing_tags = (current_contact.metadata&.[]('tags') || [])
76
+ new_tags = (existing_tags + Array(tags)).uniq
77
+
78
+ update(phone_number_id: phone_number_id, wa_id: wa_id,
79
+ metadata: { tags: new_tags })
80
+ end
81
+
82
+ # Remove tags from contact (Kapso Proxy only)
83
+ def remove_tags(phone_number_id:, wa_id:, tags:)
84
+ raise ArgumentError, 'tags cannot be empty' if tags.nil? || tags.empty?
85
+
86
+ current_contact = get(phone_number_id: phone_number_id, wa_id: wa_id)
87
+ existing_tags = (current_contact.metadata&.[]('tags') || [])
88
+ remaining_tags = existing_tags - Array(tags)
89
+
90
+ update(phone_number_id: phone_number_id, wa_id: wa_id,
91
+ metadata: { tags: remaining_tags })
92
+ end
93
+
94
+ # Search contacts by various criteria (Kapso Proxy only)
95
+ def search(phone_number_id:, query:, search_in: ['profile_name', 'phone_number'],
96
+ limit: nil, after: nil, before: nil)
97
+ assert_kapso_proxy('Contacts Search API')
98
+
99
+ raise ArgumentError, 'query cannot be empty' if query.nil? || query.strip.empty?
100
+
101
+ query_params = {
102
+ q: query,
103
+ search_in: Array(search_in).join(','),
104
+ limit: limit,
105
+ after: after,
106
+ before: before
107
+ }.compact
108
+
109
+ response = @client.request(:get, "#{phone_number_id}/contacts/search",
110
+ query: query_params, response_type: :json)
111
+ Types::PagedResponse.new(response, Types::ContactRecord)
112
+ end
113
+
114
+ # Get contact analytics (Kapso Proxy only)
115
+ def analytics(phone_number_id:, wa_id: nil, since: nil, until_time: nil,
116
+ granularity: 'day', metrics: nil)
117
+ assert_kapso_proxy('Contact Analytics API')
118
+
119
+ query_params = {
120
+ wa_id: wa_id,
121
+ since: since,
122
+ until: until_time,
123
+ granularity: granularity
124
+ }
125
+ query_params[:metrics] = Array(metrics).join(',') if metrics
126
+ query_params = query_params.compact
127
+
128
+ response = @client.request(:get, "#{phone_number_id}/contacts/analytics",
129
+ query: query_params, response_type: :json)
130
+ response
131
+ end
132
+
133
+ # Export contacts (Kapso Proxy only)
134
+ def export(phone_number_id:, format: 'csv', filters: nil)
135
+ assert_kapso_proxy('Contacts Export API')
136
+
137
+ payload = {
138
+ format: format,
139
+ filters: filters
140
+ }.compact
141
+
142
+ response = @client.request(:post, "#{phone_number_id}/contacts/export",
143
+ body: payload.to_json, response_type: :json)
144
+ response
145
+ end
146
+
147
+ # Import contacts (Kapso Proxy only)
148
+ def import(phone_number_id:, file:, format: 'csv', mapping: nil,
149
+ duplicate_handling: 'skip')
150
+ assert_kapso_proxy('Contacts Import API')
151
+
152
+ # Build multipart form data
153
+ form_data = {
154
+ 'format' => format,
155
+ 'duplicate_handling' => duplicate_handling
156
+ }
157
+
158
+ # Handle file parameter
159
+ file_obj = case file
160
+ when String
161
+ File.open(file, 'rb')
162
+ when File, IO, StringIO
163
+ file
164
+ else
165
+ raise ArgumentError, 'file must be a File, IO object, or file path string'
166
+ end
167
+
168
+ form_data['file'] = Faraday::UploadIO.new(file_obj, 'text/csv', 'contacts.csv')
169
+ form_data['mapping'] = mapping.to_json if mapping
170
+
171
+ headers = { 'Content-Type' => 'multipart/form-data' }
172
+
173
+ response = @client.request(:post, "#{phone_number_id}/contacts/import",
174
+ body: form_data, headers: headers, response_type: :json)
175
+
176
+ # Close file if we opened it
177
+ file_obj.close if file.is_a?(String) && file_obj.respond_to?(:close)
178
+
179
+ response
180
+ end
181
+
182
+ private
183
+
184
+ def assert_kapso_proxy(feature)
185
+ unless @client.kapso_proxy?
186
+ raise Errors::KapsoProxyRequiredError.new(feature)
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end