smallpict 0.0.6 → 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: 2c32e00d3a73e92ac5781a8e3d61cb90d6e81a62880d45318caaeaa87e29b39f
4
- data.tar.gz: 1aad44c760c834fc47b7bfa4c2513ec498a0c57c3830bd7317c82b5a9ed99391
3
+ metadata.gz: 559f8ad84228a8b7beb0b3b10f3cd49192701ab548aa72602bf36e87011eb75b
4
+ data.tar.gz: b4965ff68dd978df9f8168a0c863baf3256fd6876276ad18c6df2debf363e881
5
5
  SHA512:
6
- metadata.gz: 715d75a35e3dd824d2a9b98ef720ff4a8f9a6dbf79a92076517aae7ea4da5f8d8edafd8d3999b402620a846b06a56ac892e85033117748c73b932e886a63e279
7
- data.tar.gz: 6eeb642b7b22f49d7c90d50188f5afb1f4c632b61819d592a483cb7d199505bfcbd6916e3e198d67c138715e6c5513dc0f140b992f1a24b3f2153c94224c929a
6
+ metadata.gz: 6f9839d872ea23a77cb895f1138dff6f83c852a318035c56f83ff120db7453f36fc299f43775434a2463152d6fd5cfd302dc483f3a929ddb7aae8e2d94bb4aee
7
+ data.tar.gz: adbfe70b0b6b5d06584ca3160f207d9b0353ff90521bdd4c61f4cc42c76ae211c5d5a3f0fc7a240a5cf7ff6eb374a050c60a5bccefab753b3581cebf4ac1344f
data/CHANGELOG.md CHANGED
@@ -5,12 +5,75 @@ 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
+ ## [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".
47
+
48
+ ## [0.0.7] - 2026-10-31
49
+
50
+ ### Added
51
+ - `SmallPict::Mode::AUTO` (`"auto"`, also in `Mode::ALL`): the server picks the quality per image from a visual score (CMP-07). Plans: WP Pro and Business, Velocity, Momentum and sandbox; a server without auto enabled answers `400 invalid_mode` (`ValidationError`).
52
+ - `mode:` option on `optimize` (`"lossy"` default, `"extreme"`, `"lossless"`), with constants `SmallPict::Mode::LOSSY`, `EXTREME`, `LOSSLESS` and the frozen list `SmallPict::Mode::ALL`. The server checks the mode against the plan: `403 mode_not_in_plan` raises `PermissionDeniedError` with `details["mode"]` and `details["allowed_modes"]`; `400 invalid_mode` raises `ValidationError`.
53
+ - `JobStatusResult#mode_applied` and `#quality_applied`, as reported by the server (`nil` for jobs created before modes existed).
54
+ - `folder:` option on `optimize`, sent as `options.folder` and omitted when unset. The server stores the file under that folder of your account and lower-cases the value; `OptimizeResult#folder` and `JobStatusResult#folder` return it as stored (`nil` without one). The client rejects empty segments (`a//b`, leading or trailing `/`), `..` segments and more than 3 segments with `ValidationError` before any request; the server checks the full format (`400 invalid_folder` raises `ValidationError` with the server's message).
55
+ - `purge_cdn(purge_type: "folder", folder: "...")` purges one upload folder. `folder:` is required for that type (`ValidationError` before any request when missing or malformed) and is sent only with it; `url` and `all` purges are unchanged.
56
+ - `QuotaResponse#capacity`: the read-only capacity of `GET /v1/quota` as `SmallPict::Models::Capacity` (`period_end`, `storage`, `cdn`, `booster` with `next_expiry`, `lapse`, `manage_url`). Members that do not apply to the plan are `nil`; `capacity` is `nil` when the server could not read it and on older servers that do not send it. Existing quota fields are unchanged. Capacity is bought only in the dashboard; the SDK exposes `manage_url` as a string and never opens or fetches it.
57
+ - `SmallPict::Error#upgrade_url` on every error: `details["upgrade_url"]` when it is a string (sent with `402` quota, storage and read-only errors), otherwise `nil`. `PermissionDeniedError#allowed_modes` returns `details["allowed_modes"]` of `403 mode_not_in_plan`, otherwise `nil`. Status-to-class mapping is unchanged.
58
+
59
+ ### Changed
60
+ - README: example of `mode: SmallPict::Mode::EXTREME` without `quality:`.
61
+ - `429 purge_rate_limited` (hourly limit: 10 `folder` and 2 `all` purges per hour) raises `RateLimitError` without automatic retries, since the limit does not reset within seconds. Other 429 responses are still retried as before.
62
+ - Errors raised as the base `SmallPict::Error` (statuses without a dedicated class, such as `409`) now carry the server's `error.code` in `#code` instead of `"INTERNAL_ERROR"`; `"INTERNAL_ERROR"` remains when the body has no code. Typed errors keep their fixed codes.
63
+
64
+ ### Deprecated
65
+ - `lossless:` option on `optimize`. Use `mode: SmallPict::Mode::LOSSLESS` instead. `lossless: true` without `mode:` is sent as `mode: "lossless"`; `lossless: true` with a different `mode:` raises `ValidationError` before any request. Using it emits a `:deprecated` category warning (shown when `Warning[:deprecated]` is enabled). It will not be removed before the next major version.
66
+
67
+ ### Fixed
68
+ - `quality:` is no longer sent when unset (its default is now `nil` instead of `80`), so the server picks the default per mode: lossy stays at 80, and `extreme` now gets its per-codec default instead of a lossy-sized Q80 result. A quality you set is still sent. `OptimizeOptions#quality` returns `nil` when unset.
69
+ - `lossless: true` used to be ignored by the server; it is now honoured (sent as `mode: "lossless"`). The SDK no longer sends `lossless` on the wire.
70
+
8
71
  ## [0.0.2] - 2026-08-22
9
72
 
10
73
  ### Added
11
74
  - Official Ruby SDK implementation for SmallPict OpenAPI 3.1.0 API.
12
75
  - Block-based global configuration (`SmallPict.configure { |c| ... }`) and standalone client instances (`SmallPict::Client.new`).
13
- - 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.
14
77
  - 4 unified core client methods: `optimize`, `get_quota`, `purge_cdn`, and `validate_key`.
15
78
  - Helper `get_job_status` for polling asynchronous image conversion tasks.
16
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,27 +40,58 @@ 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,
56
58
  format: "avif",
57
59
  quality: 80,
58
- max_width: 1920
60
+ max_width: 1920,
61
+ mode: SmallPict::Mode::LOSSY, # LOSSY (default) | EXTREME | LOSSLESS | AUTO; replaces the deprecated `lossless:`
62
+ folder: "products/shoes" # optional, 1-3 segments; stored lower case, returned as result.folder
59
63
  )
