comprestack 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: a6e40abe6461ca88dbd5e6360f0aa988d7cd6de70e34e40a904e24835d6117aa
4
+ data.tar.gz: bbc09479df08c1a371f10e5fe2fa22742a8d746ab32280187b0ff652e22d8520
5
+ SHA512:
6
+ metadata.gz: b76f731b7b0e69401f1f0e4c4e2edfb9219fa885865a085bf37a743caafa225daced91d90fff0a8929e37407ac7637a89077e17484a25f794a0ddaa051144e70
7
+ data.tar.gz: cae34687b21dba715c78272d28165ed287248666ca61c3cd8f3fba0ea9ff7dd34ff8b0e7e0ff004f2bad04ca285e93ecdf83e573aeb165b37f396128ca4ce19a
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Comprestack
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # comprestack (Ruby)
2
+
3
+ Official Ruby SDK for the [Comprestack](https://www.comprestack.com) image optimization API.
4
+
5
+ Same REST surface as the [API docs](https://www.comprestack.com/docs) and [OpenAPI](https://api.comprestack.com/openapi.yaml). Fits Rails, Sidekiq, and Shopify apps — keep the key on the server.
6
+
7
+ ---
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ gem install comprestack
13
+ # or Gemfile:
14
+ # gem "comprestack"
15
+ ```
16
+
17
+ **Requirements:** Ruby 3.1+.
18
+
19
+ ```bash
20
+ export COMPRESTACK_API_KEY=csk_live_…
21
+ ```
22
+
23
+ ---
24
+
25
+ ## Quick start
26
+
27
+ ```ruby
28
+ require "comprestack"
29
+
30
+ client = Comprestack::Client.new # reads COMPRESTACK_API_KEY
31
+
32
+ job = client.convert("photo.jpg", "webp", quality: "balanced", maxWidth: 1600)
33
+ File.binwrite("photo.webp", client.download(job))
34
+ puts "#{job.dig("usage", "remaining")} images left this period"
35
+ ```
36
+
37
+ Compress without changing format:
38
+
39
+ ```ruby
40
+ job = client.compress(["hero.png"], quality: "high")
41
+ ```
42
+
43
+ In Rails, put the key in credentials / ENV and call from a job or controller — never from the browser.
44
+
45
+ ---
46
+
47
+ ## What you can do
48
+
49
+ | Goal | Method |
50
+ |------|--------|
51
+ | Shrink, keep format | `compress(files, **options)` |
52
+ | Change format + shrink | `convert(files, format, **options)` |
53
+ | Re-fetch job metadata | `get_job(id)` |
54
+ | Download ZIP / result | `download(job)` or `download(url)` |
55
+ | Download one file in a batch | `download_file(job_id, index)` |
56
+ | Check quota | `usage` |
57
+ | White-label billing report | `room_report(room_id)` |
58
+
59
+ Files may be a path, `IO`, or `{ data:, filename: }`. Pricing and quotas are enforced by the API.
60
+
61
+ ---
62
+
63
+ ## Options
64
+
65
+ | Field | Description |
66
+ |--------|-------------|
67
+ | `quality` | `"balanced"`, `"high"`, `"smallest"`, or `1–100` (paid) |
68
+ | `format` | **convert only** |
69
+ | `maxWidth` / `maxHeight` | Downscale to fit |
70
+ | `target_kb` | Size hint (paid) |
71
+ | `lossless` / `keep_metadata` | Encoding flags |
72
+
73
+ The client retries HTTP 429 / 503 (honours `Retry-After`). Failed calls raise `Comprestack::Error` (`status`, `code`, `retry_after`).
74
+
75
+ ---
76
+
77
+ ## Links
78
+
79
+ - [API reference](https://www.comprestack.com/docs)
80
+ - [OpenAPI 3.1](https://api.comprestack.com/openapi.yaml)
81
+ - [Dashboard — API keys](https://www.comprestack.com/dashboard?section=api)
82
+
83
+ ## License
84
+
85
+ MIT © Comprestack
@@ -0,0 +1,319 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "json"
5
+ require "net/http"
6
+ require "pathname"
7
+ require "securerandom"
8
+ require "stringio"
9
+ require "uri"
10
+
11
+ module Comprestack
12
+ # Synchronous client for the Comprestack REST API.
13
+ # Keep API keys on the server (Rails jobs, Shopify apps, workers).
14
+ class Client
15
+ USER_AGENT = "comprestack-ruby/#{VERSION}"
16
+
17
+ attr_reader :api_key, :base_url, :origin, :timeout, :max_retries
18
+
19
+ def initialize(api_key: nil, base_url: DEFAULT_API_BASE, origin: DEFAULT_ORIGIN,
20
+ timeout: 300, max_retries: 2, headers: {}, on_progress: nil, http_handler: nil)
21
+ key = api_key.to_s.strip
22
+ key = ENV["COMPRESTACK_API_KEY"].to_s.strip if key.empty?
23
+ key = ENV["COMPRESS_API_KEY"].to_s.strip if key.empty?
24
+ raise ArgumentError, "apiKey is required" if key.empty?
25
+
26
+ @api_key = key
27
+ @base_url = base_url.to_s.sub(%r{/+\z}, "")
28
+ @origin = origin.to_s.sub(%r{/+\z}, "")
29
+ @timeout = timeout.to_f
30
+ @max_retries = max_retries.to_i
31
+ @extra_headers = headers || {}
32
+ @on_progress = on_progress
33
+ @http_handler = http_handler
34
+ end
35
+
36
+ def compress(files, **options)
37
+ multipart("/compress", files, options)
38
+ end
39
+
40
+ def convert(files, format, **options)
41
+ raise ArgumentError, "format is required" if format.to_s.strip.empty?
42
+
43
+ multipart("/convert", files, options, extra: { "format" => format.to_s })
44
+ end
45
+
46
+ def get_job(job_id)
47
+ request_json("GET", "/jobs/#{CGI.escape(job_id.to_s)}")
48
+ end
49
+
50
+ def usage
51
+ request_json("GET", "/usage", timeout: [@timeout, 30].min)
52
+ end
53
+
54
+ def room_report(room_id)
55
+ request_json("GET", "/rooms/#{CGI.escape(room_id.to_s)}/report", timeout: [@timeout, 30].min)
56
+ end
57
+
58
+ def resolve_download_url(download_url)
59
+ return download_url if download_url.to_s.match?(%r{\Ahttps?://}i)
60
+
61
+ "#{@origin}/#{download_url.to_s.sub(%r{\A/+}, "")}"
62
+ end
63
+
64
+ def download(job_or_url)
65
+ url =
66
+ if job_or_url.is_a?(String)
67
+ resolve_download_url(job_or_url)
68
+ else
69
+ download_url = job_or_url.is_a?(Hash) ? (job_or_url["download_url"] || job_or_url[:download_url]) : nil
70
+ raise ArgumentError, "job must include a download_url string" unless download_url.is_a?(String)
71
+
72
+ resolve_download_url(download_url)
73
+ end
74
+
75
+ fetch_binary(url, "/jobs/.../download")
76
+ end
77
+
78
+ def download_file(job_id, index)
79
+ raise ArgumentError, "index must be a non-negative integer" unless index.is_a?(Integer) && index >= 0
80
+
81
+ path = "/jobs/#{CGI.escape(job_id.to_s)}/files/#{index}"
82
+ fetch_binary("#{@base_url}#{path}", path)
83
+ end
84
+
85
+ private
86
+
87
+ def multipart(path, files, options, extra: {})
88
+ list = files.is_a?(Array) ? files : [files]
89
+ raise ArgumentError, "at least one file is required" if list.empty?
90
+
91
+ max_attempts = @max_retries + 1
92
+ last_error = nil
93
+
94
+ (1..max_attempts).each do |attempt|
95
+ emit(phase: attempt == 1 ? "start" : "retry", path: path, attempt: attempt,
96
+ max_attempts: max_attempts, retry_after_sec: last_error&.retry_after)
97
+
98
+ boundary = "----Comprestack#{SecureRandom.hex(12)}"
99
+ body = build_multipart_body(list, options, extra, boundary)
100
+ begin
101
+ response = raw_request("POST", "#{@base_url}#{path}", body, {
102
+ "Content-Type" => "multipart/form-data; boundary=#{boundary}"
103
+ }, @timeout)
104
+ rescue Error => e
105
+ last_error = e
106
+ raise e unless attempt < max_attempts && e.retryable?
107
+
108
+ sleep retry_delay_sec(e.retry_after, attempt)
109
+ next
110
+ end
111
+
112
+ emit(phase: "complete", path: path, attempt: attempt, max_attempts: max_attempts)
113
+ return decode_json(response[:body])
114
+ end
115
+
116
+ raise last_error || Error.new("Request failed after retries", status: 503, code: "busy")
117
+ end
118
+
119
+ def build_multipart_body(files, options, extra, boundary)
120
+ parts = String.new(encoding: Encoding::BINARY)
121
+ files.each_with_index do |file, index|
122
+ filename, contents, content_type = normalize_upload(file, index)
123
+ parts << "--#{boundary}\r\n"
124
+ parts << "Content-Disposition: form-data; name=\"files[]\"; filename=\"#{escape_filename(filename)}\"\r\n"
125
+ parts << "Content-Type: #{content_type}\r\n\r\n"
126
+ parts << contents
127
+ parts << "\r\n"
128
+ end
129
+ option_fields(options).merge(stringify_keys(extra)).each do |name, value|
130
+ parts << "--#{boundary}\r\n"
131
+ parts << "Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n"
132
+ parts << value.to_s
133
+ parts << "\r\n"
134
+ end
135
+ parts << "--#{boundary}--\r\n"
136
+ parts
137
+ end
138
+
139
+ def option_fields(options)
140
+ opts = stringify_keys(options)
141
+ fields = {}
142
+ fields["quality"] = opts["quality"].to_s if opts.key?("quality") && !opts["quality"].nil?
143
+ fields["target_kb"] = opts["target_kb"].to_s if opts["target_kb"]
144
+ fields["lossless"] = opts["lossless"] ? "true" : "false" unless opts["lossless"].nil?
145
+ fields["maxWidth"] = opts["maxWidth"].to_s if opts["maxWidth"]
146
+ fields["maxHeight"] = opts["maxHeight"].to_s if opts["maxHeight"]
147
+ fields["keep_metadata"] = opts["keep_metadata"] ? "true" : "false" unless opts["keep_metadata"].nil?
148
+ fields
149
+ end
150
+
151
+ def stringify_keys(hash)
152
+ hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
153
+ end
154
+
155
+ def normalize_upload(input, index)
156
+ case input
157
+ when String, Pathname
158
+ path = input.to_s
159
+ raise ArgumentError, "File not found: #{path}" unless File.file?(path)
160
+
161
+ filename = File.basename(path)
162
+ [filename, File.binread(path).force_encoding(Encoding::BINARY), guess_content_type(filename)]
163
+ when IO, StringIO
164
+ data = input.read.to_s.dup.force_encoding(Encoding::BINARY)
165
+ input.rewind if input.respond_to?(:rewind)
166
+ name = input.respond_to?(:path) && input.path ? File.basename(input.path) : "image-#{index + 1}"
167
+ [name, data, guess_content_type(name)]
168
+ when Hash
169
+ filename = (input[:filename] || input["filename"] || "image-#{index + 1}").to_s
170
+ if (path = input[:path] || input["path"])
171
+ return normalize_upload(path, index)
172
+ end
173
+
174
+ data = input[:data] || input["data"] || input[:contents] || input["contents"]
175
+ raise ArgumentError, "dict uploads require data/contents" if data.nil?
176
+
177
+ data = data.to_s.dup.force_encoding(Encoding::BINARY)
178
+ content_type = input[:content_type] || input["content_type"] || guess_content_type(filename)
179
+ [filename, data, content_type]
180
+ else
181
+ raise ArgumentError,
182
+ "Unsupported upload input — pass a path, IO, or { data:, filename: }"
183
+ end
184
+ end
185
+
186
+ def guess_content_type(filename)
187
+ case File.extname(filename).downcase
188
+ when ".jpg", ".jpeg" then "image/jpeg"
189
+ when ".png" then "image/png"
190
+ when ".gif" then "image/gif"
191
+ when ".webp" then "image/webp"
192
+ when ".avif" then "image/avif"
193
+ when ".tif", ".tiff" then "image/tiff"
194
+ when ".bmp" then "image/bmp"
195
+ when ".ico" then "image/x-icon"
196
+ when ".heic", ".heif" then "image/heic"
197
+ else "application/octet-stream"
198
+ end
199
+ end
200
+
201
+ def escape_filename(name)
202
+ name.to_s.tr("\"\r\n", "")
203
+ end
204
+
205
+ def request_json(method, path, timeout: @timeout, retryable: true)
206
+ decode_json(request_raw(method, "#{@base_url}#{path}", nil, timeout, retryable))
207
+ end
208
+
209
+ def fetch_binary(url, path_label)
210
+ request_raw("GET", url, nil, @timeout, true, path_label: path_label)
211
+ end
212
+
213
+ def request_raw(method, url, body, timeout, retryable, path_label: nil)
214
+ max_attempts = retryable ? @max_retries + 1 : 1
215
+ last_error = nil
216
+ label = path_label || begin
217
+ i = url.index("/api/v1")
218
+ i ? url[(i + "/api/v1".length)..] : url
219
+ end
220
+
221
+ (1..max_attempts).each do |attempt|
222
+ emit(phase: attempt == 1 ? "start" : "retry", path: label, attempt: attempt, max_attempts: max_attempts)
223
+ begin
224
+ response = raw_request(method, url, body, {}, timeout)
225
+ rescue Error => e
226
+ last_error = e
227
+ raise e unless attempt < max_attempts && e.retryable?
228
+
229
+ sleep retry_delay_sec(e.retry_after, attempt)
230
+ next
231
+ end
232
+ emit(phase: "complete", path: label, attempt: attempt, max_attempts: max_attempts)
233
+ return response[:body]
234
+ end
235
+
236
+ raise last_error || Error.new("Request failed after retries", status: 503, code: "busy")
237
+ end
238
+
239
+ def raw_request(method, url, body, extra_headers, timeout)
240
+ headers = {
241
+ "Authorization" => "Bearer #{@api_key}",
242
+ "Accept" => "application/json",
243
+ "User-Agent" => USER_AGENT
244
+ }.merge(@extra_headers).merge(extra_headers)
245
+
246
+ response =
247
+ if @http_handler
248
+ @http_handler.call(method, url, body, headers, timeout)
249
+ else
250
+ default_http(method, url, body, headers, timeout)
251
+ end
252
+
253
+ status = response[:status].to_i
254
+ resp_headers = downcase_keys(response[:headers] || {})
255
+ resp_body = response[:body].to_s
256
+ retry_after = parse_retry_after(resp_headers["retry-after"])
257
+
258
+ return response.merge(headers: resp_headers) if status.between?(200, 299)
259
+
260
+ parsed = begin
261
+ JSON.parse(resp_body)
262
+ rescue JSON::ParserError
263
+ {}
264
+ end
265
+ msg = parsed["message"] || parsed["error"] || "HTTP #{status}"
266
+ code = parsed["code"] || "server_error"
267
+ raise Error.new(msg, status: status, code: code, retry_after: retry_after, body: resp_body)
268
+ end
269
+
270
+ def default_http(method, url, body, headers, timeout)
271
+ uri = URI(url)
272
+ http = Net::HTTP.new(uri.host, uri.port)
273
+ http.use_ssl = uri.scheme == "https"
274
+ http.read_timeout = timeout
275
+ http.open_timeout = [timeout, 30].min
276
+
277
+ req_class = Net::HTTP.const_get(method.capitalize)
278
+ req = req_class.new(uri.request_uri)
279
+ headers.each { |k, v| req[k] = v }
280
+ req.body = body if body
281
+
282
+ res = http.request(req)
283
+ {
284
+ status: res.code.to_i,
285
+ headers: res.to_hash.transform_values { |v| v.is_a?(Array) ? v.first : v },
286
+ body: res.body
287
+ }
288
+ end
289
+
290
+ def decode_json(body)
291
+ return {} if body.nil? || body.empty?
292
+
293
+ JSON.parse(body)
294
+ end
295
+
296
+ def downcase_keys(hash)
297
+ hash.each_with_object({}) { |(k, v), acc| acc[k.to_s.downcase] = v.is_a?(Array) ? v.first : v }
298
+ end
299
+
300
+ def parse_retry_after(raw)
301
+ return nil if raw.nil? || raw.to_s.empty?
302
+ return raw.to_f if raw.to_s.match?(/\A\d+(\.\d+)?\z/)
303
+
304
+ nil
305
+ end
306
+
307
+ def retry_delay_sec(retry_after, attempt)
308
+ return [60.0, retry_after].min if retry_after && retry_after >= 0
309
+
310
+ [10.0, 0.5 * (2**(attempt - 1))].min
311
+ end
312
+
313
+ def emit(event)
314
+ @on_progress&.call(event)
315
+ rescue StandardError
316
+ nil
317
+ end
318
+ end
319
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Comprestack
4
+ class Error < StandardError
5
+ attr_reader :status, :code, :retry_after, :body
6
+
7
+ def initialize(message, status: 0, code: "error", retry_after: nil, body: nil)
8
+ super(message)
9
+ @status = status
10
+ @code = code
11
+ @retry_after = retry_after
12
+ @body = body
13
+ end
14
+
15
+ def retryable?
16
+ status == 429 || status == 503
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Comprestack
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "comprestack/version"
4
+ require_relative "comprestack/error"
5
+ require_relative "comprestack/client"
6
+
7
+ module Comprestack
8
+ DEFAULT_API_BASE = "https://api.comprestack.com/api/v1"
9
+ DEFAULT_ORIGIN = "https://api.comprestack.com"
10
+ end
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: comprestack
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Comprestack
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-11-12 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.25'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.25'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.2'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.2'
41
+ description: Compress or convert images from Rails, Shopify, and plain Ruby. Same
42
+ REST surface as the Comprestack API.
43
+ email:
44
+ - hello@comprestack.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - LICENSE
50
+ - README.md
51
+ - lib/comprestack.rb
52
+ - lib/comprestack/client.rb
53
+ - lib/comprestack/error.rb
54
+ - lib/comprestack/version.rb
55
+ homepage: https://www.comprestack.com/docs
56
+ licenses:
57
+ - MIT
58
+ metadata:
59
+ homepage_uri: https://www.comprestack.com/docs
60
+ source_code_uri: https://github.com/MichaelGenesisII/Comprestack-API/tree/main/sdk/ruby
61
+ changelog_uri: https://www.comprestack.com/docs#changelog
62
+ rubygems_mfa_required: 'true'
63
+ post_install_message:
64
+ rdoc_options: []
65
+ require_paths:
66
+ - lib
67
+ required_ruby_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '3.1'
72
+ required_rubygems_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ requirements: []
78
+ rubygems_version: 3.5.22
79
+ signing_key:
80
+ specification_version: 4
81
+ summary: Official Ruby SDK for the Comprestack image optimization API
82
+ test_files: []