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 +4 -4
- data/CHANGELOG.md +64 -1
- data/README.md +69 -8
- data/lib/smallpict/client.rb +183 -66
- data/lib/smallpict/configuration.rb +11 -3
- data/lib/smallpict/errors.rb +12 -2
- data/lib/smallpict/folder.rb +18 -0
- data/lib/smallpict/mode.rb +13 -0
- data/lib/smallpict/models/capacity.rb +48 -0
- data/lib/smallpict/models/job_status_result.rb +5 -1
- data/lib/smallpict/models/optimize_options.rb +34 -14
- data/lib/smallpict/models/optimize_result.rb +8 -3
- data/lib/smallpict/models/quota_response.rb +3 -1
- data/lib/smallpict/version.rb +1 -1
- data/lib/smallpict.rb +6 -3
- metadata +13 -24
- data/lib/smallpict/active_storage/service.rb +0 -29
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 559f8ad84228a8b7beb0b3b10f3cd49192701ab548aa72602bf36e87011eb75b
|
|
4
|
+
data.tar.gz: b4965ff68dd978df9f8168a0c863baf3256fd6876276ad18c6df2debf363e881
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
[](https://rubygems.org/gems/smallpict)
|
|
6
6
|
[](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
|
-
-
|
|
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
|
|
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.
|
|
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"]
|
|
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
|
-
- **`
|
|
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
|
|
data/lib/smallpict/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
37
|
-
mime_type:
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
45
|
-
Models::OptimizeResult.new(res)
|
|
69
|
+
queued = request(:post, "/v1/optimize", payload, idempotency_key: opts.idempotency_key)
|
|
46
70
|
rescue QuotaExceededError => e
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
|
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"
|
|
122
|
-
"X-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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
9
|
-
@secret_key = ENV
|
|
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
|
data/lib/smallpict/errors.rb
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module SmallPict
|
|
4
4
|
class Error < StandardError
|
|
5
|
-
|
|
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
|
-
|
|
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,
|
|
7
|
-
:lossless, :strip_metadata, :filename, :mime_type, :
|
|
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:
|
|
15
|
+
quality: nil,
|
|
12
16
|
max_width: nil,
|
|
13
17
|
max_height: nil,
|
|
14
18
|
max_dimension: nil,
|
|
15
|
-
|
|
16
|
-
lossless:
|
|
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:
|
|
40
|
-
quality:
|
|
41
|
-
max_width:
|
|
42
|
-
max_height:
|
|
43
|
-
max_dimension:
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
:
|
|
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"] ||
|
|
19
|
-
|
|
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
|
data/lib/smallpict/version.rb
CHANGED
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.
|
|
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
|
+
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:
|
|
42
|
+
name: rubocop
|
|
57
43
|
requirement: !ruby/object:Gem::Requirement
|
|
58
44
|
requirements:
|
|
59
45
|
- - "~>"
|
|
60
46
|
- !ruby/object:Gem::Version
|
|
61
|
-
version: '
|
|
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: '
|
|
54
|
+
version: '1.50'
|
|
69
55
|
- !ruby/object:Gem::Dependency
|
|
70
|
-
name:
|
|
56
|
+
name: webmock
|
|
71
57
|
requirement: !ruby/object:Gem::Requirement
|
|
72
58
|
requirements:
|
|
73
59
|
- - "~>"
|
|
74
60
|
- !ruby/object:Gem::Version
|
|
75
|
-
version: '
|
|
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: '
|
|
68
|
+
version: '3.18'
|
|
83
69
|
description: High-performance next-gen image transcoding (AVIF, WebP), smart compression,
|
|
84
|
-
Edge CDN delivery
|
|
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
|