60
64
 
61
65
  puts "Optimized CDN URL: #{result.url}"
62
66
  puts "Saved: #{result.savings_percentage}% (#{result.bytes_saved} bytes)"
63
67
  ```
64
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
+
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.
84
+
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
86
+
87
+ ```ruby
88
+ result = SmallPict.optimize(
89
+ File.binread("hero-banner.png"),
90
+ format: "webp",
91
+ mode: SmallPict::Mode::EXTREME # no quality: the server picks it per format
92
+ )
93
+ ```
94
+
65
95
  ### 2. Rails Initializer (`config/initializers/smallpict.rb`)
66
96
 
67
97
  ```ruby
@@ -69,6 +99,8 @@ SmallPict.configure do |config|
69
99
  config.api_key = Rails.application.credentials.dig(:smallpict, :api_key)
70
100
  config.secret_key = Rails.application.credentials.dig(:smallpict, :secret_key)
71
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
72
104
  end
73
105
  ```
74
106
 
@@ -106,8 +138,37 @@ puts "Plan: #{quota.plan}, Quota Used: #{quota.quota_percentage}%"
106
138
  # 2. Invalidate CDN cache for updated images
107
139
  purge = SmallPict.purge_cdn(["https://cdn.smallpict.app/opt/hero-banner.avif"])
108
140
  puts purge.message
141
+
142
+ # 3. Purge one upload folder, or all of your files
143
+ SmallPict.purge_cdn(purge_type: "folder", folder: "products/shoes")
144
+ SmallPict.purge_cdn(purge_type: "all")
145
+ # Folder purges are limited to 10 per hour and "all" to 2 per hour: one more raises
146
+ # SmallPict::RateLimitError (429 purge_rate_limited), which the client does not retry.
147
+ ```
148
+
149
+ ### Capacity and upgrade links
150
+
151
+ `get_quota` also returns the read-only `capacity` of the key's product. Members that do not apply to your plan are `nil`, and `capacity` itself is `nil` when the server could not read it or does not send it.
152
+
153
+ ```ruby
154
+ cap = SmallPict.get_quota.capacity
155
+ if cap
156
+ puts "Storage: #{cap.storage.used_bytes}/#{cap.storage.limit_bytes}" if cap.storage
157
+ puts "CDN status: #{cap.cdn.status}" if cap.cdn
158
+ puts "Account lapsed (#{cap.lapse.stage}), manage it at #{cap.manage_url}" if cap.lapse
159
+ end
160
+
161
+ begin
162
+ SmallPict.optimize("photo.jpg")
163
+ rescue SmallPict::QuotaExceededError => e
164
+ puts "Add capacity at #{e.upgrade_url}" if e.upgrade_url
165
+ rescue SmallPict::PermissionDeniedError => e
166
+ puts "Allowed modes: #{e.allowed_modes.join(', ')}" if e.allowed_modes
167
+ end
109
168
  ```
