smallpict 0.0.7 → 0.0.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: 4c99d1d9f8d685017396f2c3e94e7715fbed0aa6a2e2c2315bf7dca11a1ec336
4
- data.tar.gz: d87ea5708336c3600cc10d2e7bd11c219f0f355014eedeab0041accb30fc57fa
3
+ metadata.gz: 559f8ad84228a8b7beb0b3b10f3cd49192701ab548aa72602bf36e87011eb75b
4
+ data.tar.gz: b4965ff68dd978df9f8168a0c863baf3256fd6876276ad18c6df2debf363e881
5
5
  SHA512:
6
- metadata.gz: 0ae9beee1528a5242fa7bede52a15186be036bce30f85456c03a6210bf5a6eaa7e533d73f54d3bd0fe54d1259e87286080cf1c79f04fdc489f8d72dd8b1b6bec
7
- data.tar.gz: b7166c544f39637266ee9633a7cc7cdc4bf6912e89657da7fd3707744c3186411707499614a3c79047ec66e4a7c07c6cb9857c52a1a6d1fe171ce4af0dd6ddf6
6
+ metadata.gz: 6f9839d872ea23a77cb895f1138dff6f83c852a318035c56f83ff120db7453f36fc299f43775434a2463152d6fd5cfd302dc483f3a929ddb7aae8e2d94bb4aee
7
+ data.tar.gz: adbfe70b0b6b5d06584ca3160f207d9b0353ff90521bdd4c61f4cc42c76ae211c5d5a3f0fc7a240a5cf7ff6eb374a050c60a5bccefab753b3581cebf4ac1344f
data/CHANGELOG.md CHANGED
@@ -5,7 +5,45 @@ All notable changes to the `smallpict` gem will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.0.8] - Unreleased
9
+
10
+ Next release after v0.0.7. It includes the changes first written up as 0.1.0, which was never tagged or published.
11
+
12
+ ### Fixed
13
+ - A `base_url` that ends in `/v1` (the form `SDK_SPEC.md` names) no longer produces `/v1/v1/...` paths and 404s; the client strips the suffix and adds `/v1` itself.
14
+ - A queued optimize answer without `upload_url` or `job_id` raises `SmallPict::Error` with code `invalid_response` before any upload, instead of a raw URI error from Faraday.
15
+ - Every Faraday error (for example `Faraday::SSLError` from a proxy with its own CA) is wrapped in `SmallPict::NetworkError`, so `rescue SmallPict::Error` catches it. `validate_key` returns `false` only for 401 and 403; an outage raises `NetworkError` instead of being reported as an invalid key.
16
+ - `SmallPict.configure` takes effect after the first module-level call too. The memoised default client was never rebuilt, so a second `configure` (per-tenant rake task, test suite) kept the first keys.
17
+ - The signature covers the request path without the query string, as the contract says; the status poll used to sign `/v1/optimize/status?job_id=...`, which only worked through a server-side fallback. `get_job_status` URL-encodes the job id instead of interpolating it raw into the query.
18
+ - A 2xx response whose body is not JSON (a proxy or WAF page) raises `SmallPict::Error` with code `invalid_response` instead of being read as an empty result: `get_quota` returned plan `"free"`, `validate_key` returned `true` and `optimize` polled a phantom job until `wait_timeout`.
19
+ - `mime_type` is detected when the caller gives none: from the first bytes (PNG, JPEG, GIF, WebP, AVIF), then from the filename extension, then `image/jpeg`. A PNG passed as bytes or a Tempfile was declared and uploaded as `image/jpeg`. An explicit `mime_type:` still wins.
20
+ - `Retry-After` is capped at 30 seconds (`Client::MAX_RETRY_AFTER`). A 429 or 5xx with `Retry-After: 60` slept a Puma thread for a minute per attempt, up to three times in one request; the retry now waits at most 30 s.
21
+ - Every request carries a fresh `X-Nonce`, so a retry inside the same second is no longer refused by the server as a replay (401 "Replay attack detected"); before, a transient 429 or 5xx could surface as `AuthenticationError`. `Idempotency-Key` is generated once per call and kept across retries instead of once per attempt.
22
+ - `SmallPict::Client#inspect` and `SmallPict::Configuration#inspect` (and `to_s`) no longer print the API key and secret key; they show the base URL only. `Error.sanitize` also masks server-issued `sps_...` secrets, not only the `sec_`/`secret_` fixtures.
23
+ - `optimize(bytes)` no longer raises `ArgumentError: path name contains null byte` for real image data. A String source was probed with `File.exist?`, which refuses the NUL bytes every PNG and JPEG header contains, so `SmallPict.optimize(File.binread("x.png"))` crashed before any request. A String is now sent as bytes unless it is NUL-free and names an existing file; a `Pathname` is read from disk and its basename becomes `filename`.
24
+
25
+ ### Removed
26
+ - The `fit:` option (always sent as `"cover"`, not a server field, so it never did anything) and the `content_type` field of the request body (a duplicate of `mime_type`). `content_type:` is still accepted as an alias of `mime_type:`. The `faraday-retry` runtime dependency, which was never required (retries are implemented in the client), is gone from the gemspec.
27
+ - `SmallPict::ActiveStorage::Service`. It was not an `ActiveStorage::Service` (no `upload`, `download`, `delete`, `exist?`, `url`; not configurable from `config/storage.yml`), and its `url_for` sent an empty body that the API rejects with 400, so it always raised. Use `SmallPict.optimize` on the bytes you hold.
28
+
29
+ ### Changed
30
+ - RuboCop passes and runs in CI. `.rubocop.yml` now states the code's own conventions (double quotes, table-aligned hashes, 140-column lines outside specs) and turns off the size metrics and two naming cops that argued with the public API; the remaining offences were autocorrected.
31
+ - **`secret_key` is required.** `SmallPict::Client.new` raises `ValidationError` when neither `secret_key:`, `SmallPict.configure` nor `SMALLPICT_SECRET_KEY` provides one. The Bearer-only mode (API key without signature) is gone: the API refuses it with 403 "Legacy Bearer token format is no longer supported", so a client built that way never worked. README examples pass both keys.
32
+ - Release workflow runs `bundle exec rspec` before `gem build`, passes the RubyGems key through `GEM_HOST_API_KEY` instead of writing `~/.gem/credentials`, restricts the token to `contents: read`, and pins actions to commit SHAs.
33
+
34
+ ### Fixed
35
+ - **`optimize` now returns an optimized image.** `POST /v1/optimize` only queues the job and returns a pre-signed `upload_url`; the SDK stopped there, so the worker failed every job with "client upload never arrived" and callers got an empty `url` with 0 % saved. `optimize` now PUTs the bytes to `upload_url` (plain PUT with `Content-Type` and `Content-Length` as sent, no API headers, no retry), polls `GET /v1/optimize/status` every `poll_interval` (default 1 s) and returns once the job is `completed`, with `url`, `format`, `bytes_saved`, `mode_applied`, `quality_applied` and `folder` from the job status (`SDK_SPEC.md` section 3d).
36
+
37
+ ### Added
38
+ - `poll_interval` (seconds, default 1) and `wait_timeout` (seconds, default 120) on `SmallPict::Client.new` and `SmallPict.configure`.
39
+ - `OptimizeResult#mode_applied` and `#quality_applied`.
40
+ - Error codes `job_failed` (a failed job: `SmallPict::Error` with the server's `error.message` and `details["job_id"]`) and `upload_failed` (the storage refused the PUT: `SmallPict::Error` with the HTTP status in `#status_code`). A job still running after `wait_timeout` raises `SmallPict::TimeoutError` with the job id; it keeps running on the server, and `get_job_status` reads it later. A PUT that cannot connect raises `NetworkError`.
41
+
42
+ ### Changed
43
+ - **`OptimizeResult#status` is always `"completed"`** and `#upload_url` is gone: there is no result to return before the job finished. Code that checked for `"queued"` or used `upload_url` to upload by itself can drop that.
44
+ - **An `http://` or `https://` string source raises `ValidationError`** before any request. The API never fetched URLs, so such calls failed on the server; the SDK does not download on your behalf. A source that is neither an IO, a file path nor a String of bytes raises `ValidationError` too (it used to send `filesize: 0`).
45
+ - `compressed_size` and `savings_percentage` are derived from the bytes sent and `bytes_saved` of the job status.
46
+ - README: "What `optimize` does".
9
47
 
10
48
  ## [0.0.7] - 2026-10-31
11
49
 
@@ -35,7 +73,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
35
73
  ### Added
36
74
  - Official Ruby SDK implementation for SmallPict OpenAPI 3.1.0 API.
37
75
  - Block-based global configuration (`SmallPict.configure { |c| ... }`) and standalone client instances (`SmallPict::Client.new`).
38
- - Rails ActiveStorage Service adapter (`SmallPict::ActiveStorage::Service`).
76
+ - Rails ActiveStorage Service adapter (`SmallPict::ActiveStorage::Service`). Removed in the next release: it was not an ActiveStorage service.
39
77
  - 4 unified core client methods: `optimize`, `get_quota`, `purge_cdn`, and `validate_key`.
40
78
  - Helper `get_job_status` for polling asynchronous image conversion tasks.
41
79
  - Faraday connection pooling with automatic retry and jitter on HTTP 429/5xx.
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # SmallPict Ruby SDK
2
2
 
3
- Official Ruby gem for the [SmallPict Image Optimization API](https://smallpict.app) — high-performance next-gen image transcoding (AVIF, WebP), smart compression, Edge CDN delivery, cache purging, and Rails ActiveStorage support.
3
+ Official Ruby gem for the [SmallPict Image Optimization API](https://smallpict.app) — high-performance next-gen image transcoding (AVIF, WebP), smart compression, Edge CDN delivery and cache purging.
4
4
 
5
5
  [![Gem Version](https://img.shields.io/gem/v/smallpict.svg)](https://rubygems.org/gems/smallpict)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
@@ -11,11 +11,10 @@ Official Ruby gem for the [SmallPict Image Optimization API](https://smallpict.a
11
11
  ## ⚡ Features
12
12
 
13
13
  - **💎 Idiomatic Ruby Design:** Block-based configuration (`SmallPict.configure`) and flexible standalone instances.
14
- - **🚀 Rails ActiveStorage Ready:** Built-in service adapter (`SmallPict::ActiveStorage::Service`).
15
- - **🛡️ Secure HMAC-SHA256 & Bearer Auth:** Tamper-proof payload verification.
14
+ - **🛡️ HMAC-SHA256 request signing:** every request is signed with your secret key; both `api_key` and `secret_key` are required.
16
15
  - **✨ 4 Core Unified Methods:** `optimize()`, `get_quota()`, `purge_cdn()`, and `validate_key()`.
17
16
  - **🔄 Resilience & Fault Tolerance:** Automatic `Idempotency-Key` UUID injection, 30s timeouts, and exponential backoff with jitter on HTTP 429/5xx.
18
- - **🔒 Zero-Leak Privacy:** API keys and credentials are automatically redacted from `to_s` and `inspect` error logs.
17
+ - **🔒 Zero-Leak Privacy:** API keys and secrets are redacted from error messages, and `Client#inspect` / `Configuration#inspect` show only the base URL.
19
18
 
20
19
  ---
21
20
 
@@ -24,7 +23,7 @@ Official Ruby gem for the [SmallPict Image Optimization API](https://smallpict.a
24
23
  Add to your application's `Gemfile`:
25
24
 
26
25
  ```ruby
27
- gem "smallpict", "~> 0.0.2"
26
+ gem "smallpict", "~> 0.0.8"
28
27
  ```
29
28
 
30
29
  And then execute:
@@ -41,15 +40,18 @@ gem install smallpict
41
40
 
42
41
  ### 1. Global Block Configuration & Standalone Ruby
43
42
 
43
+ `SmallPict.configure` is process-global: every `SmallPict.optimize` call in the process shares it. Code that serves several tenants with different keys builds its own `SmallPict::Client.new(api_key: ..., secret_key: ...)` per tenant.
44
+
44
45
  ```ruby
45
46
  require "smallpict"
46
47
 
47
48
  SmallPict.configure do |config|
48
49
  config.api_key = ENV["SMALLPICT_API_KEY"]
49
- config.secret_key = ENV["SMALLPICT_SECRET_KEY"] # Optional HMAC Secret Key
50
+ config.secret_key = ENV["SMALLPICT_SECRET_KEY"]
50
51
  end
51
52
 
52
53
  image_data = File.binread("hero-banner.png")
54
+ # A source can be the image bytes (String), an open File or IO, or a Pathname.
53
55
 
54
56
  result = SmallPict.optimize(
55
57
  image_data,
@@ -64,6 +66,20 @@ puts "Optimized CDN URL: #{result.url}"
64
66
  puts "Saved: #{result.savings_percentage}% (#{result.bytes_saved} bytes)"
65
67
  ```
66
68
 
69
+ #### What `optimize` does
70
+
71
+ `optimize` returns a finished job. It sends the request, uploads the bytes to the pre-signed `upload_url` the API returns, then polls the job status every `poll_interval` seconds (default 1) until the job is completed, for at most `wait_timeout` seconds (default 120). The result has `status == "completed"`, `url`, `format`, `bytes_saved`, `mode_applied`, `quality_applied` and `folder` from the job status, plus `original_size` (the bytes you sent), `compressed_size` and `savings_percentage`.
72
+
73
+ - Pass the image as bytes (a String), an IO (`File`, `Tempfile`, `StringIO`) or a local file path. An `http://` or `https://` URL raises `SmallPict::ValidationError`: the API does not fetch URLs, so download the image and pass its bytes.
74
+ - A failed job raises `SmallPict::Error` with `code == "job_failed"`, the server's message and `details["job_id"]`. A refused upload raises `SmallPict::Error` with `code == "upload_failed"` and the HTTP status in `status_code`.
75
+ - A job still running after `wait_timeout` raises `SmallPict::TimeoutError` with the job id in the message. The job keeps running on the server; read it later with `SmallPict.get_job_status(job_id)`.
76
+ - A retry after a 5xx may create a second job until the server honours `Idempotency-Key`.
77
+
78
+ ```ruby
79
+ client = SmallPict::Client.new(api_key: ENV["SMALLPICT_API_KEY"], secret_key: ENV["SMALLPICT_SECRET_KEY"],
80
+ poll_interval: 2, wait_timeout: 60)
81
+ ```
82
+
67
83
  `AUTO` picks, per image, the lowest quality that keeps the visual score (SSIMULACRA 2) at or above 70, and ignores `quality`. It is on WP Pro and Business, Velocity, Momentum and sandbox keys (5 auto jobs per minute per sandbox key); where it cannot search (PNG or GIF output, animation, time limit) the job is lossy and `mode_applied` says so.
68
84
 
69
85
  `EXTREME` gives the smallest files on every plan. Leave `quality` out so the server uses its default for the output format; a quality you set is kept or raised to the format floor. Mode comparison: https://smallpict.app/en/docs/image-settings
@@ -83,6 +99,8 @@ SmallPict.configure do |config|
83
99
  config.api_key = Rails.application.credentials.dig(:smallpict, :api_key)
84
100
  config.secret_key = Rails.application.credentials.dig(:smallpict, :secret_key)
85
101
  config.fallback_mode = :passthrough # :throw | :passthrough
102
+ config.poll_interval = 1 # seconds between job status polls in optimize
103
+ config.wait_timeout = 120 # seconds optimize waits before SmallPict::TimeoutError
86
104
  end
87
105
  ```
88
106
 
@@ -164,7 +182,7 @@ bundle exec rubocop
164
182
 
165
183
  ## 🔒 Security & Schema Compatibility (2026)
166
184
 
167
- - **`mime_type` & `content_type` Dual Support**: `OptimizeOptions` accepts both `mime_type` and `content_type` keyword arguments. Outgoing optimization payloads include both `mime_type` and `content_type` fields, ensuring seamless zero-disruption compatibility across all SmallPict API versions.
185
+ - **`content_type` alias**: `OptimizeOptions` still accepts `content_type:` as an alias of `mime_type:` for older callers; only `mime_type` is sent, which is the field the API reads.
168
186
 
169
187
  ---
170
188
 
@@ -3,10 +3,31 @@
3
3
  require "faraday"
4
4
  require "json"
5
5
  require "securerandom"
6
+ require "uri"
6
7
 
7
8
  module SmallPict
8
9
  class Client
9
- attr_reader :api_key, :secret_key, :base_url, :timeout, :max_retries, :fallback_mode
10
+ # Longest wait a Retry-After header may impose; the hourly purge limit would otherwise block a thread for an hour.
11
+ MAX_RETRY_AFTER = 30
12
+
13
+ MAGIC_MIMES = {
14
+ "\x89PNG".b => "image/png",
15
+ "\xFF\xD8\xFF".b => "image/jpeg",
16
+ "GIF8".b => "image/gif"
17
+ }.freeze
18
+
19
+ EXTENSION_MIMES = {
20
+ ".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg", ".gif" => "image/gif",
21
+ ".webp" => "image/webp", ".avif" => "image/avif", ".svg" => "image/svg+xml"
22
+ }.freeze
23
+
24
+ attr_reader :api_key, :secret_key, :base_url, :timeout, :max_retries, :fallback_mode,
25
+ :poll_interval, :wait_timeout
26
+
27
+ def inspect
28
+ "#<#{self.class.name} base_url=#{@base_url.inspect}>"
29
+ end
30
+ alias to_s inspect
10
31
 
11
32
  def initialize(
12
33
  api_key: nil,
@@ -14,16 +35,21 @@ module SmallPict
14
35
  base_url: nil,
15
36
  timeout: nil,
16
37
  max_retries: nil,
17
- fallback_mode: nil
38
+ fallback_mode: nil,
39
+ poll_interval: nil,
40
+ wait_timeout: nil
18
41
  )
19
42
  config = SmallPict.configuration
20
43
 
21
44
  @api_key = api_key || config.api_key
22
45
  @secret_key = secret_key || config.secret_key
23
- @base_url = (base_url || config.base_url).chomp("/")
46
+ # The client adds /v1 itself; SDK_SPEC names the base URL with it, so accept both forms.
47
+ @base_url = (base_url || config.base_url).chomp("/").sub(%r{/v1\z}, "")
24
48
  @timeout = (timeout || config.timeout).to_i
25
49
  @max_retries = (max_retries || config.max_retries).to_i
26
50
  @fallback_mode = (fallback_mode || config.fallback_mode).to_sym
51
+ @poll_interval = poll_interval || config.poll_interval
52
+ @wait_timeout = wait_timeout || config.wait_timeout
27
53
 
28
54
  validate_configuration!
29
55
  end
@@ -33,34 +59,38 @@ module SmallPict
33
59
  resolved = resolve_source(source, opts)
34
60
 
35
61
  payload = {
36
- filename: resolved[:filename],
37
- mime_type: resolved[:mime_type],
38
- content_type: resolved[:mime_type],
39
- filesize: resolved[:filesize],
40
- options: opts.to_h
62
+ filename: resolved[:filename],
63
+ mime_type: resolved[:mime_type],
64
+ filesize: resolved[:filesize],
65
+ options: opts.to_h
41
66
  }
42
67
 
43
68
  begin
44
- res = request(:post, "/v1/optimize", payload, idempotency_key: opts.idempotency_key)
45
- Models::OptimizeResult.new(res)
69
+ queued = request(:post, "/v1/optimize", payload, idempotency_key: opts.idempotency_key)
46
70
  rescue QuotaExceededError => e
47
- if @fallback_mode == :passthrough
48
- format_str = resolved[:mime_type].to_s.sub("image/", "")
49
- Models::OptimizeResult.new(
50
- job_id: "fallback-passthrough",
51
- status: "completed",
52
- url: "",
53
- format: format_str,
54
- original_size: resolved[:filesize],
55
- compressed_size: resolved[:filesize],
56
- bytes_saved: 0,
57
- savings_percentage: 0.0,
58
- data: resolved[:bytes]
59
- )
60
- else
61
- raise e
62
- end
71
+ raise e unless @fallback_mode == :passthrough
72
+
73
+ format_str = resolved[:mime_type].to_s.sub("image/", "")
74
+ return Models::OptimizeResult.new(
75
+ job_id: "fallback-passthrough",
76
+ status: "completed",
77
+ url: "",
78
+ format: format_str,
79
+ original_size: resolved[:filesize],
80
+ compressed_size: resolved[:filesize],
81
+ bytes_saved: 0,
82
+ savings_percentage: 0.0,
83
+ data: resolved[:bytes]
84
+ )
63
85
  end
86
+
87
+ unless queued.is_a?(Hash) && queued["upload_url"].is_a?(String) && queued["job_id"].is_a?(String)
88
+ raise Error.new("optimize response has no upload_url or job_id", code: "invalid_response", status_code: 200)
89
+ end
90
+
91
+ # The worker waits 30 s from the request for the object: upload now, poll after.
92
+ upload(queued["upload_url"], resolved[:bytes], resolved[:mime_type])
93
+ wait_for_job(queued["job_id"], resolved[:filesize])
64
94
  end
65
95
 
66
96
  def get_quota
@@ -88,23 +118,84 @@ module SmallPict
88
118
  def validate_key
89
119
  get_quota
90
120
  true
91
- rescue StandardError
121
+ rescue AuthenticationError, PermissionDeniedError
92
122
  false
93
123
  end
94
124
 
95
125
  def get_job_status(job_id)
96
126
  raise ValidationError, "job_id is required" if job_id.nil? || job_id.to_s.empty?
97
127
 
98
- res = request(:get, "/v1/optimize/status?job_id=#{job_id}")
128
+ res = request(:get, "/v1/optimize/status?job_id=#{URI.encode_www_form_component(job_id)}")
99
129
  Models::JobStatusResult.new(res)
100
130
  end
101
131
 
102
132
  private
103
133
 
134
+ # PUT the bytes to the pre-signed URL with only the two headers it was signed for:
135
+ # S3 refuses API headers or another type or length with 403. No retry: the worker
136
+ # stops waiting for the object 30 s after the job was queued.
137
+ def upload(url, bytes, mime_type)
138
+ response = connection.put(url) do |req|
139
+ req.headers = { "Content-Type" => mime_type, "Content-Length" => bytes.bytesize.to_s }
140
+ req.body = bytes
141
+ end
142
+ return if response.status >= 200 && response.status < 300
143
+
144
+ text = response.body.to_s
145
+ message = "Upload to staging failed with HTTP #{response.status}#{": #{text}" unless text.empty?}"
146
+ raise Error.new(message, code: "upload_failed", status_code: response.status)
147
+ rescue Faraday::TimeoutError => e
148
+ raise TimeoutError, "Upload to staging timed out: #{e.message}"
149
+ rescue Faraday::Error => e
150
+ raise NetworkError, e.message
151
+ end
152
+
153
+ def wait_for_job(job_id, original_size)
154
+ deadline = monotonic_now + @wait_timeout
155
+ loop do
156
+ sleep((deadline - monotonic_now).clamp(0, @poll_interval))
157
+ status = get_job_status(job_id)
158
+
159
+ case status.status
160
+ when "completed"
161
+ bytes_saved = status.bytes_saved.to_i
162
+ return Models::OptimizeResult.new(
163
+ job_id: status.job_id || job_id,
164
+ status: "completed",
165
+ url: status.url || "",
166
+ format: status.format || "",
167
+ original_size: original_size,
168
+ compressed_size: original_size - bytes_saved,
169
+ bytes_saved: bytes_saved,
170
+ mode_applied: status.mode_applied,
171
+ quality_applied: status.quality_applied,
172
+ folder: status.folder
173
+ )
174
+ when "failed"
175
+ err = status.error
176
+ message = err.is_a?(Hash) ? err["message"] : err
177
+ message = "The optimization job failed." if message.nil? || message.to_s.empty?
178
+ raise Error.new(message.to_s, code: "job_failed", details: { "job_id" => job_id })
179
+ end
180
+
181
+ if monotonic_now >= deadline
182
+ raise TimeoutError,
183
+ "Job #{job_id} did not finish within #{@wait_timeout}s; poll get_job_status(\"#{job_id}\") later."
184
+ end
185
+ end
186
+ end
187
+
188
+ def monotonic_now
189
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
190
+ end
191
+
104
192
  def validate_configuration!
105
193
  if @api_key.nil? || @api_key.to_s.strip.empty?
106
194
  raise ValidationError, "Missing required SmallPict API key. Provide `api_key:` or configure via `SmallPict.configure`."
107
195
  end
196
+ return unless @secret_key.nil? || @secret_key.to_s.strip.empty?
197
+
198
+ raise ValidationError, "Missing required SmallPict secret key. Provide `secret_key:` or set SMALLPICT_SECRET_KEY."
108
199
  end
109
200
 
110
201
  def request(method, path, body = nil, idempotency_key: nil)
@@ -115,6 +206,7 @@ module SmallPict
115
206
  body_str = body ? JSON.generate(body) : nil
116
207
  body_hash = body_str ? Crypto.sha256_hex(body_str) : Crypto::EMPTY_SHA256
117
208
 
209
+ idempotency_key ||= SecureRandom.uuid
118
210
  attempt = 0
119
211
  base_delay = 0.25 # 250ms
120
212
 
@@ -124,23 +216,18 @@ module SmallPict
124
216
  timestamp = Time.now.to_i.to_s
125
217
 
126
218
  headers = {
127
- "Accept" => "application/json",
128
- "X-API-Key" => @api_key
219
+ "Accept" => "application/json",
220
+ "X-API-Key" => @api_key
129
221
  }
130
222
  headers["Content-Type"] = "application/json" if body_str
131
223
 
132
- if @secret_key
133
- string_to_sign = Crypto.build_string_to_sign(method, clean_path, timestamp, body_hash)
134
- signature = Crypto.hmac_sha256_hex(@secret_key, string_to_sign)
135
- headers["X-Timestamp"] = timestamp
136
- headers["X-Signature"] = signature
137
- else
138
- headers["Authorization"] = "Bearer #{@api_key}"
139
- end
224
+ string_to_sign = Crypto.build_string_to_sign(method, clean_path.split("?", 2).first, timestamp, body_hash)
225
+ headers["X-Timestamp"] = timestamp
226
+ headers["X-Signature"] = Crypto.hmac_sha256_hex(@secret_key, string_to_sign)
140
227
 
141
- if %i[post patch delete].include?(method.to_s.downcase.to_sym)
142
- headers["Idempotency-Key"] = idempotency_key || SecureRandom.uuid
143
- end
228
+ # The server rejects a repeated signature as a replay unless the nonce differs.
229
+ headers["X-Nonce"] = SecureRandom.uuid
230
+ headers["Idempotency-Key"] = idempotency_key if %i[post patch delete].include?(method.to_s.downcase.to_sym)
144
231
 
145
232
  response = connection.send(method.to_s.downcase.to_sym, url) do |req|
146
233
  req.headers = headers
@@ -151,20 +238,19 @@ module SmallPict
151
238
  request_id = response.headers["x-request-id"]
152
239
  retry_after = response.headers["retry-after"]&.to_i
153
240
 
154
- if (status == 429 && !hourly_purge_limit?(response.body)) || (status >= 500 && status <= 504)
155
- if attempt <= @max_retries
156
- delay = base_delay * (2**(attempt - 1))
157
- delay = retry_after if retry_after && retry_after.positive?
158
- jitter = rand(0..100) / 1000.0
159
- sleep(delay + jitter)
160
- next
161
- end
162
- end
163
-
164
241
  parsed = parse_body(response.body)
242
+ if ((status == 429 && !hourly_purge_limit?(parsed)) || status.between?(500, 504)) && (attempt <= @max_retries)
243
+ delay = base_delay * (2**(attempt - 1))
244
+ delay = [retry_after, MAX_RETRY_AFTER].min if retry_after&.positive?
245
+ jitter = rand(0..100) / 1000.0
246
+ sleep(delay + jitter)
247
+ next
248
+ end
165
249
 
166
- if status < 200 || status >= 300
167
- handle_error_response(status, parsed, request_id, retry_after)
250
+ handle_error_response(status, parsed, request_id, retry_after) if status < 200 || status >= 300
251
+ if parsed.key?("raw")
252
+ raise Error.new("Expected a JSON body, got: #{parsed["raw"].to_s[0, 200]}",
253
+ code: "invalid_response", status_code: status, request_id: request_id)
168
254
  end
169
255
 
170
256
  return parsed
@@ -176,10 +262,10 @@ module SmallPict
176
262
  next
177
263
  end
178
264
  raise NetworkError, e.message
265
+ rescue Faraday::Error => e
266
+ raise NetworkError, e.message
179
267
  end
180
268
  end
181
-
182
- raise TimeoutError, "Request failed after maximum retry attempts"
183
269
  end
184
270
 
185
271
  def connection
@@ -191,8 +277,7 @@ module SmallPict
191
277
  end
192
278
 
193
279
  # 429 purge_rate_limited is an hourly limit: retrying within seconds cannot succeed.
194
- def hourly_purge_limit?(body_str)
195
- body = parse_body(body_str)
280
+ def hourly_purge_limit?(body)
196
281
  body.is_a?(Hash) && body["error"].is_a?(Hash) && body["error"]["code"] == "purge_rate_limited"
197
282
  end
198
283
 
@@ -229,9 +314,7 @@ module SmallPict
229
314
  when 404 then raise NotFoundError.new(message, request_id: request_id, details: details)
230
315
  when 429 then raise RateLimitError.new(message, retry_after: retry_after, request_id: request_id, details: details)
231
316
  else
232
- if status >= 500
233
- raise ServerError.new(message, status_code: status, request_id: request_id, details: details)
234
- end
317
+ raise ServerError.new(message, status_code: status, request_id: request_id, details: details) if status >= 500
235
318
 
236
319
  raise Error.new(message, code: code, status_code: status, request_id: request_id, details: details)
237
320
  end
@@ -239,32 +322,52 @@ module SmallPict
239
322
 
240
323
  def resolve_source(source, options)
241
324
  filename = options.filename || "image.jpg"
242
- mime_type = options.mime_type || (options.respond_to?(:content_type) ? options.content_type : nil) || "image/jpeg"
325
+ explicit_mime = options.mime_type
243
326
  filesize = 0
244
327
  bytes = nil
245
328
 
329
+ if source.is_a?(String) && source.start_with?("http://", "https://")
330
+ raise ValidationError, "the API does not fetch URLs; download the image and pass its bytes"
331
+ end
332
+
246
333
  if source.respond_to?(:read)
247
334
  bytes = source.read
248
335
  filesize = bytes.bytesize
249
- filename = File.basename(source.path) if source.respond_to?(:path) && options.filename.nil?
336
+ path = source.respond_to?(:to_path) ? source.to_path : (source.path if source.respond_to?(:path))
337
+ filename = File.basename(path) if path && options.filename.nil?
250
338
  elsif source.is_a?(String)
251
- if File.exist?(source) && File.file?(source)
339
+ # Image bytes hold NUL, which File.file? refuses with ArgumentError; only probe a plausible path.
340
+ if !source.include?("\0") && File.file?(source)
252
341
  filename = File.basename(source) if options.filename.nil?
253
342
  bytes = File.binread(source)
254
- filesize = bytes.bytesize
255
343
  else
256
344
  # Raw binary data
257
345
  bytes = source
258
- filesize = bytes.bytesize
259
346
  end
347
+ filesize = bytes.bytesize
348
+ else
349
+ raise ValidationError, "Unsupported image source: pass an IO, a file path or the image bytes as a String"
260
350
  end
261
351
 
262
352
  {
263
353
  filename: filename,
264
- mime_type: mime_type,
354
+ mime_type: explicit_mime || detect_mime(bytes) || extension_mime(filename) || "image/jpeg",
265
355
  filesize: filesize,
266
356
  bytes: bytes
267
357
  }
268
358
  end
359
+
360
+ def detect_mime(bytes)
361
+ head = bytes.byteslice(0, 12).to_s.b
362
+ MAGIC_MIMES.each { |magic, mime| return mime if head.start_with?(magic) }
363
+ return "image/webp" if head.start_with?("RIFF".b) && head.byteslice(8, 4) == "WEBP".b
364
+ return "image/avif" if head.byteslice(4, 8) == "ftypavif".b
365
+
366
+ nil
367
+ end
368
+
369
+ def extension_mime(filename)
370
+ EXTENSION_MIMES[File.extname(filename.to_s).downcase]
371
+ end
269
372
  end
270
373
  end
@@ -2,15 +2,23 @@
2
2
 
3
3
  module SmallPict
4
4
  class Configuration
5
- attr_accessor :api_key, :secret_key, :base_url, :timeout, :max_retries, :fallback_mode
5
+ attr_accessor :api_key, :secret_key, :base_url, :timeout, :max_retries, :fallback_mode,
6
+ :poll_interval, :wait_timeout
6
7
 
7
8
  def initialize
8
- @api_key = ENV["SMALLPICT_API_KEY"]
9
- @secret_key = ENV["SMALLPICT_SECRET_KEY"]
9
+ @api_key = ENV.fetch("SMALLPICT_API_KEY", nil)
10
+ @secret_key = ENV.fetch("SMALLPICT_SECRET_KEY", nil)
10
11
  @base_url = (ENV["SMALLPICT_BASE_URL"] || "https://api.smallpict.app").chomp("/")
11
12
  @timeout = 30
12
13
  @max_retries = 3
13
14
  @fallback_mode = :throw
15
+ @poll_interval = 1 # seconds between job status polls in optimize
16
+ @wait_timeout = 120 # seconds optimize waits for the job before TimeoutError
14
17
  end
18
+
19
+ def inspect
20
+ "#<#{self.class.name} base_url=#{@base_url.inspect}>"
21
+ end
22
+ alias to_s inspect
15
23
  end
16
24
  end
@@ -38,7 +38,8 @@ module SmallPict
38
38
  end
39
39
  end
40
40
 
41
- sanitized = sanitized.gsub(/(sec|secret)_[a-zA-Z0-9_-]{8,}/i, "***REDACTED***")
41
+ # Server-issued secrets are "sps_" + hex; sec_/secret_ are the test fixtures.
42
+ sanitized = sanitized.gsub(/(sps_[a-f0-9]{16,}|(sec|secret)_[a-zA-Z0-9_-]{8,})/i, "***REDACTED***")
42
43
  sanitized.gsub(/Bearer\s+[a-zA-Z0-9._-]+/i, "Bearer ***REDACTED***")
43
44
  end
44
45
  end
@@ -22,19 +22,19 @@ module SmallPict
22
22
  l = get(data, :lapse)
23
23
  new(
24
24
  period_end: get(data, :period_end),
25
- storage: s && CapacityStorage.new(limit_bytes: get(s, :limit_bytes), used_bytes: get(s, :used_bytes),
26
- base_bytes: get(s, :base_bytes), addon_blocks: get(s, :addon_blocks)),
27
- cdn: cd && CapacityCdn.new(limit_bytes: get(cd, :limit_bytes), used_bytes: get(cd, :used_bytes),
28
- status: get(cd, :status)),
29
- booster: CapacityBooster.new(
30
- active: get(b, :active) == true,
25
+ storage: s && CapacityStorage.new(limit_bytes: get(s, :limit_bytes), used_bytes: get(s, :used_bytes),
26
+ base_bytes: get(s, :base_bytes), addon_blocks: get(s, :addon_blocks)),
27
+ cdn: cd && CapacityCdn.new(limit_bytes: get(cd, :limit_bytes), used_bytes: get(cd, :used_bytes),
28
+ status: get(cd, :status)),
29
+ booster: CapacityBooster.new(
30
+ active: get(b, :active) == true,
31
31
  wp_quota_bytes: get(b, :wp_quota_bytes),
32
32
  api_transforms: get(b, :api_transforms),
33
- cdn_bytes: get(b, :cdn_bytes),
34
- next_expiry: e && CapacityExpiry.new(kind: get(e, :kind), units: get(e, :units),
35
- expires_at: get(e, :expires_at))
33
+ cdn_bytes: get(b, :cdn_bytes),
34
+ next_expiry: e && CapacityExpiry.new(kind: get(e, :kind), units: get(e, :units),
35
+ expires_at: get(e, :expires_at))
36
36
  ),
37
- lapse: l && CapacityLapse.new(stage: get(l, :stage), delete_after: get(l, :delete_after)),
37
+ lapse: l && CapacityLapse.new(stage: get(l, :stage), delete_after: get(l, :delete_after)),
38
38
  manage_url: get(data, :manage_url)
39
39
  )
40
40
  end
@@ -3,8 +3,8 @@
3
3
  module SmallPict
4
4
  module Models
5
5
  class OptimizeOptions
6
- attr_reader :format, :quality, :max_width, :max_height, :max_dimension, :fit,
7
- :mode, :lossless, :folder, :strip_metadata, :filename, :mime_type, :content_type, :idempotency_key
6
+ attr_reader :format, :quality, :max_width, :max_height, :max_dimension,
7
+ :mode, :lossless, :folder, :strip_metadata, :filename, :mime_type, :idempotency_key
8
8
 
9
9
  # mode: SmallPict::Mode::LOSSY (server default when omitted), EXTREME or LOSSLESS.
10
10
  # quality: optional 1-100; omitted when nil so the server picks the default per mode.
@@ -16,7 +16,6 @@ module SmallPict
16
16
  max_width: nil,
17
17
  max_height: nil,
18
18
  max_dimension: nil,
19
- fit: "cover",
20
19
  mode: nil,
21
20
  lossless: nil,
22
21
  folder: nil,
@@ -31,27 +30,24 @@ module SmallPict
31
30
  @max_width = max_width
32
31
  @max_height = max_height
33
32
  @max_dimension = max_dimension
34
- @fit = fit.to_s
35
33
  @lossless = lossless
36
34
  @mode = resolve_mode(mode, lossless)
37
35
  @folder = folder.nil? ? nil : Folder.validate!(folder)
38
36
  @strip_metadata = strip_metadata
39
37
  @filename = filename
40
38
  @mime_type = mime_type || content_type
41
- @content_type = content_type || mime_type
42
39
  @idempotency_key = idempotency_key
43
40
  end
44
41
 
45
42
  def to_h
46
43
  {
47
- format: @format,
48
- quality: @quality,
49
- max_width: @max_width,
50
- max_height: @max_height,
51
- max_dimension: @max_dimension,
52
- fit: @fit,
53
- mode: @mode,
54
- folder: @folder,
44
+ format: @format,
45
+ quality: @quality,
46
+ max_width: @max_width,
47
+ max_height: @max_height,
48
+ max_dimension: @max_dimension,
49
+ mode: @mode,
50
+ folder: @folder,
55
51
  strip_metadata: @strip_metadata
56
52
  }.compact
57
53
  end
@@ -5,7 +5,7 @@ module SmallPict
5
5
  class OptimizeResult
6
6
  attr_reader :job_id, :status, :url, :format, :original_size,
7
7
  :compressed_size, :bytes_saved, :savings_percentage,
8
- :upload_url, :data, :folder
8
+ :data, :folder, :mode_applied, :quality_applied
9
9
 
10
10
  def initialize(attributes = {})
11
11
  @job_id = attributes[:job_id] || attributes["job_id"] || "sync"
@@ -15,11 +15,14 @@ module SmallPict
15
15
  @original_size = (attributes[:original_size] || attributes["original_size"] || 0).to_i
16
16
  @compressed_size = (attributes[:compressed_size] || attributes["compressed_size"] || @original_size).to_i
17
17
  @bytes_saved = (attributes[:bytes_saved] || attributes["bytes_saved"] || [@original_size - @compressed_size, 0].max).to_i
18
- @savings_percentage = (attributes[:savings_percentage] || attributes["savings_percentage"] || calculate_savings_percentage).to_f.round(2)
19
- @upload_url = attributes[:upload_url] || attributes["upload_url"]
18
+ @savings_percentage = (attributes[:savings_percentage] || attributes["savings_percentage"] ||
19
+ calculate_savings_percentage).to_f.round(2)
20
20
  @data = attributes[:data]
21
21
  # Normalized (lower case) by the server; nil without a folder.
22
22
  @folder = attributes[:folder] || attributes["folder"]
23
+ # As reported by the job status; nil for a passthrough result.
24
+ @mode_applied = attributes[:mode_applied] || attributes["mode_applied"]
25
+ @quality_applied = attributes[:quality_applied] || attributes["quality_applied"]
23
26
  end
24
27
 
25
28
  private
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SmallPict
4
- VERSION = "0.0.7"
4
+ VERSION = "0.0.8"
5
5
  end
data/lib/smallpict.rb CHANGED
@@ -13,7 +13,6 @@ require_relative "smallpict/models/quota_response"
13
13
  require_relative "smallpict/models/purge_response"
14
14
  require_relative "smallpict/models/job_status_result"
15
15
  require_relative "smallpict/client"
16
- require_relative "smallpict/active_storage/service"
17
16
 
18
17
  module SmallPict
19
18
  class << self
@@ -23,6 +22,7 @@ module SmallPict
23
22
 
24
23
  def configure
25
24
  yield(configuration)
25
+ @default_client = nil # the next SmallPict.client picks up the new values
26
26
  end
27
27
 
28
28
  def reset_configuration!
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smallpict
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.7
4
+ version: 0.0.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - SmallPict Engineering
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-11-02 00:00:00.000000000 Z
11
+ date: 2026-11-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -24,20 +24,6 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '2.0'
27
- - !ruby/object:Gem::Dependency
28
- name: faraday-retry
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - ">="
32
- - !ruby/object:Gem::Version
33
- version: '2.0'
34
- type: :runtime
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - ">="
39
- - !ruby/object:Gem::Version
40
- version: '2.0'
41
27
  - !ruby/object:Gem::Dependency
42
28
  name: rspec
43
29
  requirement: !ruby/object:Gem::Requirement
@@ -53,35 +39,35 @@ dependencies:
53
39
  - !ruby/object:Gem::Version
54
40
  version: '3.12'
55
41
  - !ruby/object:Gem::Dependency
56
- name: webmock
42
+ name: rubocop
57
43
  requirement: !ruby/object:Gem::Requirement
58
44
  requirements:
59
45
  - - "~>"
60
46
  - !ruby/object:Gem::Version
61
- version: '3.18'
47
+ version: '1.50'
62
48
  type: :development
63
49
  prerelease: false
64
50
  version_requirements: !ruby/object:Gem::Requirement
65
51
  requirements:
66
52
  - - "~>"
67
53
  - !ruby/object:Gem::Version
68
- version: '3.18'
54
+ version: '1.50'
69
55
  - !ruby/object:Gem::Dependency
70
- name: rubocop
56
+ name: webmock
71
57
  requirement: !ruby/object:Gem::Requirement
72
58
  requirements:
73
59
  - - "~>"
74
60
  - !ruby/object:Gem::Version
75
- version: '1.50'
61
+ version: '3.18'
76
62
  type: :development
77
63
  prerelease: false
78
64
  version_requirements: !ruby/object:Gem::Requirement
79
65
  requirements:
80
66
  - - "~>"
81
67
  - !ruby/object:Gem::Version
82
- version: '1.50'
68
+ version: '3.18'
83
69
  description: High-performance next-gen image transcoding (AVIF, WebP), smart compression,
84
- Edge CDN delivery, and Rails ActiveStorage integration.
70
+ and Edge CDN delivery.
85
71
  email:
86
72
  - support@smallpict.app
87
73
  executables: []
@@ -93,7 +79,6 @@ files:
93
79
  - README.md
94
80
  - SECURITY.md
95
81
  - lib/smallpict.rb
96
- - lib/smallpict/active_storage/service.rb
97
82
  - lib/smallpict/client.rb
98
83
  - lib/smallpict/configuration.rb
99
84
  - lib/smallpict/crypto.rb
@@ -114,6 +99,7 @@ metadata:
114
99
  homepage_uri: https://smallpict.app
115
100
  source_code_uri: https://github.com/smallPict/smallpict-ruby
116
101
  changelog_uri: https://github.com/smallPict/smallpict-ruby/blob/main/CHANGELOG.md
102
+ rubygems_mfa_required: 'true'
117
103
  post_install_message:
118
104
  rdoc_options: []
119
105
  require_paths:
@@ -1,29 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module SmallPict
4
- module ActiveStorage
5
- class Service
6
- attr_reader :client
7
-
8
- def initialize(api_key: nil, secret_key: nil, base_url: nil, **options)
9
- @client = SmallPict::Client.new(
10
- api_key: api_key,
11
- secret_key: secret_key,
12
- base_url: base_url,
13
- **options
14
- )
15
- end
16
-
17
- def optimize(key, io, options = {})
18
- opts = options.merge(filename: key)
19
- @client.optimize(io, opts)
20
- end
21
-
22
- def url_for(key, options = {})
23
- opts = options.merge(filename: key)
24
- result = @client.optimize("", opts)
25
- result.url
26
- end
27
- end
28
- end
29
- end