runapi-core 0.2.6 → 0.2.8

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: b0f298f55ee5728667cb83a8db61391cd20bdc64582faec2207d5db9ce79c384
4
- data.tar.gz: fe0c644ac7dd1377e0c4b0c0fb4b545afc0ff175ac83c3c3886b5219d93aed4a
3
+ metadata.gz: 66cc8586f64564c496bb7c4008f5cbaf0a30d1b1528cea036db368a3f9bdc307
4
+ data.tar.gz: 43cb58a1644f09209e1da2e14c6128cf1a334fa245d0d285499b5e4e892a411f
5
5
  SHA512:
6
- metadata.gz: 5d473dc453e4dba6a452c54901ad2274453464de990d34c3c2c685a92d24cdb8fb9b07d6e57086913e560f034f11e5a5deea40f030bf1c1909a02724907ac4af
7
- data.tar.gz: 47ef6424ffd81307922ef8adfc7c42286b6b3cd69527478b28f0674ac23332f212c10682564da8ad23bdd3b5dc0728ae7f0ce678f8814ae4f7e40736392ffdc8
6
+ metadata.gz: 957bcd8962bcde64fd16281a2b21876ba93b505546277d5e61a0bd898cb9b1ec6369b34ffcbeb0b79df42e5a56fbe1ff1dc8531872f8cd29b49375c5c46a37a7
7
+ data.tar.gz: 43261e4cdcaef883977a9efc32ed752b9000b05602671cabc7471b000a2f01065666746ae09c4be7eeceae622cb7162f84562170eb22214b11b4b1c483e43b0d
data/README.md CHANGED
@@ -12,12 +12,39 @@ 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
+
15
42
  ## File Upload
16
43
 
17
44
  ```ruby
18
45
  client = RunApi::NanoBanana::Client.new(api_key: ENV["RUNAPI_API_KEY"])
19
46
 
20
- upload = client.files.create(source: {type: "url", url: "https://example.com/photo.jpg"})
47
+ upload = client.files.create(source: {type: "url", url: "https://cdn.runapi.ai/public/samples/image.jpg"})
21
48
  puts upload.url
22
49
  ```
23
50
 
@@ -1,11 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  module RunApi
4
6
  module Core
5
7
  class Files
6
8
  include RunApi::Core::ResourceHelpers
7
9
 
8
10
  ENDPOINT = "/api/v1/files"
11
+ PREPARE_ENDPOINT = "#{ENDPOINT}/prepare"
12
+ CONFIRM_ENDPOINT = "#{ENDPOINT}/confirm"
9
13
 
10
14
  class UploadResponse < RunApi::Core::BaseModel
11
15
  required :file_name, String
@@ -25,13 +29,9 @@ module RunApi
25
29
  def create(file: nil, source: nil, file_name: nil, options: nil)
26
30
  validate_source!(file:, source:)
27
31
 
28
- body = if file
29
- multipart_body(file, file_name:)
30
- else
31
- compact_params(source:, file_name:)
32
- end
32
+ return upload_direct(file, file_name:, options:) if file
33
33
 
34
- request(:post, ENDPOINT, body:, options:)
34
+ request(:post, ENDPOINT, body: compact_params(source:, file_name:), options:)
35
35
  end
36
36
 
37
37
  private
@@ -43,15 +43,26 @@ module RunApi
43
43
  raise ArgumentError, "Exactly one source is required: file or source"
44
44
  end
45
45
 
46
- def multipart_body(file, file_name:)
46
+ # Local files upload straight to storage: ask for a pre-authorized target,
47
+ # PUT the bytes there (never through the API), then confirm. The caller still
48
+ # makes a single create call.
49
+ def upload_direct(file, file_name:, options:)
47
50
  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
- }
51
+ bytes = File.binread(path)
52
+ prepared = @http.request(
53
+ :post,
54
+ PREPARE_ENDPOINT,
55
+ body: compact_params(
56
+ filename: file_name || File.basename(path),
57
+ byte_size: bytes.bytesize,
58
+ checksum: Digest::MD5.base64digest(bytes)
59
+ ),
60
+ options:
54
61
  )
62
+
63
+ @http.upload(prepared["upload_url"], headers: prepared["headers"], body: bytes)
64
+
65
+ request(:post, CONFIRM_ENDPOINT, body: {signed_id: prepared["signed_id"]}, options:)
55
66
  end
56
67
 
57
68
  def file_path(file)
@@ -54,6 +54,30 @@ module RunApi
54
54
  close_multipart_files(req)
55
55
  end
56
56
 
57
+ # PUT bytes straight to a pre-authorized upload URL with the exact headers
58
+ # issued for it. Skips the base URL, auth, and retries: the URL is single-use
59
+ # and the body is not safe to replay.
60
+ def upload(url, headers:, body:)
61
+ uri = URI.parse(url)
62
+ http = Net::HTTP.new(uri.host, uri.port)
63
+ http.use_ssl = (uri.scheme == "https")
64
+ http.open_timeout = @options.timeout
65
+ http.read_timeout = @options.timeout
66
+
67
+ req = Net::HTTP::Put.new(uri.request_uri)
68
+ headers.each { |key, value| req[key.to_s] = value }
69
+ req.body = body
70
+
71
+ response = http.start { |connection| connection.request(req) }
72
+ return if response.is_a?(Net::HTTPSuccess)
73
+
74
+ raise Error.from_response(response, response.body)
75
+ rescue ::Net::OpenTimeout, ::Net::ReadTimeout => e
76
+ raise TimeoutError, e.message
77
+ rescue ::SocketError, ::Errno::ECONNREFUSED, ::Errno::ECONNRESET => e
78
+ raise NetworkError, e.message
79
+ end
80
+
57
81
  private
58
82
 
59
83
  def build_connection
@@ -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.6"
5
+ VERSION = "0.2.8"
6
6
  end
7
7
 
8
8
  VERSION = Core::VERSION
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.6
4
+ version: 0.2.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - RunAPI
@@ -56,9 +56,11 @@ homepage: https://runapi.ai/models
56
56
  licenses:
57
57
  - Apache-2.0
58
58
  metadata:
59
+ runapi_slug: core
59
60
  homepage_uri: https://runapi.ai/models
60
61
  documentation_uri: https://github.com/runapi-ai/core-sdk/blob/main/ruby/README.md
61
62
  source_code_uri: https://github.com/runapi-ai/core-sdk
63
+ bug_tracker_uri: https://github.com/runapi-ai/core-sdk/issues
62
64
  changelog_uri: https://github.com/runapi-ai/core-sdk/blob/main/CHANGELOG.md
63
65
  rdoc_options: []
64
66
  require_paths: