stackshift-assets 0.1.1

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: 807ebbf9dcca45f0527128331eacd65c6438581f68aba83fc00ee8353f1436e6
4
+ data.tar.gz: 801b1dfeb2e91fcd06718a61360d723d181ac7a4b7ef7b121909669273b38345
5
+ SHA512:
6
+ metadata.gz: 6f54bb7b1538c96213db8d49c56074f3c930edb154db01e181eacfcfc52a9af8883e0ea088dff7e086363155c57e02d54664539d1a6321c0373a8cc0b18101e4
7
+ data.tar.gz: bf63657edece479337ddd300ed942814a3ae10a1451b704605e50276350ac58c5c684f594ca10b5e692772e8e3932b69f6d8e304259b3fe48d41d88c68048e3c
data/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # StackShift Assets for Ruby
2
+
3
+ Require \`stackshift/assets_client\`. Rails applications can inject the client and use resumable upload sessions for direct uploads. Every mutating call takes the current revision and sends \`If-Match\`.
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "assets_client"
4
+ require_relative "rails" if defined?(Rails::Railtie)
@@ -0,0 +1,374 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "net/http"
6
+ require "securerandom"
7
+ require "uri"
8
+
9
+ module StackShift
10
+ class AssetsClient
11
+ def initialize(api_key:, base_url: "https://api.stackshift.cloud/api/v1", cdn_base_url: "https://cdn.stackshift.cloud")
12
+ raise ArgumentError, "StackShift SDK requires an API key" if api_key.to_s.strip.empty?
13
+
14
+ @api_key = api_key
15
+ @base_url = base_url.sub(%r{/$}, "")
16
+ @cdn_base_url = cdn_base_url.sub(%r{/$}, "")
17
+ end
18
+
19
+ def list(**filters)
20
+ request(:get, "/assets", query: compact(filters))
21
+ end
22
+
23
+ def get(asset_id)
24
+ request(:get, "/assets/#{escape(asset_id)}")
25
+ end
26
+
27
+ def delete(asset_id, revision:, idempotency_key: nil)
28
+ headers = if_match(revision)
29
+ headers["Idempotency-Key"] = idempotency_key if idempotency_key
30
+ request(:delete, "/assets/#{escape(asset_id)}", headers: headers)
31
+ end
32
+
33
+ def upload(file_path, **options)
34
+ multipart(:post, "/assets/upload", file_path, options)
35
+ end
36
+
37
+ def replace(asset_id, file_path, revision:, **options)
38
+ multipart(:put, "/assets/#{escape(asset_id)}/replace", file_path, options, headers: if_match(revision))
39
+ end
40
+
41
+ def create_upload_session(idempotency_key: nil, **options)
42
+ headers = idempotency_key ? { "Idempotency-Key" => idempotency_key } : {}
43
+ request(:post, "/assets/upload-sessions", body: compact(options), headers: headers)
44
+ end
45
+
46
+ def signed_upload_url(idempotency_key: nil, **options)
47
+ headers = idempotency_key ? { "Idempotency-Key" => idempotency_key } : {}
48
+ request(:post, "/assets/upload-url", body: compact(options), headers: headers)
49
+ end
50
+
51
+ def upload_chunk(upload_url_or_token, part_number, bytes)
52
+ target = if upload_url_or_token.start_with?("http")
53
+ upload_url_or_token.sub(%r{/$}, "")
54
+ else
55
+ @base_url.sub(%r{/api/v1$}, "") + "/uploads/#{escape(upload_url_or_token)}"
56
+ end
57
+ request_absolute(:put, "#{target}/parts/#{part_number}", body: bytes, json: false, authenticated: false,
58
+ headers: { "Content-Type" => "application/octet-stream", "X-Content-SHA256" => Digest::SHA256.hexdigest(bytes) })
59
+ end
60
+
61
+ def complete_upload_session(session_id)
62
+ request(:post, "/assets/upload-sessions/#{escape(session_id)}/complete")
63
+ end
64
+
65
+ def resume_upload_session(session_id)
66
+ request(:get, "/assets/upload-sessions/#{escape(session_id)}")
67
+ end
68
+
69
+ def cancel_upload_session(session_id)
70
+ request(:delete, "/assets/upload-sessions/#{escape(session_id)}")
71
+ end
72
+
73
+ def signed_url(asset_id, expires_in: "10m", max_downloads: nil)
74
+ request(:post, "/assets/#{escape(asset_id)}/signed-url",
75
+ body: compact(expires_in: expires_in, max_downloads: max_downloads))
76
+ end
77
+
78
+ def list_buckets
79
+ request(:get, "/assets/buckets")
80
+ end
81
+
82
+ def create_bucket(**policy)
83
+ request(:post, "/assets/buckets", body: compact(policy))
84
+ end
85
+
86
+ def update_bucket(bucket_id, revision:, **policy)
87
+ request(:put, "/assets/buckets/#{escape(bucket_id)}", body: compact(policy), headers: if_match(revision))
88
+ end
89
+
90
+ def delete_bucket(bucket_id, revision:)
91
+ request(:delete, "/assets/buckets/#{escape(bucket_id)}", headers: if_match(revision))
92
+ end
93
+
94
+ def patch(asset_id, revision:, **changes)
95
+ request(:patch, "/assets/#{escape(asset_id)}", body: compact(changes), headers: if_match(revision))
96
+ end
97
+
98
+ def set_tags(asset_id, tags, revision:)
99
+ request(:put, "/assets/#{escape(asset_id)}/tags", body: { tags: tags }, headers: if_match(revision))
100
+ end
101
+
102
+ def patch_metadata(asset_id, metadata, revision:)
103
+ request(:patch, "/assets/#{escape(asset_id)}/metadata", body: { metadata: metadata }, headers: if_match(revision))
104
+ end
105
+
106
+ def get_ai_config
107
+ request(:get, "/assets/ai/config")
108
+ end
109
+
110
+ def get_policy
111
+ request(:get, "/assets/policy")
112
+ end
113
+
114
+ def update_policy(**policy)
115
+ request(:put, "/assets/policy", body: compact(policy))
116
+ end
117
+
118
+ def list_transformations
119
+ request(:get, "/assets/transformations")
120
+ end
121
+
122
+ def create_transformation(name, **options)
123
+ request(:post, "/assets/transformations", body: compact(options.merge(name: name)))
124
+ end
125
+
126
+ def delete_transformation(name)
127
+ request(:delete, "/assets/transformations/#{escape(name)}")
128
+ end
129
+
130
+ def signed_transform_url(asset_id, **options)
131
+ request(:post, "/assets/#{escape(asset_id)}/transform-url", body: compact(options))
132
+ end
133
+
134
+ def update_ai_config(revision:, enabled_actions:, monthly_spend_cap_micros:)
135
+ request(:put, "/assets/ai/config", body: {
136
+ enabled_actions: enabled_actions, monthly_spend_cap_micros: monthly_spend_cap_micros
137
+ }, headers: if_match(revision))
138
+ end
139
+
140
+ def job(job_id)
141
+ request(:get, "/assets/jobs/#{escape(job_id)}")
142
+ end
143
+
144
+ def cancel_job(job_id)
145
+ request(:post, "/assets/jobs/#{escape(job_id)}/cancel")
146
+ end
147
+
148
+ def analyze(asset_id, idempotency_key: nil)
149
+ asset_job("/assets/#{escape(asset_id)}/ai/analyze", idempotency_key)
150
+ end
151
+
152
+ def ai(asset_id)
153
+ request(:get, "/assets/#{escape(asset_id)}/ai")
154
+ end
155
+
156
+ def moderate(asset_id, idempotency_key: nil)
157
+ asset_job("/assets/#{escape(asset_id)}/moderation", idempotency_key)
158
+ end
159
+
160
+ def transcript(asset_id)
161
+ request(:get, "/assets/#{escape(asset_id)}/transcript")
162
+ end
163
+
164
+ def request_transcript(asset_id, idempotency_key: nil)
165
+ asset_job("/assets/#{escape(asset_id)}/transcript", idempotency_key)
166
+ end
167
+
168
+ def request_smart_crop(asset_id, idempotency_key: nil)
169
+ asset_job("/assets/#{escape(asset_id)}/smart-crop", idempotency_key)
170
+ end
171
+
172
+ def request_background_removal(asset_id, idempotency_key: nil)
173
+ asset_job("/assets/#{escape(asset_id)}/background-remove", idempotency_key)
174
+ end
175
+
176
+ def create_lifecycle_rule(**rule)
177
+ request(:post, "/assets/lifecycle-rules", body: compact(rule))
178
+ end
179
+
180
+ def list_lifecycle_rules
181
+ request(:get, "/assets/lifecycle-rules")
182
+ end
183
+
184
+ def delete_lifecycle_rule(rule_id)
185
+ request(:delete, "/assets/lifecycle-rules/#{escape(rule_id)}")
186
+ end
187
+
188
+ def create_domain(domain)
189
+ request(:post, "/assets/domains", body: { domain: domain })
190
+ end
191
+
192
+ def list_domains
193
+ request(:get, "/assets/domains")
194
+ end
195
+
196
+ def verify_domain(domain_id)
197
+ request(:post, "/assets/domains/#{escape(domain_id)}/verify")
198
+ end
199
+
200
+ def delete_domain(domain_id)
201
+ request(:delete, "/assets/domains/#{escape(domain_id)}")
202
+ end
203
+
204
+ def analytics
205
+ request(:get, "/assets/analytics")
206
+ end
207
+
208
+ def usage_summary
209
+ request(:get, "/assets/summary")
210
+ end
211
+
212
+ def events(limit: 50)
213
+ request(:get, "/assets/events", query: { limit: limit })
214
+ end
215
+
216
+ def create_webhook(url, event_types: ['asset.*'])
217
+ request(:post, '/assets/webhooks', body: { url: url, event_types: event_types })
218
+ end
219
+
220
+ def list_webhooks
221
+ request(:get, '/assets/webhooks')
222
+ end
223
+
224
+ def delete_webhook(webhook_id)
225
+ request(:delete, "/assets/webhooks/#{escape(webhook_id)}")
226
+ end
227
+
228
+ def list_webhook_deliveries(webhook_id, limit: 50)
229
+ request(:get, "/assets/webhooks/#{escape(webhook_id)}/deliveries", query: { limit: limit })
230
+ end
231
+
232
+ def retry_webhook_delivery(delivery_id)
233
+ request(:post, "/assets/webhook-deliveries/#{escape(delivery_id)}/retry")
234
+ end
235
+
236
+ def purge(asset_id)
237
+ request(:post, "/assets/#{escape(asset_id)}/purge")
238
+ end
239
+
240
+ def bulk(action:, items:, idempotency_key: nil, **options)
241
+ headers = idempotency_key ? { "Idempotency-Key" => idempotency_key } : {}
242
+ request(:post, "/assets/bulk", body: options.merge(action: action, items: items), headers: headers)
243
+ end
244
+
245
+ def versions(asset_id)
246
+ request(:get, "/assets/#{escape(asset_id)}/versions")
247
+ end
248
+
249
+ def create_branch(asset_id, name, from_version_id: nil)
250
+ request(:post, "/assets/#{escape(asset_id)}/branches", body: compact(name: name, from_version_id: from_version_id))
251
+ end
252
+
253
+ def restore_version(asset_id, version_id)
254
+ request(:post, "/assets/#{escape(asset_id)}/versions/#{escape(version_id)}/restore")
255
+ end
256
+
257
+ def promote_branch(asset_id, branch)
258
+ request(:post, "/assets/#{escape(asset_id)}/branches/#{escape(branch)}/promote")
259
+ end
260
+
261
+ def collections
262
+ request(:get, "/assets/collections")
263
+ end
264
+
265
+ def create_collection(**collection)
266
+ request(:post, "/assets/collections", body: compact(collection))
267
+ end
268
+
269
+ def update_collection(collection_id, **collection)
270
+ request(:put, "/assets/collections/#{escape(collection_id)}", body: compact(collection))
271
+ end
272
+
273
+ def delete_collection(collection_id)
274
+ request(:delete, "/assets/collections/#{escape(collection_id)}")
275
+ end
276
+
277
+ def saved_searches
278
+ request(:get, "/assets/saved-searches")
279
+ end
280
+
281
+ def create_saved_search(**search)
282
+ request(:post, "/assets/saved-searches", body: compact(search))
283
+ end
284
+
285
+ def update_saved_search(search_id, **search)
286
+ request(:put, "/assets/saved-searches/#{escape(search_id)}", body: compact(search))
287
+ end
288
+
289
+ def delete_saved_search(search_id)
290
+ request(:delete, "/assets/saved-searches/#{escape(search_id)}")
291
+ end
292
+
293
+ def immutable_version_url(asset_id, version_id, filename = "asset")
294
+ "#{@cdn_base_url}/assets/#{escape(asset_id)}/versions/#{escape(version_id)}/#{escape(filename)}"
295
+ end
296
+
297
+ def version_url(asset_id, version_id, token: nil)
298
+ prefix = token ? "/private/assets" : "/assets"
299
+ url = "#{@cdn_base_url}#{prefix}/#{escape(asset_id)}/versions/#{escape(version_id)}"
300
+ token ? "#{url}?token=#{escape(token)}" : url
301
+ end
302
+
303
+ def branch_url(asset_id, branch, token: nil)
304
+ prefix = token ? "/private/assets" : "/assets"
305
+ url = "#{@cdn_base_url}#{prefix}/#{escape(asset_id)}/branches/#{escape(branch)}"
306
+ token ? "#{url}?token=#{escape(token)}" : url
307
+ end
308
+
309
+ def video_url(asset_id, kind, profile = "default", token: nil)
310
+ prefix = token ? "/private/assets" : "/assets"
311
+ url = "#{@cdn_base_url}#{prefix}/#{escape(asset_id)}/video/#{escape(kind)}/#{escape(profile)}"
312
+ token ? "#{url}?token=#{escape(token)}" : url
313
+ end
314
+
315
+ private
316
+
317
+ def multipart(method, path, file_path, fields, headers: {})
318
+ raise ArgumentError, "Asset upload path is not a file" unless File.file?(file_path)
319
+
320
+ boundary = "stackshift-#{SecureRandom.hex(16)}"
321
+ body = String.new(encoding: Encoding::BINARY)
322
+ compact(fields).each do |name, value|
323
+ serialized = value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value.to_s
324
+ body << "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{name}\"\r\n\r\n#{serialized}\r\n"
325
+ end
326
+ filename = File.basename(file_path).gsub(/[\"\r\n]/, "_")
327
+ body << "--#{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"#{filename}\"\r\n"
328
+ body << "Content-Type: application/octet-stream\r\n\r\n" << File.binread(file_path) << "\r\n--#{boundary}--\r\n"
329
+ request_absolute(method, "#{@base_url}#{path}", body: body,
330
+ headers: headers.merge("Content-Type" => "multipart/form-data; boundary=#{boundary}"), json: false, authenticated: true)
331
+ end
332
+
333
+ def asset_job(path, idempotency_key)
334
+ headers = idempotency_key ? { "Idempotency-Key" => idempotency_key } : {}
335
+ request(:post, path, headers: headers)
336
+ end
337
+
338
+ def request(method, path, body: nil, query: nil, headers: {})
339
+ uri = URI("#{@base_url}#{path}")
340
+ uri.query = URI.encode_www_form(query) if query && !query.empty?
341
+ request_absolute(method, uri.to_s, body: body, headers: headers, json: true, authenticated: true)
342
+ end
343
+
344
+ def request_absolute(method, url, body:, headers:, json:, authenticated:)
345
+ uri = URI(url)
346
+ klass = Net::HTTP.const_get(method.to_s.capitalize)
347
+ outgoing = klass.new(uri)
348
+ outgoing["Authorization"] = "Bearer #{@api_key}" if authenticated
349
+ headers.each { |name, value| outgoing[name] = value }
350
+ if body
351
+ outgoing.body = json ? JSON.generate(body) : body
352
+ outgoing["Content-Type"] ||= "application/json" if json
353
+ end
354
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(outgoing) }
355
+ payload = response.body.to_s.empty? ? nil : JSON.parse(response.body)
356
+ unless response.is_a?(Net::HTTPSuccess)
357
+ raise "#{payload&.dig('error', 'message') || payload&.dig('message') || 'StackShift API request failed'} (#{response.code})"
358
+ end
359
+ payload.is_a?(Hash) && payload.key?("data") ? payload["data"] : payload
360
+ end
361
+
362
+ def compact(values)
363
+ values.reject { |_key, value| value.nil? || value == "" }
364
+ end
365
+
366
+ def escape(value)
367
+ URI.encode_www_form_component(value.to_s)
368
+ end
369
+
370
+ def if_match(revision)
371
+ { "If-Match" => %("#{Integer(revision)}") }
372
+ end
373
+ end
374
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StackShift
4
+ class AssetsRailtie < Rails::Railtie
5
+ initializer "stackshift.assets" do
6
+ Rails.application.config.x.stackshift_assets = lambda do
7
+ StackShift::AssetsClient.new(
8
+ api_key: ENV.fetch("STACKSHIFT_API_KEY"),
9
+ base_url: ENV.fetch("STACKSHIFT_API_URL", "https://api.stackshift.cloud/api/v1"),
10
+ cdn_base_url: ENV.fetch("STACKSHIFT_CDN_URL", "https://cdn.stackshift.cloud")
11
+ )
12
+ end
13
+ end
14
+ end
15
+ end
metadata ADDED
@@ -0,0 +1,46 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: stackshift-assets
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - StackShift
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-02 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email:
15
+ - support@stackshift.cloud
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - README.md
21
+ - lib/stackshift/assets.rb
22
+ - lib/stackshift/assets_client.rb
23
+ - lib/stackshift/rails.rb
24
+ homepage:
25
+ licenses: []
26
+ metadata: {}
27
+ post_install_message:
28
+ rdoc_options: []
29
+ require_paths:
30
+ - lib
31
+ required_ruby_version: !ruby/object:Gem::Requirement
32
+ requirements:
33
+ - - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: '3.1'
36
+ required_rubygems_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ requirements: []
42
+ rubygems_version: 3.0.3.1
43
+ signing_key:
44
+ specification_version: 4
45
+ summary: Official StackShift Assets SDK for Ruby and Rails
46
+ test_files: []