aho-sdk 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,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Auto-generated by bin/generate_sdks.rb - DO NOT EDIT
4
+
5
+ require_relative "http_client"
6
+ require_relative "page"
7
+ require_relative "cursor_page"
8
+
9
+ module AhoSdk
10
+ # Manage holder credentials and presentations
11
+ #
12
+ # @example
13
+ # client = AhoSdk::Holder.new(api_key: ENV["AHO_HOLDER_API_KEY"])
14
+ # client.credentials.list
15
+ # client.presentations.list
16
+ #
17
+ class Holder
18
+ # @param api_key [String] API key for authentication
19
+ # @param base_url [String] Base URL (default: https://aho.com)
20
+ # @param timeout [Integer] Request timeout in seconds (default: 30)
21
+ # @param logger [Logger] Optional logger for debugging
22
+ def initialize(api_key:, base_url: "https://aho.com", timeout: 30, logger: nil)
23
+ @client = HttpClient.new(api_key: api_key, base_url: base_url, timeout: timeout, logger: logger)
24
+ @credentials = CredentialsResource.new(@client)
25
+ @presentations = PresentationsResource.new(@client)
26
+ end
27
+
28
+ # @return [CredentialsResource]
29
+ attr_reader :credentials
30
+ # @return [PresentationsResource]
31
+ attr_reader :presentations
32
+
33
+
34
+ # Credentials resource operations
35
+ # @api private
36
+ class CredentialsResource
37
+ # @api private
38
+ def initialize(client)
39
+ @client = client
40
+ end
41
+
42
+ # List holder credentials
43
+ #
44
+ # @return [Hash]
45
+ def list(page: nil, per_page: nil, status: nil, sort: nil, direction: nil)
46
+ fetch_page = ->(p) {
47
+ response = @client.get("/api/v1/holder/credentials", params: { page: p, per_page: per_page, status: status, sort: sort, direction: direction })
48
+ Page.new(data: response[:data], meta: response[:meta], fetch_next: fetch_page)
49
+ }
50
+ fetch_page.call(page)
51
+ end
52
+
53
+ # Get credential details
54
+ #
55
+ # @return [Hash]
56
+ def get(uuid:)
57
+ @client.get("/api/v1/holder/credentials/#{uuid}")
58
+ end
59
+ end
60
+
61
+ # Presentations resource operations
62
+ # @api private
63
+ class PresentationsResource
64
+ # @api private
65
+ def initialize(client)
66
+ @client = client
67
+ end
68
+
69
+ # List holder presentations
70
+ #
71
+ # @return [Hash]
72
+ def list(page: nil, per_page: nil, status: nil, credential_uuid: nil, sort: nil, direction: nil)
73
+ fetch_page = ->(p) {
74
+ response = @client.get("/api/v1/holder/presentations", params: { page: p, per_page: per_page, status: status, credential_uuid: credential_uuid, sort: sort, direction: direction })
75
+ Page.new(data: response[:data], meta: response[:meta], fetch_next: fetch_page)
76
+ }
77
+ fetch_page.call(page)
78
+ end
79
+
80
+ # Create a credential presentation
81
+ #
82
+ # @return [Hash]
83
+ def create(body: nil, idempotency_key: nil)
84
+ @client.post("/api/v1/holder/presentations", body: body, idempotency_key: idempotency_key)
85
+ end
86
+
87
+ # Get presentation details
88
+ #
89
+ # @return [Hash]
90
+ def get(uuid:)
91
+ @client.get("/api/v1/holder/presentations/#{uuid}")
92
+ end
93
+
94
+ # Revoke a presentation
95
+ #
96
+ # @return [Hash]
97
+ def delete(uuid:)
98
+ @client.delete("/api/v1/holder/presentations/#{uuid}")
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,420 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Auto-generated by bin/generate_sdks.rb - DO NOT EDIT
4
+
5
+ require "net/http"
6
+ require "json"
7
+ require "uri"
8
+ require "securerandom"
9
+
10
+ module AhoSdk
11
+ # HTTP client with retry logic, credential redaction, and error handling
12
+ class HttpClient
13
+ DEFAULT_TIMEOUT = 30 # seconds
14
+ DEFAULT_MAX_RETRIES = 3
15
+ DEFAULT_RETRY_DELAY = 1.0 # seconds
16
+
17
+ # Only retry idempotent methods to prevent duplicate operations
18
+ IDEMPOTENT_METHODS = %i[get delete put head options].freeze
19
+
20
+ # @param api_key [String, nil] API key for authentication (nil for public endpoints)
21
+ # @param base_url [String] Base URL for the API
22
+ # @param timeout [Integer] Request timeout in seconds
23
+ # @param max_retries [Integer] Maximum number of retries on rate limit (default: 3)
24
+ # @param retry_delay [Float] Base delay in seconds for exponential backoff (default: 1.0)
25
+ # @param logger [Logger, nil] Optional logger for debugging
26
+ def initialize(api_key:, base_url: "https://aho.com", timeout: DEFAULT_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES, retry_delay: DEFAULT_RETRY_DELAY, logger: nil)
27
+ @api_key = api_key
28
+ @base_url = base_url.chomp("/")
29
+ @timeout = timeout
30
+ @max_retries = max_retries
31
+ @retry_delay = retry_delay
32
+ @logger = logger
33
+ end
34
+
35
+ def get(path, params: {}, accept: "application/json")
36
+ request(:get, path, params: params, accept: accept)
37
+ end
38
+
39
+ def post(path, body: nil, params: {}, accept: "application/json", idempotency_key: nil)
40
+ request(:post, path, body: body, params: params, accept: accept, idempotency_key: idempotency_key)
41
+ end
42
+
43
+ def patch(path, body: nil, params: {}, accept: "application/json", idempotency_key: nil)
44
+ request(:patch, path, body: body, params: params, accept: accept, idempotency_key: idempotency_key)
45
+ end
46
+
47
+ def put(path, body: nil, params: {}, accept: "application/json", idempotency_key: nil)
48
+ request(:put, path, body: body, params: params, accept: accept, idempotency_key: idempotency_key)
49
+ end
50
+
51
+ def delete(path, params: {}, accept: "application/json")
52
+ request(:delete, path, params: params, accept: accept)
53
+ end
54
+
55
+ def head(path, params: {})
56
+ request(:head, path, params: params, accept: "*/*")
57
+ end
58
+
59
+ def options(path, params: {})
60
+ request(:options, path, params: params, accept: "*/*")
61
+ end
62
+
63
+ # POST with multipart/form-data for file uploads
64
+ # @param path [String] API path
65
+ # @param files [Hash] File parameters { param_name: File or String path }
66
+ # @param fields [Hash] Additional form fields
67
+ # @param params [Hash] Query parameters
68
+ # @param idempotency_key [String, nil] Optional idempotency key
69
+ def post_multipart(path, files: {}, fields: {}, params: {}, idempotency_key: nil)
70
+ multipart_request(:post, path, files: files, fields: fields, params: params, idempotency_key: idempotency_key)
71
+ end
72
+
73
+ # PATCH with multipart/form-data for file uploads
74
+ def patch_multipart(path, files: {}, fields: {}, params: {}, idempotency_key: nil)
75
+ multipart_request(:patch, path, files: files, fields: fields, params: params, idempotency_key: idempotency_key)
76
+ end
77
+
78
+ # PUT with multipart/form-data for file uploads
79
+ def put_multipart(path, files: {}, fields: {}, params: {}, idempotency_key: nil)
80
+ multipart_request(:put, path, files: files, fields: fields, params: params, idempotency_key: idempotency_key)
81
+ end
82
+
83
+ private
84
+
85
+ def request(method, path, body: nil, params: {}, accept: "application/json", idempotency_key: nil)
86
+ uri = build_uri(path, params)
87
+ req = build_request(method, uri, body, accept, idempotency_key: idempotency_key)
88
+
89
+ log_request(method, uri)
90
+
91
+ retries = 0
92
+ begin
93
+ execute_request(uri, req, accept)
94
+ rescue RateLimitError => e
95
+ raise unless IDEMPOTENT_METHODS.include?(method) && retries < @max_retries
96
+
97
+ retries += 1
98
+ delay = e.retry_after || (@retry_delay * (2**(retries - 1)))
99
+ log_retry(method, uri, retries, delay)
100
+ sleep(delay)
101
+ retry
102
+ end
103
+ end
104
+
105
+ def multipart_request(method, path, files:, fields:, params: {}, idempotency_key: nil)
106
+ uri = build_uri(path, params)
107
+ req = build_multipart_request(method, uri, files, fields, idempotency_key: idempotency_key)
108
+
109
+ log_request(method, uri)
110
+
111
+ retries = 0
112
+ begin
113
+ execute_request(uri, req, "application/json")
114
+ rescue RateLimitError => e
115
+ raise unless IDEMPOTENT_METHODS.include?(method) && retries < @max_retries
116
+
117
+ retries += 1
118
+ delay = e.retry_after || (@retry_delay * (2**(retries - 1)))
119
+ log_retry(method, uri, retries, delay)
120
+ sleep(delay)
121
+ retry
122
+ end
123
+ end
124
+
125
+ def execute_request(uri, req, accept)
126
+ http = Net::HTTP.new(uri.host, uri.port)
127
+ http.use_ssl = uri.scheme == "https"
128
+ http.open_timeout = @timeout
129
+ http.read_timeout = @timeout
130
+
131
+ response = http.request(req)
132
+ handle_response(response, accept)
133
+ rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, Net::OpenTimeout, Net::ReadTimeout => e
134
+ raise NetworkError, e.message
135
+ end
136
+
137
+ def build_uri(path, params)
138
+ uri = URI.parse("#{@base_url}#{path}")
139
+ uri.query = URI.encode_www_form(flatten_params(params)) if params.any?
140
+ uri
141
+ end
142
+
143
+ # Flatten nested arrays for query params: { status: ['a', 'b'] } => [['status[]', 'a'], ['status[]', 'b']]
144
+ def flatten_params(params)
145
+ params.compact.flat_map do |key, value|
146
+ case value
147
+ when Array
148
+ value.map { |v| [ "#{key}[]", v ] }
149
+ else
150
+ [ [ key, value ] ]
151
+ end
152
+ end
153
+ end
154
+
155
+ def build_request(method, uri, body, accept, idempotency_key: nil)
156
+ klass = {
157
+ get: Net::HTTP::Get,
158
+ post: Net::HTTP::Post,
159
+ patch: Net::HTTP::Patch,
160
+ put: Net::HTTP::Put,
161
+ delete: Net::HTTP::Delete
162
+ }[method]
163
+
164
+ req = klass.new(uri)
165
+ req["X-API-Key"] = @api_key if @api_key
166
+ req["Content-Type"] = "application/json"
167
+ req["Accept"] = accept
168
+ req["User-Agent"] = "aho-sdk/#{VERSION} ruby/#{RUBY_VERSION}"
169
+ req["Idempotency-Key"] = idempotency_key if idempotency_key
170
+ req.body = body.to_json if body
171
+ req
172
+ end
173
+
174
+ def build_multipart_request(method, uri, files, fields, idempotency_key: nil)
175
+ klass = {
176
+ post: Net::HTTP::Post,
177
+ patch: Net::HTTP::Patch,
178
+ put: Net::HTTP::Put
179
+ }[method]
180
+
181
+ boundary = "----RubyMultipartClient#{SecureRandom.hex(16)}"
182
+
183
+ req = klass.new(uri)
184
+ req["X-API-Key"] = @api_key if @api_key
185
+ req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
186
+ req["Accept"] = "application/json"
187
+ req["User-Agent"] = "aho-sdk/#{VERSION} ruby/#{RUBY_VERSION}"
188
+ req["Idempotency-Key"] = idempotency_key if idempotency_key
189
+ req.body = build_multipart_body(boundary, files, fields)
190
+ req
191
+ end
192
+
193
+ def build_multipart_body(boundary, files, fields)
194
+ parts = []
195
+
196
+ # Add regular form fields
197
+ fields.each do |name, value|
198
+ parts << "--#{boundary}\r\n"
199
+ parts << "Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n"
200
+ parts << "#{value}\r\n"
201
+ end
202
+
203
+ # Add file fields
204
+ files.each do |name, file_or_path|
205
+ file, filename, content_type = normalize_file(file_or_path)
206
+
207
+ parts << "--#{boundary}\r\n"
208
+ parts << "Content-Disposition: form-data; name=\"#{name}\"; filename=\"#{filename}\"\r\n"
209
+ parts << "Content-Type: #{content_type}\r\n\r\n"
210
+ parts << file.read
211
+ parts << "\r\n"
212
+
213
+ file.close if file.respond_to?(:close) && file_or_path.is_a?(String)
214
+ end
215
+
216
+ parts << "--#{boundary}--\r\n"
217
+ parts.join
218
+ end
219
+
220
+ def normalize_file(file_or_path)
221
+ case file_or_path
222
+ when String
223
+ # Path string - open file
224
+ file = File.open(file_or_path, "rb")
225
+ filename = File.basename(file_or_path)
226
+ content_type = mime_type_for(filename)
227
+ [ file, filename, content_type ]
228
+ when Hash
229
+ # Hash with file, filename, content_type
230
+ file = file_or_path[:file]
231
+ file = File.open(file, "rb") if file.is_a?(String)
232
+ filename = file_or_path[:filename] || (file.respond_to?(:path) ? File.basename(file.path) : "file")
233
+ content_type = file_or_path[:content_type] || mime_type_for(filename)
234
+ [ file, filename, content_type ]
235
+ else
236
+ # File object
237
+ filename = file_or_path.respond_to?(:path) ? File.basename(file_or_path.path) : "file"
238
+ content_type = mime_type_for(filename)
239
+ [ file_or_path, filename, content_type ]
240
+ end
241
+ end
242
+
243
+ # Auto-detect MIME type from file extension
244
+ MIME_TYPES = {
245
+ ".png" => "image/png",
246
+ ".jpg" => "image/jpeg",
247
+ ".jpeg" => "image/jpeg",
248
+ ".gif" => "image/gif",
249
+ ".webp" => "image/webp",
250
+ ".svg" => "image/svg+xml",
251
+ ".pdf" => "application/pdf",
252
+ ".zip" => "application/zip",
253
+ ".gz" => "application/gzip",
254
+ ".json" => "application/json",
255
+ ".xml" => "application/xml",
256
+ ".txt" => "text/plain",
257
+ ".html" => "text/html",
258
+ ".css" => "text/css",
259
+ ".js" => "application/javascript"
260
+ }.freeze
261
+
262
+ def mime_type_for(filename)
263
+ ext = File.extname(filename).downcase
264
+ MIME_TYPES[ext] || "application/octet-stream"
265
+ end
266
+
267
+ # Log request with credentials redacted
268
+ def log_request(method, uri)
269
+ return unless @logger
270
+
271
+ @logger.debug { "[AhoSdk] #{method.upcase} #{uri}" }
272
+ end
273
+
274
+ def log_retry(method, uri, attempt, delay)
275
+ return unless @logger
276
+
277
+ @logger.warn { "[AhoSdk] Retry #{attempt}/#{@max_retries} for #{method.upcase} #{uri} after #{delay}s" }
278
+ end
279
+
280
+ # Binary content types that should return raw bytes
281
+ BINARY_CONTENT_TYPES = %w[
282
+ image/png image/jpeg image/gif image/webp image/svg+xml
283
+ application/pdf application/octet-stream application/zip application/gzip
284
+ ].freeze
285
+
286
+ def handle_response(response, accept)
287
+ body = response.body.to_s
288
+ request_id = response["X-Request-Id"]
289
+ content_type = response["Content-Type"]&.split(";")&.first&.strip
290
+
291
+ case response.code.to_i
292
+ when 200..299
293
+ return nil if body.empty?
294
+
295
+ # Return raw bytes for binary content types
296
+ if binary_content_type?(content_type) || binary_content_type?(accept)
297
+ return response.body
298
+ end
299
+
300
+ JSON.parse(body, symbolize_names: true)
301
+ when 400
302
+ raise BadRequestError.new(body, response.code.to_i, request_id)
303
+ when 401
304
+ raise AuthenticationError.new(body, response.code.to_i, request_id)
305
+ when 403
306
+ raise ForbiddenError.new(body, response.code.to_i, request_id)
307
+ when 404
308
+ raise NotFoundError.new(body, response.code.to_i, request_id)
309
+ when 409
310
+ raise ConflictError.new(body, response.code.to_i, request_id)
311
+ when 422
312
+ raise ValidationError.new(body, response.code.to_i, request_id)
313
+ when 429
314
+ raise RateLimitError.new(body, response.code.to_i, request_id, response["Retry-After"]&.to_f)
315
+ when 500..599
316
+ raise ServerError.new(body, response.code.to_i, request_id)
317
+ else
318
+ raise ApiError.new(body, response.code.to_i, request_id)
319
+ end
320
+ end
321
+
322
+ def binary_content_type?(content_type)
323
+ return false unless content_type
324
+
325
+ BINARY_CONTENT_TYPES.any? { |bin_type| content_type.start_with?(bin_type) } ||
326
+ content_type.start_with?("image/")
327
+ end
328
+ end
329
+
330
+ # Base error class for API errors
331
+ class ApiError < StandardError
332
+ attr_reader :status_code, :request_id, :error_code, :details, :raw_body
333
+
334
+ def initialize(body, status_code, request_id = nil)
335
+ @status_code = status_code
336
+ @request_id = request_id
337
+ @raw_body = body
338
+
339
+ begin
340
+ parsed = JSON.parse(body, symbolize_names: true)
341
+ error = parsed[:error] || parsed
342
+ @error_code = error[:code]
343
+ @details = normalize_details(error[:details])
344
+ super(error[:message] || body)
345
+ rescue JSON::ParserError
346
+ @error_code = "unknown"
347
+ @details = []
348
+ super(body)
349
+ end
350
+ end
351
+
352
+ private
353
+
354
+ # Normalize details to array of FieldError-like hashes
355
+ # API can return: string, array of strings, array of FieldError objects, hash, or nil
356
+ def normalize_details(details)
357
+ case details
358
+ when nil
359
+ []
360
+ when String
361
+ [ { field: "_base", issue: "error", hint: details } ]
362
+ when Array
363
+ details.map do |item|
364
+ case item
365
+ when String
366
+ { field: "_base", issue: "error", hint: item }
367
+ when Hash
368
+ item
369
+ else
370
+ { field: "_base", issue: "error", hint: item.to_s }
371
+ end
372
+ end
373
+ when Hash
374
+ # Legacy format: { field_name: [errors] }
375
+ details.flat_map do |field, errors|
376
+ Array(errors).map { |e| { field: field.to_s, issue: "validation_failed", hint: e } }
377
+ end
378
+ else
379
+ [ { field: "_base", issue: "error", hint: details.to_s } ]
380
+ end
381
+ end
382
+ end
383
+
384
+ class BadRequestError < ApiError; end
385
+ class AuthenticationError < ApiError; end
386
+ class ForbiddenError < ApiError; end
387
+ class NotFoundError < ApiError; end
388
+ class ConflictError < ApiError; end
389
+ class ServerError < ApiError; end
390
+
391
+ class ValidationError < ApiError
392
+ # Returns array of { field:, issue:, hint: } hashes
393
+ def field_errors
394
+ details || []
395
+ end
396
+
397
+ # Group errors by field name for convenience
398
+ def errors_by_field
399
+ field_errors.group_by { |e| e[:field] }
400
+ end
401
+ end
402
+
403
+ class RateLimitError < ApiError
404
+ attr_reader :retry_after
405
+
406
+ def initialize(body, status_code, request_id, retry_after)
407
+ super(body, status_code, request_id)
408
+ @retry_after = retry_after
409
+ end
410
+ end
411
+
412
+ class NetworkError < StandardError
413
+ attr_reader :status_code
414
+
415
+ def initialize(message)
416
+ @status_code = 0
417
+ super(message)
418
+ end
419
+ end
420
+ end