dexa-ai-sdk 0.3.5

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.
Files changed (91) hide show
  1. checksums.yaml +7 -0
  2. data/.fernignore +5 -0
  3. data/.rubocop.yml +107 -0
  4. data/CONTRIBUTING.md +120 -0
  5. data/README.md +199 -0
  6. data/Rakefile +20 -0
  7. data/custom.gemspec.rb +23 -0
  8. data/lib/dexa-ai-sdk/client.rb +26 -0
  9. data/lib/dexa-ai-sdk/environment.rb +7 -0
  10. data/lib/dexa-ai-sdk/errors/api_error.rb +8 -0
  11. data/lib/dexa-ai-sdk/errors/client_error.rb +17 -0
  12. data/lib/dexa-ai-sdk/errors/connection_error.rb +11 -0
  13. data/lib/dexa-ai-sdk/errors/redirect_error.rb +8 -0
  14. data/lib/dexa-ai-sdk/errors/response_error.rb +63 -0
  15. data/lib/dexa-ai-sdk/errors/server_error.rb +11 -0
  16. data/lib/dexa-ai-sdk/errors/timeout_error.rb +8 -0
  17. data/lib/dexa-ai-sdk/internal/errors/constraint_error.rb +10 -0
  18. data/lib/dexa-ai-sdk/internal/errors/type_error.rb +10 -0
  19. data/lib/dexa-ai-sdk/internal/http/base_request.rb +81 -0
  20. data/lib/dexa-ai-sdk/internal/http/raw_client.rb +295 -0
  21. data/lib/dexa-ai-sdk/internal/iterators/cursor_item_iterator.rb +33 -0
  22. data/lib/dexa-ai-sdk/internal/iterators/cursor_page_iterator.rb +117 -0
  23. data/lib/dexa-ai-sdk/internal/iterators/item_iterator.rb +111 -0
  24. data/lib/dexa-ai-sdk/internal/iterators/offset_item_iterator.rb +35 -0
  25. data/lib/dexa-ai-sdk/internal/iterators/offset_page_iterator.rb +151 -0
  26. data/lib/dexa-ai-sdk/internal/json/request.rb +50 -0
  27. data/lib/dexa-ai-sdk/internal/json/serializable.rb +25 -0
  28. data/lib/dexa-ai-sdk/internal/multipart/multipart_encoder.rb +141 -0
  29. data/lib/dexa-ai-sdk/internal/multipart/multipart_form_data.rb +83 -0
  30. data/lib/dexa-ai-sdk/internal/multipart/multipart_form_data_part.rb +51 -0
  31. data/lib/dexa-ai-sdk/internal/multipart/multipart_request.rb +41 -0
  32. data/lib/dexa-ai-sdk/internal/types/array.rb +47 -0
  33. data/lib/dexa-ai-sdk/internal/types/boolean.rb +34 -0
  34. data/lib/dexa-ai-sdk/internal/types/enum.rb +56 -0
  35. data/lib/dexa-ai-sdk/internal/types/hash.rb +36 -0
  36. data/lib/dexa-ai-sdk/internal/types/model/field.rb +38 -0
  37. data/lib/dexa-ai-sdk/internal/types/model.rb +220 -0
  38. data/lib/dexa-ai-sdk/internal/types/type.rb +35 -0
  39. data/lib/dexa-ai-sdk/internal/types/union.rb +161 -0
  40. data/lib/dexa-ai-sdk/internal/types/unknown.rb +15 -0
  41. data/lib/dexa-ai-sdk/internal/types/utils.rb +123 -0
  42. data/lib/dexa-ai-sdk/tasks/client.rb +190 -0
  43. data/lib/dexa-ai-sdk/tasks/types/decide_request.rb +37 -0
  44. data/lib/dexa-ai-sdk/tasks/types/decide_request_output_language.rb +32 -0
  45. data/lib/dexa-ai-sdk/tasks/types/decide_request_response_format.rb +11 -0
  46. data/lib/dexa-ai-sdk/tasks/types/decide_request_response_format_type.rb +13 -0
  47. data/lib/dexa-ai-sdk/tasks/types/decide_request_target_language.rb +32 -0
  48. data/lib/dexa-ai-sdk/tasks/types/extract_request.rb +37 -0
  49. data/lib/dexa-ai-sdk/tasks/types/extract_request_output_language.rb +32 -0
  50. data/lib/dexa-ai-sdk/tasks/types/extract_request_response_format.rb +11 -0
  51. data/lib/dexa-ai-sdk/tasks/types/extract_request_response_format_type.rb +13 -0
  52. data/lib/dexa-ai-sdk/tasks/types/extract_request_target_language.rb +32 -0
  53. data/lib/dexa-ai-sdk/types/audio_input.rb +15 -0
  54. data/lib/dexa-ai-sdk/types/audio_input_type.rb +12 -0
  55. data/lib/dexa-ai-sdk/types/choice.rb +11 -0
  56. data/lib/dexa-ai-sdk/types/choice_finish_reason.rb +13 -0
  57. data/lib/dexa-ai-sdk/types/content_part.rb +21 -0
  58. data/lib/dexa-ai-sdk/types/content_part_language.rb +30 -0
  59. data/lib/dexa-ai-sdk/types/content_part_type.rb +15 -0
  60. data/lib/dexa-ai-sdk/types/decide_response.rb +21 -0
  61. data/lib/dexa-ai-sdk/types/decide_result.rb +11 -0
  62. data/lib/dexa-ai-sdk/types/decide_task.rb +21 -0
  63. data/lib/dexa-ai-sdk/types/decide_task_target_language.rb +30 -0
  64. data/lib/dexa-ai-sdk/types/error_response.rb +9 -0
  65. data/lib/dexa-ai-sdk/types/error_response_error.rb +15 -0
  66. data/lib/dexa-ai-sdk/types/extract_response.rb +19 -0
  67. data/lib/dexa-ai-sdk/types/extract_task.rb +21 -0
  68. data/lib/dexa-ai-sdk/types/extract_task_target_language.rb +30 -0
  69. data/lib/dexa-ai-sdk/types/image_input.rb +15 -0
  70. data/lib/dexa-ai-sdk/types/image_input_type.rb +12 -0
  71. data/lib/dexa-ai-sdk/types/image_url.rb +11 -0
  72. data/lib/dexa-ai-sdk/types/image_url_detail.rb +13 -0
  73. data/lib/dexa-ai-sdk/types/message.rb +13 -0
  74. data/lib/dexa-ai-sdk/types/message_content.rb +13 -0
  75. data/lib/dexa-ai-sdk/types/message_language.rb +30 -0
  76. data/lib/dexa-ai-sdk/types/message_role.rb +13 -0
  77. data/lib/dexa-ai-sdk/types/response_message.rb +13 -0
  78. data/lib/dexa-ai-sdk/types/response_message_language.rb +30 -0
  79. data/lib/dexa-ai-sdk/types/response_message_role.rb +11 -0
  80. data/lib/dexa-ai-sdk/types/run_tasks_request.rb +29 -0
  81. data/lib/dexa-ai-sdk/types/run_tasks_request_output_language.rb +30 -0
  82. data/lib/dexa-ai-sdk/types/run_tasks_request_response_format.rb +9 -0
  83. data/lib/dexa-ai-sdk/types/run_tasks_request_response_format_type.rb +11 -0
  84. data/lib/dexa-ai-sdk/types/run_tasks_request_target_language.rb +30 -0
  85. data/lib/dexa-ai-sdk/types/run_tasks_response.rb +19 -0
  86. data/lib/dexa-ai-sdk/types/task.rb +15 -0
  87. data/lib/dexa-ai-sdk/types/usage.rb +13 -0
  88. data/lib/dexa-ai-sdk/version.rb +5 -0
  89. data/lib/dexa-ai-sdk.rb +86 -0
  90. data/reference.md +414 -0
  91. metadata +139 -0
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DexaAiSdk
4
+ module Internal
5
+ module Http
6
+ # @api private
7
+ class BaseRequest
8
+ attr_reader :base_url, :path, :method, :headers, :query, :request_options
9
+
10
+ # @param base_url [String] The base URL for the request
11
+ # @param path [String] The path for the request
12
+ # @param method [String] The HTTP method for the request (:get, :post, etc.)
13
+ # @param headers [Hash] Additional headers for the request (optional)
14
+ # @param query [Hash] Query parameters for the request (optional)
15
+ # @param request_options [DexaAiSdk::RequestOptions, Hash{Symbol=>Object}, nil]
16
+ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_options: {})
17
+ @base_url = base_url
18
+ @path = path
19
+ @method = method
20
+ @headers = headers
21
+ @query = query
22
+ @request_options = request_options
23
+ end
24
+
25
+ # @return [Hash] The query parameters merged with additional query parameters from request options.
26
+ def encode_query
27
+ additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {}
28
+ @query.merge(additional_query)
29
+ end
30
+
31
+ # @return [Hash] The additional body parameters from request options, keyed by wire-format name.
32
+ def additional_body_parameters
33
+ @request_options&.dig(:additional_body_parameters) || @request_options&.dig("additional_body_parameters") || {}
34
+ end
35
+
36
+ # Child classes should implement:
37
+ # - encode_headers: Returns the encoded HTTP request headers.
38
+ # - encode_body: Returns the encoded HTTP request body.
39
+
40
+ private
41
+
42
+ # Merges additional_body_parameters from request_options on top of the request body.
43
+ # Keys are used as-is (API wire-format names) and are normalized to strings so that a
44
+ # caller-supplied key always overrides the SDK-serialized field with the same name.
45
+ # When the body is nil the additional parameters become the body, except for GET and HEAD
46
+ # requests, which are sent without a body (the additional parameters are ignored). Bodies
47
+ # that are not hash-like (arrays, primitives) are returned unchanged.
48
+ # @param body [Object, nil] The request body.
49
+ # @return [Object, nil] The merged request body.
50
+ def merge_additional_body_parameters(body)
51
+ additional_body = additional_body_parameters
52
+ return body if additional_body.nil? || additional_body.empty?
53
+ return body if body.nil? && bodyless_method?
54
+
55
+ additional_body = additional_body.to_h.transform_keys(&:to_s)
56
+ return additional_body if body.nil?
57
+ return body if body.is_a?(::Array) || !body.respond_to?(:to_h)
58
+
59
+ body.to_h.transform_keys(&:to_s).merge(additional_body)
60
+ end
61
+
62
+ # @return [Boolean] Whether the request method is one that is sent without a body (GET, HEAD).
63
+ def bodyless_method?
64
+ %w[GET HEAD].include?(@method.to_s.upcase)
65
+ end
66
+
67
+ # Merges additional_headers from request_options into sdk_headers, filtering out
68
+ # any keys that collide with SDK-set or client-protected headers (case-insensitive).
69
+ # @param sdk_headers [Hash] Headers set by the SDK for this request type.
70
+ # @param protected_keys [Array<String>] Additional header keys that must not be overridden.
71
+ # @return [Hash] The merged headers.
72
+ def merge_additional_headers(sdk_headers, protected_keys: [])
73
+ additional_headers = @request_options&.dig(:additional_headers) || @request_options&.dig("additional_headers") || {}
74
+ all_protected = (sdk_headers.keys + protected_keys).to_set { |k| k.to_s.downcase }
75
+ filtered = additional_headers.reject { |key, _| all_protected.include?(key.to_s.downcase) }
76
+ sdk_headers.merge(filtered)
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DexaAiSdk
4
+ module Internal
5
+ module Http
6
+ # @api private
7
+ class RawClient
8
+ # Default HTTP status codes that trigger a retry
9
+ RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504, 521, 522, 524].freeze
10
+ # Initial delay between retries in seconds
11
+ INITIAL_RETRY_DELAY = 0.5
12
+ # Maximum delay between retries in seconds
13
+ MAX_RETRY_DELAY = 60.0
14
+ # Jitter factor for randomizing retry delays (20%)
15
+ JITTER_FACTOR = 0.2
16
+
17
+ # @return [String] The base URL for requests
18
+ attr_reader :base_url
19
+
20
+ # @param base_url [String] The base url for the request.
21
+ # @param max_retries [Integer] The number of times to retry a failed request, defaults to 2.
22
+ # @param timeout [Float] The timeout for the request, defaults to 60.0 seconds.
23
+ # @param headers [Hash] The headers for the request.
24
+ # @param overridable_headers [Array<String>] The names of the client-level headers a request
25
+ # may replace via `additional_headers`. Holds the API's global headers; SDK metadata and
26
+ # auth headers are absent from it and so stay protected.
27
+ # @param auth_provider [Object, nil] An optional auth provider responding to
28
+ # `auth_headers`. When present its headers are resolved on every request so
29
+ # token-based schemes (e.g. OAuth) can refresh an expired token mid-session.
30
+ def initialize(base_url:, max_retries: 2, timeout: 60.0, headers: {}, overridable_headers: [], auth_provider: nil)
31
+ @base_url = base_url
32
+ @max_retries = max_retries
33
+ @timeout = timeout
34
+ @auth_provider = auth_provider
35
+ @default_headers = {
36
+ "X-Fern-Language": "Ruby",
37
+ "X-Fern-SDK-Name": "dexa-ai-sdk",
38
+ "X-Fern-SDK-Version": "0.0.1"
39
+ }.merge(headers)
40
+ @overridable_headers = overridable_headers.to_set { |name| name.to_s.downcase }
41
+ end
42
+
43
+ # @param request [DexaAiSdk::Internal::Http::BaseRequest] The HTTP request.
44
+ # @return [HTTP::Response] The HTTP response.
45
+ def send(request)
46
+ url = build_url(request)
47
+ # Resolve auth headers once per request (not per retry) so token-based
48
+ # providers refresh at most once here; static providers are cheap.
49
+ auth_headers = resolve_auth_headers
50
+ timeout = request_timeout(request)
51
+ attempt = 0
52
+ response = nil
53
+
54
+ loop do
55
+ http_request = build_http_request(
56
+ url:,
57
+ method: request.method,
58
+ headers: request.encode_headers(protected_keys: protected_header_keys + auth_headers.keys),
59
+ body: request.encode_body,
60
+ auth_headers: auth_headers
61
+ )
62
+
63
+ conn = connect(url)
64
+ conn.open_timeout = timeout
65
+ conn.read_timeout = timeout
66
+ conn.write_timeout = timeout
67
+ conn.continue_timeout = timeout
68
+
69
+ response = wrap_transport_errors { conn.request(http_request) }
70
+
71
+ break unless should_retry?(response, attempt)
72
+
73
+ delay = retry_delay(response, attempt)
74
+ sleep(delay)
75
+ attempt += 1
76
+ end
77
+
78
+ response
79
+ end
80
+
81
+ # Socket-level `Errno` failures. Other `SystemCallError`s, such as file errors raised by a
82
+ # custom HTTP client, are not connection failures and propagate unchanged.
83
+ NETWORK_ERRNOS = [
84
+ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EPIPE,
85
+ Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ENETDOWN, Errno::EHOSTDOWN,
86
+ Errno::EADDRNOTAVAIL
87
+ ].freeze
88
+
89
+ # Runs a single request attempt, re-raising transport failures as SDK errors so that
90
+ # rescuing `Errors::ApiError` covers them. The original exception is kept as `cause`.
91
+ # These failures are not retried: the server may already have processed the request.
92
+ # @return [Net::HTTPResponse] The HTTP response.
93
+ def wrap_transport_errors
94
+ yield
95
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Errno::ETIMEDOUT => e
96
+ raise DexaAiSdk::Errors::TimeoutError, e.message
97
+ rescue EOFError, SocketError, OpenSSL::SSL::SSLError, *NETWORK_ERRNOS,
98
+ Net::ProtocolError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError => e
99
+ raise DexaAiSdk::Errors::ConnectionError, e.message
100
+ end
101
+
102
+ # @param request [DexaAiSdk::Internal::Http::BaseRequest] The HTTP request.
103
+ # @return [Float] The request's `timeout_in_seconds` option, or the client-level timeout.
104
+ def request_timeout(request)
105
+ options = request.request_options || {}
106
+ timeout = options.key?(:timeout_in_seconds) ? options[:timeout_in_seconds] : options["timeout_in_seconds"]
107
+ (timeout.nil? ? @timeout : timeout).to_f
108
+ end
109
+
110
+ # The client-level header names that `additional_headers` must not replace: every default
111
+ # header except the API's global headers, which are overridable per request.
112
+ # @return [Array<Symbol, String>] The protected header names.
113
+ def protected_header_keys
114
+ return @default_headers.keys if @overridable_headers.empty?
115
+
116
+ @default_headers.keys.reject { |name| @overridable_headers.include?(name.to_s.downcase) }
117
+ end
118
+
119
+ # Determines if a request should be retried based on the response status code.
120
+ # @param response [Net::HTTPResponse] The HTTP response.
121
+ # @param attempt [Integer] The current retry attempt (0-indexed).
122
+ # @return [Boolean] Whether the request should be retried.
123
+ def should_retry?(response, attempt)
124
+ return false if attempt >= @max_retries
125
+
126
+ status = response.code.to_i
127
+ RETRYABLE_STATUSES.include?(status)
128
+ end
129
+
130
+ # Calculates the delay before the next retry attempt using exponential backoff with jitter.
131
+ # Respects Retry-After header if present.
132
+ # @param response [Net::HTTPResponse] The HTTP response.
133
+ # @param attempt [Integer] The current retry attempt (0-indexed).
134
+ # @return [Float] The delay in seconds before the next retry.
135
+ def retry_delay(response, attempt)
136
+ # Check for Retry-After header (can be seconds or HTTP date)
137
+ retry_after = response["Retry-After"]
138
+ if retry_after
139
+ delay = parse_retry_after(retry_after)
140
+ return [delay, MAX_RETRY_DELAY].min if delay&.positive?
141
+ end
142
+
143
+ # Exponential backoff with jitter: base_delay * 2^attempt
144
+ base_delay = INITIAL_RETRY_DELAY * (2**attempt)
145
+ add_jitter([base_delay, MAX_RETRY_DELAY].min)
146
+ end
147
+
148
+ # Parses the Retry-After header value.
149
+ # @param value [String] The Retry-After header value (seconds or HTTP date).
150
+ # @return [Float, nil] The delay in seconds, or nil if parsing fails.
151
+ def parse_retry_after(value)
152
+ # Try parsing as integer (seconds)
153
+ seconds = Integer(value, exception: false)
154
+ return seconds.to_f if seconds
155
+
156
+ # Try parsing as HTTP date
157
+ begin
158
+ retry_time = Time.httpdate(value)
159
+ delay = retry_time - Time.now
160
+ delay.positive? ? delay : nil
161
+ rescue ArgumentError
162
+ nil
163
+ end
164
+ end
165
+
166
+ # Adds random jitter to a delay value.
167
+ # @param delay [Float] The base delay in seconds.
168
+ # @return [Float] The delay with jitter applied.
169
+ def add_jitter(delay)
170
+ jitter = delay * JITTER_FACTOR * (rand - 0.5) * 2
171
+ [delay + jitter, 0].max
172
+ end
173
+
174
+ LOCALHOST_HOSTS = %w[localhost 127.0.0.1 [::1]].freeze
175
+
176
+ # @param request [DexaAiSdk::Internal::Http::BaseRequest] The HTTP request.
177
+ # @return [URI::Generic] The URL.
178
+ def build_url(request)
179
+ encoded_query = request.encode_query
180
+
181
+ # If the path is already an absolute URL, use it directly
182
+ if request.path.start_with?("http://", "https://")
183
+ url = request.path
184
+ url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any?
185
+ parsed = URI.parse(url)
186
+ validate_https!(parsed)
187
+ return parsed
188
+ end
189
+
190
+ path = request.path.start_with?("/") ? request.path[1..] : request.path
191
+ base = request.base_url || @base_url
192
+ url = "#{base.chomp("/")}/#{path}"
193
+ url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any?
194
+ parsed = URI.parse(url)
195
+ validate_https!(parsed)
196
+ parsed
197
+ end
198
+
199
+ # Raises if the URL uses http:// for a non-localhost host, which would
200
+ # send authentication credentials in plaintext.
201
+ # @param url [URI::Generic] The parsed URL.
202
+ def validate_https!(url)
203
+ return if url.scheme != "http"
204
+ return if LOCALHOST_HOSTS.include?(url.host)
205
+
206
+ raise ArgumentError,
207
+ "Refusing to send request to non-HTTPS URL: #{url}. " \
208
+ "HTTP is only allowed for localhost. Use HTTPS or pass a localhost URL."
209
+ end
210
+
211
+ # Resolves the auth headers to send with the next request. Delegates to the
212
+ # configured auth provider (if any) on every call so that token-based
213
+ # providers (e.g. OAuth client-credentials) can refresh an expired token
214
+ # before the request is sent. Returns an empty hash when no provider is set,
215
+ # which keeps the api-key / basic / bearer / no-auth paths unchanged.
216
+ # @return [Hash] The auth headers for the current request.
217
+ def resolve_auth_headers
218
+ return {} if @auth_provider.nil?
219
+
220
+ @auth_provider.auth_headers
221
+ end
222
+
223
+ # @param url [URI::Generic] The url to the resource.
224
+ # @param method [String] The HTTP method to use.
225
+ # @param headers [Hash] The headers for the request.
226
+ # @param body [String, nil] The body for the request.
227
+ # @param auth_headers [Hash] The auth headers resolved for this request. These
228
+ # take precedence over the static default headers but not over per-request
229
+ # headers, mirroring the previous baked-header precedence.
230
+ # @return [HTTP::Request] The HTTP request.
231
+ def build_http_request(url:, method:, headers: {}, body: nil, auth_headers: {})
232
+ request = Net::HTTPGenericRequest.new(
233
+ method,
234
+ !body.nil?,
235
+ method != "HEAD",
236
+ url
237
+ )
238
+
239
+ request_headers = @default_headers.merge(auth_headers).merge(headers)
240
+ request_headers.each { |name, value| request[name] = value }
241
+ request.body = body if body
242
+
243
+ # Net::HTTP disables its transparent gzip/deflate decoding as soon as an
244
+ # Accept-Encoding header is set explicitly on the request. Re-enable it so
245
+ # that compressed response bodies are still inflated.
246
+ request.extend(DecodeContent) if request_headers.keys.any? { |name| name.to_s.casecmp("accept-encoding").zero? }
247
+
248
+ request
249
+ end
250
+
251
+ # Keeps Net::HTTP's transparent gzip/deflate response decoding enabled
252
+ # even when an Accept-Encoding header is set explicitly on the request.
253
+ # @api private
254
+ module DecodeContent
255
+ def decode_content # rubocop:disable Naming/PredicateMethod
256
+ true
257
+ end
258
+ end
259
+
260
+ # @param query [Hash] The query for the request.
261
+ # @return [String, nil] The encoded query.
262
+ def encode_query(query)
263
+ query.to_h.empty? ? nil : URI.encode_www_form(query)
264
+ end
265
+
266
+ # @param url [URI::Generic] The url to connect to.
267
+ # @return [Net::HTTP] The HTTP connection.
268
+ def connect(url)
269
+ is_https = (url.scheme == "https")
270
+
271
+ port = if url.port
272
+ url.port
273
+ elsif is_https
274
+ Net::HTTP.https_default_port
275
+ else
276
+ Net::HTTP.http_default_port
277
+ end
278
+
279
+ http = Net::HTTP.new(url.host, port)
280
+ http.use_ssl = is_https
281
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER if is_https
282
+ # NOTE: We handle retries at the application level with HTTP status code awareness,
283
+ # so we set max_retries to 0 to disable Net::HTTP's built-in network-level retries.
284
+ http.max_retries = 0
285
+ http
286
+ end
287
+
288
+ # @return [String]
289
+ def inspect
290
+ "#<#{self.class.name}:0x#{object_id.to_s(16)} @base_url=#{@base_url.inspect}>"
291
+ end
292
+ end
293
+ end
294
+ end
295
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DexaAiSdk
4
+ module Internal
5
+ class CursorItemIterator < ItemIterator
6
+ # Instantiates a CursorItemIterator, an Enumerable class which wraps calls to a cursor-based paginated API and yields individual items from it.
7
+ #
8
+ # @param initial_cursor [String] The initial cursor to use when iterating, if any.
9
+ # @param cursor_field [Symbol] The field in API responses to extract the next cursor from.
10
+ # @param item_field [Symbol] The field in API responses to extract the items to iterate over.
11
+ # @param block [Proc] A block which is responsible for receiving a cursor to use and returning the given page from the API.
12
+ # @return [DexaAiSdk::Internal::CursorItemIterator]
13
+ def initialize(initial_cursor:, cursor_field:, item_field:, &block)
14
+ super()
15
+ @item_field = item_field
16
+ @http_response = nil
17
+ get_page = track_http_responses(block)
18
+ @page_iterator = CursorPageIterator.new(initial_cursor:, cursor_field:, &get_page)
19
+ # Items are read through their own page cursor so that reading `pages` never skips items.
20
+ @item_pages = CursorPageIterator.new(initial_cursor:, cursor_field:, &get_page)
21
+ @page = nil
22
+ @item_index = 0
23
+ end
24
+
25
+ # Returns the CursorPageIterator mediating access to the underlying API.
26
+ #
27
+ # @return [DexaAiSdk::Internal::CursorPageIterator]
28
+ def pages
29
+ @page_iterator
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DexaAiSdk
4
+ module Internal
5
+ class CursorPageIterator
6
+ include Enumerable
7
+
8
+ # The raw HTTP response from the most recent page response.
9
+ # @return [Net::HTTPResponse, nil]
10
+ attr_reader :http_response
11
+
12
+ # Instantiates a CursorPageIterator, an Enumerable class which wraps calls to a cursor-based paginated API and yields pages of items.
13
+ #
14
+ # @param initial_cursor [String] The initial cursor to use when iterating, if any.
15
+ # @param cursor_field [Symbol] The name of the field in API responses to extract the next cursor from.
16
+ # @param block [Proc] A block which is responsible for receiving a cursor to use and returning the given page from the API.
17
+ # The block should return a two-element array: [parsed_page, raw_http_response].
18
+ # @return [DexaAiSdk::Internal::CursorPageIterator]
19
+ def initialize(initial_cursor:, cursor_field:, &block)
20
+ @initial_cursor = initial_cursor
21
+ @cursor_field = cursor_field
22
+ @get_next_page = block
23
+ @http_response = nil
24
+ @first_page_requested = false
25
+ @first_page = nil
26
+ rewind
27
+ end
28
+
29
+ # Sends the request for the first page now instead of on the first iteration, so an API error for that page
30
+ # is raised here. The page is kept, and every loop that starts from the first page reuses it instead of
31
+ # requesting it again. Does nothing if the first page was already requested.
32
+ #
33
+ # @return [self]
34
+ def load_first_page
35
+ return self if @first_page_requested
36
+
37
+ @first_page = fetch_page(@initial_cursor)
38
+ rewind
39
+ self
40
+ end
41
+
42
+ # Uses the first page that `other` loaded with `load_first_page`, unless this iterator already sent a request.
43
+ #
44
+ # @param other [DexaAiSdk::Internal::CursorPageIterator]
45
+ # @return [NilClass]
46
+ def reuse_first_page(other)
47
+ return if @first_page_requested || other.first_page.nil?
48
+
49
+ @first_page = other.first_page
50
+ rewind
51
+ end
52
+
53
+ # Iterates over each page returned by the API, starting again from the first page on every call.
54
+ #
55
+ # @param block [Proc] The block which each retrieved page is yielded to.
56
+ # @return [NilClass, Enumerator] An Enumerator when no block is given.
57
+ def each(&block)
58
+ return enum_for(:each) unless block_given?
59
+
60
+ rewind
61
+ while (page = next_page)
62
+ block.call(page)
63
+ end
64
+ end
65
+
66
+ # Resets page-by-page iteration (`next_page` / `next?`) to the first page.
67
+ #
68
+ # @return [NilClass]
69
+ def rewind
70
+ @need_initial_load = @initial_cursor.nil?
71
+ @cursor = @initial_cursor
72
+ @at_first_page = true
73
+ nil
74
+ end
75
+
76
+ # Whether another page will be available from the API.
77
+ #
78
+ # @return [Boolean]
79
+ def next?
80
+ (@at_first_page && !@first_page.nil?) || @need_initial_load || !@cursor.nil?
81
+ end
82
+
83
+ # Retrieves the next page from the API.
84
+ #
85
+ # @return [Object, nil]
86
+ def next_page
87
+ if @at_first_page && !@first_page.nil?
88
+ page = @first_page
89
+ else
90
+ return if !@need_initial_load && @cursor.nil?
91
+
92
+ page = fetch_page(@cursor)
93
+ end
94
+ @at_first_page = false
95
+ @need_initial_load = false
96
+ @cursor = page.send(@cursor_field)
97
+ page
98
+ end
99
+
100
+ protected
101
+
102
+ attr_reader :first_page
103
+
104
+ private
105
+
106
+ def fetch_page(cursor)
107
+ result = @get_next_page.call(cursor)
108
+ @first_page_requested = true
109
+ return result unless result.is_a?(Array)
110
+
111
+ fetched_page, raw_response = result
112
+ @http_response = raw_response
113
+ fetched_page
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DexaAiSdk
4
+ module Internal
5
+ class ItemIterator
6
+ include Enumerable
7
+
8
+ # The raw HTTP response from the most recent page request made by this pager or its `pages`.
9
+ # @return [Net::HTTPResponse, nil]
10
+ attr_reader :http_response
11
+
12
+ # Iterates over each item returned by the API, starting again from the first page on every call.
13
+ # Each loop is independent of other loops and of `next_element`.
14
+ #
15
+ # @param block [Proc] The block which each retrieved item is yielded to.
16
+ # @return [NilClass, Enumerator] An Enumerator when no block is given.
17
+ def each(&)
18
+ return enum_for(:each) unless block_given?
19
+
20
+ # Each loop reads through its own copy of the page stream, so repeated or nested loops never
21
+ # share a position with each other or with `next_element`.
22
+ pass = @item_pages.dup.tap(&:rewind)
23
+ while (page = pass.next_page)
24
+ (page.send(@item_field) || []).each(&)
25
+ end
26
+ nil
27
+ end
28
+
29
+ # Resets manual item-by-item iteration (`next_element` / `next?`) to the first page.
30
+ #
31
+ # @return [NilClass]
32
+ def rewind
33
+ @item_pages.rewind
34
+ @page = nil
35
+ @item_index = 0
36
+ nil
37
+ end
38
+
39
+ # Whether another item will be available from the API.
40
+ #
41
+ # @return [Boolean]
42
+ def next?
43
+ load_next_page if @page.nil?
44
+ return false if @page.nil?
45
+
46
+ return true if any_items_in_cached_page?
47
+
48
+ load_next_page
49
+ any_items_in_cached_page?
50
+ end
51
+
52
+ # Sends the request for the first page now instead of on the first iteration, so an API error for that page
53
+ # is raised here. The page is kept, and every loop (over items or `pages`) that starts from the first page
54
+ # reuses it instead of requesting it again. Does nothing if this pager already sent a request.
55
+ #
56
+ # @return [self]
57
+ def load_first_page
58
+ return self if @requested
59
+
60
+ @item_pages.load_first_page
61
+ @page_iterator.reuse_first_page(@item_pages)
62
+ self
63
+ end
64
+
65
+ # Retrieves the next item from the API.
66
+ def next_element
67
+ item = next_item_from_cached_page
68
+ return item if item
69
+
70
+ load_next_page
71
+ next_item_from_cached_page
72
+ end
73
+
74
+ private
75
+
76
+ # Wraps the page-fetching block so that `http_response` reflects the most recent request.
77
+ def track_http_responses(get_page)
78
+ proc do |*args|
79
+ result = get_page.call(*args)
80
+ @requested = true
81
+ @http_response = result[1] if result.is_a?(Array)
82
+ result
83
+ end
84
+ end
85
+
86
+ def cached_page_items
87
+ return [] unless @page
88
+
89
+ @page.send(@item_field) || []
90
+ end
91
+
92
+ def next_item_from_cached_page
93
+ items = cached_page_items
94
+ return if @item_index >= items.length
95
+
96
+ item = items[@item_index]
97
+ @item_index += 1
98
+ item
99
+ end
100
+
101
+ def any_items_in_cached_page?
102
+ @item_index < cached_page_items.length
103
+ end
104
+
105
+ def load_next_page
106
+ @page = @item_pages.next_page
107
+ @item_index = 0
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DexaAiSdk
4
+ module Internal
5
+ class OffsetItemIterator < ItemIterator
6
+ # Instantiates an OffsetItemIterator, an Enumerable class which wraps calls to an offset-based paginated API and yields the individual items from it.
7
+ #
8
+ # @param initial_page [Integer] The initial page or offset to start from when iterating.
9
+ # @param item_field [Symbol] The name of the field in API responses to extract the items to iterate over.
10
+ # @param has_next_field [Symbol] The name of the field in API responses containing a boolean of whether another page exists.
11
+ # @param step [Boolean] If true, treats the page number as a true offset (i.e. increments the page number by the number of items returned from each call rather than just 1)
12
+ # @param block [Proc] A block which is responsible for receiving a page number to use and returning the given page from the API.
13
+ #
14
+ # @return [DexaAiSdk::Internal::OffsetItemIterator]
15
+ def initialize(initial_page:, item_field:, has_next_field:, step:, &block)
16
+ super()
17
+ @item_field = item_field
18
+ @http_response = nil
19
+ get_page = track_http_responses(block)
20
+ @page_iterator = OffsetPageIterator.new(initial_page:, item_field:, has_next_field:, step:, &get_page)
21
+ # Items are read through their own page cursor so that reading `pages` never skips items.
22
+ @item_pages = OffsetPageIterator.new(initial_page:, item_field:, has_next_field:, step:, &get_page)
23
+ @page = nil
24
+ @item_index = 0
25
+ end
26
+
27
+ # Returns the OffsetPageIterator that is mediating access to the underlying API.
28
+ #
29
+ # @return [DexaAiSdk::Internal::OffsetPageIterator]
30
+ def pages
31
+ @page_iterator
32
+ end
33
+ end
34
+ end
35
+ end