api2convert 10.2.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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +99 -0
  3. data/LICENSE +21 -0
  4. data/README.md +164 -0
  5. data/docs/CHANGELOG.md +9 -0
  6. data/docs/SDK_CONTRACT.md +99 -0
  7. data/examples/convert.rb +20 -0
  8. data/examples/webhook.rb +32 -0
  9. data/lib/api2convert/client.rb +139 -0
  10. data/lib/api2convert/config.rb +66 -0
  11. data/lib/api2convert/errors.rb +121 -0
  12. data/lib/api2convert/http/net_http_sender.rb +148 -0
  13. data/lib/api2convert/http/request.rb +47 -0
  14. data/lib/api2convert/http/response.rb +23 -0
  15. data/lib/api2convert/http/transport.rb +290 -0
  16. data/lib/api2convert/input_type.rb +16 -0
  17. data/lib/api2convert/job_status.rb +29 -0
  18. data/lib/api2convert/model/conversion.rb +30 -0
  19. data/lib/api2convert/model/input_file.rb +37 -0
  20. data/lib/api2convert/model/job.rb +66 -0
  21. data/lib/api2convert/model/job_message.rb +30 -0
  22. data/lib/api2convert/model/output_file.rb +40 -0
  23. data/lib/api2convert/model/preset.rb +32 -0
  24. data/lib/api2convert/model/status.rb +24 -0
  25. data/lib/api2convert/resource/contracts.rb +16 -0
  26. data/lib/api2convert/resource/conversions.rb +33 -0
  27. data/lib/api2convert/resource/jobs.rb +112 -0
  28. data/lib/api2convert/resource/presets.rb +43 -0
  29. data/lib/api2convert/resource/stats.rb +35 -0
  30. data/lib/api2convert/result.rb +185 -0
  31. data/lib/api2convert/support/data.rb +89 -0
  32. data/lib/api2convert/support/secret.rb +27 -0
  33. data/lib/api2convert/upload/file_uploader.rb +87 -0
  34. data/lib/api2convert/upload/multipart_stream.rb +74 -0
  35. data/lib/api2convert/version.rb +8 -0
  36. data/lib/api2convert/webhook/event.rb +23 -0
  37. data/lib/api2convert/webhook/verifier.rb +74 -0
  38. data/lib/api2convert.rb +62 -0
  39. data/openapi/api2convert.openapi.json +3325 -0
  40. metadata +88 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f3d6b5c540037362377c06e4096518c623017c17882b39233f932f73f0b70d14
