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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE.txt +18 -0
- data/README.md +278 -0
- data/lib/aho_sdk/account.rb +226 -0
- data/lib/aho_sdk/cursor_page.rb +87 -0
- data/lib/aho_sdk/holder.rb +102 -0
- data/lib/aho_sdk/http_client.rb +420 -0
- data/lib/aho_sdk/issuer.rb +502 -0
- data/lib/aho_sdk/page.rb +82 -0
- data/lib/aho_sdk/public.rb +60 -0
- data/lib/aho_sdk/schemas.rb +85 -0
- data/lib/aho_sdk/system.rb +46 -0
- data/lib/aho_sdk/verifier.rb +152 -0
- data/lib/aho_sdk/version.rb +7 -0
- data/lib/aho_sdk.rb +88 -0
- metadata +74 -0
|
@@ -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
|