runapi-core 0.2.5 → 0.2.7

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 43b27ac4a9c29db093d7efcad4686efe7730aa9ddb906a956c0f2a4b353e0ccd
4
- data.tar.gz: a42105c4a366fa9a17642f519392ae6dd50978a32ae73fae6c62f38f8b02cb28
3
+ metadata.gz: f165d79fca32b409a50b34fc13a629eb666e063a1fc2a72e51bd63f1dcf91534
4
+ data.tar.gz: 338918ccaeff5eb66a9366f2e56e868f55e8b728580064989453d6942c5babe1
5
5
  SHA512:
6
- metadata.gz: 9bee66f588446e79bbbf8eea48964bb2505bfee41c4dc9c68ce1622359e0a536e498a895c065ef8efb54c2ee41b5df19b8d7c55b8707d181c3585d2887cb29b5
7
- data.tar.gz: bb4dc1ff3c4f564c89bc20e4838263864704d54a80f315f093dba9d77e6efabce80bc4087748b816aec20ac0674141e30b1d3ee632e6d92b1f006bb4c07d2b3b
6
+ metadata.gz: 59a92208fc80fbaeede0a56af09e4a8d41c91a40bdbbd8242635bf762985c1177b340253de20dad30dc44b3a4fd4d76587102c00ceb80b84194c4594a5892b72
7
+ data.tar.gz: 96b6055a8ff2a405b500c76418fc91c9b7ebdae676b6832e59052b0700b2500b39813faadcb13204ace7ecea6b754765245c04193b7d31dcf92c95dd0c30a2ad
data/README.md CHANGED
@@ -12,6 +12,45 @@ gem install runapi-core
12
12
 
13
13
  Use the core gem for common client options, error classes, request helpers, and task polling behavior that model SDKs share. Public SDK docs live at https://runapi.ai/docs#runapi-sdks and the model catalog lives at https://runapi.ai/models.
14
14
 
15
+ ## Request Identifiers
16
+
17
+ RunAPI accepts an optional `X-Client-Request-Id` header on public API calls. Use printable ASCII values up to 512 characters. Accepted values are echoed in the response and stored with the RunAPI task for support and reconciliation.
18
+
19
+ High-level Ruby model SDK methods currently return parsed response bodies. When an integration needs to send a client request id or read `X-RunAPI-Task-Id`, make the call through direct HTTP or a custom transport so response headers stay available.
20
+
21
+ ```ruby
22
+ require "json"
23
+ require "net/http"
24
+ require "uri"
25
+
26
+ uri = URI("https://runapi.ai/api/v1/suno/text_to_music")
27
+ request = Net::HTTP::Post.new(uri)
28
+ request["Authorization"] = "Bearer #{ENV.fetch("RUNAPI_API_KEY")}"
29
+ request["Content-Type"] = "application/json"
30
+ request["X-Client-Request-Id"] = "order-123"
31
+ request.body = JSON.generate({
32
+ prompt: "A chill lo-fi beat",
33
+ model: "suno-v4.5-plus",
34
+ vocal_mode: "instrumental"
35
+ })
36
+
37
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
38
+ runapi_task_id = response["X-RunAPI-Task-Id"]
39
+ body = JSON.parse(response.body)
40
+ ```
41
+
42
+ ## File Upload
43
+
44
+ ```ruby
45
+ client = RunApi::NanoBanana::Client.new(api_key: ENV["RUNAPI_API_KEY"])
46
+
47
+ upload = client.files.create(source: {type: "url", url: "https://cdn.runapi.ai/public/samples/image.jpg"})
48
+ puts upload.url
49
+ ```
50
+
51
+ > [!IMPORTANT]
52
+ > Uploaded file URLs expire 1 hour after creation. Pass them to a model promptly rather than storing them for later use.
53
+
15
54
  ## License
16
55
 
