runapi-core 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ac730f3383d61bc33ade1919255a2cefb32dfd194f486c53db53cbbc4ce13da4
4
+ data.tar.gz: 4cd216b5432076999c28f3c90fbc73082318d08865236e90dcb0a7e6fe0f04a7
5
+ SHA512:
6
+ metadata.gz: fd05422a84596bc05e34bfd9b43b307bfad36377c6674daad1a9f0aa8df8e6e8b2751bdd9a628ae6f95b06765a66d617d43e566133e4d19bc689d3f4ce1e39db
7
+ data.tar.gz: 3baeb19bd5d4e9ff253b43187e1dd95647d75619af2d4fec0a4c87b541fa0ed8fa18e8d7d21e0b73f727757e9c33428dfa49077359c45839656327b22f537b23
data/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RunApi
4
+ module Core
5
+ # API key resolution helpers.
6
+ module Auth
7
+ ENV_VAR_NAME = "RUNAPI_API_KEY"
8
+
9
+ MISSING_KEY_MESSAGE = "API key is required. Pass api_key or set the RUNAPI_API_KEY environment variable."
10
+
11
+ # Resolve the API key from (in priority order):
12
+ # 1. the explicit argument
13
+ # 2. RunApi.api_key (global configuration)
14
+ # 3. the RUNAPI_API_KEY environment variable
15
+ #
16
+ # All sources are trimmed; blank values are treated as missing.
17
+ # Raises {RunApi::Core::AuthenticationError} when no source yields a value.
18
+ #
19
+ # @param explicit [String, nil] API key passed directly to a client constructor.
20
+ # @return [String] the resolved API key
21
+ def self.resolve_api_key(explicit = nil)
22
+ resolved = normalize(explicit) ||
23
+ normalize(RunApi.respond_to?(:api_key) ? RunApi.api_key : nil) ||
24
+ normalize(ENV[ENV_VAR_NAME])
25
+
26
+ raise AuthenticationError, MISSING_KEY_MESSAGE unless resolved
27
+
28
+ resolved
29
+ end
30
+
31
+ def self.normalize(value)
32
+ return nil unless value.is_a?(String)
33
+
34
+ trimmed = value.strip
35
+ trimmed.empty? ? nil : trimmed
36
+ end
37
+ private_class_method :normalize
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RunApi
4
+ module Core
5
+ # Lightweight response model with typed field declarations and recursive coercion.
6
+ class BaseModel
7
+ Field = Struct.new(:name, :required, :type, :item_type, :enum, keyword_init: true)
8
+
9
+ class << self
10
+ def fields
11
+ @fields ||= begin
12
+ parent_fields = superclass.respond_to?(:fields) ? superclass.fields : {}
13
+ parent_fields.transform_values(&:dup)
14
+ end
15
+ end
16
+
17
+ def required(name, type = nil, enum: nil)
18
+ define_field(name, type, required: true, enum: enum)
19
+ end
20
+
21
+ def optional(name, type = nil, enum: nil)
22
+ define_field(name, type, required: false, enum: enum)
23
+ end
24
+
25
+ def from_hash(payload)
26
+ return payload if payload.is_a?(self)
27
+
28
+ unless payload.is_a?(Hash)
29
+ raise TypeError, "Expected Hash for #{name}, got #{payload.class}"
30
+ end
31
+
32
+ new(payload)
33
+ end
34
+
35
+ def coerce(value, as: DynamicModel)
36
+ target_model = resolve_type(as)
37
+
38
+ case value
39
+ when nil
40
+ nil
41
+ when BaseModel
42
+ if target_model && target_model <= BaseModel && !value.is_a?(target_model)
43
+ target_model.from_hash(value.to_h)
44
+ else
45
+ value
46
+ end
47
+ when Hash
48
+ model = (target_model && target_model <= BaseModel) ? target_model : DynamicModel
49
+ model.from_hash(value)
50
+ when Array
51
+ value.map { |item| coerce(item, as: target_model || DynamicModel) }
52
+ else
53
+ value
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def define_field(name, type, required:, enum:)
60
+ key = name.to_s
61
+
62
+ field_type = type
63
+ item_type = nil
64
+ if type.is_a?(Array)
65
+ raise ArgumentError, "Array field type must contain exactly one item type" unless type.size == 1
66
+
67
+ field_type = Array
68
+ item_type = type.first
69
+ end
70
+
71
+ fields[key] = Field.new(
72
+ name: key,
73
+ required: required,
74
+ type: field_type,
75
+ item_type: item_type,
76
+ enum: enum
77
+ )
78
+
79
+ define_method(name) { @attributes[key] } unless method_defined?(name)
80
+ end
81
+
82
+ def resolve_type(type)
83
+ type.respond_to?(:call) ? type.call : type
84
+ end
85
+ end
86
+
87
+ def initialize(attributes = {})
88
+ source = normalize_input(attributes)
89
+ @attributes = {}
90
+
91
+ assign_declared_fields!(source)
92
+ assign_extra_fields!(source)
93
+ end
94
+
95
+ def [](key)
96
+ @attributes[key.to_s]
97
+ end
98
+
99
+ def dig(*keys)
100
+ current = self
101
+ keys.each do |key|
102
+ current = case current
103
+ when BaseModel
104
+ current[key]
105
+ when Hash
106
+ current[key] || current[key.to_s] || current[key.to_sym]
107
+ when Array
108
+ key.is_a?(Integer) ? current[key] : nil
109
+ end
110
+ return nil if current.nil?
111
+ end
112
+ current
113
+ end
114
+
115
+ def to_h
116
+ @attributes.each_with_object({}) do |(key, value), out|
117
+ out[key] = serialize(value)
118
+ end
119
+ end
120
+ alias_method :to_hash, :to_h
121
+
122
+ def ==(other)
123
+ case other
124
+ when BaseModel
125
+ to_h == other.to_h
126
+ when Hash
127
+ to_h == stringify_keys(other)
128
+ else
129
+ super
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ def assign_declared_fields!(source)
136
+ self.class.fields.each_value do |field|
137
+ if source.key?(field.name)
138
+ assign_attribute(field.name, coerce_declared_value(source.delete(field.name), field))
139
+ next
140
+ end
141
+
142
+ raise ValidationError, "#{field.name} is required" if field.required
143
+ end
144
+ end
145
+
146
+ def assign_extra_fields!(source)
147
+ source.each do |key, value|
148
+ assign_attribute(key, coerce_dynamic_value(value))
149
+ define_dynamic_reader!(key)
150
+ end
151
+ end
152
+
153
+ def assign_attribute(key, value)
154
+ @attributes[key.to_s] = value
155
+ end
156
+
157
+ def coerce_declared_value(value, field)
158
+ coerced =
159
+ if field.type == Array && value.is_a?(Array)
160
+ value.map { |item| coerce_with_type(item, field.item_type) }
161
+ else
162
+ coerce_with_type(value, field.type)
163
+ end
164
+
165
+ validate_enum!(field, coerced)
166
+ coerced
167
+ end
168
+
169
+ def coerce_with_type(value, type)
170
+ return coerce_dynamic_value(value) if type.nil?
171
+
172
+ resolved_type = self.class.send(:resolve_type, type)
173
+
174
+ if resolved_type && resolved_type <= BaseModel && value.is_a?(Hash)
175
+ return resolved_type.from_hash(value)
176
+ end
177
+
178
+ return coerce_dynamic_value(value) if resolved_type.nil?
179
+
180
+ value
181
+ end
182
+
183
+ def validate_enum!(field, value)
184
+ return if value.nil? || field.enum.nil?
185
+
186
+ allowed = field.enum.respond_to?(:call) ? field.enum.call : field.enum
187
+ return unless allowed
188
+
189
+ invalid_value = Array(value).find do |item|
190
+ allowed.none? { |candidate| candidate == item || candidate.to_s == item.to_s }
191
+ end
192
+ return unless invalid_value
193
+
194
+ raise ValidationError, "Invalid #{field.name}: #{invalid_value}. Must be one of: #{allowed.join(", ")}"
195
+ end
196
+
197
+ def coerce_dynamic_value(value)
198
+ case value
199
+ when Hash
200
+ DynamicModel.from_hash(value)
201
+ when Array
202
+ value.map { |item| coerce_dynamic_value(item) }
203
+ else
204
+ value
205
+ end
206
+ end
207
+
208
+ def serialize(value)
209
+ case value
210
+ when BaseModel
211
+ value.to_h
212
+ when Array
213
+ value.map { |item| serialize(item) }
214
+ else
215
+ value
216
+ end
217
+ end
218
+
219
+ def normalize_input(attributes)
220
+ return {} if attributes.nil?
221
+
222
+ unless attributes.is_a?(Hash)
223
+ raise TypeError, "Expected Hash, got #{attributes.class}"
224
+ end
225
+
226
+ attributes.each_with_object({}) do |(key, value), out|
227
+ out[key.to_s] = value
228
+ end
229
+ end
230
+
231
+ def stringify_keys(value)
232
+ case value
233
+ when Hash
234
+ value.each_with_object({}) do |(k, v), out|
235
+ out[k.to_s] = stringify_keys(v)
236
+ end
237
+ when Array
238
+ value.map { |item| stringify_keys(item) }
239
+ else
240
+ value
241
+ end
242
+ end
243
+
244
+ def define_dynamic_reader!(key)
245
+ name = key.to_s
246
+ return unless /\A[a-z_][a-zA-Z0-9_]*\z/.match?(name)
247
+ return if respond_to?(name)
248
+
249
+ define_singleton_method(name) { @attributes[name] }
250
+ end
251
+ end
252
+
253
+ # Generic response model used when no API-specific typed model is provided.
254
+ class DynamicModel < BaseModel
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RunApi
4
+ class << self
5
+ attr_accessor :api_key, :base_url
6
+
7
+ def configure
8
+ yield self
9
+ end
10
+ end
11
+
12
+ self.base_url = Core::Constants::DEFAULT_BASE_URL
13
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RunApi
4
+ module Core
5
+ module Constants
6
+ TIMEOUTS = {
7
+ http_request: 900,
8
+ polling_interval: 2,
9
+ polling_max_wait: 900
10
+ }.freeze
11
+
12
+ RETRY_CONFIG = {
13
+ max_retries: 2,
14
+ base_delay: 0.5,
15
+ max_delay: 5.0
16
+ }.freeze
17
+
18
+ DEFAULT_BASE_URL = "https://runapi.ai"
19
+
20
+ SDK_USER_AGENT = "runapi-sdk-ruby/#{RunApi::VERSION}".freeze
21
+
22
+ IDEMPOTENT_METHODS = %w[GET HEAD PUT DELETE OPTIONS].freeze
23
+
24
+ RETRYABLE_STATUS_CODES = [ 429, 500, 502, 503, 504 ].freeze
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module RunApi
6
+ module Core
7
+ # Base error class for all RunAPI SDK errors.
8
+ # Includes HTTP status, request ID, and response details.
9
+ class Error < StandardError
10
+ # @return [Integer, nil] HTTP status code if available.
11
+ attr_reader :status
12
+ # @return [String, nil] Request ID from response headers.
13
+ attr_reader :request_id
14
+ # @return [Hash, String, nil] Parsed response body or error details.
15
+ attr_reader :details
16
+
17
+ def initialize(message = nil, status: nil, request_id: nil, details: nil)
18
+ super(message)
19
+ @status = status
20
+ @request_id = request_id
21
+ @details = details
22
+ end
23
+
24
+ def to_h
25
+ {
26
+ error: self.class.name,
27
+ message: message,
28
+ status: status,
29
+ request_id: request_id,
30
+ details: details
31
+ }.compact
32
+ end
33
+
34
+ STATUS_MAP = {
35
+ 400 => "ValidationError",
36
+ 401 => "AuthenticationError",
37
+ 402 => "InsufficientCreditsError",
38
+ 404 => "NotFoundError",
39
+ 409 => "ConflictError",
40
+ 422 => "ValidationError",
41
+ 429 => "RateLimitError",
42
+ 451 => "ValidationError",
43
+ 455 => "ServiceUnavailableError",
44
+ 500 => "ServerError",
45
+ 501 => "ServerError",
46
+ 502 => "ServerError",
47
+ 503 => "ServiceUnavailableError",
48
+ 504 => "ServerError",
49
+ 505 => "ServerError",
50
+ 531 => "ServerError"
51
+ }.freeze
52
+
53
+ DEFAULT_MESSAGES = {
54
+ 400 => "Bad request",
55
+ 401 => "Unauthorized",
56
+ 402 => "Insufficient credits",
57
+ 404 => "Not found",
58
+ 408 => "Request timeout",
59
+ 409 => "Conflict",
60
+ 413 => "Payload too large",
61
+ 415 => "Unsupported media type",
62
+ 422 => "Validation failed",
63
+ 429 => "Too many requests",
64
+ 451 => "Failed to fetch image",
65
+ 455 => "Service unavailable (maintenance)",
66
+ 503 => "Service unavailable"
67
+ }.freeze
68
+
69
+ class << self
70
+ # Constructs appropriate error class from HTTP response.
71
+ # Maps status codes to specific error types and extracts error messages.
72
+ #
73
+ # @param response [Net::HTTPResponse] HTTP response object
74
+ # @param body [String, nil] Response body as string
75
+ # @return [Error] Specific error instance based on status code
76
+ def from_response(response, body = nil)
77
+ status = response.code.to_i
78
+ request_id = response["x-request-id"]
79
+
80
+ parsed_body = parse_body(body)
81
+ message = extract_message(parsed_body) ||
82
+ DEFAULT_MESSAGES[status] ||
83
+ "Request failed"
84
+
85
+ retry_after = parse_retry_after(response["retry-after"])
86
+
87
+ error_class_name = STATUS_MAP[status]
88
+ error_class = if error_class_name
89
+ Core.const_get(error_class_name)
90
+ else
91
+ Error
92
+ end
93
+
94
+ kwargs = {
95
+ status: status,
96
+ request_id: request_id,
97
+ details: parsed_body
98
+ }
99
+ kwargs[:retry_after] = retry_after if error_class == RateLimitError
100
+
101
+ error_class.new(message, **kwargs)
102
+ end
103
+
104
+ private
105
+
106
+ def parse_body(body)
107
+ return nil if body.to_s.empty?
108
+ return extract_html_error(body) if body.match?(/<!doctype|<html/i)
109
+
110
+ JSON.parse(body)
111
+ rescue JSON::ParserError
112
+ body
113
+ end
114
+
115
+ def extract_html_error(html)
116
+ title = html[%r{<title>(.*?)</title>}mi, 1]
117
+ h1 = html[%r{<h1>(.*?)</h1>}mi, 1]
118
+
119
+ error_text = title || h1 || "HTML Error Page"
120
+
121
+ error_text = error_text.gsub(/&[a-z]+;/i, " ")
122
+ .gsub(/<[^>]+>/, "")
123
+ .strip
124
+
125
+ {
126
+ "error" => error_text,
127
+ "is_html_error" => true,
128
+ "message" => "Server returned HTML error page: #{error_text}"
129
+ }
130
+ end
131
+
132
+ def extract_message(body)
133
+ return nil unless body.is_a?(Hash)
134
+
135
+ (body["error"].is_a?(Hash) ? body.dig("error", "message") : body["error"]) ||
136
+ body.dig("errors", 0) ||
137
+ body["message"] ||
138
+ body["detail"] ||
139
+ body["errorMessage"] ||
140
+ body["msg"]
141
+ end
142
+
143
+ def parse_retry_after(value)
144
+ return nil if value.nil?
145
+
146
+ numeric = Float(value, exception: false)
147
+ return numeric if numeric
148
+
149
+ begin
150
+ Time.httpdate(value) - Time.now.utc
151
+ rescue ArgumentError
152
+ nil
153
+ end
154
+ end
155
+ end
156
+ end
157
+
158
+ # Raised when API key is missing or invalid (HTTP 401).
159
+ class AuthenticationError < Error
160
+ def initialize(message = "Unauthorized", **kwargs)
161
+ super(message, status: 401, **kwargs)
162
+ end
163
+ end
164
+
165
+ # Raised when rate limit is exceeded (HTTP 429). Includes retry-after delay.
166
+ class RateLimitError < Error
167
+ # @return [Numeric, nil] Suggested retry delay in seconds from Retry-After header.
168
+ attr_reader :retry_after
169
+
170
+ def initialize(message = "Too many requests", retry_after: nil, **kwargs)
171
+ super(message, status: 429, **kwargs)
172
+ @retry_after = retry_after
173
+ end
174
+ end
175
+
176
+ # Raised when account has insufficient credits (HTTP 402).
177
+ class InsufficientCreditsError < Error
178
+ def initialize(message = "Insufficient credits", **kwargs)
179
+ super(message, status: 402, **kwargs)
180
+ end
181
+ end
182
+
183
+ # Raised when requested resource does not exist (HTTP 404).
184
+ class NotFoundError < Error
185
+ def initialize(message = "Not found", **kwargs)
186
+ super(message, status: 404, **kwargs)
187
+ end
188
+ end
189
+
190
+ # Raised when request validation fails (HTTP 400, 422).
191
+ class ValidationError < Error
192
+ def initialize(message = "Validation failed", **kwargs)
193
+ super
194
+ end
195
+ end
196
+
197
+ # Raised when service is temporarily unavailable (HTTP 503).
198
+ class ServiceUnavailableError < Error
199
+ def initialize(message = "Service unavailable", **kwargs)
200
+ super(message, status: kwargs.delete(:status) || 503, **kwargs)
201
+ end
202
+ end
203
+
204
+ # Raised when network connection fails or request cannot be sent.
205
+ class NetworkError < Error
206
+ def initialize(message = "Network error", **kwargs)
207
+ super
208
+ end
209
+ end
210
+
211
+ # Raised when HTTP request exceeds configured timeout.
212
+ class TimeoutError < Error
213
+ def initialize(message = "Request timed out", **kwargs)
214
+ super
215
+ end
216
+ end
217
+
218
+ # Raised when polling for task completion exceeds maximum wait time.
219
+ class TaskTimeoutError < Error
220
+ def initialize(message = "Task polling timed out", **kwargs)
221
+ super
222
+ end
223
+ end
224
+
225
+ # Raised when async task fails during processing.
226
+ class TaskFailedError < Error
227
+ def initialize(message = "Task failed", **kwargs)
228
+ super
229
+ end
230
+ end
231
+
232
+ # Raised when request conflicts with current resource state (HTTP 409).
233
+ class ConflictError < Error
234
+ def initialize(message = "Conflict", **kwargs)
235
+ super(message, status: 409, **kwargs)
236
+ end
237
+ end
238
+
239
+ # Raised when server encounters an internal error (HTTP 5xx).
240
+ class ServerError < Error
241
+ def initialize(message = "Server error", **kwargs)
242
+ super(message, status: kwargs.delete(:status) || 500, **kwargs)
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RunApi
4
+ module Core
5
+ class HttpClient
6
+ STALE_CONNECTION_ERRORS = [ Errno::EPIPE, EOFError, IOError, OpenSSL::SSL::SSLError ].freeze
7
+
8
+ def initialize(options)
9
+ @options = options
10
+ @pool = ConnectionPool.new(size: 5, timeout: 5) do
11
+ build_connection
12
+ end
13
+ end
14
+
15
+ def request(method, path, body: nil, options: nil)
16
+ uri = URI.join(@options.base_url, path)
17
+ req = build_request(method, uri, body, options)
18
+ max_retries = options&.max_retries || @options.max_retries
19
+ retries = 0
20
+ stale_retried = false
21
+
22
+ loop do
23
+ response = begin
24
+ @pool.with do |http|
25
+ http.start unless http.started?
26
+ http.request(req)
27
+ end
28
+ rescue *STALE_CONNECTION_ERRORS
29
+ unless stale_retried
30
+ stale_retried = true
31
+ next
32
+ end
33
+ raise NetworkError, "Connection lost"
34
+ rescue ::Net::OpenTimeout, ::Net::ReadTimeout => e
35
+ raise TimeoutError, e.message
36
+ rescue ::SocketError, ::Errno::ECONNREFUSED, ::Errno::ECONNRESET => e
37
+ raise NetworkError, e.message
38
+ end
39
+
40
+ return parse_body(response.body) if response.is_a?(Net::HTTPSuccess)
41
+
42
+ error = Error.from_response(response, response.body)
43
+
44
+ if retryable?(method, response.code.to_i) && retries < max_retries
45
+ retries += 1
46
+ sleep(retry_delay(retries, error))
47
+ stale_retried = false
48
+ next
49
+ end
50
+
51
+ raise error
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def build_connection
58
+ uri = URI.parse(@options.base_url)
59
+ http = Net::HTTP.new(uri.host, uri.port)
60
+ http.use_ssl = (uri.scheme == "https")
61
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
62
+ http.open_timeout = @options.timeout
63
+ http.read_timeout = @options.timeout
64
+ http
65
+ end
66
+
67
+ def build_request(method, uri, body, options)
68
+ klass = Net::HTTP.const_get(method.to_s.capitalize)
69
+ req = klass.new(uri.request_uri)
70
+
71
+ req["Authorization"] = "Bearer #{@options.api_key}"
72
+ req["Content-Type"] = "application/json"
73
+ req["Accept"] = "application/json"
74
+ req["User-Agent"] = Constants::SDK_USER_AGENT
75
+
76
+ options&.headers&.each { |k, v| req[k.to_s] = v }
77
+
78
+ req.body = JSON.generate(body) if body
79
+ req
80
+ end
81
+
82
+ def retryable?(method, status)
83
+ Constants::IDEMPOTENT_METHODS.include?(method.to_s.upcase) &&
84
+ Constants::RETRYABLE_STATUS_CODES.include?(status)
85
+ end
86
+
87
+ def retry_delay(attempt, error)
88
+ if error.is_a?(RateLimitError) && error.retry_after&.positive?
89
+ return error.retry_after
90
+ end
91
+
92
+ base = @options.retry_base_delay * (2**(attempt - 1))
93
+ jitter = rand * base * 0.5
94
+ [ base + jitter, @options.retry_max_delay ].min
95
+ end
96
+
97
+ def parse_body(body)
98
+ return nil if body.nil? || body.empty?
99
+
100
+ JSON.parse(body)
101
+ rescue JSON::ParserError
102
+ body
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RunApi
4
+ module Core
5
+ module Polling
6
+ ACTIVE_STATUSES = %w[pending processing].freeze
7
+
8
+ def self.poll_until_complete(options = PollingOptions.new)
9
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + options.max_wait
10
+
11
+ loop do
12
+ response = yield
13
+ status = value_for(response, "status").to_s.downcase
14
+
15
+ return response if status == "completed"
16
+
17
+ if status == "failed"
18
+ message = value_for(response, "error") || "Task failed"
19
+ raise TaskFailedError.new(message, details: details_for(response))
20
+ end
21
+
22
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
23
+ raise TaskTimeoutError, "Task polling timed out after #{options.max_wait}s"
24
+ end
25
+
26
+ unless ACTIVE_STATUSES.include?(status)
27
+ raise TaskFailedError.new("Unknown task status: #{status}", details: details_for(response))
28
+ end
29
+
30
+ sleep(options.poll_interval)
31
+ end
32
+ end
33
+
34
+ def self.value_for(response, key)
35
+ case response
36
+ when Core::BaseModel
37
+ response[key]
38
+ when Hash
39
+ response[key] || response[key.to_sym]
40
+ end
41
+ end
42
+ private_class_method :value_for
43
+
44
+ def self.details_for(response)
45
+ response.is_a?(Core::BaseModel) ? response.to_h : response
46
+ end
47
+ private_class_method :details_for
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RunApi
4
+ module Core
5
+ module ResourceHelpers
6
+ private
7
+
8
+ # Performs an HTTP request and coerces JSON responses into typed model objects.
9
+ # Keeps existing request signatures so current stubs and custom transports keep working.
10
+ def request(method, path, body: :__runapi_no_body__, options: nil, response_class: default_response_class)
11
+ response = if body == :__runapi_no_body__
12
+ if options
13
+ @http.request(method, path, options: options)
14
+ else
15
+ @http.request(method, path)
16
+ end
17
+ else
18
+ kwargs = { body: body }
19
+ kwargs[:options] = options if options
20
+ @http.request(method, path, **kwargs)
21
+ end
22
+
23
+ Core::BaseModel.coerce(response, as: response_class)
24
+ end
25
+
26
+ def compact_params(params)
27
+ params.reject { |_, v| v.nil? || (v.is_a?(String) && v.strip.empty?) }
28
+ end
29
+
30
+ def param(params, key)
31
+ return params[key] if params.key?(key)
32
+ params[key.to_s] if params.key?(key.to_s)
33
+ end
34
+
35
+ def validate_optional!(params, key, allowed)
36
+ value = param(params, key)
37
+ return unless value
38
+
39
+ unless allowed.include?(value)
40
+ raise Core::ValidationError, "Invalid #{key}: #{value}. Must be one of: #{allowed.join(", ")}"
41
+ end
42
+ end
43
+
44
+ def default_response_class
45
+ if self.class.const_defined?(:RESPONSE_CLASS, false)
46
+ self.class::RESPONSE_CLASS
47
+ else
48
+ Core::TaskResponse
49
+ end
50
+ end
51
+
52
+ # Run polling and, once the task reports `completed`, re-coerce the payload
53
+ # into the resource's narrowed response class (when defined). This lets
54
+ # `run()` callers rely on result fields being present without a nil check.
55
+ def poll_until_complete(polling_opts = Core::PollingOptions.new, &block)
56
+ response = Core::Polling.poll_until_complete(polling_opts, &block)
57
+ return response unless self.class.const_defined?(:COMPLETED_RESPONSE_CLASS, false)
58
+
59
+ completed_class = self.class::COMPLETED_RESPONSE_CLASS
60
+ return response if response.is_a?(completed_class)
61
+
62
+ payload = response.is_a?(Core::BaseModel) ? response.to_h : response
63
+ completed_class.from_hash(payload)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RunApi
4
+ module Core
5
+ # HTTP methods supported by the SDK.
6
+ HTTP_METHODS = %i[get post put patch delete head options].freeze
7
+
8
+ # Task status values returned by async operations.
9
+ TASK_STATUSES = %w[pending processing completed failed].freeze
10
+
11
+ # Status values for async task results (excludes 'pending').
12
+ ASYNC_TASK_STATUSES = %w[processing completed failed].freeze
13
+
14
+ # Configuration options for API clients.
15
+ #
16
+ # @example
17
+ # options = ClientOptions.new(
18
+ # api_key: "your-api-key",
19
+ # base_url: "https://runapi.ai",
20
+ # timeout: 60,
21
+ # max_retries: 3
22
+ # )
23
+ #
24
+ # @!attribute [rw] api_key
25
+ # @return [String] API key for authentication. Required.
26
+ # @!attribute [rw] base_url
27
+ # @return [String] Base URL for API requests. Defaults to RunApi.base_url.
28
+ # @!attribute [rw] timeout
29
+ # @return [Integer] Request timeout in seconds. Defaults to 900 (15 minutes).
30
+ # @!attribute [rw] max_retries
31
+ # @return [Integer] Maximum number of retry attempts. Defaults to 2.
32
+ # @!attribute [rw] retry_base_delay
33
+ # @return [Float] Base delay between retries in seconds. Defaults to 0.5.
34
+ # @!attribute [rw] retry_max_delay
35
+ # @return [Float] Maximum delay between retries in seconds. Defaults to 5.0.
36
+ # @!attribute [rw] http_client
37
+ # @return [Object, nil] Custom HTTP transport object. Must respond to
38
+ # `#request(method, path, body:, options:)` with the same interface as {HttpClient}.
39
+ # When set, the SDK skips building its own {HttpClient} and delegates all HTTP calls
40
+ # to this object.
41
+ ClientOptions = Struct.new(
42
+ :api_key,
43
+ :base_url,
44
+ :timeout,
45
+ :max_retries,
46
+ :retry_base_delay,
47
+ :retry_max_delay,
48
+ :http_client,
49
+ keyword_init: true
50
+ ) do
51
+ def initialize(**kwargs)
52
+ super
53
+ self.base_url ||= RunApi.base_url
54
+ self.timeout ||= Constants::TIMEOUTS[:http_request]
55
+ self.max_retries ||= Constants::RETRY_CONFIG[:max_retries]
56
+ self.retry_base_delay ||= Constants::RETRY_CONFIG[:base_delay]
57
+ self.retry_max_delay ||= Constants::RETRY_CONFIG[:max_delay]
58
+ end
59
+ end
60
+
61
+ # Per-request options that override client-level defaults.
62
+ #
63
+ # @example
64
+ # client.text_to_image.run(
65
+ # { prompt: "A sunset" },
66
+ # options: RequestOptions.new(timeout: 30, headers: { "X-Custom" => "value" })
67
+ # )
68
+ #
69
+ # @!attribute [rw] headers
70
+ # @return [Hash<String, String>, nil] Additional HTTP headers. Merged with client-level headers.
71
+ # @!attribute [rw] timeout
72
+ # @return [Integer, nil] Request timeout in seconds. Overrides client-level timeout.
73
+ # @!attribute [rw] max_retries
74
+ # @return [Integer, nil] Maximum retry attempts. Overrides client-level max_retries.
75
+ RequestOptions = Struct.new(
76
+ :headers,
77
+ :timeout,
78
+ :max_retries,
79
+ keyword_init: true
80
+ )
81
+
82
+ # Options for polling async task completion.
83
+ # Used internally by resource `run` methods.
84
+ #
85
+ # @!attribute [rw] poll_interval
86
+ # @return [Integer] Polling interval in seconds. Defaults to 2.
87
+ # @!attribute [rw] max_wait
88
+ # @return [Integer] Maximum wait time in seconds. Defaults to 900 (15 minutes).
89
+ PollingOptions = Struct.new(
90
+ :poll_interval,
91
+ :max_wait,
92
+ keyword_init: true
93
+ ) do
94
+ def initialize(**kwargs)
95
+ super
96
+ self.poll_interval ||= Constants::TIMEOUTS[:polling_interval]
97
+ self.max_wait ||= Constants::TIMEOUTS[:polling_max_wait]
98
+ end
99
+ end
100
+
101
+ # Typed response structure for async task operations.
102
+ # Additional API-specific fields are preserved and exposed via dot notation.
103
+ #
104
+ # @!attribute [r] id
105
+ # @return [String, nil] Task ID for tracking and retrieval.
106
+ # @!attribute [r] status
107
+ # @return [String, nil] Current task status.
108
+ # @!attribute [r] error
109
+ # @return [String, nil] Error message if task failed.
110
+ # @!attribute [r] data
111
+ # @return [RunApi::Core::DynamicModel, nil] Task-specific result data.
112
+ class TaskResponse < BaseModel
113
+ module Status
114
+ PENDING = "pending"
115
+ PROCESSING = "processing"
116
+ COMPLETED = "completed"
117
+ FAILED = "failed"
118
+
119
+ ALL = [ PENDING, PROCESSING, COMPLETED, FAILED ].freeze
120
+ end
121
+
122
+ optional :id, String
123
+ optional :status, String
124
+ optional :error, String
125
+ optional :data, -> { DynamicModel }
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RunApi
4
+ module Core
5
+ VERSION = "0.1.0"
6
+ end
7
+
8
+ VERSION = Core::VERSION
9
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "openssl"
6
+ require "uri"
7
+ require "securerandom"
8
+ require "connection_pool"
9
+
10
+ require_relative "core/version"
11
+ require_relative "core/constants"
12
+ require_relative "core/configuration"
13
+ require_relative "core/base_model"
14
+ require_relative "core/types"
15
+ require_relative "core/errors"
16
+ require_relative "core/auth"
17
+ require_relative "core/http_client"
18
+ require_relative "core/polling"
19
+ require_relative "core/resource_helpers"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "runapi/core"
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: runapi-core
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - RunAPI
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: connection_pool
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.4'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.4'
26
+ description: RunAPI core SDK for JavaScript, Ruby, and Go
27
+ email:
28
+ - contact@runapi.ai
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - LICENSE
34
+ - lib/runapi-core.rb
35
+ - lib/runapi/core.rb
36
+ - lib/runapi/core/auth.rb
37
+ - lib/runapi/core/base_model.rb
38
+ - lib/runapi/core/configuration.rb
39
+ - lib/runapi/core/constants.rb
40
+ - lib/runapi/core/errors.rb
41
+ - lib/runapi/core/http_client.rb
42
+ - lib/runapi/core/polling.rb
43
+ - lib/runapi/core/resource_helpers.rb
44
+ - lib/runapi/core/types.rb
45
+ - lib/runapi/core/version.rb
46
+ homepage: https://runapi.ai/docs#runapi-sdks
47
+ licenses:
48
+ - Apache-2.0
49
+ metadata:
50
+ homepage_uri: https://runapi.ai/docs#runapi-sdks
51
+ documentation_uri: https://runapi.ai/docs#runapi-sdks
52
+ source_code_uri: https://github.com/runapi-ai/core-sdk
53
+ changelog_uri: https://github.com/runapi-ai/core-sdk/blob/main/CHANGELOG.md
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: 3.1.0
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ requirements: []
68
+ rubygems_version: 4.0.6
69
+ specification_version: 4
70
+ summary: Shared SDK primitives for RunAPI JavaScript, Ruby, and Go SDKs.
71
+ test_files: []