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.
- checksums.yaml +7 -0
- data/.fernignore +5 -0
- data/.rubocop.yml +107 -0
- data/CONTRIBUTING.md +120 -0
- data/README.md +199 -0
- data/Rakefile +20 -0
- data/custom.gemspec.rb +23 -0
- data/lib/dexa-ai-sdk/client.rb +26 -0
- data/lib/dexa-ai-sdk/environment.rb +7 -0
- data/lib/dexa-ai-sdk/errors/api_error.rb +8 -0
- data/lib/dexa-ai-sdk/errors/client_error.rb +17 -0
- data/lib/dexa-ai-sdk/errors/connection_error.rb +11 -0
- data/lib/dexa-ai-sdk/errors/redirect_error.rb +8 -0
- data/lib/dexa-ai-sdk/errors/response_error.rb +63 -0
- data/lib/dexa-ai-sdk/errors/server_error.rb +11 -0
- data/lib/dexa-ai-sdk/errors/timeout_error.rb +8 -0
- data/lib/dexa-ai-sdk/internal/errors/constraint_error.rb +10 -0
- data/lib/dexa-ai-sdk/internal/errors/type_error.rb +10 -0
- data/lib/dexa-ai-sdk/internal/http/base_request.rb +81 -0
- data/lib/dexa-ai-sdk/internal/http/raw_client.rb +295 -0
- data/lib/dexa-ai-sdk/internal/iterators/cursor_item_iterator.rb +33 -0
- data/lib/dexa-ai-sdk/internal/iterators/cursor_page_iterator.rb +117 -0
- data/lib/dexa-ai-sdk/internal/iterators/item_iterator.rb +111 -0
- data/lib/dexa-ai-sdk/internal/iterators/offset_item_iterator.rb +35 -0
- data/lib/dexa-ai-sdk/internal/iterators/offset_page_iterator.rb +151 -0
- data/lib/dexa-ai-sdk/internal/json/request.rb +50 -0
- data/lib/dexa-ai-sdk/internal/json/serializable.rb +25 -0
- data/lib/dexa-ai-sdk/internal/multipart/multipart_encoder.rb +141 -0
- data/lib/dexa-ai-sdk/internal/multipart/multipart_form_data.rb +83 -0
- data/lib/dexa-ai-sdk/internal/multipart/multipart_form_data_part.rb +51 -0
- data/lib/dexa-ai-sdk/internal/multipart/multipart_request.rb +41 -0
- data/lib/dexa-ai-sdk/internal/types/array.rb +47 -0
- data/lib/dexa-ai-sdk/internal/types/boolean.rb +34 -0
- data/lib/dexa-ai-sdk/internal/types/enum.rb +56 -0
- data/lib/dexa-ai-sdk/internal/types/hash.rb +36 -0
- data/lib/dexa-ai-sdk/internal/types/model/field.rb +38 -0
- data/lib/dexa-ai-sdk/internal/types/model.rb +220 -0
- data/lib/dexa-ai-sdk/internal/types/type.rb +35 -0
- data/lib/dexa-ai-sdk/internal/types/union.rb +161 -0
- data/lib/dexa-ai-sdk/internal/types/unknown.rb +15 -0
- data/lib/dexa-ai-sdk/internal/types/utils.rb +123 -0
- data/lib/dexa-ai-sdk/tasks/client.rb +190 -0
- data/lib/dexa-ai-sdk/tasks/types/decide_request.rb +37 -0
- data/lib/dexa-ai-sdk/tasks/types/decide_request_output_language.rb +32 -0
- data/lib/dexa-ai-sdk/tasks/types/decide_request_response_format.rb +11 -0
- data/lib/dexa-ai-sdk/tasks/types/decide_request_response_format_type.rb +13 -0
- data/lib/dexa-ai-sdk/tasks/types/decide_request_target_language.rb +32 -0
- data/lib/dexa-ai-sdk/tasks/types/extract_request.rb +37 -0
- data/lib/dexa-ai-sdk/tasks/types/extract_request_output_language.rb +32 -0
- data/lib/dexa-ai-sdk/tasks/types/extract_request_response_format.rb +11 -0
- data/lib/dexa-ai-sdk/tasks/types/extract_request_response_format_type.rb +13 -0
- data/lib/dexa-ai-sdk/tasks/types/extract_request_target_language.rb +32 -0
- data/lib/dexa-ai-sdk/types/audio_input.rb +15 -0
- data/lib/dexa-ai-sdk/types/audio_input_type.rb +12 -0
- data/lib/dexa-ai-sdk/types/choice.rb +11 -0
- data/lib/dexa-ai-sdk/types/choice_finish_reason.rb +13 -0
- data/lib/dexa-ai-sdk/types/content_part.rb +21 -0
- data/lib/dexa-ai-sdk/types/content_part_language.rb +30 -0
- data/lib/dexa-ai-sdk/types/content_part_type.rb +15 -0
- data/lib/dexa-ai-sdk/types/decide_response.rb +21 -0
- data/lib/dexa-ai-sdk/types/decide_result.rb +11 -0
- data/lib/dexa-ai-sdk/types/decide_task.rb +21 -0
- data/lib/dexa-ai-sdk/types/decide_task_target_language.rb +30 -0
- data/lib/dexa-ai-sdk/types/error_response.rb +9 -0
- data/lib/dexa-ai-sdk/types/error_response_error.rb +15 -0
- data/lib/dexa-ai-sdk/types/extract_response.rb +19 -0
- data/lib/dexa-ai-sdk/types/extract_task.rb +21 -0
- data/lib/dexa-ai-sdk/types/extract_task_target_language.rb +30 -0
- data/lib/dexa-ai-sdk/types/image_input.rb +15 -0
- data/lib/dexa-ai-sdk/types/image_input_type.rb +12 -0
- data/lib/dexa-ai-sdk/types/image_url.rb +11 -0
- data/lib/dexa-ai-sdk/types/image_url_detail.rb +13 -0
- data/lib/dexa-ai-sdk/types/message.rb +13 -0
- data/lib/dexa-ai-sdk/types/message_content.rb +13 -0
- data/lib/dexa-ai-sdk/types/message_language.rb +30 -0
- data/lib/dexa-ai-sdk/types/message_role.rb +13 -0
- data/lib/dexa-ai-sdk/types/response_message.rb +13 -0
- data/lib/dexa-ai-sdk/types/response_message_language.rb +30 -0
- data/lib/dexa-ai-sdk/types/response_message_role.rb +11 -0
- data/lib/dexa-ai-sdk/types/run_tasks_request.rb +29 -0
- data/lib/dexa-ai-sdk/types/run_tasks_request_output_language.rb +30 -0
- data/lib/dexa-ai-sdk/types/run_tasks_request_response_format.rb +9 -0
- data/lib/dexa-ai-sdk/types/run_tasks_request_response_format_type.rb +11 -0
- data/lib/dexa-ai-sdk/types/run_tasks_request_target_language.rb +30 -0
- data/lib/dexa-ai-sdk/types/run_tasks_response.rb +19 -0
- data/lib/dexa-ai-sdk/types/task.rb +15 -0
- data/lib/dexa-ai-sdk/types/usage.rb +13 -0
- data/lib/dexa-ai-sdk/version.rb +5 -0
- data/lib/dexa-ai-sdk.rb +86 -0
- data/reference.md +414 -0
- 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
|