17
56
  Licensed under the Apache License, Version 2.0.
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RunApi
4
+ module Core
5
+ class Account
6
+ include RunApi::Core::ResourceHelpers
7
+
8
+ INFO_ENDPOINT = "/api/v1/me"
9
+ BALANCE_ENDPOINT = "/api/v1/me/balance"
10
+
11
+ class AccountRecord < RunApi::Core::BaseModel
12
+ required :id, Integer
13
+ required :name, String
14
+ end
15
+
16
+ class InfoResponse < RunApi::Core::BaseModel
17
+ required :id, Integer
18
+ required :name, String
19
+ required :email, String
20
+ required :account, AccountRecord
21
+ end
22
+
23
+ class BalanceResponse < RunApi::Core::BaseModel
24
+ required :balance_cents, Integer
25
+ required :paid_balance_cents, Integer
26
+ required :bonus_balance_cents, Integer
27
+ required :spent_cents_today, Integer
28
+ required :spent_cents_total, Integer
29
+ end
30
+
31
+ def initialize(http)
32
+ @http = http
33
+ end
34
+
35
+ def info(options: nil)
36
+ request(:get, INFO_ENDPOINT, options:, response_class: InfoResponse)
37
+ end
38
+
39
+ def balance(options: nil)
40
+ request(:get, BALANCE_ENDPOINT, options:, response_class: BalanceResponse)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RunApi
4
+ module Core
5
+ # Base class for every RunAPI client. Resolves the API key, builds the shared
6
+ # HTTP client, and exposes the Universal Resources (file upload, account) that
7
+ # are available on any client regardless of which model gem was required.
8
+ #
9
+ # Provider clients inherit from this and build their model resources from the
10
+ # protected +http+ reader.
11
+ class Client
12
+ # @return [Files] Temporary file upload operations.
13
+ attr_reader :files
14
+ # @return [Account] Account info and balance operations.
15
+ attr_reader :account
16
+
17
+ def initialize(api_key: nil, **options)
18
+ @api_key = Core::Auth.resolve_api_key(api_key)
19
+ client_options = Core::ClientOptions.new(api_key: @api_key, **options)
20
+ @http = client_options.http_client || Core::HttpClient.new(client_options)
21
+ @files = Files.new(@http)
22
+ @account = Account.new(@http)
23
+ end
24
+
25
+ protected
26
+
27
+ attr_reader :http
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RunApi
4
+ module Core
5
+ class Files
6
+ include RunApi::Core::ResourceHelpers
7
+
8
+ ENDPOINT = "/api/v1/files"
9
+
10
+ class UploadResponse < RunApi::Core::BaseModel
11
+ required :file_name, String
12
+ required :url, String
13
+ required :size_bytes, Integer
14
+ required :mime_type, String
15
+ required :created_at, String
16
+ required :expires_at, String
17
+ end
18
+
19
+ RESPONSE_CLASS = UploadResponse
20
+
21
+ def initialize(http)
22
+ @http = http
23
+ end
24
+
25
+ def create(file: nil, source: nil, file_name: nil, options: nil)
26
+ validate_source!(file:, source:)
27
+
28
+ body = if file
29
+ multipart_body(file, file_name:)
30
+ else
31
+ compact_params(source:, file_name:)
32
+ end
33
+
34
+ request(:post, ENDPOINT, body:, options:)
35
+ end
36
+
37
+ private
38
+
39
+ def validate_source!(file:, source:)
40
+ source_count = [file, source].count { |value| !value.nil? }
41
+ return if source_count == 1
42
+
43
+ raise ArgumentError, "Exactly one source is required: file or source"
44
+ end
45
+
46
+ def multipart_body(file, file_name:)
47
+ path = file_path(file)
48
+ filename = file_name || File.basename(path)
49
+ Core::MultipartBody.new(
50
+ fields: compact_params(file_name: file_name),
51
+ files: {
52
+ file: Core::MultipartFile.new(path:, filename:)
53
+ }
54
+ )
55
+ end
56
+
57
+ def file_path(file)
58
+ return file if file.is_a?(String)
59
+ return file.path if file.respond_to?(:path)
60
+
61
+ raise ArgumentError, "file must be a file path or respond to :path"
62
+ end
63
+ end
64
+ end
65
+ end
@@ -50,6 +50,8 @@ module RunApi
50
50
 
51
51
  raise error
52
52
  end
53
+ ensure
54
+ close_multipart_files(req)
53
55
  end
54
56
 
55
57
  private
@@ -69,16 +71,45 @@ module RunApi
69
71
  req = klass.new(uri.request_uri)
70
72
 
71
73
  req["Authorization"] = "Bearer #{@options.api_key}"
72
- req["Content-Type"] = "application/json"
73
74
  req["Accept"] = "application/json"
74
75
  req["User-Agent"] = Constants::SDK_USER_AGENT
75
76
 
76
77
  options&.headers&.each { |k, v| req[k.to_s] = v }
77
78
 
78
- req.body = JSON.generate(body) if body
79
+ if body.is_a?(MultipartBody)
80
+ req.set_form(multipart_parts(body), "multipart/form-data")
81
+ elsif body
82
+ req["Content-Type"] = "application/json"
83
+ req.body = JSON.generate(body)
84
+ end
79
85
  req
80
86
  end
81
87
 
