kompressr-sdk 0.1.0

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: 6105efcaa3539d479fb065278d443dc2be27ca97a39ca1099be93dd5b74834d1
4
+ data.tar.gz: a4acb4f82539d245b315a7966b6370c0d9e794f64056c54e6374e8f59fbe04b8
5
+ SHA512:
6
+ metadata.gz: c488eee0f85d32ccb024694c74a40119c5b974205b22e89b9e0cf1ade554cf68dc45092f735939143d296ae5a88bf1ef056f04d0696d54b0a5982748eda0ab79
7
+ data.tar.gz: 5accd48a87e135c7a71e64a7a25c3bd9b2ed7f2d41e2e57753e8ac9971f3c3640238d70bafdcfa9a626f8932438850c5bee42ce1bbda808ff91d7dd0c97f731e
data/API.md ADDED
@@ -0,0 +1,120 @@
1
+ # Ruby API reference
2
+
3
+ Require `kompressr`. Public classes are `Kompressr::Client`, `Kompressr::PlatformClient`, `Kompressr::CancellationToken`, `Kompressr::Error` and `Kompressr::IssuedKey`. All methods are synchronous. Responses use string-keyed hashes, except key creation/rotation, which return `IssuedKey`. Unknown response fields are dropped; field matching is case-sensitive at every level.
4
+
5
+ ## Configuration
6
+
7
+ ```ruby
8
+ files = Kompressr::Client.new(
9
+ api_key: ENV.fetch('KOMPRESSR_API_KEY'),
10
+ project_id: ENV.fetch('KOMPRESSR_PROJECT_ID'), # Needed only for upload listing.
11
+ request_timeout: 30,
12
+ upload_timeout: 600,
13
+ max_read_retries: 2
14
+ )
15
+ ```
16
+
17
+ | Keyword | Default | Meaning |
18
+ | --- | --- | --- |
19
+ | `api_key` | Required | Supplied project or scoped credential; no fallback credentials |
20
+ | `project_id` | Omitted | File client only; UUID default for upload listing |
21
+ | `api_base_url` | `https://kompressr.com` | Management HTTP(S) origin |
22
+ | `upload_base_url` | Derived | Production uses `https://uploads.kompressr.com`; a custom management origin is also used for uploads unless overridden |
23
+ | `request_timeout` | `30` | Management request deadline, in seconds |
24
+ | `upload_timeout` | `600` | Upload request deadline, in seconds |
25
+ | `max_read_retries` | `2` | Additional GET attempts, integer 0–5 |
26
+
27
+ Origins cannot contain credentials, a path other than `/`, query, fragment or IPv6 zone identifier. HTTP supports local testing; production defaults use HTTPS. TLS verifies the trust chain and original hostname and sends that hostname as SNI. The standard OpenSSL CA store is used. Clients expose resolved `api_base_url`, `upload_base_url` and `platform`; credentials are redacted from inspection. Constructor strings are copied where they are retained.
28
+
29
+ Individual HTTP methods accept `timeout: nil` and `cancel: nil`. A supplied timeout must be positive, finite seconds no greater than 2,147,483.647; `nil` selects the configured budget. `wait_until_ready` has its own overall timeout, described below. `false` is invalid. A cancel value must be a `CancellationToken`. Cancellation is cooperative and observed approximately every 10 ms while waiting for network activity, admission or retry delay. Normal OS thread scheduling can add latency.
30
+
31
+ ## Uploads and delivery
32
+
33
+ | Method | Required positional arguments | Result |
34
+ | --- | --- | --- |
35
+ | `upload_file` | Local regular-file path, String or Pathname | Accepted upload |
36
+ | `upload_bytes` | Binary String, filename | Accepted upload |
37
+ | `get_upload_status` | Asset ID | Upload asset |
38
+ | `wait_until_ready` | Asset ID | Processed, active and deliverable upload asset |
39
+ | `generate_private_url` | Asset ID | Private link |
40
+
41
+ Upload keywords: `visibility: 'public'|'private'`, `quality: '480p'|'720p'|'1080p'|'4k'`, `data:`, `private_url_expires_in:`, `timeout:` and `cancel:`. Omitted visibility uses the server default. Quality applies to video. Private URL lifetime is an integer from 60 through 604800 seconds. Multipart fields precede the file. Filenames contain valid UTF-8, no control characters, and at most 4096 bytes; directory components are removed. Raw file content is binary and has no UTF-8 requirement.
42
+
43
+ `upload_file` opens/closes its own file and checks that it is regular before and after opening. A nonblocking open prevents a raced FIFO replacement from blocking at open. It streams 64 KiB chunks and detects a source that ends before the initial length. Keep the file unchanged during upload. `upload_bytes` snapshots its String before dispatch; supplying a large String therefore uses memory proportional to the bytes already supplied. Arbitrary IO objects, streams, sockets, devices and pipes are unsupported. There is no `upload_stream` method in this release.
44
+
45
+ Metadata is a String-keyed Hash of at most five scalars: String, finite Integer/Float, boolean or nil. Keys are trimmed, unique after trimming, nonempty and at most 64 UTF-8 bytes. String values allow 512 bytes. Encoded upload metadata allows 4096 bytes; platform metadata allows 8192. `nil` scalar values are preserved. UTF-8 String values and binary Strings containing valid UTF-8 bytes are accepted; incompatible encodings must be converted by the caller.
46
+
47
+ An accepted upload requires `id`, `status`, `deletion_status`, `visibility`, `original_name`, `original_size`, `asset_type`. Wire `asset_id` is normalized to `id`. Optional fields include `cdn_url`, `cdn_url_avif`, `private_url`, `private_url_expires_at`, `data`, `external_id`, `options`, `quality`, `warning`, `deletion_requested_at`.
48
+
49
+ Upload status adds `kompressd_size`, `savings_percent`, `can_generate_private_url`; `original_name` can be absent/empty for older records. Optional `created_at`, `input_format` and `output_format` are retained. Sizes are bytes. Processing states: pending, processing, processed, failed, unprocessable, unknown. Deletion states: active, deleting, deleted, legacy_deleted. Private assets cannot contain public delivery URLs; nonactive assets cannot contain delivery URLs.
50
+
51
+ `wait_until_ready(asset_id, timeout: 120, poll_interval: 2, cancel: nil)` polls within one overall deadline; interval must be positive and at most 30 seconds. Failed, unprocessable and unknown states raise `PROCESSING_FAILED`; nonactive deletion raises `ASSET_DELETED`. A processed public asset without `cdn_url`, or processed private asset without `can_generate_private_url: true`, raises `INVALID_RESPONSE` immediately. Pending/processing assets continue polling even if a URL is already present. Waiting never reuploads or signs automatically. Timeout/cancellation preserves the ID for recovery. An earlier per-request timeout remains `REQUEST_TIMEOUT`; overall wait exhaustion becomes `WAIT_TIMEOUT`.
52
+
53
+ `generate_private_url(asset_id, expires_in: 3600, variant: 'webp', ...)` also supports `variant: 'avif'`. The returned hash contains `asset_id`, `variant`, `private_url`, `expires_at`, `expires_in`. The service rejects unavailable variants and assets that cannot be signed. Treat private URLs as credentials.
54
+
55
+ ## Listing and lifecycle
56
+
57
+ | Method | Arguments | Result |
58
+ | --- | --- | --- |
59
+ | `list_uploads` | Upload filters below | Page of upload assets |
60
+ | `iterate_uploads` | Same filters | Lazy Enumerator of upload assets |
61
+ | `list_assets` | `page: 1`, `limit: 20`, `deletion_status:`, request options | Page of asset states |
62
+ | `get_asset` | Asset ID, request options | Asset state |
63
+ | `delete_asset` | Asset ID, request options | Accepted/completed deletion state |
64
+ | `bulk_delete_assets` | Array of 1–100 explicit IDs, request options | Per-item bulk results |
65
+
66
+ Upload filters: `project_id:` (UUID, from constructor or call), `page: 1`, `limit: 20` (1–100), `visibility:`, `external_id:` (up to 512 UTF-8 bytes), `data:` (up to five String-valued filters), `status:` (pending/processing/processed/failed/unprocessable or integer 1–6), `deletion_status:`, `timeout:`, `cancel:`. The server defaults deletion filtering to active.
67
+
68
+ Pages contain `data`, `page`, `limit`, `total`. The Enumerator makes no request until consumed, validates returned page progression, and stops on an empty page or reported total. Each page has its own request deadline. Stopping iteration prevents later page requests. Offset listings can change while records are added or deleted.
69
+
70
+ Asset state requires `id`, `project_id`, `kind`, `source` (upload/origin), numeric `processing_status`, `deletion_status`, `storage_credited_mb`. Optional fields: `deletion_requested_at`, `accounting_status` (pending/settled/unavailable), `last_failure: {code, message, at}`. This lifecycle shape has no upload delivery metadata. List credits are zero; detail provides accounting.
71
+
72
+ Bulk deletion returns `results` and `counts: {total, accepted, failed}`. Each result includes `id`, `status`, `accepted` and, as appropriate, `state`, `code`, `error`. The SDK validates all outcomes and counts together. Duplicate input IDs are sent unchanged and count against the input limit; responses use first-seen deduplication. HTTP 200 can contain failed items. A malformed bulk response does not choose an arbitrary ID for its error.
73
+
74
+ Input asset IDs must be Ruby Integers from 1 through 9007199254740991. Strings, booleans and Floats are rejected as IDs. Deletion is permanent and asynchronous; acceptance does not prove physical removal or cache expiry. There is no implicit batch expansion, restore, delete-by-URL or mutation retry.
75
+
76
+ ## Platform administration
77
+
78
+ Use `files.platform` or `Kompressr::PlatformClient.new(api_key: ..., ...)` with project authority. The standalone client accepts the shared configuration, excluding `project_id`.
79
+
80
+ | Method | Required positional arguments | Optional keywords / result |
81
+ | --- | --- | --- |
82
+ | `list_users` | None | `page:`, `limit:`; user page plus `project_active_count`, `org_active_count`, optional `usage_period` |
83
+ | `create_user` | Customer user ID | `display_name:`, `metadata:`; `{created, project_user}` |
84
+ | `update_user` | Project user UUID | `display_name:`, `metadata:`; project user |
85
+ | `suspend_user` | Project user UUID | Project user |
86
+ | `reactivate_user` | Project user UUID | Project user |
87
+ | `list_upload_keys` | Project user UUID | `page:`, `limit:`; page of upload keys |
88
+ | `create_upload_key` | Project user UUID, name | Policy below; `IssuedKey` |
89
+ | `rotate_upload_key` | Project user UUID, key UUID | `IssuedKey` |
90
+ | `revoke_upload_key` | Project user UUID, key UUID | `nil` on success |
91
+
92
+ All accept request options. Customer user IDs allow 1–256 UTF-8 bytes; display names and key names allow up to 120. Empty display names and empty metadata hashes can clear those fields. Missing/nil optional fields are omitted.
93
+
94
+ Key policy keywords: `scopes:` (upload/status/list/private_url/delete), `allowed_visibility:` (public/private/both), `allowed_asset_types:` (positive integer IDs), `expires_at:` (future ISO 8601 timestamp with timezone). Omitted/nil scopes use service defaults, excluding deletion. An explicit empty scopes array is rejected without dispatch. A nonempty array replaces defaults.
95
+
96
+ An omitted/nil or empty `allowed_asset_types` list adds no asset-type restriction. Omitted/nil `expires_at` leaves the expiration policy to the service. Persist the project-user ID and provisioned key securely; list operations do not reveal the secret again.
97
+
98
+ Project users contain `id`, `project_id`, `customer_user_id`, active/suspended `status`; optional display name, metadata, creation/activity timestamps, asset/storage/key counts and usage counters are retained. Key records contain `id`, `key_id`, `project_id`, `project_user_id`, `name`, `scopes`, `allowed_visibility`, optional asset-type restrictions and timestamps. Returned key records never include a secret field.
99
+
100
+ IssuedKey exposes `id`, `key_id`, optional `upload_key`, and `reveal_api_key`. The last method returns a copy of the one-time provisioning/rotation secret. Routine inspection and JSON redact it. Ruby reflection can access object internals; this wrapper prevents accidental logging rather than providing a security boundary against code running in the same process.
101
+
102
+ ## Errors and timing
103
+
104
+ `Kompressr::Error` exposes `kind`, `code`, `status`, `retry_after_ms`, `asset_id`, `may_have_succeeded`, plus standard `message`. `to_h`, JSON and inspection contain this safe metadata. Kinds: validation, api, network, timeout, cancelled, invalid_response, processing. Exceptions omit raw response bodies and nested transport exceptions; messages redact the supplied key, bearer credentials and URLs.
105
+
106
+ Only GET network failures and HTTP 429/502/503/504 automatically retry, up to `max_read_retries`. Backoff starts near 250 ms and includes small jitter. Header `Retry-After` (seconds/date) or JSON `retry_after` (seconds) is a minimum delay; enormous values are saturated without an early retry. Deadline/cancellation during backoff retains the latest status and retry delay. Permission denials, malformed data and processing failures are not retryable reads.
107
+
108
+ Writes are never replayed and redirects are never followed. A write becomes potentially successful when request bytes may have been sent. Losing the response or receiving malformed successful data retains that uncertainty. Upload ID recovery requires valid JSON with `success: true` and valid numeric `data.asset_id`; broken JSON and invalid Unicode do not produce IDs. Single-asset mutations retain the requested ID. Cancellation before dispatch does not claim success.
109
+
110
+ During body transmission, the client also watches for an early server response. A permission, size-limit or rate-limit rejection can stop the upload before the whole file is sent, while retaining its API status, code and retry hint. Informational responses allow transmission to continue. A disconnect without a usable response still retains write uncertainty.
111
+
112
+ When `may_have_succeeded` is true, persist `asset_id` if available and inspect the existing operation's outcome. If an upload has no recoverable ID, reconcile it with your application's records or an authorized listing before retrying. A readiness error can be resumed with the same ID; there is no need to upload again. For key creation or rotation, reconcile administrative state before retrying because the one-time secret might have been issued without reaching your application.
113
+
114
+ The monotonic request deadline includes connection admission, DNS, TCP, TLS, writes, all response reads and retry delays. Every attempt has a fresh HTTP/1.1 connection closed on completion. Responses are limited to 2 MiB, headers/trailers to 64 KiB each, and status/chunk lines to 8 KiB; at most five informational responses are accepted. Content-Length, chunked and close-delimited bodies are supported. Ambiguous framing, unsupported protocol upgrades and nonidentity content encoding are rejected. No decompressor runs on a server response. JSON parsing is limited to 100 nesting levels and rejects invalid UTF-8 and invalid escaped Unicode.
115
+
116
+ The transport uses Socket/OpenSSL nonblocking I/O and a small HTTP/1.1 parser. Net::HTTP's per-phase timeouts do not by themselves provide the same total deadline and cancellation across DNS/body reads; this implementation avoids asynchronously interrupting arbitrary threads. It has no native retry layer, proxy discovery or shared connection pool.
117
+
118
+ DNS uses a process-wide maximum of eight background workers, with no queued resolver jobs. A timed-out/cancelled lookup cannot connect or send a late request. A blocked OS lookup holds its slot until it returns; later hostname requests can time out waiting for admission. Numeric IP origins bypass DNS. At most 64 attempts hold connection slots per process. Waiting for either limit remains inside the same deadline. Addresses are tried sequentially with a share of the remaining budget, preserving hostname/SNI; this can be slower than racing address families. Capacity is reset after a process fork, supporting preloaded applications.
119
+
120
+ File validation/opening and the byte snapshot precede the HTTP deadline. Regular local file reads occur in bounded chunks, but OS/filesystem stalls and arbitrary Ruby thread scheduling are not forcibly interrupted. Local file access errors before dispatch remain normal Ruby filesystem exceptions. The caller supplies trusted local file paths and keeps input files/options unchanged during calls. MRI Ruby 3.2+ is the supported runtime; asynchronous scheduler integration and other Ruby engines are unverified.
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Support MRI Ruby 3.2 and later, including isolated installation of required runtime gems.
6
+ - Initial Ruby client for file upload, readiness, signing, listing, iteration and asset deletion.
7
+ - Project-user administration and scoped-key issuance, rotation and revocation.
8
+ - Total request deadlines, cooperative cancellation, bounded reads and streaming regular-file uploads.
9
+ - Safe GET retries, mutation uncertainty, strict response validation and explicit secret access.
10
+ - Rails-compatible service example, native package verification and public release instructions.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kompressr
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/PARITY.md ADDED
@@ -0,0 +1,32 @@
1
+ # Ruby behavior and verification
2
+
3
+ The compatibility baseline is the JS SDK file API and the shared [SDK behavior contract](../contracts/sdk-behavior.md). Ruby uses synchronous methods, String-keyed hashes, native integer IDs and a lazy Enumerator. It supports regular local-file paths and binary Strings; there is no arbitrary stream method or framework adapter.
4
+
5
+ | Behavior | Implementation | Real-boundary regression coverage |
6
+ | --- | --- | --- |
7
+ | Separate management/upload origins and field-before-file multipart | `Client`, `Multipart` | Multipart body/header inspection and separate servers |
8
+ | Accepted upload, resumable wait, private signing | `Client` | Pending→processed, private readiness/signing, terminal states, early request deadline vs wait deadline |
9
+ | Strict lifecycle fields and exact matching | `Validation` | Case variants cannot override status/IDs; private/deleted delivery URL contradictions fail |
10
+ | Listing/filtering/lazy pages | `Client` | Encoded filters, no eager request, page progression, total/empty termination |
11
+ | Single/bulk deletion | `Client`, `Validation` | Requested ID retention, accepted deletion state, duplicates and mixed outcomes |
12
+ | Nine identity/key methods | `PlatformClient` | Real HTTP path/method/body checks, nullable metadata, state transitions, issuance/rotation/revocation |
13
+ | Supplied credential and denied authority | `PlatformClient`, `Transport` | HTTP 403 retained without retry/escalation; secret redaction |
14
+ | Empty scopes reject; omission selects defaults | `PlatformClient` | Empty scopes sends zero requests; omitted scopes absent on wire |
15
+ | Retry and mutation uncertainty | `Transport` | Eligible GET retries, attempt limits, EOF, no mutation replay/redirect, malformed writes |
16
+ | Retry-After and safe errors | `Context`, `Transport` | Huge delays, HTTP date/JSON fallback, cancellation/backoff context, raw response redaction |
17
+ | Total deadlines and cancellation | `Context`, `Network` | Delayed headers/body/TLS, pre/postdispatch cancellation, DNS no-late-write and slot exhaustion |
18
+ | TLS trust/hostname/SNI | `Network` | Locally generated CA/leaf, accepted hostname, mismatch and untrusted CA rejection |
19
+ | Bounded HTTP parsing | `Wire` | Content-Length, chunked/trailers, close-delimited, interim statuses, framing conflicts, EOF, size/line limits |
20
+ | Early upload rejection | `Wire` | HTTP/TLS denials before the body is consumed, partial response headers and informational responses during upload |
21
+ | No response decompression | `Wire` | Gzip expansion fixture rejected before decompression; compressed errors retain status/delay |
22
+ | Unicode response safety | `Transport`, `Validation` | Raw invalid UTF-8 and escaped lone surrogates remain typed errors for success and denial |
23
+ | Streaming and cleanup | `Multipart`, `Network` | 32 MiB file upload, bounded retained String memory, repeated cancellation, bounded DNS threads, process exit |
24
+ | Concurrent requests | `Transport`, `Capacity` | Separate credentials remain isolated; shared-client calls retain their own state; connection admission honors cancellation and deadlines |
25
+ | Application examples | `examples/` | Private upload and Rails service execute against HTTP fixtures; recovery IDs are saved before waiting, including uncertain-upload recovery; CLI recovery files are private and never overwritten |
26
+ | Actual distribution use | `scripts/verify.rb` | Strict gem build, allowlist check, clean gem home install, same HTTP regressions and example execution |
27
+
28
+ The CI matrix targets MRI Ruby 3.2, 3.3, 3.4 and 4.0. Run the native verifier on each before release; it checks source and a clean installation of the `.gem`. Tests generate temporary TLS certificates locally; no signing material is committed. The large-file test measures retained String memory while uploading a 32 MiB regular file, with garbage collection between samples; its bound is 8 MiB above baseline. This is a bounded-buffer regression, not a claim about total process RSS or every runtime's allocator.
29
+
30
+ The SDK is synchronous and has no automatic native-library recovery beyond its explicit GET retry loop. DNS admission is bounded at eight workers and connection admission at 64 attempts. A blocked OS resolver retains a slot until it finishes but cannot issue a late request. Filesystem stalls and Ruby thread scheduling remain OS/runtime boundaries. See [API.md](API.md#errors-and-timing) for exact limits and supported runtimes.
31
+
32
+ These local fixtures establish client behavior, not deployed plan entitlement, tenant isolation or registry publication.
data/README.md ADDED
@@ -0,0 +1,149 @@
1
+ # Kompressr Ruby SDK
2
+
3
+ Upload, optimize and deliver files from Ruby applications. The SDK works independently of Rails, Sinatra, Hanami or any other framework. It provides a synchronous client for request handlers, background jobs and command-line applications.
4
+
5
+ Requires MRI Ruby 3.2 or later with OpenSSL and a trusted CA store. Other Ruby implementations and asynchronous fiber schedulers are not supported.
6
+
7
+ Bundler installs the required standard-library gems when your Ruby version bundles older ones. Installing the JSON or OpenSSL gems from source requires Ruby development headers and a C compiler; OpenSSL also requires its development libraries.
8
+
9
+ ## Install
10
+
11
+ Add to your application's Gemfile:
12
+
13
+ ```ruby
14
+ gem 'kompressr-sdk', '~> 0.1.0', require: 'kompressr'
15
+ ```
16
+
17
+ Run `bundle install`. For a standalone script, install with `gem install kompressr-sdk` and load it with `require 'kompressr'`. The gem declares its Ruby standard-gem dependencies and has no framework dependency.
18
+
19
+ ## Upload your first file
20
+
21
+ Create a project in the [Kompressr dashboard](https://kompressr.com/dashboard/projects), select a plan with API upload access, and store its API key in your backend's `KOMPRESSR_API_KEY` environment variable. See [pricing](https://kompressr.com/pricing#file-support) for plan features and limits.
22
+
23
+ ```ruby
24
+ require 'kompressr'
25
+
26
+ files = Kompressr::Client.new(api_key: ENV.fetch('KOMPRESSR_API_KEY'))
27
+ accepted = files.upload_file('image.png', visibility: 'public')
28
+ asset_id = accepted.fetch('id')
29
+ # Persist asset_id in your application's database before waiting.
30
+ ready = files.wait_until_ready(asset_id)
31
+ delivery_url = ready.fetch('cdn_url')
32
+ ```
33
+
34
+ An accepted upload can still be processing. Save its ID before waiting so a timeout does not lose the recovery handle. Results are hashes with **string keys**. `upload_bytes(bytes, filename)` accepts a Ruby String containing binary file content. File paths stream in bounded chunks; arbitrary IO objects, pipes and custom readers are unsupported.
35
+
36
+ ## Private files and recovery
37
+
38
+ ```ruby
39
+ accepted = files.upload_file('attachment.png', visibility: 'private')
40
+ asset_id = accepted.fetch('id')
41
+ # Save asset_id against the authenticated user's attachment record here.
42
+
43
+ begin
44
+ files.wait_until_ready(asset_id, timeout: 120)
45
+ signed = files.generate_private_url(asset_id, expires_in: 3600)
46
+ delivery_url = signed.fetch('private_url')
47
+ # Return this URL only to a user authorized to access the attachment.
48
+ rescue Kompressr::Error => error
49
+ if %w[WAIT_TIMEOUT WAIT_CANCELLED REQUEST_TIMEOUT REQUEST_CANCELLED].include?(error.code)
50
+ # Resume from the saved asset_id in a later job. Do not upload again.
51
+ else
52
+ raise
53
+ end
54
+ end
55
+ ```
56
+
57
+ Private URLs expire independently of their assets. Generate a fresh URL when an authorized user needs access, and keep signed URLs out of ordinary logs. A timeout or cancellation stops the caller; it does not roll back accepted server work. If an upload fails with `error.may_have_succeeded`, save `error.asset_id` when present and inspect that asset before retrying. If no ID was recovered, reconcile the upload through your project records or listing before starting another upload.
58
+
59
+ The [private-upload example](https://github.com/kompressrcdn/clients/blob/main/sdk-ruby/examples/private_upload.rb) saves a recovery ID to a new local file before waiting. From `sdk-ruby`, with `KOMPRESSR_API_KEY` set, run:
60
+
61
+ ```sh
62
+ bundle exec ruby examples/private_upload.rb image.png asset-id.txt
63
+ ```
64
+
65
+ It keeps the signed URL out of terminal output. In an application, use its `save_asset_id:` callback to persist the ID in your database. To resume from a saved file:
66
+
67
+ ```ruby
68
+ saved_id = Integer(File.read('asset-id.txt').strip, 10)
69
+ files.wait_until_ready(saved_id)
70
+ signed = files.generate_private_url(saved_id)
71
+ ```
72
+
73
+ The example refuses to overwrite an existing recovery file. An empty file means no ID was saved; an uncertain upload still needs reconciliation before retrying.
74
+
75
+ Cancel an operation from another thread using `Kompressr::CancellationToken.new`, pass it as `cancel: token`, then call `token.cancel`. See [errors and timing](https://github.com/kompressrcdn/clients/blob/main/sdk-ruby/API.md#errors-and-timing) for fields and deadline behavior.
76
+
77
+ ## Scoped keys for application users
78
+
79
+ Keep the project key on a trusted backend. Scoped-key management requires a plan that includes that feature; ordinary API upload access alone does not grant it. Use the project key to create a Kompressr identity for your authenticated application user, then issue the permissions that user needs:
80
+
81
+ ```ruby
82
+ created = files.platform.create_user('customer-123', display_name: 'Customer 123')
83
+ user_id = created.fetch('project_user').fetch('id')
84
+ issued = files.platform.create_upload_key(
85
+ user_id, 'Chat attachments',
86
+ scopes: %w[upload status list private_url],
87
+ allowed_visibility: 'private'
88
+ )
89
+ scoped_key = issued.reveal_api_key
90
+ # Store scoped_key in your secret store, bound to this authenticated user.
91
+ user_files = Kompressr::Client.new(
92
+ api_key: scoped_key,
93
+ project_id: ENV.fetch('KOMPRESSR_PROJECT_ID') # Required when listing uploads.
94
+ )
95
+ ```
96
+
97
+ `IssuedKey#inspect`, `to_s`, `to_h` and JSON serialization redact the returned secret. Access it explicitly with `reveal_api_key`; keep that value out of logs. Omitting `scopes` uses the server's defaults: upload, status, listing and private signing. A nonempty list replaces those defaults. **An explicit empty list is rejected before HTTP**; if application permission filtering leaves no scopes, do not issue a key. Deletion requires the explicit `delete` scope.
98
+
99
+ Store the project-user ID and scoped key once for the user; reuse them for later uploads. Never give the project key to a browser or mobile client. The service enforces project ownership, scopes and plan entitlement. Constructing a platform client with a scoped credential does not grant administrative permissions. Your application remains responsible for authenticating users, storing credentials, checking conversation/attachment access and persisting recovery records. See the [chatbot guide](https://github.com/kompressrcdn/clients/blob/main/guides/chatbot-integration.md) for the complete flow; its application boundaries also apply to Ruby.
100
+
101
+ ## Rails and other frameworks
102
+
103
+ Use the same `Kompressr::Client` in Rails services or jobs; no adapter is required. Keep long uploads and readiness waits in background jobs when they would occupy a web request worker for too long. [`examples/rails_service.rb`](https://github.com/kompressrcdn/clients/blob/main/sdk-ruby/examples/rails_service.rb) illustrates a framework-independent service class you can call from Rails. It accepts a trusted regular file path, which may come from your framework's uploaded temporary file after validation.
104
+
105
+ ```ruby
106
+ service = KompressrAttachmentService.new(files: user_files)
107
+ service.upload(validated_file_path, attachment_record: attachment)
108
+ # In a job, use the ID that upload saved on your record:
109
+ service.prepare(attachment.kompressr_asset_id)
110
+ # After checking the requesting user's access to this attachment:
111
+ url = service.download_url(attachment.kompressr_asset_id)
112
+ ```
113
+
114
+ Create a `kompressr_asset_id` bigint column in your application and load/copy the example service before using it. Persist temporary uploads to durable storage before passing their paths to a later job. Configure job retries to resume an existing asset ID rather than repeat an accepted or uncertain upload.
115
+
116
+ The client is synchronous and thread-safe when inputs remain unchanged during a call. It creates a fresh connection per attempt and has no persistent connection pool to close. It does not automatically use proxy environment variables. This release does not promise asynchronous fiber scheduler integration, JRuby or TruffleRuby compatibility.
117
+
118
+ ## More operations
119
+
120
+ ```ruby
121
+ files = Kompressr::Client.new(
122
+ api_key: ENV.fetch('KOMPRESSR_API_KEY'),
123
+ project_id: ENV.fetch('KOMPRESSR_PROJECT_ID')
124
+ )
125
+ files.iterate_uploads(visibility: 'private', limit: 50).each do |asset|
126
+ # Reconcile asset.fetch('id') with your own records.
127
+ end
128
+
129
+ state = files.get_asset(123)
130
+ deletion = files.delete_asset(123)
131
+ batch = files.bulk_delete_assets([124, 125])
132
+ failures = batch.fetch('results').reject { |item| item.fetch('accepted') }
133
+ ```
134
+
135
+ Deletion is permanent and asynchronous. Acceptance means cleanup was requested; it does not establish physical removal or cache invalidation. Bulk deletion keeps each item's outcome, including failures returned inside HTTP 200.
136
+
137
+ Read the [API reference](https://github.com/kompressrcdn/clients/blob/main/sdk-ruby/API.md), [behavior and coverage map](https://github.com/kompressrcdn/clients/blob/main/sdk-ruby/PARITY.md) and [runnable examples](https://github.com/kompressrcdn/clients/blob/main/sdk-ruby/examples).
138
+
139
+ ## Verify from source
140
+
141
+ ```sh
142
+ cd sdk-ruby
143
+ bundle install
144
+ ruby scripts/verify.rb
145
+ ```
146
+
147
+ Verification uses local HTTP/TLS fixtures, builds `pkg/kompressr-sdk-0.1.0.gem`, installs it in an isolated gem directory and reruns the regressions against the installed package. It requires no live service credentials. For release verification, run `node scripts/verify-all.mjs ruby` from the repository root instead of the native command; it runs the native verifier plus shared contract, source and archive checks and requires Node.js and Python 3 as well as Ruby.
148
+
149
+ Report security issues privately using the repository's [security policy](https://github.com/kompressrcdn/clients/blob/main/SECURITY.md). MIT licensed; see [LICENSE](https://github.com/kompressrcdn/clients/blob/main/sdk-ruby/LICENSE).
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kompressr'
4
+
5
+ module KompressrExamples
6
+ # Persist the accepted ID through the supplied application callback before waiting.
7
+ def self.private_upload(client, path, save_asset_id:)
8
+ asset_id = nil
9
+ accepted = client.upload_file(path, visibility: 'private')
10
+ asset_id = accepted.fetch('id')
11
+ save_asset_id.call(asset_id)
12
+ client.wait_until_ready(asset_id)
13
+ client.generate_private_url(asset_id, expires_in: 3600)
14
+ rescue Kompressr::Error => error
15
+ # A malformed upload response can still contain a trustworthy recovery ID.
16
+ save_asset_id.call(error.asset_id) if asset_id.nil? && error.may_have_succeeded && error.asset_id
17
+ raise
18
+ end
19
+ end
20
+
21
+ if $PROGRAM_NAME == __FILE__
22
+ abort 'Usage: bundle exec ruby examples/private_upload.rb FILE ASSET_ID_FILE' unless ARGV.length == 2
23
+ begin
24
+ client = Kompressr::Client.new(api_key: ENV.fetch('KOMPRESSR_API_KEY'))
25
+ # Reserve a new recovery file before uploading; never overwrite an earlier ID.
26
+ File.open(ARGV.fetch(1), File::WRONLY | File::CREAT | File::EXCL, 0o600) do |record|
27
+ save = lambda do |id|
28
+ record.write("#{id}\n")
29
+ record.flush
30
+ record.fsync
31
+ puts "Saved asset #{id} for recovery."
32
+ end
33
+ result = KompressrExamples.private_upload(client, ARGV.fetch(0), save_asset_id: save)
34
+ puts "Asset #{result.fetch('asset_id')} is ready. A private link was generated; keep it out of logs."
35
+ end
36
+ rescue Kompressr::Error => error
37
+ warn "Kompressr request stopped: #{error.code}."
38
+ warn "Resume using asset #{error.asset_id}; do not upload the file again." if error.asset_id
39
+ warn 'The write may have succeeded. Check its outcome before retrying.' if error.may_have_succeeded
40
+ exit 1
41
+ end
42
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kompressr'
4
+
5
+ # Place this service in app/services or call it from a background job. Your
6
+ # controller authenticates the user and verifies attachment ownership first.
7
+ class KompressrAttachmentService
8
+ def initialize(files: Kompressr::Client.new(api_key: ENV.fetch('KOMPRESSR_API_KEY')))
9
+ @files = files
10
+ end
11
+
12
+ def upload(path, attachment_record:)
13
+ accepted = @files.upload_file(path, visibility: 'private')
14
+ attachment_record.update!(kompressr_asset_id: accepted.fetch('id'))
15
+ accepted
16
+ rescue Kompressr::Error => error
17
+ if error.may_have_succeeded && error.asset_id
18
+ attachment_record.update!(kompressr_asset_id: error.asset_id)
19
+ end
20
+ raise
21
+ end
22
+
23
+ # A retrying job resumes here using the saved ID instead of uploading again.
24
+ def prepare(asset_id)
25
+ @files.wait_until_ready(asset_id)
26
+ end
27
+
28
+ # Call only after checking that the current user can access this attachment.
29
+ def download_url(asset_id)
30
+ @files.generate_private_url(asset_id, expires_in: 900).fetch('private_url')
31
+ end
32
+
33
+ def inspect = '#<KompressrAttachmentService credentials redacted>'
34
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kompressr
4
+ # A synchronous, framework-independent client. Results use string-keyed hashes.
5
+ class Client
6
+ attr_reader :api_base_url, :upload_base_url, :platform
7
+
8
+ def initialize(project_id: nil, **configuration)
9
+ @project_id = project_id.nil? ? nil : Validation.uuid(project_id, 'project_id').dup.freeze
10
+ @transport = Internal::Transport.new(**configuration)
11
+ @api_base_url, @upload_base_url = @transport.api_base_url, @transport.upload_base_url
12
+ @platform = PlatformClient.new(**configuration)
13
+ end
14
+ def inspect = '#<Kompressr::Client credentials redacted>'
15
+ alias to_s inspect
16
+
17
+ def upload_file(path, **options)
18
+ Validation.invalid('Supply a local file path.') unless (path.instance_of?(String) || path.respond_to?(:to_path)) && !path.to_s.empty?
19
+ path = path.to_path if path.respond_to?(:to_path)
20
+ Validation.invalid('Supply a local file path without null bytes.') unless path.instance_of?(String) && !path.b.include?("\0")
21
+ filename = Validation.filename(File.basename(path))
22
+ Validation.invalid('Upload path must name a regular local file.') unless File.stat(path).file?
23
+ # NONBLOCK avoids a raced replacement with a FIFO blocking open. fstat then
24
+ # rejects anything other than the regular file the SDK owns and closes.
25
+ flags = File::RDONLY | File::NONBLOCK
26
+ File.open(path, flags) do |file|
27
+ file.binmode
28
+ Validation.invalid('Upload path must name a regular local file.') unless file.stat.file?
29
+ upload_source(file, file.stat.size, filename, **options)
30
+ end
31
+ end
32
+
33
+ def upload_bytes(content, filename, **options)
34
+ Validation.invalid('content must be a Ruby String containing the file bytes.') unless content.instance_of?(String)
35
+ snapshot = content.b.dup.freeze
36
+ upload_source(snapshot, snapshot.bytesize, Validation.filename(filename), **options)
37
+ end
38
+
39
+ def get_upload_status(asset_id, timeout: nil, cancel: nil)
40
+ Validation.positive(asset_id, 'asset_id')
41
+ @transport.request("/api/upload/#{asset_id}/status", timeout: timeout, cancel: cancel, asset_id: asset_id) do |value|
42
+ result = Validation.upload(Validation.data(value))
43
+ Validation.bad('API returned a different asset ID.') unless result['id'] == asset_id
44
+ result
45
+ end
46
+ end
47
+
48
+ def wait_until_ready(asset_id, timeout: 120, poll_interval: 2, cancel: nil)
49
+ Validation.positive(asset_id, 'asset_id')
50
+ duration = Validation.seconds(timeout)
51
+ interval = Validation.seconds(poll_interval, 'poll_interval')
52
+ Validation.invalid('poll_interval must not exceed 30 seconds.') if interval > 30
53
+ Validation.invalid('cancel must be a CancellationToken.') unless cancel.nil? || cancel.instance_of?(CancellationToken)
54
+ context = Internal::Context.new(duration, cancel, false, asset_id)
55
+ loop do
56
+ context.check!
57
+ request_budget = [context.remaining, @transport.request_timeout].min
58
+ begin
59
+ result = get_upload_status(asset_id, timeout: request_budget, cancel: cancel)
60
+ rescue Error => error
61
+ context.status, context.retry_after_ms = error.status, error.retry_after_ms
62
+ raise
63
+ end
64
+ context.check!
65
+ if result['deletion_status'] != 'active'
66
+ raise Error.new('Asset deletion has been requested.', kind: 'processing', code: 'ASSET_DELETED', asset_id: asset_id), cause: nil
67
+ end
68
+ if %w[failed unprocessable unknown].include?(result['status'])
69
+ raise Error.new('Asset processing did not succeed.', kind: 'processing', code: 'PROCESSING_FAILED', asset_id: asset_id), cause: nil
70
+ end
71
+ if result['status'] == 'processed'
72
+ return result if result['visibility'] == 'private' ? result['can_generate_private_url'] : result.key?('cdn_url')
73
+ raise Error.new('Processed asset is not deliverable; verify server compatibility.', kind: 'invalid_response',
74
+ code: 'INVALID_RESPONSE', asset_id: asset_id), cause: nil
75
+ end
76
+ context.pause(interval)
77
+ end
78
+ rescue Error => error
79
+ if error.kind == 'cancelled' || (error.kind == 'timeout' && context && context.remaining <= 0.001)
80
+ code = error.kind == 'cancelled' ? 'WAIT_CANCELLED' : 'WAIT_TIMEOUT'
81
+ raise Error.new('Readiness wait stopped; keep the asset ID and check again later.', kind: error.kind, code: code,
82
+ status: error.status, retry_after_ms: error.retry_after_ms, asset_id: asset_id), cause: nil
83
+ end
84
+ raise
85
+ end
86
+
87
+ def generate_private_url(asset_id, expires_in: 3600, variant: 'webp', timeout: nil, cancel: nil)
88
+ Validation.positive(asset_id, 'asset_id')
89
+ ttl(expires_in)
90
+ Validation.enum(variant, %w[webp avif], 'variant')
91
+ @transport.request("/api/upload/#{asset_id}/private-url", method: 'POST', body: { 'expires_in' => expires_in, 'variant' => variant }, timeout: timeout, cancel: cancel, asset_id: asset_id) do |value|
92
+ result = Validation.private_url(Validation.data(value))
93
+ Validation.bad('API returned a different asset ID.') unless result['asset_id'] == asset_id
94
+ result
95
+ end
96
+ end
97
+
98
+ def list_uploads(project_id: nil, page: 1, limit: 20, visibility: nil, external_id: nil, data: nil,
99
+ status: nil, deletion_status: nil, timeout: nil, cancel: nil)
100
+ query = pagination(page, limit)
101
+ project = project_id.nil? ? @project_id : project_id
102
+ Validation.invalid('Upload listing requires project_id from the client or this call.') if project.nil?
103
+ query['project_id'] = Validation.uuid(project, 'project_id')
104
+ query['visibility'] = Validation.enum(visibility, %w[public private], 'visibility') unless visibility.nil?
105
+ query['deletion_status'] = Validation.enum(deletion_status, Validation::DELETION, 'deletion_status') unless deletion_status.nil?
106
+ unless status.nil?
107
+ selected = status.instance_of?(String) ? { 'pending' => 1, 'processing' => 2, 'processed' => 3, 'failed' => 4, 'unprocessable' => 5 }[status] : status
108
+ query['status'] = Validation.positive(selected, 'status', 6)
109
+ end
110
+ query['external_id'] = Validation.text(external_id, 'external_id', 512, empty: true, trim: false) unless external_id.nil?
111
+ unless data.nil?
112
+ Validation.customer_data(data).each do |key, value|
113
+ Validation.invalid('Data filter values must be strings.') unless value.instance_of?(String)
114
+ query["data.#{key}"] = value
115
+ end
116
+ end
117
+ @transport.request("/api/uploads?#{URI.encode_www_form(query)}", timeout: timeout, cancel: cancel) do |value|
118
+ Validation.page(Validation.success(value)) { |row| Validation.upload(row) }
119
+ end
120
+ end
121
+
122
+ def iterate_uploads(**options)
123
+ Enumerator.new do |output|
124
+ current = options.fetch(:page, 1)
125
+ loop do
126
+ result = list_uploads(**options.merge(page: current))
127
+ Validation.bad('Invalid pagination response.') unless result['page'] == current
128
+ result['data'].each { |row| output << row }
129
+ break if result['data'].empty? || result['page'] * result['limit'] >= result['total']
130
+ current += 1
131
+ end
132
+ end
133
+ end
134
+
135
+ def list_assets(page: 1, limit: 20, deletion_status: nil, timeout: nil, cancel: nil)
136
+ query = pagination(page, limit)
137
+ query['deletion_status'] = Validation.enum(deletion_status, Validation::DELETION, 'deletion_status') unless deletion_status.nil?
138
+ @transport.request("/api/assets?#{URI.encode_www_form(query)}", timeout: timeout, cancel: cancel) do |value|
139
+ Validation.page(value) { |row| Validation.asset_state(row) }
140
+ end
141
+ end
142
+
143
+ def get_asset(asset_id, timeout: nil, cancel: nil)
144
+ Validation.positive(asset_id, 'asset_id')
145
+ @transport.request("/api/assets/#{asset_id}", timeout: timeout, cancel: cancel, asset_id: asset_id) do |value|
146
+ result = Validation.asset_state(value)
147
+ Validation.bad('API returned a different asset ID.') unless result['id'] == asset_id
148
+ result
149
+ end
150
+ end
151
+
152
+ def delete_asset(asset_id, timeout: nil, cancel: nil)
153
+ Validation.positive(asset_id, 'asset_id')
154
+ @transport.request("/api/assets/#{asset_id}", method: 'DELETE', timeout: timeout, cancel: cancel, asset_id: asset_id) do |value|
155
+ result = Validation.asset_state(value)
156
+ Validation.bad('Deletion was not accepted for the requested asset.') unless result['id'] == asset_id && result['deletion_status'] != 'active'
157
+ result
158
+ end
159
+ end
160
+
161
+ def bulk_delete_assets(asset_ids, timeout: nil, cancel: nil)
162
+ Validation.invalid('Select 1 to 100 explicit asset IDs.') unless asset_ids.instance_of?(Array) && asset_ids.size.between?(1, 100)
163
+ asset_ids.each { |id| Validation.positive(id, 'asset_id') }
164
+ expected = asset_ids.uniq
165
+ @transport.request('/api/assets/bulk-delete', method: 'POST', body: { 'asset_ids' => asset_ids }, timeout: timeout, cancel: cancel) do |value|
166
+ Validation.bulk(value, expected)
167
+ end
168
+ end
169
+
170
+ private
171
+
172
+ def pagination(page, limit)
173
+ { 'page' => Validation.positive(page, 'page', 2_147_483_647), 'limit' => Validation.positive(limit, 'limit', 100) }
174
+ end
175
+ def ttl(value)
176
+ Validation.positive(value, 'expires_in', 604_800)
177
+ Validation.invalid('Private URL lifetime must be at least 60 seconds.') if value < 60
178
+ end
179
+ def upload_source(source, size, name, visibility: nil, quality: nil, data: nil, private_url_expires_in: nil, timeout: nil, cancel: nil)
180
+ fields = {}
181
+ fields['visibility'] = Validation.enum(visibility, %w[public private], 'visibility') unless visibility.nil?
182
+ fields['kmp-quality'] = Validation.enum(quality, %w[480p 720p 1080p 4k], 'quality') unless quality.nil?
183
+ fields['data'] = JSON.generate(Validation.customer_data(data)) unless data.nil?
184
+ unless private_url_expires_in.nil?
185
+ ttl(private_url_expires_in)
186
+ fields['private_url_expires_in'] = private_url_expires_in.to_s
187
+ end
188
+ body = Internal::Multipart.new(source, size, name, fields)
189
+ @transport.request('/api/upload', method: 'POST', multipart: body, upload: true, timeout: timeout, cancel: cancel, recover_upload_id: true) do |value|
190
+ response = Validation.success(value)
191
+ result = Validation.upload(response['data'], accepted: true)
192
+ result['warning'] = response['warning'] if response['warning'].instance_of?(String)
193
+ result
194
+ end
195
+ end
196
+ end
197
+ end