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.
- checksums.yaml +7 -0
- data/AGENTS.md +99 -0
- data/LICENSE +21 -0
- data/README.md +164 -0
- data/docs/CHANGELOG.md +9 -0
- data/docs/SDK_CONTRACT.md +99 -0
- data/examples/convert.rb +20 -0
- data/examples/webhook.rb +32 -0
- data/lib/api2convert/client.rb +139 -0
- data/lib/api2convert/config.rb +66 -0
- data/lib/api2convert/errors.rb +121 -0
- data/lib/api2convert/http/net_http_sender.rb +148 -0
- data/lib/api2convert/http/request.rb +47 -0
- data/lib/api2convert/http/response.rb +23 -0
- data/lib/api2convert/http/transport.rb +290 -0
- data/lib/api2convert/input_type.rb +16 -0
- data/lib/api2convert/job_status.rb +29 -0
- data/lib/api2convert/model/conversion.rb +30 -0
- data/lib/api2convert/model/input_file.rb +37 -0
- data/lib/api2convert/model/job.rb +66 -0
- data/lib/api2convert/model/job_message.rb +30 -0
- data/lib/api2convert/model/output_file.rb +40 -0
- data/lib/api2convert/model/preset.rb +32 -0
- data/lib/api2convert/model/status.rb +24 -0
- data/lib/api2convert/resource/contracts.rb +16 -0
- data/lib/api2convert/resource/conversions.rb +33 -0
- data/lib/api2convert/resource/jobs.rb +112 -0
- data/lib/api2convert/resource/presets.rb +43 -0
- data/lib/api2convert/resource/stats.rb +35 -0
- data/lib/api2convert/result.rb +185 -0
- data/lib/api2convert/support/data.rb +89 -0
- data/lib/api2convert/support/secret.rb +27 -0
- data/lib/api2convert/upload/file_uploader.rb +87 -0
- data/lib/api2convert/upload/multipart_stream.rb +74 -0
- data/lib/api2convert/version.rb +8 -0
- data/lib/api2convert/webhook/event.rb +23 -0
- data/lib/api2convert/webhook/verifier.rb +74 -0
- data/lib/api2convert.rb +62 -0
- data/openapi/api2convert.openapi.json +3325 -0
- 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).
|
data/examples/convert.rb
ADDED
|
@@ -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)"
|
data/examples/webhook.rb
ADDED
|
@@ -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
|