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 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
+ [![Gem Version](https://badge.fury.io/rb/smallpict.svg)](https://badge.fury.io/rb/smallpict)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
7
+ [![Ruby Version](https://img.shields.io/badge/ruby-%3E%3D3.0-ruby.svg)](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,5 @@
1
+ # Security Policy
2
+
3
+ Please report vulnerabilities directly to [security@smallpict.app](mailto:security@smallpict.app).
4
+
5
+ Do not log raw API keys, signatures, or full image payloads.
@@ -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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmallPict
4
+ module Models
5
+ class PurgeResponse
6
+ attr_reader :message
7
+
8
+ def initialize(attributes = {})
9
+ @message = attributes[:message] || attributes["message"] || "Purge accepted"
10
+ end
11
+ end
12
+ end
13
+ 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmallPict
4
+ VERSION = "0.0.1"
5
+ 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: []