110
169
 
170
+ Capacity is bought only in the dashboard. `manage_url` and `upgrade_url` are plain strings to show your users; the SDK never opens or fetches them.
171
+
111
172
  ---
112
173
 
113
174
  ## 🧪 Testing
@@ -121,7 +182,7 @@ bundle exec rubocop
121
182
 
122
183
  ## 🔒 Security & Schema Compatibility (2026)
123
184
 
124
- - **`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.
125
186
 
126
187
  ---
127
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
+ )
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)
63
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
@@ -68,12 +98,18 @@ module SmallPict
68
98
  Models::QuotaResponse.new(res)
69
99
  end
70
100
 
71
- def purge_cdn(urls = [], purge_type: "url")
101
+ # purge_type: "url" (default), "folder" (with `folder:`) or "all".
102
+ def purge_cdn(urls = [], purge_type: "url", folder: nil)
72
103
  url_list = urls.is_a?(Array) ? urls : [urls]
73
104
  payload = {
74
105
  purge_type: purge_type.to_s,
75
106
  urls: url_list
76
107
  }
108
+ if payload[:purge_type] == "folder"
109
+ raise ValidationError, "folder is required for purge_type \"folder\"" if folder.nil? || folder.to_s.empty?
110
+
111
+ payload[:folder] = Folder.validate!(folder)
112
+ end
77
113
 
78
114
  res = request(:post, "/v1/purge", payload)
79
115
  Models::PurgeResponse.new(res)
@@ -82,23 +118,84 @@ module SmallPict
82
118
  def validate_key
83
119
  get_quota
84
120
  true
85
- rescue StandardError
121
+ rescue AuthenticationError, PermissionDeniedError
86
122
  false
87
123
  end
88
124
 
89
125
  def get_job_status(job_id)
90
126
  raise ValidationError, "job_id is required" if job_id.nil? || job_id.to_s.empty?
91
127
 
92
- 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)}")
93
129
  Models::JobStatusResult.new(res)
94
130
  end
95
131
 
96
132
  private
97
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
+
98
192
  def validate_configuration!
99
193
  if @api_key.nil? || @api_key.to_s.strip.empty?
100
194
  raise ValidationError, "Missing required SmallPict API key. Provide `api_key:` or configure via `SmallPict.configure`."
101
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."
102
199
  end
103
200
 
104
201
  def request(method, path, body = nil, idempotency_key: nil)
@@ -109,6 +206,7 @@ module SmallPict
109
206
  body_str = body ? JSON.generate(body) : nil
110
207
  body_hash = body_str ? Crypto.sha256_hex(body_str) : Crypto::EMPTY_SHA256
111
208
 
209
+ idempotency_key ||= SecureRandom.uuid
112
210
  attempt = 0
113
211
  base_delay = 0.25 # 250ms
114
212
 
@@ -118,23 +216,18 @@ module SmallPict
118
216
  timestamp = Time.now.to_i.to_s
119
217
 
120
218
  headers = {
121
- "Accept" => "application/json",
122
- "X-API-Key" => @api_key
219
+ "Accept" => "application/json",
220
+ "X-API-Key" => @api_key
123
221
  }
124
222
  headers["Content-Type"] = "application/json" if body_str
125
223
 
126
- if @secret_key
127
- string_to_sign = Crypto.build_string_to_sign(method, clean_path, timestamp, body_hash)
128
- signature = Crypto.hmac_sha256_hex(@secret_key, string_to_sign)
129
- headers["X-Timestamp"] = timestamp
130
- headers["X-Signature"] = signature
131
- else
132
- headers["Authorization"] = "Bearer #{@api_key}"
133
- 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)
134
227
 
135
- if %i[post patch delete].include?(method.to_s.downcase.to_sym)
136
- headers["Idempotency-Key"] = idempotency_key || SecureRandom.uuid
137
- 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)
138
231
 
139
232
  response = connection.send(method.to_s.downcase.to_sym, url) do |req|
140
233
  req.headers = headers
@@ -145,20 +238,19 @@ module SmallPict
145
238
  request_id = response.headers["x-request-id"]
146
239
  retry_after = response.headers["retry-after"]&.to_i
147
240
 
148
- if status == 429 || (status >= 500 && status <= 504)
149
- if attempt <= @max_retries
150
- delay = base_delay * (2**(attempt - 1))
151
- delay = retry_after if retry_after && retry_after.positive?
152
- jitter = rand(0..100) / 1000.0
153
- sleep(delay + jitter)
154
- next
155
- end
156
- end
157
-
158
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
159
249
 
160
- if status < 200 || status >= 300
161
- 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)
162
254
  end
163
255
 
164
256
  return parsed
@@ -170,10 +262,10 @@ module SmallPict
170
262
  next
171
263
  end
172
264
  raise NetworkError, e.message
265
+ rescue Faraday::Error => e
266
+ raise NetworkError, e.message
173
267
  end
174
268
  end
175
-
176
- raise TimeoutError, "Request failed after maximum retry attempts"
177
269
  end
178
270
 
179
271
  def connection
@@ -184,6 +276,11 @@ module SmallPict
184
276
  end
185
277
  end
186
278
 
279
+ # 429 purge_rate_limited is an hourly limit: retrying within seconds cannot succeed.
280
+ def hourly_purge_limit?(body)
281
+ body.is_a?(Hash) && body["error"].is_a?(Hash) && body["error"]["code"] == "purge_rate_limited"
282
+ end
283
+
187
284
  def parse_body(body_str)
188
285
  return {} if body_str.nil? || body_str.empty?
189
286
 
@@ -195,11 +292,13 @@ module SmallPict
195
292
  def handle_error_response(status, body, request_id, retry_after)
196
293
  message = "API request failed with HTTP #{status}"
197
294
  details = nil
295
+ code = "INTERNAL_ERROR"
198
296
 
199
297
  if body.is_a?(Hash)
200
298
  if body["error"].is_a?(Hash)
201
299
  message = body["error"]["message"] || message
202
300
  details = body["error"]["details"]
301
+ code = body["error"]["code"] if body["error"]["code"].is_a?(String)
203
302
  elsif body["error"].is_a?(String)
204
303
  message = body["error"]
205
304
  elsif body["message"].is_a?(String)
@@ -215,42 +314,60 @@ module SmallPict
215
314
  when 404 then raise NotFoundError.new(message, request_id: request_id, details: details)
216
315
  when 429 then raise RateLimitError.new(message, retry_after: retry_after, request_id: request_id, details: details)
217
316
  else
218
- if status >= 500
219
- raise ServerError.new(message, status_code: status, request_id: request_id, details: details)
220
- end
317
+ raise ServerError.new(message, status_code: status, request_id: request_id, details: details) if status >= 500
221
318
 
222
- raise Error.new(message, status_code: status, request_id: request_id, details: details)
319
+ raise Error.new(message, code: code, status_code: status, request_id: request_id, details: details)
223
320
  end
224
321
  end
225
322
 
226
323
  def resolve_source(source, options)
227
324
  filename = options.filename || "image.jpg"
228
- mime_type = options.mime_type || (options.respond_to?(:content_type) ? options.content_type : nil) || "image/jpeg"
325
+ explicit_mime = options.mime_type
229
326
  filesize = 0
230
327
  bytes = nil
231
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
+
232
333
  if source.respond_to?(:read)
233
334
  bytes = source.read
234
335
  filesize = bytes.bytesize
235
- 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?
236
338
  elsif source.is_a?(String)
237
- 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)
238
341
  filename = File.basename(source) if options.filename.nil?
239
342
  bytes = File.binread(source)
240
- filesize = bytes.bytesize
241
343
  else
242
344
  # Raw binary data
243
345
  bytes = source
244
- filesize = bytes.bytesize
245
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"
246
350
  end
247
351
 
248
352
  {
249
353
  filename: filename,
250
- mime_type: mime_type,
354
+ mime_type: explicit_mime || detect_mime(bytes) || extension_mime(filename) || "image/jpeg",
251
355
  filesize: filesize,
252
356
  bytes: bytes
253
357
  }
254
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
255
372
  end
256
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
@@ -2,7 +2,9 @@
2
2
 
3
3
  module SmallPict
4
4
  class Error < StandardError
5
- attr_reader :code, :status_code, :request_id, :details
5
+ # upgrade_url: dashboard page where the missing capacity is added
6
+ # (details.upgrade_url), or nil. The SDK never opens or fetches it.
7
+ attr_reader :code, :status_code, :request_id, :details, :upgrade_url
6
8
 
7
9
  def initialize(message = "", code: "INTERNAL_ERROR", status_code: nil, request_id: nil, details: nil)
8
10
  @raw_message = self.class.sanitize(message)
@@ -10,6 +12,8 @@ module SmallPict
10
12
  @status_code = status_code
11
13
  @request_id = request_id
12
14
  @details = details
15
+ url = details.is_a?(Hash) ? (details["upgrade_url"] || details[:upgrade_url]) : nil
16
+ @upgrade_url = url.is_a?(String) ? url : nil
13
17
  super(@raw_message)
14
18
  end
15
19
 
@@ -34,7 +38,8 @@ module SmallPict
34
38
  end
35
39
  end
36
40
 
37
- 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***")
38
43
  sanitized.gsub(/Bearer\s+[a-zA-Z0-9._-]+/i, "Bearer ***REDACTED***")
39
44
  end
40
45
  end
@@ -58,8 +63,13 @@ module SmallPict
58
63
  end
59
64
 
60
65
  class PermissionDeniedError < Error
66
+ # Modes the plan allows, from details.allowed_modes of 403 mode_not_in_plan; otherwise nil.
67
+ attr_reader :allowed_modes
68
+
61
69
  def initialize(message = "Permission denied for this resource", request_id: nil, details: nil)
62
70
  super(message, code: "FORBIDDEN", status_code: 403, request_id: request_id, details: details)
71
+ modes = details.is_a?(Hash) ? (details["allowed_modes"] || details[:allowed_modes]) : nil
72
+ @allowed_modes = modes.is_a?(Array) ? modes.grep(String) : nil
63
73
  end
64
74
  end
65
75
 
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmallPict
4
+ # Light client-side check of an upload folder. The server is the authority
5
+ # (400 invalid_folder): it lower-cases the value and enforces the full format.
6
+ module Folder
7
+ MAX_SEGMENTS = 3
8
+
9
+ def self.validate!(folder)
10
+ segments = folder.to_s.split("/", -1)
11
+ if segments.empty? || segments.any?(&:empty?) || segments.include?("..") || segments.size > MAX_SEGMENTS
12
+ raise ValidationError, "Invalid folder #{folder.inspect}: use 1-#{MAX_SEGMENTS} non-empty segments separated by '/', without '..'."
13
+ end
14
+
15
+ folder.to_s
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmallPict
4
+ # Compression modes for `optimize(mode: ...)`. The server checks the mode
5
+ # against the plan (403 mode_not_in_plan); the SDK does not.
6
+ module Mode
7
+ LOSSY = "lossy"
8
+ EXTREME = "extreme"
9
+ LOSSLESS = "lossless"
10
+ AUTO = "auto" # the server picks the quality per image from a visual score (CMP-07)
11
+ ALL = [LOSSY, EXTREME, LOSSLESS, AUTO].freeze
12
+ end
13
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmallPict
4
+ module Models
5
+ CapacityStorage = Data.define(:limit_bytes, :used_bytes, :base_bytes, :addon_blocks)
6
+ CapacityCdn = Data.define(:limit_bytes, :used_bytes, :status)
7
+ CapacityBooster = Data.define(:active, :wp_quota_bytes, :api_transforms, :cdn_bytes, :next_expiry)
8
+ CapacityExpiry = Data.define(:kind, :units, :expires_at)
9
+ CapacityLapse = Data.define(:stage, :delete_after)
10
+
11
+ # Read-only capacity of the key's product from GET /v1/quota. A member that
12
+ # does not apply to the product or plan is nil. manage_url is a plain string:
13
+ # the SDK never opens or fetches it; capacity is bought only in the dashboard.
14
+ Capacity = Data.define(:period_end, :storage, :cdn, :booster, :lapse, :manage_url) do
15
+ def self.from_hash(data)
16
+ return nil unless data.is_a?(Hash)
17
+
18
+ s = get(data, :storage)
19
+ cd = get(data, :cdn)
20
+ b = get(data, :booster) || {}
21
+ e = get(b, :next_expiry)
22
+ l = get(data, :lapse)
23
+ new(
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,
31
+ wp_quota_bytes: get(b, :wp_quota_bytes),
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))
36
+ ),
37
+ lapse: l && CapacityLapse.new(stage: get(l, :stage), delete_after: get(l, :delete_after)),
38
+ manage_url: get(data, :manage_url)
39
+ )
40
+ end
41
+
42
+ # String keys from JSON, symbol keys from callers; keeps false and nil as sent.
43
+ def self.get(hash, key)
44
+ hash.is_a?(Hash) ? hash.fetch(key.to_s) { hash[key] } : nil
45
+ end
46
+ end
47
+ end
48
+ end
@@ -4,7 +4,7 @@ module SmallPict
4
4
  module Models
5
5
  class JobStatusResult
6
6
  attr_reader :job_id, :status, :url, :format, :bytes_saved,
7
- :error, :created_at, :updated_at
7
+ :error, :created_at, :updated_at, :mode_applied, :quality_applied, :folder
8
8
 
9
9
  def initialize(attributes = {})
10
10
  @job_id = attributes[:job_id] || attributes["job_id"]
@@ -15,6 +15,10 @@ module SmallPict
15
15
  @error = attributes[:error] || attributes["error"]
16
16
  @created_at = attributes[:created_at] || attributes["created_at"]
17
17
  @updated_at = attributes[:updated_at] || attributes["updated_at"]
18
+ # Reported by the server; nil for jobs created before modes existed.
19
+ @mode_applied = attributes[:mode_applied] || attributes["mode_applied"]
20
+ @quality_applied = attributes[:quality_applied] || attributes["quality_applied"]
21
+ @folder = attributes[:folder] || attributes["folder"]
18
22
  end
19
23
  end
20
24
  end
@@ -3,17 +3,22 @@
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
- :lossless, :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
+ # mode: SmallPict::Mode::LOSSY (server default when omitted), EXTREME or LOSSLESS.
10
+ # quality: optional 1-100; omitted when nil so the server picks the default per mode.
11
+ # folder: optional upload folder, e.g. "products/shoes"; the server lower-cases it.
12
+ # lossless: deprecated, use `mode: SmallPict::Mode::LOSSLESS`; removal not before the next major.
9
13
  def initialize(
10
14
  format: "auto",
11
- quality: 80,
15
+ quality: nil,
12
16
  max_width: nil,
13
17
  max_height: nil,
14
18
  max_dimension: nil,
15
- fit: "cover",
16
- lossless: false,
19
+ mode: nil,
20
+ lossless: nil,
21
+ folder: nil,
17
22
  strip_metadata: true,
18
23
  filename: nil,
19
24
  mime_type: nil,
@@ -25,27 +30,42 @@ module SmallPict
25
30
  @max_width = max_width
26
31
  @max_height = max_height
27
32
  @max_dimension = max_dimension
28
- @fit = fit.to_s
29
33
  @lossless = lossless
34
+ @mode = resolve_mode(mode, lossless)
35
+ @folder = folder.nil? ? nil : Folder.validate!(folder)
30
36
  @strip_metadata = strip_metadata
31
37
  @filename = filename
32
38
  @mime_type = mime_type || content_type
33
- @content_type = content_type || mime_type
34
39
  @idempotency_key = idempotency_key
35
40
  end
36
41
 
37
42
  def to_h
38
43
  {
39
- format: @format,
40
- quality: @quality,
41
- max_width: @max_width,
42
- max_height: @max_height,
43
- max_dimension: @max_dimension,
44
- fit: @fit,
45
- lossless: @lossless,
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,
46
51
  strip_metadata: @strip_metadata
47
52
  }.compact
48
53
  end
54
+
55
+ private
56
+
57
+ # `lossless:` is deprecated: map it to `mode:` and never send it on the wire.
58
+ def resolve_mode(mode, lossless)
59
+ mode = mode&.to_s
60
+ unless lossless.nil?
61
+ Kernel.warn("SmallPict: `lossless:` is deprecated, use `mode: SmallPict::Mode::LOSSLESS`.",
62
+ category: :deprecated)
63
+ end
64
+ return mode unless lossless == true
65
+ return Mode::LOSSLESS if mode.nil? || mode == Mode::LOSSLESS
66
+
67
+ raise ValidationError, "`lossless: true` contradicts `mode: #{mode.inspect}`; set only `mode:`."
68
+ end
49
69
  end