88
+ def multipart_parts(body)
89
+ opened_files = []
90
+ field_parts = body.fields.map { |key, value| [key, value.to_s] }
91
+ file_parts = body.files.map do |key, file|
92
+ options = {filename: file.filename}
93
+ options[:content_type] = file.content_type if file.content_type
94
+ opened_files << File.open(file.path, "rb")
95
+ [key, opened_files.last, options]
96
+ end
97
+ field_parts + file_parts
98
+ rescue
99
+ opened_files.each { |file| file.close unless file.closed? }
100
+ raise
101
+ end
102
+
103
+ def close_multipart_files(request)
104
+ body_data = request&.instance_variable_get(:@body_data)
105
+ return unless body_data
106
+
107
+ body_data.each do |part|
108
+ file = part[1]
109
+ file.close if file.is_a?(File) && !file.closed?
110
+ end
111
+ end
112
+
82
113
  def retryable?(method, status)
83
114
  Constants::IDEMPOTENT_METHODS.include?(method.to_s.upcase) &&
84
115
  Constants::RETRYABLE_STATUS_CODES.include?(status)
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RunApi
4
+ module Core
5
+ MultipartFile = Struct.new(:path, :filename, :content_type, keyword_init: true)
6
+
7
+ class MultipartBody
8
+ attr_reader :fields, :files
9
+
10
+ def initialize(fields: {}, files: {})
11
+ @fields = stringify_keys(fields)
12
+ @files = stringify_keys(files)
13
+ end
14
+
15
+ private
16
+
17
+ def stringify_keys(hash)
18
+ hash.each_with_object({}) do |(key, value), result|
19
+ result[key.to_s] = value
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -41,6 +41,151 @@ module RunApi
41
41
  end
42
42
  end
43
43
 
44
+ # ---- Contract validation ------------------------------------------
45
+ # Validates request params against the generated contract: model
46
+ # membership, then per-field required/enum/integer/min/max/length, then
47
+ # declared cross-field rules. `schema` is one action entry from the generated
48
+ # per-package CONTRACT (CONTRACT["<action>"]).
49
+
50
+ def validate_contract!(schema, params)
51
+ model = param_value(params, "model")
52
+ models = schema["models"] || []
53
+ unless models.include?(model)
54
+ raise Core::ValidationError, "model must be one of: #{models.sort.join(", ")}"
55
+ end
56
+
57
+ fields = schema.dig("fields_by_model", model) || {}
58
+ fields.each do |field, rules|
59
+ validate_schema_field!(params, field, rules)
60
+ end
61
+
62
+ Array(schema["rules"]).each { |rule| enforce_contract_rule!(params, rule) }
63
+ end
64
+
65
+ def validate_schema_field!(params, field, rules)
66
+ present = field_present?(params, field)
67
+ raise Core::ValidationError, "#{field} is required" if rules["required"] && !present
68
+ return unless present
69
+
70
+ value = param_value(params, field)
71
+ if (enum = rules["enum"]) && !enum_value_allowed?(enum, value)
72
+ raise Core::ValidationError, "#{field} must be one of: #{enum.join(", ")}"
73
+ end
74
+
75
+ validate_schema_integer!(field, value, rules) if rules["type"] == "integer"
76
+ validate_schema_range!(field, value, rules) if rules.key?("min") || rules.key?("max")
77
+ end
78
+
79
+ # Mirrors GatewayEntry#validate_schema_integer!: a type: integer field
80
+ # rejects non-integer numbers (e.g. 11.5), which min/max alone admit.
81
+ def validate_schema_integer!(field, value, rules)
82
+ return if value.is_a?(Integer)
83
+
84
+ min = rules["min"]
85
+ max = rules["max"]
86
+ detail = (min && max) ? " between #{min} and #{max}" : ""
87
+ raise Core::ValidationError, "#{field} must be an integer#{detail}"
88
+ end
89
+
90
+ def validate_schema_range!(field, value, rules)
91
+ if rules["length"]
92
+ measured = value.to_s.length
93
+ unit = "characters"
94
+ else
95
+ raise Core::ValidationError, "#{field} must be a number" unless value.is_a?(Numeric)
96
+
97
+ measured = value
98
+ unit = nil
99
+ end
100
+
101
+ min = rules["min"]
102
+ max = rules["max"]
103
+ return if (min.nil? || measured >= min) && (max.nil? || measured <= max)
104
+
105
+ raise Core::ValidationError, schema_range_message(field, min, max, unit)
106
+ end
107
+
108
+ def schema_range_message(field, min, max, unit)
109
+ suffix = unit ? " #{unit}" : ""
110
+ if min && max
111
+ "#{field} must be between #{min} and #{max}#{suffix}"
112
+ elsif min
113
+ "#{field} must be at least #{min}#{suffix}"
114
+ else
115
+ "#{field} must be at most #{max}#{suffix}"
116
+ end
117
+ end
118
+
119
+ def enum_value_allowed?(enum, value)
120
+ enum.any? do |allowed|
121
+ if allowed.is_a?(Numeric)
122
+ value.is_a?(Numeric) && value == allowed
123
+ elsif value.is_a?(Numeric)
124
+ allowed == value
125
+ else
126
+ allowed.to_s == value.to_s
127
+ end
128
+ end
129
+ end
130
+
131
+ def enforce_contract_rule!(params, rule)
132
+ conditions = rule["when"] || {}
133
+ return unless conditions.all? { |field, value| rule_condition_met?(params, field, value) }
134
+
135
+ context = conditions.map { |field, value| "#{field} is #{value}" }.join(" and ")
136
+ Array(rule["required"]).each do |field|
137
+ next if field_present?(params, field)
138
+
139
+ raise Core::ValidationError, "#{field} is required when #{context}"
140
+ end
141
+ Array(rule["forbidden"]).each do |field|
142
+ next unless field_present?(params, field)
143
+
144
+ raise Core::ValidationError, "#{field} is not allowed when #{context}"
145
+ end
146
+ end
147
+
148
+ def rule_condition_met?(params, field, value)
149
+ return false unless param_key?(params, field)
150
+
151
+ param_value(params, field).to_s == value.to_s
152
+ end
153
+
154
+ def field_present?(params, field)
155
+ return false unless param_key?(params, field)
156
+
157
+ value = param_value(params, field)
158
+ case value
159
+ when false then true
160
+ when Array then value.any? { |item| present_value?(item) }
161
+ else present_value?(value)
162
+ end
163
+ end
164
+
165
+ def param_key?(params, field)
166
+ params.key?(field.to_sym) || params.key?(field.to_s)
167
+ end
168
+
169
+ # Indifferent value lookup for a string-or-symbol field across params that
170
+ # may be keyed either way.
171
+ def param_value(params, field)
172
+ if params.key?(field.to_sym)
173
+ params[field.to_sym]
174
+ elsif params.key?(field.to_s)
175
+ params[field.to_s]
176
+ end
177
+ end
178
+
179
+ def present_value?(value)
180
+ case value
181
+ when nil, false then false
182
+ when true then true
183
+ when String then !value.strip.empty?
184
+ when Array, Hash then !value.empty?
185
+ else true
186
+ end
187
+ end
188
+
44
189
  def default_response_class