4
+ data.tar.gz: 37b28638fbef0fd35e3eff63abb8557fd27c4400a4c4e63bab170dbf86d0fd8e
5
+ SHA512:
6
+ metadata.gz: 83f600ae36bd429c61df404004a744b2cb51dad8aedab4c739c79ed83b8c95d77542c5162d0c356a84cb7515d369457f6264c92c1d03c898a84c74768148444c
7
+ data.tar.gz: 63031c428a7bb3ea8e976ac2861c38dc27b83585ab41c0927ad24660a3c1d9a2aee10724b6b9b743ea947019e026f941ab84272fefb472b88a0fbd5710c3a4e0
data/AGENTS.md ADDED
@@ -0,0 +1,99 @@
1
+ # AGENTS — maintaining the API2Convert Ruby SDK
2
+
3
+ This SDK is **hand-written** (not generated from OpenAPI) and kept in sync with the API by a human
4
+ **or an AI agent**. This file is the playbook. The model: a committed spec snapshot is the diff
5
+ baseline, a fixed behavior contract protects the ergonomics, and the RSpec suite is the guardrail.
6
+
7
+ It is one of five official ports (PHP, Python, Java, Node.js, Ruby) that all implement the same
8
+ language-agnostic contract in [`docs/SDK_CONTRACT.md`](docs/SDK_CONTRACT.md).
9
+
10
+ ## Why hand-written
11
+
12
+ The conversion flow is multi-step (create → upload → poll → download) and the **upload step is not in
13
+ the OpenAPI spec at all**, so a generator cannot produce a usable client. We optimise for a
14
+ junior-friendly surface — one-call `convert` — and use AI to keep it current.
15
+
16
+ ## Repo layout
17
+
18
+ | Path | What it is |
19
+ | ---------------------------------------- | -------------------------------------------------------------------------------- |
20
+ | `lib/api2convert/client.rb` | The client + the `convert` / `convert_async` façade. **Hand-authored.** |
21
+ | `lib/api2convert/result.rb` | `ConversionResult` + `FileDownload` helpers. **Hand-authored.** |
22
+ | `lib/api2convert/upload/*` | Streamed multipart upload to the per-job server. **Hand-authored** (not in spec).|
23
+ | `lib/api2convert/webhook/*` | Webhook HMAC verification + parsing. **Hand-authored.** |
24
+ | `lib/api2convert/resource/*` | One class per API tag (Jobs, Conversions, Presets, Stats, Contracts). **Derived.**|
25
+ | `lib/api2convert/model/*`, `*_status.rb` | Value objects (`from_hash` factories) / enum modules. **Derived** from the spec. |
26
+ | `lib/api2convert/http/*` | Transport: auth, retries/backoff, error mapping, redirect policy, the sender seam.|
27
+ | `lib/api2convert/errors.rb` | The typed exception hierarchy. |
28
+ | `openapi/api2convert.openapi.json` | **Committed spec snapshot** the SDK targets — the diff baseline (md5-identical to siblings). |
29
+ | `docs/SDK_CONTRACT.md` | The fixed, language-agnostic public surface + semantics (md5-identical to siblings). |
30
+ | `spec/unit/*` | Offline golden specs (`FakeHttpSender`). **The guardrail.** |
31
+ | `spec/security/*` | The independent security suite (real loopback servers). **The redirect/leak guardrail.** |
32
+ | `spec/live/*` | Live conformance (auto-skips without `API2CONVERT_API_KEY`). |
33
+
34
+ ## How to update the SDK to a new API version
35
+
36
+ 1. **Refresh the snapshot.** Overwrite `openapi/api2convert.openapi.json` from
37
+ `https://api.api2convert.com/v2/openapi.json` (or `/v2/schema`) and `git diff` it.
38
+ 2. **Diff it** — new/removed/renamed operations, new fields, new enum values.
39
+ 3. **Update the DERIVED layer to match the diff, and nothing else:**
40
+ - New/changed fields → update the relevant `model/*` class + its `from_hash`.
41
+ - New operation → add a method on the matching `resource/*` class (mirror the existing style).
42
+ - New input/output target types → extend `job_status.rb` / `input_type.rb`.
43
+ 4. **Do NOT change the hand-authored public API** (`convert`, `convert_async`, `download`, upload,
44
+ `wait`, webhook verification, error classes) unless `docs/SDK_CONTRACT.md` changes first. If a
45
+ real product change requires it, update the contract in the same change and bump the **major**
46
+ version.
47
+ 5. **Lint + test (the guardrail):**
48
+ ```console
49
+ bundle exec rake check # rubocop + unit specs + security suite — all must pass
50
+ ```
51
+ Add or update a golden spec for any new behavior. Keep the live conformance spec runnable.
52
+ 6. **Record + version.** Add a `docs/CHANGELOG.md` entry and bump `Api2Convert::VERSION` per SemVer
53
+ (additive spec change → minor; breaking public-surface change → major). The five SDKs version
54
+ together against the shared contract.
55
+
56
+ ## Guarantees to uphold (don't break these)
57
+
58
+ - **Never commit a real API key, token or secret** — not in source, specs, fixtures, examples, CI
59
+ files or commit messages. Keys come only from environment variables (`API2CONVERT_API_KEY`) or
60
+ masked/protected CI variables; specs use obvious fakes (`test-key`, `secret-key`, `whsec_test`).
61
+ The behat test keys live only in the `behat-api` repo's `behat.yml.dist_*` — supply one via the
62
+ env var when running `rake spec:live`, never paste it here. The SDK must never log or expose a
63
+ key/token in errors. Secret-scan before any release.
64
+ - **The contract is law.** Public method names, signatures and semantics match `docs/SDK_CONTRACT.md`
65
+ across every SDK language. Adapt only to Ruby idiom (see divergences below).
66
+ - **Upload uses the per-job `X-Oc-Token`, never the account key.** There is a spec for this.
67
+ - **Secret-bearing requests never follow redirects.** The key/token/download-password ride in custom
68
+ `X-Oc-*` headers. `Net::HTTP` does not follow redirects by default — the SDK relies on that and
69
+ only opts the no-secret download path into following redirects. `spec/security` proves the
70
+ guarantee with real loopback servers.
71
+ - **`convert` stays one call** for the common case (path/URL/IO → `to` → `save`).
72
+ - **Transient failures retry; failures surface as typed exceptions.** Never leak a raw
73
+ `Net::HTTP`/socket error (wrap it in `NetworkError`). A non-idempotent `POST` is never blindly
74
+ retried.
75
+ - **Ruby 3.1+, zero runtime dependencies, standard library only.** Don't add runtime deps.
76
+
77
+ ## Ruby-idiom divergences from the contract
78
+
79
+ The contract fixes names and semantics; these are the _only_ places Ruby deviates, all for idiom:
80
+
81
+ - **The client is `Api2Convert::Client`** (a module namespace can't be instantiated); the module-level
82
+ `Api2Convert.webhooks` mirrors the contract's client-less webhook verifier.
83
+ - **Method and option names are `snake_case`** (`convert_async`, `add_input`, `download_password`,
84
+ `poll_interval`).
85
+ - **The "extra" `convert` controls are Ruby keyword args** (`category:`, `timeout:`, `output_index:`,
86
+ `filename:`, `download_password:`), kept separate from the open-ended positional `options` Hash so
87
+ API option keys can never collide with SDK keys.
88
+ - **Exceptions are named `...Error`** and extend `StandardError`; the poll timeout is
89
+ `ConversionTimeoutError` (not shadowing `Timeout::Error`).
90
+ - **`Job` exposes predicates** `completed?` / `failed?` / `canceled?` / `terminal?` and keeps `raw`.
91
+ - **Models are frozen value objects with `from_hash` factories**, hydrated defensively via
92
+ `Support::Data` (tolerate missing/extra fields — never raise on a surprising payload).
93
+ - **The HTTP sender is a seam**: `Client.new(..., http_sender:, sleeper:, rng:)` injects a fake for
94
+ unit specs; the default is `Http::NetHttpSender`.
95
+
96
+ ## Conventions
97
+
98
+ - Resource methods are thin: build the request, call `Transport`, hydrate a model.
99
+ - Keep the README quickstart copy-pasteable; if you change the happy path, update the README example.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Qaamgo Media GmbH
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,164 @@
1
+ # API2Convert Ruby SDK
2
+
3
+ Official Ruby SDK for the [API2Convert](https://www.api2convert.com) file-conversion API.
4
+ Convert, compress and transform images, documents, audio, video, ebooks, archives and CAD —
5
+ and run operations like OCR, merge, thumbnail and website capture — in one line of code.
6
+
7
+ Zero runtime dependencies: built entirely on the Ruby standard library.
8
+
9
+ ## Installation
10
+
11
+ ```ruby
12
+ # Gemfile
13
+ gem "api2convert"
14
+ ```
15
+
16
+ ```console
17
+ $ bundle install
18
+ # or
19
+ $ gem install api2convert
20
+ ```
21
+
22
+ Requires Ruby 3.1+.
23
+
24
+ ## Quick start
25
+
26
+ ```ruby
27
+ require "api2convert"
28
+
29
+ client = Api2Convert::Client.new("YOUR_API_KEY")
30
+
31
+ # 1) From a local file
32
+ client.convert("photo.png", "jpg").save("photo.jpg")
33
+
34
+ # 2) From a URL
35
+ client.convert("https://example.com/photo.png", "jpg").save("photo.jpg")
36
+
37
+ # 3) With conversion options (discover them via client.options)
38
+ client.convert("photo.png", "jpg", { "quality" => 85, "width" => 1280, "height" => 720 })
39
+ .save("out/")
40
+ ```
41
+
42
+ The API key falls back to the `API2CONVERT_API_KEY` environment variable when you
43
+ construct the client without one.
44
+
45
+ ## Working with the result
46
+
47
+ ```ruby
48
+ result = client.convert("report.docx", "pdf")
49
+
50
+ result.save("report.pdf") # stream to a file path
51
+ result.save("downloads/") # ...or a directory (keeps the API filename)
52
+ bytes = result.contents # the raw bytes
53
+ url = result.url # just the (self-contained) download URL
54
+ ```
55
+
56
+ ## Password-protected output
57
+
58
+ ```ruby
59
+ result = client.convert("statement.docx", "pdf", download_password: "hunter2")
60
+ result.save("statement.pdf") # the password is remembered and applied automatically
61
+
62
+ # Or when you hold an OutputFile from elsewhere:
63
+ client.download(output, "hunter2").save("out/")
64
+ ```
65
+
66
+ ## Async + webhooks
67
+
68
+ ```ruby
69
+ job = client.convert_async("movie.mov", "mp4",
70
+ callback: "https://your-app.example.com/webhooks/api2convert")
71
+
72
+ # In your webhook handler (Rack-style):
73
+ payload = request.body.read
74
+ signature = request.get_header("HTTP_X_OC_SIGNATURE")
75
+
76
+ begin
77
+ event = Api2Convert.webhooks.construct_event(payload, signature, "YOUR_WEBHOOK_SECRET")
78
+ job = event.job
79
+ # react to job.status.code
80
+ rescue Api2Convert::SignatureVerificationError
81
+ # reject the request (400)
82
+ end
83
+ ```
84
+
85
+ ## Error handling
86
+
87
+ ```ruby
88
+ begin
89
+ client.convert("photo.png", "jpg").save("photo.jpg")
90
+ rescue Api2Convert::ValidationError # bad target / option
91
+ rescue Api2Convert::AuthenticationError # bad or missing API key
92
+ rescue Api2Convert::RateLimitError => e # too many requests — retry after e.retry_after
93
+ rescue Api2Convert::ConversionFailedError => e # the job failed — inspect e.errors
94
+ end
95
+ ```
96
+
97
+ Transient failures (429 / 5xx / network) are retried automatically with capped,
98
+ jittered exponential backoff; a non-idempotent `POST` is never blindly replayed.
99
+
100
+ ## Power user: the full job API
101
+
102
+ ```ruby
103
+ job = client.jobs.create(
104
+ "process" => false,
105
+ "conversion" => [{ "target" => "pdf", "options" => { "pdf_a" => true } }]
106
+ )
107
+
108
+ client.jobs.upload(job, "contract.docx") # local file
109
+ client.jobs.add_input(job.id, "type" => "remote",
110
+ "source" => "https://example.com/appendix.docx") # or URL
111
+
112
+ client.jobs.start(job.id)
113
+ done = client.jobs.wait(job.id, 120)
114
+
115
+ done.output.each { |output| client.download(output).save("out/") }
116
+ ```
117
+
118
+ ## Discover options
119
+
120
+ ```ruby
121
+ client.options("jpg") # => { "quality" => {...}, "width" => {...}, ... }
122
+ ```
123
+
124
+ ## Configuration
125
+
126
+ ```ruby
127
+ client = Api2Convert::Client.new(
128
+ "YOUR_API_KEY",
129
+ base_url: "https://api.api2convert.com/v2", # custom API host
130
+ timeout: 30, # per-request network timeout (seconds)
131
+ max_retries: 2, # automatic retries for transient failures
132
+ poll_interval: 1.0, # first poll interval (seconds)
133
+ poll_max_interval: 5.0, # backoff cap (seconds)
134
+ poll_timeout: 300 # give up waiting after this many seconds
135
+ )
136
+ ```
137
+
138
+ ## Development
139
+
140
+ ```console
141
+ $ bundle install
142
+ $ bundle exec rake spec # offline unit suite
143
+ $ bundle exec rake spec:security # independent security suite (real loopback servers)
144
+ $ bundle exec rake check # rubocop + unit + security — the guardrail
145
+ ```
146
+
147
+ Live conformance tests hit the real API and are skipped unless a key is present:
148
+
149
+ ```console
150
+ $ API2CONVERT_API_KEY=<your key> bundle exec rake spec:live
151
+ ```
152
+
153
+ If your machine's Ruby is older than the gem targets, run the guardrail on a
154
+ supported Ruby with the bundled `Dockerfile`:
155
+
156
+ ```console
157
+ $ docker build -t api2convert-ruby .
158
+ $ docker run --rm api2convert-ruby # rake check on Ruby 3.x
159
+ $ docker build --build-arg RUBY_VERSION=3.4 -t a2c:3.4 . # pin a specific version
160
+ ```
161
+
162
+ ## License
163
+
164
+ MIT © Qaamgo Media GmbH
data/docs/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ All notable changes to the API2Convert Ruby SDK are documented here. The five
4
+ official SDKs (PHP, Python, Java, Node.js, Ruby) version together against the
5
+ shared [`SDK_CONTRACT.md`](SDK_CONTRACT.md).
6
+
7
+ ## [Unreleased]
8
+
9
+ - Initial development; not yet publicly released.
@@ -0,0 +1,99 @@
1
+ # API2Convert SDK contract
2
+
3
+ The **language-agnostic** behavior contract every official API2Convert SDK implements. It is the
4
+ source of truth for the public surface and its semantics, and the spec that future ports
5
+ (TypeScript, Python, …) follow so the SDKs stay equivalent.
6
+
7
+ Two layers make up an SDK:
8
+
9
+ - **Derived layer** — typed models and one method per API operation. Tracks the OpenAPI spec
10
+ (`openapi/api2convert.openapi.json`); an AI update may freely change it to match the spec.
11
+ - **Hand-authored layer** — the ergonomics below (`convert`, upload, polling, download, webhook
12
+ verification). These flows are **not** in the spec. Their **public signatures and semantics are
13
+ fixed**: change them only when this document changes, and bump the major version when you do.
14
+
15
+ > When the two disagree, this contract wins for the hand-authored layer; the spec wins for the
16
+ > derived layer.
17
+
18
+ ## Protocol facts (the API the SDK speaks)
19
+
20
+ - Base URL `https://api.api2convert.com/v2`. Auth header `X-Oc-Api-Key: <key>` on account requests.
21
+ - **Create job** `POST /jobs` `{ conversion:[{category?,target,options?}], input?:[…], process:bool,
22
+ callback?, notify_status?, download_passwords?:[…] }` → response includes `id`, per-job `token`,
23
+ per-job upload `server`, and `status.code`. `download_passwords` protects every output of the job;
24
+ any password in the list then unlocks its downloads. The API never returns the plaintext back.
25
+ - **Upload** (not in the spec): `POST {server}/upload-file/{job_id}`, `multipart/form-data` field
26
+ `file`, authenticated with the per-job **`X-Oc-Token`** header — never the account key.
27
+ - **Add remote input**: `POST /jobs/{id}/input` `{ type:'remote', source:'https://…' }`.
28
+ - **Start**: `PATCH /jobs/{id}` `{ process:true }`.
29
+ - **Poll**: `GET /jobs/{id}` → terminal when `status.code ∈ {completed, failed, canceled}`
30
+ (`failed`/`canceled` are unsuccessful terminals; non-terminal: `created`, `incomplete`,
31
+ `downloading`, `queued`, `processing`, and any unknown code). Poll with backoff; clamp the
32
+ interval to a floor (never busy-loop) and the total wait to a ceiling (never poll unbounded).
33
+ - **Download**: `GET output.uri` — self-contained, no auth; `X-Oc-Download-Password` header if set.
34
+ - **Discover options**: `GET /conversions?category=&target=`.
35
+ - **Errors**: HTTP body `{ "message": "…" }`; job-level `errors[]` / `warnings[]` of
36
+ `{ source, id_source, code, message, details }`.
37
+
38
+ ## Public surface (every SDK must provide)
39
+
40
+ ### Client
41
+ - Construct with an API key (falling back to the `API2CONVERT_API_KEY` env var) and options
42
+ (`baseUrl`, `timeout`, `maxRetries`, `pollInterval`, `pollMaxInterval`, `pollTimeout`).
43
+ - `convert(input, to, options?, {category?, timeout?, outputIndex?, filename?, downloadPassword?}) →
44
+ ConversionResult` — create → (upload | remote input) → start → **poll to completion** → return.
45
+ `to` is the target format string; `options` is the conversion-options map (passed 1:1 to the API's
46
+ conversion `options`); the remaining controls are optional named/keyword arguments (never mixed
47
+ into the options map, so open-ended API options can't collide with SDK keys). `input` is a local
48
+ path, a URL (`^https?://`), or a stream. A URL is sent as a single started job with a `remote`
49
+ input; anything else is staged, uploaded, then started. `downloadPassword`, when given, is sent as
50
+ the job's `download_passwords` and **remembered on the returned result** so its downloads apply it
51
+ automatically (see below).
52
+ - `convertAsync(input, to, options?, {callback?, category?, filename?, downloadPassword?}) → Job` —
53
+ same, but returns once started without polling; sets `notify_status: true` when a `callback` is
54
+ given. `downloadPassword` sets the job's `download_passwords` (a later download must supply it,
55
+ since the returned `Job` is not a result wrapper).
56
+ - `download(output, downloadPassword?) → FileDownload`.
57
+ - `options(target, category?) → map` — discover the valid conversion options for a target
58
+ (category optional). Sugar for `conversions().options(target, category?)`.
59
+ - Resource accessors: `jobs()`, `conversions()`, `presets()`, `stats()`, `contracts()`.
60
+ - `webhooks()` — usable without a configured client (static/standalone).
61
+
62
+ ### ConversionResult / FileDownload
63
+ - `save(pathOrDir, downloadPassword?) → path` — streams to disk; a directory keeps the API filename.
64
+ - `contents(downloadPassword?) → binary`, `url() → string`, `output()`, `outputs()`.
65
+ - **Download-password transparency**: a password supplied at conversion time (`convert(...,
66
+ downloadPassword)`) or to `download(output, downloadPassword)` is remembered and sent as the
67
+ `X-Oc-Download-Password` header on every download from that result/handle — callers do not
68
+ re-supply it. An explicit `downloadPassword` argument to `save()` / `contents()` overrides the
69
+ remembered one for that call.
70
+
71
+ ### Jobs resource
72
+ - `create(payload, idempotencyKey?)`, `get(id)`, `list(status?, page?)`, `update(id, payload)`,
73
+ `start(id)`, `cancel(id)`, `addInput(id, input)`, `upload(job, file, filename?)`, `outputs(id)`.
74
+ - `wait(id, timeoutSeconds?, throwOnFailure=true)` — poll with backoff until terminal; raise
75
+ `ConversionFailedException` on `failed`/`canceled` (unless disabled), `TimeoutException` past the
76
+ deadline. Interval is floored and the total wait is capped, so no configuration can busy-loop or
77
+ poll unbounded.
78
+
79
+ ### Webhooks
80
+ - `constructEvent(rawBody, signature, secret) → WebhookEvent` — verify HMAC-SHA256 (matching the
81
+ server's signed-webhooks scheme) then deserialize; raise `SignatureVerificationException` on a
82
+ missing/wrong signature. Empty secret skips verification.
83
+ - `parse(rawBody) → WebhookEvent` — deserialize without verifying (pre-signed-webhooks).
84
+
85
+ ## Cross-cutting semantics
86
+ - **Auth**: account key as `X-Oc-Api-Key`; uploads use the per-job token.
87
+ - **Retries**: automatically retry with capped, jittered exponential backoff, honoring `Retry-After`
88
+ (delay-seconds or HTTP-date form, clamped to a ceiling). `429` is retried for every method; `5xx`
89
+ and network errors are retried only for idempotent methods (`GET`/`HEAD`/`PUT`/`DELETE`/`OPTIONS`/
90
+ `TRACE`) or a request carrying an `Idempotency-Key`, so a bare non-idempotent `POST` is never
91
+ blindly re-sent (no duplicate jobs). Only replayable (seekable/empty) bodies are retried at all.
92
+ Surface the failure as a typed exception once retries are exhausted.
93
+ - **Forward-compat headers**: send an `Idempotency-Key` on create when supplied; read `Retry-After`
94
+ / `RateLimit-*`; capture `X-Request-Id` onto exceptions. All degrade gracefully if absent today.
95
+ - **Errors** map by status: 400/422 → validation, 401/403 → auth, 402 → payment, 404 → not-found,
96
+ 429 → rate-limit (with `retryAfter`), 5xx → server, other 4xx → generic API error. A failed job →
97
+ conversion-failed (carrying the job and its `errors`).
98
+ - **Naming**: method names and option keys are identical across languages, adapted only to each
99
+ language's idiom (camelCase / snake_case).
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Minimal synchronous conversion example.
4
+ #
5
+ # API2CONVERT_API_KEY=<your key> ruby -Ilib examples/convert.rb path/to/photo.png jpg out.jpg
6
+ #
7
+ # Falls back to converting a public sample image when no source is given.
8
+
9
+ require "api2convert"
10
+
11
+ source = ARGV[0] || "https://example-files.online-convert.com/raster%20image/jpg/example.jpg"
12
+ target = ARGV[1] || "png"
13
+ destination = ARGV[2] || "output.#{target}"
14
+
15
+ client = Api2Convert::Client.new # reads API2CONVERT_API_KEY
16
+
17
+ result = client.convert(source, target)
18
+ path = result.save(destination)
19
+
20
+ puts "Converted #{source} -> #{path} (#{File.size(path)} bytes)"
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A tiny Rack app that verifies API2Convert webhook callbacks.
4
+ #
5
+ # API2CONVERT_WEBHOOK_SECRET=<your secret> rackup -Ilib examples/webhook.rb
6
+ #
7
+ # Point your job's `callback` URL at this endpoint (see convert_async). Until
8
+ # signed webhooks are enabled for your account no signature is sent — leave the
9
+ # secret empty to skip verification, or use Api2Convert.webhooks.parse.
10
+
11
+ require "api2convert"
12
+ require "rack"
13
+
14
+ SECRET = ENV.fetch("API2CONVERT_WEBHOOK_SECRET", "")
15
+
16
+ app = lambda do |env|
17
+ request = Rack::Request.new(env)
18
+ payload = request.body.read
19
+ signature = env["HTTP_X_OC_SIGNATURE"]
20
+
21
+ begin
22
+ event = Api2Convert.webhooks.construct_event(payload, signature, SECRET)
23
+ job = event.job
24
+ warn "job #{job.id} is now #{job.status.code}"
25
+ [200, { "content-type" => "text/plain" }, ["ok"]]
26
+ rescue Api2Convert::SignatureVerificationError => e
27
+ warn "rejected webhook: #{e.message}"
28
+ [400, { "content-type" => "text/plain" }, ["invalid signature"]]
29
+ end
30
+ end
31
+
32
+ run app
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Api2Convert
4
+ # The API2Convert client — convert, compress and transform files with one call.
5
+ #
6
+ # {#convert} hides the multi-step job lifecycle (create -> upload -> start ->
7
+ # poll -> download). For full control, use {#jobs} and the other resources.
8
+ #
9
+ # Quick start:
10
+ #
11
+ # client = Api2Convert::Client.new("YOUR_API_KEY")
12
+ # client.convert("invoice.docx", "pdf").save("invoice.pdf")
13
+ class Client
14
+ # Matches a source that is a public URL (sent as a `remote` input) rather than
15
+ # a local path. Anchored and linear — ReDoS-safe against a pathological input.
16
+ URL_RE = %r{\Ahttps?://}i
17
+
18
+ # Build the client. +api_key+ falls back to the `API2CONVERT_API_KEY`
19
+ # environment variable when empty.
20
+ #
21
+ # Options: +base_url+, +timeout+, +max_retries+, +poll_interval+,
22
+ # +poll_max_interval+, +poll_timeout+. For testing, inject +http_sender+ (an
23
+ # object responding to `call(request)`), +sleeper+ (a proc) and +rng+ (a proc).
24
+ def initialize(api_key = "", base_url: nil, timeout: nil, max_retries: nil,
25
+ poll_interval: nil, poll_max_interval: nil, poll_timeout: nil,
26
+ http_sender: nil, sleeper: nil, rng: nil)
27
+ api_key = api_key.to_s
28
+ api_key = ENV["API2CONVERT_API_KEY"].to_s if api_key.empty?
29
+ if api_key.empty?
30
+ raise ArgumentError,
31
+ "No API key provided. Pass it to the constructor or set the " \
32
+ "API2CONVERT_API_KEY environment variable."
33
+ end
34
+
35
+ config = Config.create(
36
+ api_key,
37
+ base_url: base_url, timeout: timeout, max_retries: max_retries,
38
+ poll_interval: poll_interval, poll_max_interval: poll_max_interval,
39
+ poll_timeout: poll_timeout
40
+ )
41
+ sender = http_sender || Http::NetHttpSender.new(timeout: config.timeout)
42
+ @transport = Http::Transport.new(sender, config, sleeper: sleeper, rng: rng)
43
+
44
+ uploader = Upload::FileUploader.new(@transport)
45
+ @jobs = Resource::Jobs.new(@transport, uploader)
46
+ @conversions = Resource::Conversions.new(@transport)
47
+ @presets = Resource::Presets.new(@transport)
48
+ @stats = Resource::Stats.new(@transport)
49
+ @contracts = Resource::Contracts.new(@transport)
50
+ end
51
+
52
+ # Convert a file and wait for the result.
53
+ #
54
+ # Hand it a local path, a public URL, or an open IO, name the target format,
55
+ # and get back a result you can `save`. +options+ are the target-specific
56
+ # conversion options (discover them via {#options}). A +download_password+ is
57
+ # remembered and applied automatically on download.
58
+ #
59
+ # @return [Result::ConversionResult]
60
+ def convert(source, to, options = nil, category: nil, timeout: nil,
61
+ output_index: nil, filename: nil, download_password: nil)
62
+ job = start_conversion(source, to, options, category, nil, filename, download_password)
63
+ done = @jobs.wait(job.id, timeout)
64
+ Result::ConversionResult.new(done, @transport, output_index.nil? ? 0 : output_index, download_password)
65
+ end
66
+
67
+ # Start a conversion without waiting.
68
+ #
69
+ # Pass a +callback+ URL to be notified (sets `notify_status`), or poll later
70
+ # with `client.jobs.get(job.id)` / `client.jobs.wait(job.id)`.
71
+ #
72
+ # @return [Model::Job]
73
+ def convert_async(source, to, options = nil, callback: nil, category: nil,
74
+ filename: nil, download_password: nil)
75
+ start_conversion(source, to, options, category, callback, filename, download_password)
76
+ end
77
+
78
+ # A {Result::FileDownload} for an output file. A +download_password+ is
79
+ # remembered and sent automatically on download (overridable per call).
80
+ def download(output, download_password = nil)
81
+ Result::FileDownload.new(@transport, output, download_password)
82
+ end
83
+
84
+ # Discover the valid options (type / enum / default / range) for a target.
85
+ def options(target, category = nil)
86
+ @conversions.options(target, category)
87
+ end
88
+
89
+ attr_reader :jobs, :conversions, :presets, :stats, :contracts
90
+
91
+ # Webhook verifier — usable without a configured client.
92
+ def self.webhooks
93
+ Webhook::Verifier.new
94
+ end
95
+
96
+ # No persistent connection is held (the transport opens per request), so this
97
+ # is a no-op provided for symmetry with the sibling SDKs.
98
+ def close
99
+ nil
100
+ end
101
+
102
+ # Redacted representation. The default `#inspect` would recurse into the
103
+ # transport and its config and dump the API key in cleartext, so it is
104
+ # overridden to surface only the (non-secret) base URL.
105
+ def inspect
106
+ "#<#{self.class.name} base_url=#{@transport.config.base_url.inspect}>"
107
+ end
108
+
109
+ def to_s
110
+ inspect
111
+ end
112
+
113
+ private
114
+
115
+ def start_conversion(source, to, options, category, callback, filename, download_password)
116
+ conversion = { "target" => to }
117
+ conversion["category"] = category unless category.nil?
118
+ conversion["options"] = options if !options.nil? && !options.empty?
119
+
120
+ payload = { "conversion" => [conversion] }
121
+ unless callback.nil?
122
+ payload["callback"] = callback
123
+ payload["notify_status"] = true
124
+ end
125
+ payload["download_passwords"] = [download_password] unless download_password.nil?
126
+
127
+ if source.is_a?(String) && source =~ URL_RE
128
+ payload["process"] = true
129
+ payload["input"] = [{ "type" => "remote", "source" => source }]
130
+ return @jobs.create(payload)
131
+ end
132
+
133
+ payload["process"] = false
134
+ created = @jobs.create(payload)
135
+ @jobs.upload(created, source, filename)
136
+ @jobs.start(created.id)
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Api2Convert
4
+ # Immutable client configuration.
5
+ #
6
+ # Build via {Config.create}, which clamps every knob so a caller value can
7
+ # neither busy-loop the poll (interval floor) nor poll unbounded (timeout
8
+ # ceiling).
9
+ class Config
10
+ # Default API base URL — includes the `/v2` path segment, no trailing slash.
11
+ DEFAULT_BASE_URL = "https://api.api2convert.com/v2"
12
+
13
+ # Hard floor for the job-poll interval (seconds); prevents a busy-spin self-DDOS.
14
+ MIN_POLL_INTERVAL = 0.5
15
+
16
+ # Hard ceiling for the total job-poll timeout (4 hours); bounds an unbounded poll.
17
+ MAX_POLL_TIMEOUT = 14_400
18
+
19
+ attr_reader :api_key, :base_url, :timeout, :max_retries,
20
+ :poll_interval, :poll_max_interval, :poll_timeout
21
+
22
+ # The constructor does not clamp — use {create} (the single entry point the
23
+ # client uses) so a caller value can never busy-loop or poll unbounded.
24
+ def initialize(api_key:, base_url:, timeout:, max_retries:,
25
+ poll_interval:, poll_max_interval:, poll_timeout:)
26
+ @api_key = api_key
27
+ @base_url = base_url
28
+ @timeout = timeout
29
+ @max_retries = max_retries
30
+ @poll_interval = poll_interval
31
+ @poll_max_interval = poll_max_interval
32
+ @poll_timeout = poll_timeout
33
+ freeze
34
+ end
35
+
36
+ def self.create(api_key, base_url: nil, timeout: nil, max_retries: nil,
37
+ poll_interval: nil, poll_max_interval: nil, poll_timeout: nil)
38
+ interval = [MIN_POLL_INTERVAL, (poll_interval.nil? ? 1.0 : poll_interval).to_f].max
39
+ max_interval = [interval, (poll_max_interval.nil? ? 5.0 : poll_max_interval).to_f].max
40
+ timeout_value = (poll_timeout.nil? ? 300 : poll_timeout).to_i.clamp(0, MAX_POLL_TIMEOUT)
41
+
42
+ new(
43
+ api_key: api_key,
44
+ base_url: (base_url.nil? ? DEFAULT_BASE_URL : base_url).sub(%r{/+\z}, ""),
45
+ timeout: [1, (timeout.nil? ? 30 : timeout).to_i].max,
46
+ max_retries: [0, (max_retries.nil? ? 2 : max_retries).to_i].max,
47
+ poll_interval: interval,
48
+ poll_max_interval: max_interval,
49
+ poll_timeout: timeout_value
50
+ )
51
+ end
52
+
53
+ # Redacted representation — the API key is masked so it can never be printed
54
+ # in cleartext by `p config`, a logger, or an object dumped in a backtrace.
55
+ def inspect
56
+ "#<#{self.class.name} api_key=#{Support::Secret.mask(@api_key)} " \
57
+ "base_url=#{@base_url.inspect} timeout=#{@timeout} max_retries=#{@max_retries} " \
58
+ "poll_interval=#{@poll_interval} poll_max_interval=#{@poll_max_interval} " \
59
+ "poll_timeout=#{@poll_timeout}>"
60
+ end
61
+
62
+ def to_s
63
+ inspect
64
+ end
65
+ end
66
+ end