50
70
  end
51
71
  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
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,9 +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
+ # Normalized (lower case) by the server; nil without a folder.
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"]
21
26
  end
22
27
 
23
28
  private
@@ -5,7 +5,7 @@ module SmallPict
5
5
  class QuotaResponse
6
6
  attr_reader :plan, :bytes_used, :quota_limit, :quota_percentage,
7
7
  :cdn_egress_used_bytes, :cdn_egress_quota_bytes,
8
- :active_keys_count, :active_sites_count
8
+ :active_keys_count, :active_sites_count, :capacity
9
9
 
10
10
  def initialize(attributes = {})
11
11
  @plan = attributes[:plan] || attributes["plan"] || "free"
@@ -16,6 +16,8 @@ module SmallPict
16
16
  @cdn_egress_quota_bytes = attributes[:cdn_egress_quota_bytes] || attributes["cdn_egress_quota_bytes"]
17
17
  @active_keys_count = attributes[:active_keys_count] || attributes["active_keys_count"]
18
18
  @active_sites_count = attributes[:active_sites_count] || attributes["active_sites_count"]
19
+ # nil when the server could not read capacity, and from older servers that omit it.
20
+ @capacity = Capacity.from_hash(attributes[:capacity] || attributes["capacity"])
19
21
  end
20
22
 