45
190
  if self.class.const_defined?(:RESPONSE_CLASS, false)
46
191
  self.class::RESPONSE_CLASS
@@ -2,7 +2,7 @@
2
2
 
3
3
  module RunApi
4
4
  module Core
5
- VERSION = "0.2.5"
5
+ VERSION = "0.2.7"
6
6
  end
7
7
 
8
8
  VERSION = Core::VERSION
data/lib/runapi/core.rb CHANGED
@@ -14,6 +14,10 @@ require_relative "core/base_model"
14
14
  require_relative "core/types"
15
15
  require_relative "core/errors"
16
16
  require_relative "core/auth"
17
+ require_relative "core/multipart_body"
17
18
  require_relative "core/http_client"
18
19
  require_relative "core/polling"
19
20
  require_relative "core/resource_helpers"
21
+ require_relative "core/files"
22
+ require_relative "core/account"
23
+ require_relative "core/client"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: runapi-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.5
4
+ version: 0.2.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - RunAPI
@@ -38,12 +38,16 @@ files:
38
38
  - README.md
39
39
  - lib/runapi-core.rb
40
40
  - lib/runapi/core.rb
41
+ - lib/runapi/core/account.rb
41
42
  - lib/runapi/core/auth.rb
42
43
  - lib/runapi/core/base_model.rb
44
+ - lib/runapi/core/client.rb
43
45
  - lib/runapi/core/configuration.rb
44
46
  - lib/runapi/core/constants.rb
45
47
  - lib/runapi/core/errors.rb
48
+ - lib/runapi/core/files.rb
46
49
  - lib/runapi/core/http_client.rb
50
+ - lib/runapi/core/multipart_body.rb
47
51
  - lib/runapi/core/polling.rb
48
52
  - lib/runapi/core/resource_helpers.rb
49
53
  - lib/runapi/core/types.rb
@@ -52,9 +56,11 @@ homepage: https://runapi.ai/models
52
56
  licenses:
53
57
  - Apache-2.0
54
58
  metadata:
59
+ runapi_slug: core
55
60
  homepage_uri: https://runapi.ai/models
56
61
  documentation_uri: https://github.com/runapi-ai/core-sdk/blob/main/ruby/README.md
57
62
  source_code_uri: https://github.com/runapi-ai/core-sdk
63
+ bug_tracker_uri: https://github.com/runapi-ai/core-sdk/issues
58
64
  changelog_uri: https://github.com/runapi-ai/core-sdk/blob/main/CHANGELOG.md
59
65
  rdoc_options: []
60
66
  require_paths: