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