21
23
  private
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SmallPict
4
- VERSION = "0.0.6"
4
+ VERSION = "0.0.8"
5
5
  end
data/lib/smallpict.rb CHANGED
@@ -4,13 +4,15 @@ require_relative "smallpict/version"
4
4
  require_relative "smallpict/configuration"
5
5
  require_relative "smallpict/crypto"
6
6
  require_relative "smallpict/errors"
7
+ require_relative "smallpict/mode"
8
+ require_relative "smallpict/folder"
7
9
  require_relative "smallpict/models/optimize_options"
8
10
  require_relative "smallpict/models/optimize_result"
11
+ require_relative "smallpict/models/capacity"
9
12
  require_relative "smallpict/models/quota_response"
10
13
  require_relative "smallpict/models/purge_response"
11
14
  require_relative "smallpict/models/job_status_result"
12
15
  require_relative "smallpict/client"
13
- require_relative "smallpict/active_storage/service"
14
16
 
15
17
  module SmallPict
16
18
  class << self
@@ -20,6 +22,7 @@ module SmallPict
20
22
 
21
23
  def configure
22
24
  yield(configuration)
25
+ @default_client = nil # the next SmallPict.client picks up the new values
23
26
  end
24
27
 
25
28
  def reset_configuration!
@@ -39,8 +42,8 @@ module SmallPict
39
42
  client.get_quota
40
43
  end
41
44
 
42
- def purge_cdn(urls = [], purge_type: "url")
43
- client.purge_cdn(urls, purge_type: purge_type)
45
+ def purge_cdn(urls = [], purge_type: "url", folder: nil)
46
+ client.purge_cdn(urls, purge_type: purge_type, folder: folder)
44
47
  end
45
48
 
46
49
  def validate_key
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.6
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-10-13 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,11 +79,13 @@ 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
100
85
  - lib/smallpict/errors.rb
86
+ - lib/smallpict/folder.rb
87
+ - lib/smallpict/mode.rb
88
+ - lib/smallpict/models/capacity.rb
101
89
  - lib/smallpict/models/job_status_result.rb
102
90
  - lib/smallpict/models/optimize_options.rb
103
91
  - lib/smallpict/models/optimize_result.rb
@@ -111,6 +99,7 @@ metadata:
111
99
  homepage_uri: https://smallpict.app
112
100
  source_code_uri: https://github.com/smallPict/smallpict-ruby
113
101
  changelog_uri: https://github.com/smallPict/smallpict-ruby/blob/main/CHANGELOG.md
102
+ rubygems_mfa_required: 'true'
114
103
  post_install_message:
115
104
  rdoc_options: []
116
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