smallpict 0.0.1
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 +7 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE +21 -0
- data/README.md +122 -0
- data/SECURITY.md +5 -0
- data/lib/smallpict/active_storage/service.rb +29 -0
- data/lib/smallpict/client.rb +255 -0
- data/lib/smallpict/configuration.rb +16 -0
- data/lib/smallpict/crypto.rb +26 -0
- data/lib/smallpict/errors.rb +98 -0
- data/lib/smallpict/models/job_status_result.rb +21 -0
- data/lib/smallpict/models/optimize_options.rb +46 -0
- data/lib/smallpict/models/optimize_result.rb +32 -0
- data/lib/smallpict/models/purge_response.rb +13 -0
- data/lib/smallpict/models/quota_response.rb +30 -0
- data/lib/smallpict/version.rb +5 -0
- data/lib/smallpict.rb +54 -0
- metadata +133 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 706bf93d2afc08096d18ce827d3aaa4fc90fad68e5da7da9a38ffd242a9050cd
|
|
4
|
+
data.tar.gz: bc5bef9512ab160df50f785d9989d7e53d92520d064d644849c81accc5a7b374
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 61276a0d74ab07c795ebfc666ea6cd0b4604c763c26226eba8b75833e31b11c6f898bff0e7ff3c4d86254366bf3c4050a4f78552e737422620dc622cb023792d
|
|
7
|
+
data.tar.gz: 858e4a55bc5a820198f19562e0827c7ea411c825acc9dff62db7230a3f138d4bd3dd0be99eba9b27686ead4a5357f8f11207c560d329aa6be6b246760bc43e15
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the `smallpict` gem will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [1.0.0] - 2026-08-22
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Official Ruby SDK implementation for SmallPict OpenAPI 3.1.0 API.
|
|
12
|
+
- Block-based global configuration (`SmallPict.configure { |c| ... }`) and standalone client instances (`SmallPict::Client.new`).
|
|
13
|
+
- Rails ActiveStorage Service adapter (`SmallPict::ActiveStorage::Service`).
|
|
14
|
+
- 4 unified core client methods: `optimize`, `get_quota`, `purge_cdn`, and `validate_key`.
|
|
15
|
+
- Helper `get_job_status` for polling asynchronous image conversion tasks.
|
|
16
|
+
- Faraday connection pooling with automatic retry and jitter on HTTP 429/5xx.
|
|
17
|
+
- Custom exception hierarchy with automatic secret redaction in `to_s` and `inspect`.
|
|
18
|
+
- Optional `:passthrough` fallback mode on quota limit exhaustion.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SmallPict Engineering <support@smallpict.app>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# SmallPict Ruby SDK
|
|
2
|
+
|
|
3
|
+
Official Ruby gem for the [SmallPict Image Optimization API](https://smallpict.app) — high-performance next-gen image transcoding (AVIF, WebP), smart compression, Edge CDN delivery, cache purging, and Rails ActiveStorage support.
|
|
4
|
+
|
|
5
|
+
[](https://badge.fury.io/rb/smallpict)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://www.ruby-lang.org)
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## ⚡ Features
|
|
12
|
+
|
|
13
|
+
- **💎 Idiomatic Ruby Design:** Block-based configuration (`SmallPict.configure`) and flexible standalone instances.
|
|
14
|
+
- **🚀 Rails ActiveStorage Ready:** Built-in service adapter (`SmallPict::ActiveStorage::Service`).
|
|
15
|
+
- **🛡️ Secure HMAC-SHA256 & Bearer Auth:** Tamper-proof payload verification.
|
|
16
|
+
- **✨ 4 Core Unified Methods:** `optimize()`, `get_quota()`, `purge_cdn()`, and `validate_key()`.
|
|
17
|
+
- **🔄 Resilience & Fault Tolerance:** Automatic `Idempotency-Key` UUID injection, 30s timeouts, and exponential backoff with jitter on HTTP 429/5xx.
|
|
18
|
+
- **🔒 Zero-Leak Privacy:** API keys and credentials are automatically redacted from `to_s` and `inspect` error logs.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 📥 Installation
|
|
23
|
+
|
|
24
|
+
Add to your application's `Gemfile`:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
gem "smallpict", "~> 1.0"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
And then execute:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
bundle install
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## 🚀 Quick Start
|
|
39
|
+
|
|
40
|
+
### 1. Global Block Configuration & Standalone Ruby
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
require "smallpict"
|
|
44
|
+
|
|
45
|
+
SmallPict.configure do |config|
|
|
46
|
+
config.api_key = ENV["SMALLPICT_API_KEY"]
|
|
47
|
+
config.secret_key = ENV["SMALLPICT_SECRET_KEY"] # Optional HMAC Secret Key
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
image_data = File.binread("hero-banner.png")
|
|
51
|
+
|
|
52
|
+
result = SmallPict.optimize(
|
|
53
|
+
image_data,
|
|
54
|
+
format: "avif",
|
|
55
|
+
quality: 80,
|
|
56
|
+
max_width: 1920
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
puts "Optimized CDN URL: #{result.url}"
|
|
60
|
+
puts "Saved: #{result.savings_percentage}% (#{result.bytes_saved} bytes)"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 2. Rails Initializer (`config/initializers/smallpict.rb`)
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
SmallPict.configure do |config|
|
|
67
|
+
config.api_key = Rails.application.credentials.dig(:smallpict, :api_key)
|
|
68
|
+
config.secret_key = Rails.application.credentials.dig(:smallpict, :secret_key)
|
|
69
|
+
config.fallback_mode = :passthrough # :throw | :passthrough
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 3. Rails Controller Example
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
class MediaController < ApplicationController
|
|
77
|
+
def create
|
|
78
|
+
uploaded_file = params[:image]
|
|
79
|
+
|
|
80
|
+
result = SmallPict.optimize(
|
|
81
|
+
uploaded_file.tempfile,
|
|
82
|
+
format: "auto",
|
|
83
|
+
quality: 85
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
render json: {
|
|
87
|
+
url: result.url,
|
|
88
|
+
format: result.format,
|
|
89
|
+
savings: "#{result.savings_percentage}%"
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## 📊 Account Quota & Edge CDN Purge
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
# 1. Check real-time quota usage
|
|
101
|
+
quota = SmallPict.get_quota
|
|
102
|
+
puts "Plan: #{quota.plan}, Quota Used: #{quota.quota_percentage}%"
|
|
103
|
+
|
|
104
|
+
# 2. Invalidate CDN cache for updated images
|
|
105
|
+
purge = SmallPict.purge_cdn(["https://cdn.smallpict.app/opt/hero-banner.avif"])
|
|
106
|
+
puts purge.message
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## 🧪 Testing
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
bundle exec rspec
|
|
115
|
+
bundle exec rubocop
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## 📄 License
|
|
121
|
+
|
|
122
|
+
MIT © [SmallPict Engineering](https://smallpict.app)
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
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
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module SmallPict
|
|
8
|
+
class Client
|
|
9
|
+
attr_reader :api_key, :secret_key, :base_url, :timeout, :max_retries, :fallback_mode
|
|
10
|
+
|
|
11
|
+
def initialize(
|
|
12
|
+
api_key: nil,
|
|
13
|
+
secret_key: nil,
|
|
14
|
+
base_url: nil,
|
|
15
|
+
timeout: nil,
|
|
16
|
+
max_retries: nil,
|
|
17
|
+
fallback_mode: nil
|
|
18
|
+
)
|
|
19
|
+
config = SmallPict.configuration
|
|
20
|
+
|
|
21
|
+
@api_key = api_key || config.api_key
|
|
22
|
+
@secret_key = secret_key || config.secret_key
|
|
23
|
+
@base_url = (base_url || config.base_url).chomp("/")
|
|
24
|
+
@timeout = (timeout || config.timeout).to_i
|
|
25
|
+
@max_retries = (max_retries || config.max_retries).to_i
|
|
26
|
+
@fallback_mode = (fallback_mode || config.fallback_mode).to_sym
|
|
27
|
+
|
|
28
|
+
validate_configuration!
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def optimize(source, options = {})
|
|
32
|
+
opts = options.is_a?(Models::OptimizeOptions) ? options : Models::OptimizeOptions.new(**options)
|
|
33
|
+
resolved = resolve_source(source, opts)
|
|
34
|
+
|
|
35
|
+
payload = {
|
|
36
|
+
filename: resolved[:filename],
|
|
37
|
+
mime_type: resolved[:mime_type],
|
|
38
|
+
filesize: resolved[:filesize],
|
|
39
|
+
options: opts.to_h
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
begin
|
|
43
|
+
res = request(:post, "/v1/optimize", payload, idempotency_key: opts.idempotency_key)
|
|
44
|
+
Models::OptimizeResult.new(res)
|
|
45
|
+
rescue QuotaExceededError => e
|
|
46
|
+
if @fallback_mode == :passthrough
|
|
47
|
+
format_str = resolved[:mime_type].to_s.sub("image/", "")
|
|
48
|
+
Models::OptimizeResult.new(
|
|
49
|
+
job_id: "fallback-passthrough",
|
|
50
|
+
status: "completed",
|
|
51
|
+
url: "",
|
|
52
|
+
format: format_str,
|
|
53
|
+
original_size: resolved[:filesize],
|
|
54
|
+
compressed_size: resolved[:filesize],
|
|
55
|
+
bytes_saved: 0,
|
|
56
|
+
savings_percentage: 0.0,
|
|
57
|
+
data: resolved[:bytes]
|
|
58
|
+
)
|
|
59
|
+
else
|
|
60
|
+
raise e
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def get_quota
|
|
66
|
+
res = request(:get, "/v1/quota")
|
|
67
|
+
Models::QuotaResponse.new(res)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def purge_cdn(urls = [], purge_type: "url")
|
|
71
|
+
url_list = urls.is_a?(Array) ? urls : [urls]
|
|
72
|
+
payload = {
|
|
73
|
+
purge_type: purge_type.to_s,
|
|
74
|
+
urls: url_list
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
res = request(:post, "/v1/purge", payload)
|
|
78
|
+
Models::PurgeResponse.new(res)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def validate_key
|
|
82
|
+
get_quota
|
|
83
|
+
true
|
|
84
|
+
rescue StandardError
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def get_job_status(job_id)
|
|
89
|
+
raise ValidationError, "job_id is required" if job_id.nil? || job_id.to_s.empty?
|
|
90
|
+
|
|
91
|
+
res = request(:get, "/v1/optimize/status?job_id=#{job_id}")
|
|
92
|
+
Models::JobStatusResult.new(res)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def validate_configuration!
|
|
98
|
+
if @api_key.nil? || @api_key.to_s.strip.empty?
|
|
99
|
+
raise ValidationError, "Missing required SmallPict API key. Provide `api_key:` or configure via `SmallPict.configure`."
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def request(method, path, body = nil, idempotency_key: nil)
|
|
104
|
+
clean_path = path.start_with?("/") ? path : "/#{path}"
|
|
105
|
+
clean_path = "/v1#{clean_path}" unless clean_path.start_with?("/v1/") || clean_path.start_with?("/v2/")
|
|
106
|
+
|
|
107
|
+
url = "#{@base_url}#{clean_path}"
|
|
108
|
+
body_str = body ? JSON.generate(body) : nil
|
|
109
|
+
body_hash = body_str ? Crypto.sha256_hex(body_str) : Crypto::EMPTY_SHA256
|
|
110
|
+
|
|
111
|
+
attempt = 0
|
|
112
|
+
base_delay = 0.25 # 250ms
|
|
113
|
+
|
|
114
|
+
while attempt <= @max_retries
|
|
115
|
+
attempt += 1
|
|
116
|
+
begin
|
|
117
|
+
timestamp = Time.now.to_i.to_s
|
|
118
|
+
|
|
119
|
+
headers = {
|
|
120
|
+
"Accept" => "application/json",
|
|
121
|
+
"X-API-Key" => @api_key
|
|
122
|
+
}
|
|
123
|
+
headers["Content-Type"] = "application/json" if body_str
|
|
124
|
+
|
|
125
|
+
if @secret_key
|
|
126
|
+
string_to_sign = Crypto.build_string_to_sign(method, clean_path, timestamp, body_hash)
|
|
127
|
+
signature = Crypto.hmac_sha256_hex(@secret_key, string_to_sign)
|
|
128
|
+
headers["X-Timestamp"] = timestamp
|
|
129
|
+
headers["X-Signature"] = signature
|
|
130
|
+
else
|
|
131
|
+
headers["Authorization"] = "Bearer #{@api_key}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
if %i[post patch delete].include?(method.to_s.downcase.to_sym)
|
|
135
|
+
headers["Idempotency-Key"] = idempotency_key || SecureRandom.uuid
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
response = connection.send(method.to_s.downcase.to_sym, url) do |req|
|
|
139
|
+
req.headers = headers
|
|
140
|
+
req.body = body_str if body_str
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
status = response.status
|
|
144
|
+
request_id = response.headers["x-request-id"]
|
|
145
|
+
retry_after = response.headers["retry-after"]&.to_i
|
|
146
|
+
|
|
147
|
+
if status == 429 || (status >= 500 && status <= 504)
|
|
148
|
+
if attempt <= @max_retries
|
|
149
|
+
delay = base_delay * (2**(attempt - 1))
|
|
150
|
+
delay = retry_after if retry_after && retry_after.positive?
|
|
151
|
+
jitter = rand(0..100) / 1000.0
|
|
152
|
+
sleep(delay + jitter)
|
|
153
|
+
next
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
parsed = parse_body(response.body)
|
|
158
|
+
|
|
159
|
+
if status < 200 || status >= 300
|
|
160
|
+
handle_error_response(status, parsed, request_id, retry_after)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
return parsed
|
|
164
|
+
rescue Faraday::TimeoutError => e
|
|
165
|
+
raise TimeoutError, e.message
|
|
166
|
+
rescue Faraday::ConnectionFailed => e
|
|
167
|
+
if attempt <= @max_retries
|
|
168
|
+
sleep(base_delay * (2**(attempt - 1)))
|
|
169
|
+
next
|
|
170
|
+
end
|
|
171
|
+
raise NetworkError, e.message
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
raise TimeoutError, "Request failed after maximum retry attempts"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def connection
|
|
179
|
+
@connection ||= Faraday.new do |f|
|
|
180
|
+
f.options.timeout = @timeout
|
|
181
|
+
f.options.open_timeout = @timeout
|
|
182
|
+
f.adapter Faraday.default_adapter
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def parse_body(body_str)
|
|
187
|
+
return {} if body_str.nil? || body_str.empty?
|
|
188
|
+
|
|
189
|
+
JSON.parse(body_str)
|
|
190
|
+
rescue JSON::ParserError
|
|
191
|
+
{ "raw" => body_str }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def handle_error_response(status, body, request_id, retry_after)
|
|
195
|
+
message = "API request failed with HTTP #{status}"
|
|
196
|
+
details = nil
|
|
197
|
+
|
|
198
|
+
if body.is_a?(Hash)
|
|
199
|
+
if body["error"].is_a?(Hash)
|
|
200
|
+
message = body["error"]["message"] || message
|
|
201
|
+
details = body["error"]["details"]
|
|
202
|
+
elsif body["error"].is_a?(String)
|
|
203
|
+
message = body["error"]
|
|
204
|
+
elsif body["message"].is_a?(String)
|
|
205
|
+
message = body["message"]
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
case status
|
|
210
|
+
when 400 then raise ValidationError.new(message, request_id: request_id, details: details)
|
|
211
|
+
when 401 then raise AuthenticationError.new(message, request_id: request_id, details: details)
|
|
212
|
+
when 402 then raise QuotaExceededError.new(message, request_id: request_id, details: details)
|
|
213
|
+
when 403 then raise PermissionDeniedError.new(message, request_id: request_id, details: details)
|
|
214
|
+
when 404 then raise NotFoundError.new(message, request_id: request_id, details: details)
|
|
215
|
+
when 429 then raise RateLimitError.new(message, retry_after: retry_after, request_id: request_id, details: details)
|
|
216
|
+
else
|
|
217
|
+
if status >= 500
|
|
218
|
+
raise ServerError.new(message, status_code: status, request_id: request_id, details: details)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
raise Error.new(message, status_code: status, request_id: request_id, details: details)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def resolve_source(source, options)
|
|
226
|
+
filename = options.filename || "image.jpg"
|
|
227
|
+
mime_type = options.mime_type || "image/jpeg"
|
|
228
|
+
filesize = 0
|
|
229
|
+
bytes = nil
|
|
230
|
+
|
|
231
|
+
if source.respond_to?(:read)
|
|
232
|
+
bytes = source.read
|
|
233
|
+
filesize = bytes.bytesize
|
|
234
|
+
filename = File.basename(source.path) if source.respond_to?(:path) && options.filename.nil?
|
|
235
|
+
elsif source.is_a?(String)
|
|
236
|
+
if File.exist?(source) && File.file?(source)
|
|
237
|
+
filename = File.basename(source) if options.filename.nil?
|
|
238
|
+
bytes = File.binread(source)
|
|
239
|
+
filesize = bytes.bytesize
|
|
240
|
+
else
|
|
241
|
+
# Raw binary data
|
|
242
|
+
bytes = source
|
|
243
|
+
filesize = bytes.bytesize
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
{
|
|
248
|
+
filename: filename,
|
|
249
|
+
mime_type: mime_type,
|
|
250
|
+
filesize: filesize,
|
|
251
|
+
bytes: bytes
|
|
252
|
+
}
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmallPict
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :api_key, :secret_key, :base_url, :timeout, :max_retries, :fallback_mode
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@api_key = ENV["SMALLPICT_API_KEY"]
|
|
9
|
+
@secret_key = ENV["SMALLPICT_SECRET_KEY"]
|
|
10
|
+
@base_url = (ENV["SMALLPICT_BASE_URL"] || "https://api.smallpict.app").chomp("/")
|
|
11
|
+
@timeout = 30
|
|
12
|
+
@max_retries = 3
|
|
13
|
+
@fallback_mode = :throw
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module SmallPict
|
|
6
|
+
module Crypto
|
|
7
|
+
EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def sha256_hex(data)
|
|
12
|
+
return EMPTY_SHA256 if data.nil? || data.empty?
|
|
13
|
+
|
|
14
|
+
OpenSSL::Digest::SHA256.hexdigest(data)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def hmac_sha256_hex(secret_key, string_to_sign)
|
|
18
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret_key.to_s, string_to_sign.to_s)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def build_string_to_sign(method, path, timestamp, body_hash)
|
|
22
|
+
clean_path = path.start_with?("/") ? path : "/#{path}"
|
|
23
|
+
"#{method.to_s.upcase}\n#{clean_path}\n#{timestamp}\n#{body_hash}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmallPict
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
attr_reader :code, :status_code, :request_id, :details
|
|
6
|
+
|
|
7
|
+
def initialize(message = "", code: "INTERNAL_ERROR", status_code: nil, request_id: nil, details: nil)
|
|
8
|
+
@raw_message = self.class.sanitize(message)
|
|
9
|
+
@code = code
|
|
10
|
+
@status_code = status_code
|
|
11
|
+
@request_id = request_id
|
|
12
|
+
@details = details
|
|
13
|
+
super(@raw_message)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def to_s
|
|
17
|
+
status_str = @status_code ? " HTTP #{@status_code}" : ""
|
|
18
|
+
req_str = @request_id ? " (Request ID: #{@request_id})" : ""
|
|
19
|
+
"[#{@code}#{status_str}]: #{@raw_message}#{req_str}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def inspect
|
|
23
|
+
"#<#{self.class.name} code=#{@code.inspect} status_code=#{@status_code.inspect} message=#{@raw_message.inspect}>"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.sanitize(msg)
|
|
27
|
+
return "" if msg.nil? || msg.empty?
|
|
28
|
+
|
|
29
|
+
sanitized = msg.to_s.gsub(/sp_(live|test|sdk|wp)_[a-zA-Z0-9_-]{10,}/) do |match|
|
|
30
|
+
if match.length > 14
|
|
31
|
+
"#{match[0..9]}...#{match[-4..]}"
|
|
32
|
+
else
|
|
33
|
+
"#{match[0..5]}..."
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
sanitized = sanitized.gsub(/(sec|secret)_[a-zA-Z0-9_-]{8,}/i, "***REDACTED***")
|
|
38
|
+
sanitized.gsub(/Bearer\s+[a-zA-Z0-9._-]+/i, "Bearer ***REDACTED***")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class ValidationError < Error
|
|
43
|
+
def initialize(message = "Validation failed", request_id: nil, details: nil)
|
|
44
|
+
super(message, code: "VALIDATION_FAILED", status_code: 400, request_id: request_id, details: details)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class AuthenticationError < Error
|
|
49
|
+
def initialize(message = "Authentication failed", request_id: nil, details: nil)
|
|
50
|
+
super(message, code: "UNAUTHORIZED", status_code: 401, request_id: request_id, details: details)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class QuotaExceededError < Error
|
|
55
|
+
def initialize(message = "Storage or optimization quota exceeded", request_id: nil, details: nil)
|
|
56
|
+
super(message, code: "QUOTA_EXCEEDED", status_code: 402, request_id: request_id, details: details)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class PermissionDeniedError < Error
|
|
61
|
+
def initialize(message = "Permission denied for this resource", request_id: nil, details: nil)
|
|
62
|
+
super(message, code: "FORBIDDEN", status_code: 403, request_id: request_id, details: details)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
class NotFoundError < Error
|
|
67
|
+
def initialize(message = "Resource or job ID not found", request_id: nil, details: nil)
|
|
68
|
+
super(message, code: "NOT_FOUND", status_code: 404, request_id: request_id, details: details)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class RateLimitError < Error
|
|
73
|
+
attr_reader :retry_after
|
|
74
|
+
|
|
75
|
+
def initialize(message = "Rate limit exceeded", retry_after: nil, request_id: nil, details: nil)
|
|
76
|
+
super(message, code: "RATE_LIMIT_EXCEEDED", status_code: 429, request_id: request_id, details: details)
|
|
77
|
+
@retry_after = retry_after
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
class ServerError < Error
|
|
82
|
+
def initialize(message = "Internal server error occurred", status_code: 500, request_id: nil, details: nil)
|
|
83
|
+
super(message, code: "INTERNAL_ERROR", status_code: status_code, request_id: request_id, details: details)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class TimeoutError < Error
|
|
88
|
+
def initialize(message = "Request timed out after maximum duration", request_id: nil, details: nil)
|
|
89
|
+
super(message, code: "TIMEOUT_ERROR", status_code: 408, request_id: request_id, details: details)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
class NetworkError < Error
|
|
94
|
+
def initialize(message = "Network communication error", details: nil)
|
|
95
|
+
super(message, code: "NETWORK_ERROR", status_code: nil, request_id: nil, details: details)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmallPict
|
|
4
|
+
module Models
|
|
5
|
+
class JobStatusResult
|
|
6
|
+
attr_reader :job_id, :status, :url, :format, :bytes_saved,
|
|
7
|
+
:error, :created_at, :updated_at
|
|
8
|
+
|
|
9
|
+
def initialize(attributes = {})
|
|
10
|
+
@job_id = attributes[:job_id] || attributes["job_id"]
|
|
11
|
+
@status = attributes[:status] || attributes["status"] || "processing"
|
|
12
|
+
@url = attributes[:url] || attributes["url"]
|
|
13
|
+
@format = attributes[:format] || attributes["format"]
|
|
14
|
+
@bytes_saved = attributes[:bytes_saved] || attributes["bytes_saved"]
|
|
15
|
+
@error = attributes[:error] || attributes["error"]
|
|
16
|
+
@created_at = attributes[:created_at] || attributes["created_at"]
|
|
17
|
+
@updated_at = attributes[:updated_at] || attributes["updated_at"]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmallPict
|
|
4
|
+
module Models
|
|
5
|
+
class OptimizeOptions
|
|
6
|
+
attr_reader :format, :quality, :max_width, :max_height, :fit,
|
|
7
|
+
:lossless, :strip_metadata, :filename, :mime_type, :idempotency_key
|
|
8
|
+
|
|
9
|
+
def initialize(
|
|
10
|
+
format: "auto",
|
|
11
|
+
quality: 80,
|
|
12
|
+
max_width: nil,
|
|
13
|
+
max_height: nil,
|
|
14
|
+
fit: "cover",
|
|
15
|
+
lossless: false,
|
|
16
|
+
strip_metadata: true,
|
|
17
|
+
filename: nil,
|
|
18
|
+
mime_type: nil,
|
|
19
|
+
idempotency_key: nil
|
|
20
|
+
)
|
|
21
|
+
@format = format.to_s
|
|
22
|
+
@quality = quality&.clamp(1, 100)
|
|
23
|
+
@max_width = max_width
|
|
24
|
+
@max_height = max_height
|
|
25
|
+
@fit = fit.to_s
|
|
26
|
+
@lossless = lossless
|
|
27
|
+
@strip_metadata = strip_metadata
|
|
28
|
+
@filename = filename
|
|
29
|
+
@mime_type = mime_type
|
|
30
|
+
@idempotency_key = idempotency_key
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_h
|
|
34
|
+
{
|
|
35
|
+
format: @format,
|
|
36
|
+
quality: @quality,
|
|
37
|
+
max_width: @max_width,
|
|
38
|
+
max_height: @max_height,
|
|
39
|
+
fit: @fit,
|
|
40
|
+
lossless: @lossless,
|
|
41
|
+
strip_metadata: @strip_metadata
|
|
42
|
+
}.compact
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmallPict
|
|
4
|
+
module Models
|
|
5
|
+
class OptimizeResult
|
|
6
|
+
attr_reader :job_id, :status, :url, :format, :original_size,
|
|
7
|
+
:compressed_size, :bytes_saved, :savings_percentage,
|
|
8
|
+
:upload_url, :data
|
|
9
|
+
|
|
10
|
+
def initialize(attributes = {})
|
|
11
|
+
@job_id = attributes[:job_id] || attributes["job_id"] || "sync"
|
|
12
|
+
@status = attributes[:status] || attributes["status"] || "completed"
|
|
13
|
+
@url = attributes[:url] || attributes["url"] || ""
|
|
14
|
+
@format = attributes[:format] || attributes["format"] || "auto"
|
|
15
|
+
@original_size = (attributes[:original_size] || attributes["original_size"] || 0).to_i
|
|
16
|
+
@compressed_size = (attributes[:compressed_size] || attributes["compressed_size"] || @original_size).to_i
|
|
17
|
+
@bytes_saved = (attributes[:bytes_saved] || attributes["bytes_saved"] || [@original_size - @compressed_size, 0].max).to_i
|
|
18
|
+
@savings_percentage = (attributes[:savings_percentage] || attributes["savings_percentage"] || calculate_savings_percentage).to_f.round(2)
|
|
19
|
+
@upload_url = attributes[:upload_url] || attributes["upload_url"]
|
|
20
|
+
@data = attributes[:data]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def calculate_savings_percentage
|
|
26
|
+
return 0.0 if @original_size.zero?
|
|
27
|
+
|
|
28
|
+
((@bytes_saved.to_f / @original_size) * 100.0).round(2)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmallPict
|
|
4
|
+
module Models
|
|
5
|
+
class QuotaResponse
|
|
6
|
+
attr_reader :plan, :bytes_used, :quota_limit, :quota_percentage,
|
|
7
|
+
:cdn_egress_used_bytes, :cdn_egress_quota_bytes,
|
|
8
|
+
:active_keys_count, :active_sites_count
|
|
9
|
+
|
|
10
|
+
def initialize(attributes = {})
|
|
11
|
+
@plan = attributes[:plan] || attributes["plan"] || "free"
|
|
12
|
+
@bytes_used = (attributes[:bytes_used] || attributes["bytes_used"] || 0).to_i
|
|
13
|
+
@quota_limit = (attributes[:quota_limit] || attributes["quota_limit"] || 0).to_i
|
|
14
|
+
@quota_percentage = (attributes[:quota_percentage] || attributes["quota_percentage"] || calculate_percentage).to_f.round(2)
|
|
15
|
+
@cdn_egress_used_bytes = attributes[:cdn_egress_used_bytes] || attributes["cdn_egress_used_bytes"]
|
|
16
|
+
@cdn_egress_quota_bytes = attributes[:cdn_egress_quota_bytes] || attributes["cdn_egress_quota_bytes"]
|
|
17
|
+
@active_keys_count = attributes[:active_keys_count] || attributes["active_keys_count"]
|
|
18
|
+
@active_sites_count = attributes[:active_sites_count] || attributes["active_sites_count"]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def calculate_percentage
|
|
24
|
+
return 0.0 if @quota_limit.zero?
|
|
25
|
+
|
|
26
|
+
((@bytes_used.to_f / @quota_limit) * 100.0).round(2)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/smallpict.rb
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "smallpict/version"
|
|
4
|
+
require_relative "smallpict/configuration"
|
|
5
|
+
require_relative "smallpict/crypto"
|
|
6
|
+
require_relative "smallpict/errors"
|
|
7
|
+
require_relative "smallpict/models/optimize_options"
|
|
8
|
+
require_relative "smallpict/models/optimize_result"
|
|
9
|
+
require_relative "smallpict/models/quota_response"
|
|
10
|
+
require_relative "smallpict/models/purge_response"
|
|
11
|
+
require_relative "smallpict/models/job_status_result"
|
|
12
|
+
require_relative "smallpict/client"
|
|
13
|
+
require_relative "smallpict/active_storage/service"
|
|
14
|
+
|
|
15
|
+
module SmallPict
|
|
16
|
+
class << self
|
|
17
|
+
def configuration
|
|
18
|
+
@configuration ||= Configuration.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def configure
|
|
22
|
+
yield(configuration)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def reset_configuration!
|
|
26
|
+
@configuration = Configuration.new
|
|
27
|
+
@default_client = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def client
|
|
31
|
+
@default_client ||= Client.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def optimize(source, options = {})
|
|
35
|
+
client.optimize(source, options)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def get_quota
|
|
39
|
+
client.get_quota
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def purge_cdn(urls = [], purge_type: "url")
|
|
43
|
+
client.purge_cdn(urls, purge_type: purge_type)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate_key
|
|
47
|
+
client.validate_key
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def get_job_status(job_id)
|
|
51
|
+
client.get_job_status(job_id)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: smallpict
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- SmallPict Engineering
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-09-01 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: faraday
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
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
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rspec
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '3.12'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '3.12'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: webmock
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '3.18'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '3.18'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: rubocop
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '1.50'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '1.50'
|
|
83
|
+
description: High-performance next-gen image transcoding (AVIF, WebP), smart compression,
|
|
84
|
+
Edge CDN delivery, and Rails ActiveStorage integration.
|
|
85
|
+
email:
|
|
86
|
+
- support@smallpict.app
|
|
87
|
+
executables: []
|
|
88
|
+
extensions: []
|
|
89
|
+
extra_rdoc_files: []
|
|
90
|
+
files:
|
|
91
|
+
- CHANGELOG.md
|
|
92
|
+
- LICENSE
|
|
93
|
+
- README.md
|
|
94
|
+
- SECURITY.md
|
|
95
|
+
- lib/smallpict.rb
|
|
96
|
+
- lib/smallpict/active_storage/service.rb
|
|
97
|
+
- lib/smallpict/client.rb
|
|
98
|
+
- lib/smallpict/configuration.rb
|
|
99
|
+
- lib/smallpict/crypto.rb
|
|
100
|
+
- lib/smallpict/errors.rb
|
|
101
|
+
- lib/smallpict/models/job_status_result.rb
|
|
102
|
+
- lib/smallpict/models/optimize_options.rb
|
|
103
|
+
- lib/smallpict/models/optimize_result.rb
|
|
104
|
+
- lib/smallpict/models/purge_response.rb
|
|
105
|
+
- lib/smallpict/models/quota_response.rb
|
|
106
|
+
- lib/smallpict/version.rb
|
|
107
|
+
homepage: https://smallpict.app
|
|
108
|
+
licenses:
|
|
109
|
+
- MIT
|
|
110
|
+
metadata:
|
|
111
|
+
homepage_uri: https://smallpict.app
|
|
112
|
+
source_code_uri: https://github.com/tuxnoob/smallpict-ruby
|
|
113
|
+
changelog_uri: https://github.com/tuxnoob/smallpict-ruby/blob/main/CHANGELOG.md
|
|
114
|
+
post_install_message:
|
|
115
|
+
rdoc_options: []
|
|
116
|
+
require_paths:
|
|
117
|
+
- lib
|
|
118
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
119
|
+
requirements:
|
|
120
|
+
- - ">="
|
|
121
|
+
- !ruby/object:Gem::Version
|
|
122
|
+
version: 3.0.0
|
|
123
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
124
|
+
requirements:
|
|
125
|
+
- - ">="
|
|
126
|
+
- !ruby/object:Gem::Version
|
|
127
|
+
version: '0'
|
|
128
|
+
requirements: []
|
|
129
|
+
rubygems_version: 3.4.19
|
|
130
|
+
signing_key:
|
|
131
|
+
specification_version: 4
|
|
132
|
+
summary: Official Ruby SDK for SmallPict Image Optimization API
|
|
133
|
+
test_files: []
|