api2convert 10.2.0 → 10.3.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 +4 -4
- data/AGENTS.md +1 -1
- data/README.md +43 -0
- data/docs/CHANGELOG.md +4 -2
- data/docs/SDK_CONTRACT.md +57 -6
- data/examples/add_watermark.rb +36 -0
- data/examples/audio_operations.rb +21 -0
- data/examples/authentication.rb +13 -0
- data/examples/capture_website.rb +29 -0
- data/examples/compare_files.rb +34 -0
- data/examples/compress_files.rb +18 -0
- data/examples/convert_files.rb +24 -0
- data/examples/create_archives.rb +29 -0
- data/examples/create_hashes.rb +19 -0
- data/examples/create_thumbnails.rb +22 -0
- data/examples/extract_assets.rb +16 -0
- data/examples/file_analysis.rb +17 -0
- data/examples/image_operations.rb +21 -0
- data/examples/job_lifecycle.rb +29 -0
- data/examples/presets.rb +14 -0
- data/examples/quickstart.rb +22 -0
- data/examples/rate_limits.rb +12 -0
- data/examples/statistics.rb +12 -0
- data/examples/uploading_files.rb +27 -0
- data/examples/webhooks.rb +24 -0
- data/lib/api2convert/client.rb +29 -5
- data/lib/api2convert/cloud_provider.rb +29 -0
- data/lib/api2convert/http/net_http_sender.rb +31 -3
- data/lib/api2convert/http/request.rb +7 -5
- data/lib/api2convert/http/transport.rb +18 -3
- data/lib/api2convert/model/cloud_input.rb +108 -0
- data/lib/api2convert/model/conversion.rb +7 -3
- data/lib/api2convert/model/input_file.rb +8 -3
- data/lib/api2convert/model/output_target.rb +78 -0
- data/lib/api2convert/resource/jobs.rb +6 -2
- data/lib/api2convert/resource/stats.rb +2 -1
- data/lib/api2convert/result.rb +51 -20
- data/lib/api2convert/support/data.rb +8 -1
- data/lib/api2convert/support/redactor.rb +83 -0
- data/lib/api2convert/upload/file_uploader.rb +2 -2
- data/lib/api2convert/version.rb +1 -1
- data/lib/api2convert.rb +4 -0
- data/openapi/api2convert.openapi.json +51 -51
- metadata +26 -3
- data/examples/convert.rb +0 -20
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Webhooks — start a conversion and be notified via a callback URL.
|
|
4
|
+
#
|
|
5
|
+
# API2CONVERT_API_KEY=<your key> ruby -Ilib examples/webhooks.rb
|
|
6
|
+
#
|
|
7
|
+
# convert_async returns immediately with a started job; the API POSTs to your
|
|
8
|
+
# callback when it finishes. See examples/webhook.rb for a receiver that verifies
|
|
9
|
+
# the callback signature.
|
|
10
|
+
|
|
11
|
+
require "api2convert"
|
|
12
|
+
|
|
13
|
+
DOCX = "https://example-files.online-convert.com/document/docx/example.docx"
|
|
14
|
+
|
|
15
|
+
client = Api2Convert::Client.new # reads API2CONVERT_API_KEY
|
|
16
|
+
|
|
17
|
+
job = client.convert_async(
|
|
18
|
+
DOCX, "pdf",
|
|
19
|
+
callback: "https://your-app.example.com/api2convert/webhook",
|
|
20
|
+
category: "document"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
puts "started job #{job.id} (#{job.status.code})"
|
|
24
|
+
puts "the API will POST to the callback URL when it completes"
|
data/lib/api2convert/client.rb
CHANGED
|
@@ -57,9 +57,18 @@ module Api2Convert
|
|
|
57
57
|
# remembered and applied automatically on download.
|
|
58
58
|
#
|
|
59
59
|
# @return [Result::ConversionResult]
|
|
60
|
+
# A {Model::CloudInput} imports the source straight from customer storage (a
|
|
61
|
+
# started job, like a remote URL). Pass +output_targets+ (a list of
|
|
62
|
+
# {Model::OutputTarget}) to deliver the output(s) to customer storage instead
|
|
63
|
+
# of producing a downloadable file — the job then completes with **no** local
|
|
64
|
+
# output and the returned result is not downloaded (calling `output`/`save`
|
|
65
|
+
# on it would have nothing to fetch). Output targets are attached to the
|
|
66
|
+
# conversion's `output_target` and never merged into +options+.
|
|
60
67
|
def convert(source, to, options = nil, category: nil, timeout: nil,
|
|
61
|
-
output_index: nil, filename: nil, download_password: nil
|
|
62
|
-
|
|
68
|
+
output_index: nil, filename: nil, download_password: nil,
|
|
69
|
+
output_targets: nil)
|
|
70
|
+
job = start_conversion(source, to, options, category, nil, filename,
|
|
71
|
+
download_password, output_targets)
|
|
63
72
|
done = @jobs.wait(job.id, timeout)
|
|
64
73
|
Result::ConversionResult.new(done, @transport, output_index.nil? ? 0 : output_index, download_password)
|
|
65
74
|
end
|
|
@@ -71,8 +80,9 @@ module Api2Convert
|
|
|
71
80
|
#
|
|
72
81
|
# @return [Model::Job]
|
|
73
82
|
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,
|
|
83
|
+
filename: nil, download_password: nil, output_targets: nil)
|
|
84
|
+
start_conversion(source, to, options, category, callback, filename,
|
|
85
|
+
download_password, output_targets)
|
|
76
86
|
end
|
|
77
87
|
|
|
78
88
|
# A {Result::FileDownload} for an output file. A +download_password+ is
|
|
@@ -112,10 +122,16 @@ module Api2Convert
|
|
|
112
122
|
|
|
113
123
|
private
|
|
114
124
|
|
|
115
|
-
def start_conversion(source, to, options, category, callback, filename,
|
|
125
|
+
def start_conversion(source, to, options, category, callback, filename,
|
|
126
|
+
download_password, output_targets = nil)
|
|
116
127
|
conversion = { "target" => to }
|
|
117
128
|
conversion["category"] = category unless category.nil?
|
|
118
129
|
conversion["options"] = options if !options.nil? && !options.empty?
|
|
130
|
+
# Cloud delivery targets attach to the conversion's `output_target` — never
|
|
131
|
+
# merged into the options map.
|
|
132
|
+
unless output_targets.nil? || output_targets.empty?
|
|
133
|
+
conversion["output_target"] = Array(output_targets).map(&:to_h)
|
|
134
|
+
end
|
|
119
135
|
|
|
120
136
|
payload = { "conversion" => [conversion] }
|
|
121
137
|
unless callback.nil?
|
|
@@ -124,6 +140,14 @@ module Api2Convert
|
|
|
124
140
|
end
|
|
125
141
|
payload["download_passwords"] = [download_password] unless download_password.nil?
|
|
126
142
|
|
|
143
|
+
# A cloud input imports from customer storage — a started job with the
|
|
144
|
+
# descriptor inline, exactly like a remote URL (never staged/uploaded).
|
|
145
|
+
if source.is_a?(Model::CloudInput)
|
|
146
|
+
payload["process"] = true
|
|
147
|
+
payload["input"] = [source.to_h]
|
|
148
|
+
return @jobs.create(payload)
|
|
149
|
+
end
|
|
150
|
+
|
|
127
151
|
if source.is_a?(String) && source =~ URL_RE
|
|
128
152
|
payload["process"] = true
|
|
129
153
|
payload["input"] = [{ "type" => "remote", "source" => source }]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Api2Convert
|
|
4
|
+
# The cloud storage providers the API can import inputs from and deliver
|
|
5
|
+
# outputs to — the values of a cloud descriptor's `source` (input) / `type`
|
|
6
|
+
# (output) field.
|
|
7
|
+
#
|
|
8
|
+
# This is **build-side vocabulary only**: it types the input builder
|
|
9
|
+
# ({Model::CloudInput}) and output-target serialization ({Model::OutputTarget}).
|
|
10
|
+
# Read models keep `source`/`type`/`status` as raw strings, so an unknown
|
|
11
|
+
# provider string returned by the server round-trips untyped and never raises —
|
|
12
|
+
# there is deliberately no strict parse.
|
|
13
|
+
#
|
|
14
|
+
# Import support (a {Model::CloudInput} factory) exists for {AMAZON_S3},
|
|
15
|
+
# {AZURE}, {FTP} and {GOOGLE_CLOUD}. {GDRIVE} and {YOUTUBE} are **output-only**
|
|
16
|
+
# (they validate as an output `type` but have no downloader); Google Drive
|
|
17
|
+
# *input* uses the separate `gdrive_picker` input type.
|
|
18
|
+
module CloudProvider
|
|
19
|
+
AMAZON_S3 = "amazons3"
|
|
20
|
+
AZURE = "azure"
|
|
21
|
+
FTP = "ftp"
|
|
22
|
+
GDRIVE = "gdrive"
|
|
23
|
+
GOOGLE_CLOUD = "googlecloud"
|
|
24
|
+
YOUTUBE = "youtube"
|
|
25
|
+
|
|
26
|
+
# The full provider vocabulary, in canonical order.
|
|
27
|
+
ALL = [AMAZON_S3, AZURE, FTP, GDRIVE, GOOGLE_CLOUD, YOUTUBE].freeze
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -13,7 +13,7 @@ module Api2Convert
|
|
|
13
13
|
# `Net::HTTP` does not follow redirects on its own — this SDK relies on that.
|
|
14
14
|
# A request only follows a 3xx when {Request#follow_redirects} is true (the
|
|
15
15
|
# no-secret download path); a redirect is then re-issued as a bare GET carrying
|
|
16
|
-
# only `Accept`/`User-Agent`, so a custom `X-
|
|
16
|
+
# only `Accept`/`User-Agent`, so a custom `X-Api2convert-*` secret header can never be
|
|
17
17
|
# forwarded to the redirect target.
|
|
18
18
|
#
|
|
19
19
|
# An {HttpSender} is any object responding to `call(request) -> Response`. Unit
|
|
@@ -23,6 +23,15 @@ module Api2Convert
|
|
|
23
23
|
MAX_REDIRECTS = 5
|
|
24
24
|
REDIRECT_CODES = [301, 302, 303, 307, 308].freeze
|
|
25
25
|
|
|
26
|
+
# Caps how much of a control-plane (API / error) JSON body the SDK buffers
|
|
27
|
+
# into memory, so a hostile or buggy server cannot force an unbounded read
|
|
28
|
+
# (OOM) on that path. Mirrors the shipped Go SDK's `maxResponseBytes = 16 <<
|
|
29
|
+
# 20` (transport.go), whose readAllAndClose reads through an
|
|
30
|
+
# io.LimitReader(rc, maxResponseBytes). File downloads are streamed straight
|
|
31
|
+
# to the sink (never buffered) and bounded separately, so this cap covers the
|
|
32
|
+
# buffered control-plane path only.
|
|
33
|
+
MAX_RESPONSE_BYTES = 16 * 1024 * 1024 # 16 MiB
|
|
34
|
+
|
|
26
35
|
# Errors worth surfacing when they strike mid-stream (after bytes have
|
|
27
36
|
# already reached the sink). They are re-raised as {NetworkError} so the
|
|
28
37
|
# transport does NOT retry — replaying would re-stream the whole body and
|
|
@@ -83,13 +92,13 @@ module Api2Convert
|
|
|
83
92
|
stream_body(res, sink)
|
|
84
93
|
result = to_response(res, "")
|
|
85
94
|
else
|
|
86
|
-
result = to_response(res, res
|
|
95
|
+
result = to_response(res, read_capped_body(res))
|
|
87
96
|
end
|
|
88
97
|
end
|
|
89
98
|
end
|
|
90
99
|
|
|
91
100
|
if redirect_to
|
|
92
|
-
# Re-issue as a bare GET carrying only non-secret headers, so no X-
|
|
101
|
+
# Re-issue as a bare GET carrying only non-secret headers, so no X-Api2convert-*
|
|
93
102
|
# secret header is ever forwarded to the redirect target.
|
|
94
103
|
safe = {}
|
|
95
104
|
%w[Accept User-Agent].each { |k| safe[k] = headers[k] unless headers[k].nil? }
|
|
@@ -100,6 +109,25 @@ module Api2Convert
|
|
|
100
109
|
result
|
|
101
110
|
end
|
|
102
111
|
|
|
112
|
+
# Buffer a control-plane (API / error) response body into memory, bounded by
|
|
113
|
+
# {MAX_RESPONSE_BYTES}. Because `Net::HTTP` is used in block form the body is
|
|
114
|
+
# not read until this runs, so — unlike an SDK-side cap applied after a whole
|
|
115
|
+
# `res.body` — we accumulate chunk-by-chunk and abort the instant the total
|
|
116
|
+
# crosses the cap, before an over-cap body is ever fully resident. Mirrors the
|
|
117
|
+
# Go SDK's io.LimitReader-wrapped read. Only the cap is raised here; genuine
|
|
118
|
+
# transport errors propagate exactly as `res.body` would, so the retry loop
|
|
119
|
+
# still classifies them.
|
|
120
|
+
def read_capped_body(res)
|
|
121
|
+
buffer = +""
|
|
122
|
+
res.read_body do |chunk|
|
|
123
|
+
buffer << chunk
|
|
124
|
+
next unless buffer.bytesize > MAX_RESPONSE_BYTES
|
|
125
|
+
|
|
126
|
+
raise Api2Convert::NetworkError, "API response body exceeds 16 MiB"
|
|
127
|
+
end
|
|
128
|
+
buffer
|
|
129
|
+
end
|
|
130
|
+
|
|
103
131
|
def stream_body(res, sink)
|
|
104
132
|
res.read_body { |chunk| sink.write(chunk) }
|
|
105
133
|
rescue *STREAM_ERRORS => e
|
|
@@ -11,7 +11,7 @@ module Api2Convert
|
|
|
11
11
|
class Request
|
|
12
12
|
attr_reader :method, :url, :headers, :body, :body_stream, :content_length, :response_sink
|
|
13
13
|
# Whether the sender may follow a 3xx redirect. Set by the transport per
|
|
14
|
-
# request: authenticated requests carry a secret in a custom `X-
|
|
14
|
+
# request: authenticated requests carry a secret in a custom `X-Api2convert-*` header,
|
|
15
15
|
# so they must NOT follow redirects (the header could leak to another host).
|
|
16
16
|
attr_accessor :follow_redirects
|
|
17
17
|
|
|
@@ -29,12 +29,14 @@ module Api2Convert
|
|
|
29
29
|
@response_sink = response_sink
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
# Redacted representation — {#headers} carries the raw `X-
|
|
33
|
-
# `X-
|
|
34
|
-
# them in cleartext in a log line or a backtrace. Mask every `X-
|
|
32
|
+
# Redacted representation — {#headers} carries the raw `X-Api2convert-Api-Key` /
|
|
33
|
+
# `X-Api2convert-Download-Password` secrets, so the default `#inspect` would print
|
|
34
|
+
# them in cleartext in a log line or a backtrace. Mask every `X-Api2convert-*` value
|
|
35
|
+
# (and the legacy `X-Oc-*` prefix, so a redaction gap can never open up).
|
|
35
36
|
def inspect
|
|
36
37
|
safe = @headers.to_h do |key, value|
|
|
37
|
-
|
|
38
|
+
secret = key.to_s.downcase.start_with?("x-api2convert-", "x-oc-")
|
|
39
|
+
[key, secret ? Support::Secret.mask(value) : value]
|
|
38
40
|
end
|
|
39
41
|
"#<#{self.class.name} method=#{@method.inspect} url=#{@url.inspect} headers=#{safe.inspect}>"
|
|
40
42
|
end
|
|
@@ -61,7 +61,7 @@ module Api2Convert
|
|
|
61
61
|
|
|
62
62
|
# Perform an authenticated JSON request and return the decoded body.
|
|
63
63
|
def request(method, path, body = nil, query = nil, headers = nil)
|
|
64
|
-
request_headers = { "X-
|
|
64
|
+
request_headers = { "X-Api2convert-Api-Key" => @config.api_key }
|
|
65
65
|
request_headers.merge!(headers) unless headers.nil?
|
|
66
66
|
content = nil
|
|
67
67
|
unless body.nil?
|
|
@@ -83,7 +83,7 @@ module Api2Convert
|
|
|
83
83
|
# a non-seekable body so it is sent once.
|
|
84
84
|
#
|
|
85
85
|
# +follow_redirects+ defaults to false: authenticated requests carry a secret
|
|
86
|
-
# in a custom `X-
|
|
86
|
+
# in a custom `X-Api2convert-*` header, which a redirect could leak to another host.
|
|
87
87
|
# Only the self-contained download path (no account key) opts in.
|
|
88
88
|
def send_request(build, replayable: true, follow_redirects: false)
|
|
89
89
|
attempt = 0
|
|
@@ -125,6 +125,15 @@ module Api2Convert
|
|
|
125
125
|
def interpret(response)
|
|
126
126
|
ensure_successful(response)
|
|
127
127
|
|
|
128
|
+
# Every API request rides the no-follow path (secrets travel in X-Api2convert-* headers), so a 3xx
|
|
129
|
+
# passes ensure_successful (status < 400) but was deliberately not followed; decoding its
|
|
130
|
+
# body would yield an empty model. Surface it as a typed error instead.
|
|
131
|
+
status = response.status
|
|
132
|
+
if status >= 300 && status < 400
|
|
133
|
+
raise NetworkError, "API2Convert returned an unexpected redirect (HTTP #{status}); " \
|
|
134
|
+
"the request was not followed."
|
|
135
|
+
end
|
|
136
|
+
|
|
128
137
|
raw = response.body
|
|
129
138
|
return {} if raw.nil? || raw.empty?
|
|
130
139
|
|
|
@@ -143,7 +152,13 @@ module Api2Convert
|
|
|
143
152
|
status = response.status
|
|
144
153
|
return if status < 400
|
|
145
154
|
|
|
146
|
-
body
|
|
155
|
+
# Belt-and-suspenders: deep-redact the decoded error body before it lands
|
|
156
|
+
# on the exception. Cloud credentials ride in the plaintext request body;
|
|
157
|
+
# the API only ever echoes field *names* (never a value), but a future
|
|
158
|
+
# server/proxy change must not be able to surface a secret through
|
|
159
|
+
# `error.body`. The `message` is server-provided text, never derived from
|
|
160
|
+
# the request body.
|
|
161
|
+
body = Support::Redactor.redact_body(decode_safe(response))
|
|
147
162
|
api_message = body["message"]
|
|
148
163
|
message = api_message.is_a?(String) ? api_message : fallback_message(response)
|
|
149
164
|
request_id = response.header("x-request-id")
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Api2Convert
|
|
4
|
+
module Model
|
|
5
|
+
# A cloud-storage input descriptor:
|
|
6
|
+
# `{ type:"cloud", source:<provider>, parameters, credentials }`.
|
|
7
|
+
#
|
|
8
|
+
# Hand it to `client.convert` / `convert_async` as the input, or to
|
|
9
|
+
# `client.jobs.add_input(job_id, cloud_input)`; either way it emits the wire
|
|
10
|
+
# descriptor via {#to_h}. Like a remote URL, a cloud input is a **started**
|
|
11
|
+
# job (`process => true`), not a staged upload.
|
|
12
|
+
#
|
|
13
|
+
# The per-provider factories carry each provider's required keys **verbatim**
|
|
14
|
+
# — flat and lowercase, exactly as the API expects (`accesskeyid`, not
|
|
15
|
+
# `access_key_id`). The required keys are constructor arguments (structural
|
|
16
|
+
# correctness), **not** a runtime gate: the builder never rejects a descriptor
|
|
17
|
+
# the permissive, asynchronously-validating server would accept. Optional and
|
|
18
|
+
# forward-compat keys go through the trailing +parameters+ / +credentials+
|
|
19
|
+
# maps, or the generic {.of} escape hatch.
|
|
20
|
+
#
|
|
21
|
+
# Google Drive *input* uses the `gdrive_picker` input type (the generic
|
|
22
|
+
# `add_input` raw-map path this wave); `gdrive`/`youtube` are output-only.
|
|
23
|
+
#
|
|
24
|
+
# `credentials` ride in the plaintext body, so {#inspect} masks the **whole**
|
|
25
|
+
# credentials object to `[REDACTED]` and any sensitive `parameters` leaf.
|
|
26
|
+
class CloudInput
|
|
27
|
+
attr_reader :source, :parameters, :credentials
|
|
28
|
+
|
|
29
|
+
# +source+ is the provider string; +parameters+ are non-secret locator keys
|
|
30
|
+
# (`bucket`, `file`, `host`, …); +credentials+ are secret keys.
|
|
31
|
+
def initialize(source:, parameters: {}, credentials: {})
|
|
32
|
+
@source = source
|
|
33
|
+
@parameters = parameters
|
|
34
|
+
@credentials = credentials
|
|
35
|
+
freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Generic escape hatch: any provider (a {CloudProvider} constant or a
|
|
39
|
+
# forward-compat string) with free-form maps.
|
|
40
|
+
def self.of(source, parameters: {}, credentials: {})
|
|
41
|
+
new(source: source.to_s, parameters: parameters, credentials: credentials)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Import from Amazon S3. Extra/forward-compat keys merge into
|
|
45
|
+
# +parameters+ / +credentials+.
|
|
46
|
+
def self.amazon_s3(bucket:, file:, accesskeyid:, secretaccesskey:,
|
|
47
|
+
parameters: {}, credentials: {})
|
|
48
|
+
new(
|
|
49
|
+
source: CloudProvider::AMAZON_S3,
|
|
50
|
+
parameters: { "bucket" => bucket, "file" => file }.merge(parameters),
|
|
51
|
+
credentials: { "accesskeyid" => accesskeyid, "secretaccesskey" => secretaccesskey }
|
|
52
|
+
.merge(credentials)
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Import from Azure Blob Storage.
|
|
57
|
+
def self.azure(container:, file:, accountname:, accountkey:,
|
|
58
|
+
parameters: {}, credentials: {})
|
|
59
|
+
new(
|
|
60
|
+
source: CloudProvider::AZURE,
|
|
61
|
+
parameters: { "container" => container, "file" => file }.merge(parameters),
|
|
62
|
+
credentials: { "accountname" => accountname, "accountkey" => accountkey }.merge(credentials)
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Import from an FTP server.
|
|
67
|
+
def self.ftp(host:, file:, username:, password:, parameters: {}, credentials: {})
|
|
68
|
+
new(
|
|
69
|
+
source: CloudProvider::FTP,
|
|
70
|
+
parameters: { "host" => host, "file" => file }.merge(parameters),
|
|
71
|
+
credentials: { "username" => username, "password" => password }.merge(credentials)
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Import from Google Cloud Storage.
|
|
76
|
+
def self.google_cloud(projectid:, bucket:, file:, keyfile:, parameters: {}, credentials: {})
|
|
77
|
+
new(
|
|
78
|
+
source: CloudProvider::GOOGLE_CLOUD,
|
|
79
|
+
parameters: { "projectid" => projectid, "bucket" => bucket, "file" => file }.merge(parameters),
|
|
80
|
+
credentials: { "keyfile" => keyfile }.merge(credentials)
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The wire descriptor sent to `POST /jobs` (inline `input`) or
|
|
85
|
+
# `POST /jobs/{id}/input`.
|
|
86
|
+
def to_h
|
|
87
|
+
{
|
|
88
|
+
"type" => InputType::CLOUD,
|
|
89
|
+
"source" => @source,
|
|
90
|
+
"parameters" => @parameters,
|
|
91
|
+
"credentials" => @credentials
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Redacted representation — the whole `credentials` object renders as
|
|
96
|
+
# `[REDACTED]`; sensitive `parameters` leaves are masked too. Safe to log.
|
|
97
|
+
def inspect
|
|
98
|
+
"#<#{self.class.name} type=cloud source=#{@source.inspect} " \
|
|
99
|
+
"parameters=#{Support::Redactor.parameters(@parameters).inspect} " \
|
|
100
|
+
"credentials=#{Support::Redactor::MARKER}>"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def to_s
|
|
104
|
+
inspect
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -4,14 +4,17 @@ module Api2Convert
|
|
|
4
4
|
module Model
|
|
5
5
|
# A single conversion within a job: the target format plus its options.
|
|
6
6
|
class Conversion
|
|
7
|
-
attr_reader :target, :id, :category, :options, :metadata
|
|
7
|
+
attr_reader :target, :id, :category, :options, :metadata, :output_targets
|
|
8
8
|
|
|
9
|
-
def initialize(target: "", id: nil, category: nil, options: {}, metadata: {}
|
|
9
|
+
def initialize(target: "", id: nil, category: nil, options: {}, metadata: {},
|
|
10
|
+
output_targets: [])
|
|
10
11
|
@target = target
|
|
11
12
|
@id = id
|
|
12
13
|
@category = category
|
|
13
14
|
@options = options
|
|
14
15
|
@metadata = metadata
|
|
16
|
+
# Cloud delivery targets for this conversion's output, if any.
|
|
17
|
+
@output_targets = output_targets
|
|
15
18
|
freeze
|
|
16
19
|
end
|
|
17
20
|
|
|
@@ -22,7 +25,8 @@ module Api2Convert
|
|
|
22
25
|
id: Support::Data.nullable_str(d["id"]),
|
|
23
26
|
category: Support::Data.nullable_str(d["category"]),
|
|
24
27
|
options: Support::Data.as_object(d["options"]),
|
|
25
|
-
metadata: Support::Data.as_object(d["metadata"])
|
|
28
|
+
metadata: Support::Data.as_object(d["metadata"]),
|
|
29
|
+
output_targets: Support::Data.map_objects(d["output_target"]) { |x| OutputTarget.from_hash(x) }
|
|
26
30
|
)
|
|
27
31
|
end
|
|
28
32
|
end
|
|
@@ -4,10 +4,11 @@ module Api2Convert
|
|
|
4
4
|
module Model
|
|
5
5
|
# An input file attached to a job.
|
|
6
6
|
class InputFile
|
|
7
|
-
attr_reader :id, :type, :source, :status, :filename, :size, :content_type, :options
|
|
7
|
+
attr_reader :id, :type, :source, :status, :filename, :size, :content_type, :options,
|
|
8
|
+
:parameters
|
|
8
9
|
|
|
9
10
|
def initialize(id: nil, type: "", source: nil, status: nil, filename: nil,
|
|
10
|
-
size: nil, content_type: nil, options: {})
|
|
11
|
+
size: nil, content_type: nil, options: {}, parameters: {})
|
|
11
12
|
@id = id
|
|
12
13
|
@type = type
|
|
13
14
|
@source = source
|
|
@@ -16,6 +17,9 @@ module Api2Convert
|
|
|
16
17
|
@size = size
|
|
17
18
|
@content_type = content_type
|
|
18
19
|
@options = options
|
|
20
|
+
# Cloud-input locator keys (`bucket`, `file`, `host`, …); empty for
|
|
21
|
+
# non-cloud inputs. Credentials are never surfaced on read.
|
|
22
|
+
@parameters = parameters
|
|
19
23
|
freeze
|
|
20
24
|
end
|
|
21
25
|
|
|
@@ -29,7 +33,8 @@ module Api2Convert
|
|
|
29
33
|
filename: Support::Data.nullable_str(d["filename"]),
|
|
30
34
|
size: Support::Data.nullable_int(d["size"]),
|
|
31
35
|
content_type: Support::Data.nullable_str(d["content_type"]),
|
|
32
|
-
options: Support::Data.as_object(d["options"])
|
|
36
|
+
options: Support::Data.as_object(d["options"]),
|
|
37
|
+
parameters: Support::Data.as_object(d["parameters"])
|
|
33
38
|
)
|
|
34
39
|
end
|
|
35
40
|
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Api2Convert
|
|
4
|
+
module Model
|
|
5
|
+
# A cloud-storage delivery target for a conversion's output:
|
|
6
|
+
# `{ type:<provider>, parameters, credentials }`.
|
|
7
|
+
#
|
|
8
|
+
# Attach one (or more) to a conversion via
|
|
9
|
+
# `client.convert(..., output_targets: [...])` / `convert_async(...)`, or
|
|
10
|
+
# inline in a raw `jobs.create` conversion map. When any output target is set
|
|
11
|
+
# the conversion delivers straight to your storage and produces **no** local
|
|
12
|
+
# output — so `convert` returns the completed job without downloading.
|
|
13
|
+
#
|
|
14
|
+
# This wave ships the **generic** shape only (`type` + free-form
|
|
15
|
+
# `parameters`/`credentials`); the per-provider output keys live in a separate
|
|
16
|
+
# service and diverge per provider, so there are no per-provider output
|
|
17
|
+
# factories yet.
|
|
18
|
+
#
|
|
19
|
+
# Serialization ({#to_h}) emits `{ type, parameters, credentials }` and
|
|
20
|
+
# **omits `status`** (server-set, read-only). On read ({.from_hash}) `type`,
|
|
21
|
+
# `parameters` and `status` round-trip as raw values; `credentials` are
|
|
22
|
+
# **never** surfaced (the API returns them empty). `credentials` ride in the
|
|
23
|
+
# plaintext body, so {#inspect} masks the whole object to `[REDACTED]`.
|
|
24
|
+
class OutputTarget
|
|
25
|
+
attr_reader :type, :parameters, :credentials, :status
|
|
26
|
+
|
|
27
|
+
# +status+ is server-set on read (`waiting|uploading|completed|failed`) and
|
|
28
|
+
# never sent on create.
|
|
29
|
+
def initialize(type:, parameters: {}, credentials: {}, status: nil)
|
|
30
|
+
@type = type
|
|
31
|
+
@parameters = parameters
|
|
32
|
+
@credentials = credentials
|
|
33
|
+
@status = status
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Generic constructor accepting a {CloudProvider} constant or a
|
|
38
|
+
# forward-compat string.
|
|
39
|
+
def self.of(type, parameters: {}, credentials: {})
|
|
40
|
+
new(type: type.to_s, parameters: parameters, credentials: credentials)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The wire descriptor sent on create — `{ type, parameters, credentials }`,
|
|
44
|
+
# with `status` omitted (server-set, read-only).
|
|
45
|
+
def to_h
|
|
46
|
+
{
|
|
47
|
+
"type" => @type,
|
|
48
|
+
"parameters" => @parameters,
|
|
49
|
+
"credentials" => @credentials
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Hydrate from a `GET /jobs/{id}` `output_target[]` element. `type`/`status`
|
|
54
|
+
# stay raw strings (an unknown provider round-trips untyped); `credentials`
|
|
55
|
+
# are deliberately not surfaced.
|
|
56
|
+
def self.from_hash(data)
|
|
57
|
+
d = Support::Data.as_object(data)
|
|
58
|
+
new(
|
|
59
|
+
type: Support::Data.as_str(d["type"]),
|
|
60
|
+
parameters: Support::Data.as_object(d["parameters"]),
|
|
61
|
+
credentials: {},
|
|
62
|
+
status: Support::Data.nullable_str(d["status"])
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Redacted representation — credentials masked. Safe to log.
|
|
67
|
+
def inspect
|
|
68
|
+
"#<#{self.class.name} type=#{@type.inspect} " \
|
|
69
|
+
"parameters=#{Support::Redactor.parameters(@parameters).inspect} " \
|
|
70
|
+
"credentials=#{Support::Redactor::MARKER} status=#{@status.inspect}>"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def to_s
|
|
74
|
+
inspect
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -52,10 +52,14 @@ module Api2Convert
|
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
# Attach an input by descriptor, e.g. a remote URL:
|
|
55
|
-
# `add_input(job_id, { "type" => "remote", "source" => "https://..." })
|
|
55
|
+
# `add_input(job_id, { "type" => "remote", "source" => "https://..." })`,
|
|
56
|
+
# a Google Drive picker
|
|
57
|
+
# (`{ "type" => "gdrive_picker", "source" => file_id, "credentials" => { "token" => ... } }`),
|
|
58
|
+
# or a {Model::CloudInput} builder.
|
|
56
59
|
def add_input(job_id, descriptor)
|
|
60
|
+
payload = descriptor.is_a?(Model::CloudInput) ? descriptor.to_h : descriptor
|
|
57
61
|
Model::InputFile.from_hash(
|
|
58
|
-
@transport.request("POST", "/jobs/#{Support::Data.encode_segment(job_id)}/input",
|
|
62
|
+
@transport.request("POST", "/jobs/#{Support::Data.encode_segment(job_id)}/input", payload)
|
|
59
63
|
)
|
|
60
64
|
end
|
|
61
65
|
|
|
@@ -4,7 +4,8 @@ module Api2Convert
|
|
|
4
4
|
module Resource
|
|
5
5
|
# API usage statistics. The response shape is free-form (returned as-is).
|
|
6
6
|
#
|
|
7
|
-
# +filter+ is
|
|
7
|
+
# +filter+ is "single" (only the calling API key) or "all" (every key on the account, the default).
|
|
8
|
+
# The request is scoped by the X-Api2convert-Api-Key header, so never pass a key as +filter+.
|
|
8
9
|
class Stats
|
|
9
10
|
def initialize(transport)
|
|
10
11
|
@transport = transport
|
data/lib/api2convert/result.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "tempfile"
|
|
4
5
|
|
|
5
6
|
module Api2Convert
|
|
6
7
|
module Result
|
|
@@ -24,11 +25,11 @@ module Api2Convert
|
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
# Stream the file to disk. +path_or_dir+ is a file path, or a directory (the
|
|
27
|
-
# API filename is used). The body is streamed
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
# path written
|
|
28
|
+
# API filename is used). The body is streamed chunk-by-chunk to a sibling temp
|
|
29
|
+
# file (never buffered whole in memory), then atomically renamed over the
|
|
30
|
+
# target only after a clean write+close — so an arbitrarily large output cannot
|
|
31
|
+
# exhaust memory and a mid-stream failure never truncates the target nor
|
|
32
|
+
# destroys a pre-existing complete file at that path. Returns the path written.
|
|
32
33
|
def save(path_or_dir, download_password = nil)
|
|
33
34
|
target = resolve_target(path_or_dir.to_s)
|
|
34
35
|
parent = File.dirname(target)
|
|
@@ -48,7 +49,7 @@ module Api2Convert
|
|
|
48
49
|
def contents(download_password = nil)
|
|
49
50
|
password = resolve_password(download_password)
|
|
50
51
|
# A passwordless download follows storage redirects; a password-protected
|
|
51
|
-
# one must not (the X-
|
|
52
|
+
# one must not (the X-Api2convert-Download-Password header could leak on a redirect).
|
|
52
53
|
@transport.download(@output.uri, headers(password), follow_redirects: password.nil?)
|
|
53
54
|
end
|
|
54
55
|
|
|
@@ -65,30 +66,60 @@ module Api2Convert
|
|
|
65
66
|
private
|
|
66
67
|
|
|
67
68
|
def stream_to_file(target, password)
|
|
68
|
-
|
|
69
|
+
parent = File.dirname(target)
|
|
70
|
+
parent = "." if parent.empty?
|
|
71
|
+
# Stream to a sibling temp file and rename over the target only after a clean
|
|
72
|
+
# write+close. This never truncates the target up front and never destroys a
|
|
73
|
+
# pre-existing complete file on a mid-stream failure — a download either fully
|
|
74
|
+
# replaces the target or leaves it untouched.
|
|
75
|
+
temp = create_temp(parent, target)
|
|
76
|
+
|
|
77
|
+
committed = false
|
|
69
78
|
begin
|
|
70
|
-
|
|
71
|
-
@
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
79
|
+
@transport.download(
|
|
80
|
+
@output.uri, headers(password), follow_redirects: password.nil?, sink: temp
|
|
81
|
+
)
|
|
82
|
+
# Flush + close BEFORE the rename so a truncated-on-close write can never be
|
|
83
|
+
# committed as a complete file, and a close/flush fault surfaces here rather
|
|
84
|
+
# than being swallowed on the success path.
|
|
85
|
+
temp.close
|
|
86
|
+
File.rename(temp.path, target)
|
|
87
|
+
committed = true
|
|
88
|
+
rescue SystemCallError => e
|
|
89
|
+
# A network read failure mid-stream is already a (non-retryable) NetworkError
|
|
90
|
+
# raised by the sender, which is NOT a SystemCallError and so passes straight
|
|
91
|
+
# through; reaching this rescue means a write / flush / rename fault — a
|
|
92
|
+
# genuine filesystem error.
|
|
93
|
+
raise Api2Convert::Error, "Could not write file: #{target}: #{e.message}"
|
|
78
94
|
ensure
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
|
|
95
|
+
# On any failure (network break, refused-redirect NetworkError, write/close/
|
|
96
|
+
# rename fault) only the temp file is removed; the target is never touched
|
|
97
|
+
# unless the rename already committed a complete download.
|
|
98
|
+
unless committed
|
|
99
|
+
begin
|
|
100
|
+
temp.close unless temp.closed?
|
|
101
|
+
rescue SystemCallError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
FileUtils.rm_f(temp.path)
|
|
105
|
+
end
|
|
83
106
|
end
|
|
84
107
|
end
|
|
85
108
|
|
|
109
|
+
def create_temp(parent, target)
|
|
110
|
+
temp = Tempfile.create([".a2c-download-", ".part"], parent)
|
|
111
|
+
temp.binmode
|
|
112
|
+
temp
|
|
113
|
+
rescue SystemCallError
|
|
114
|
+
raise Api2Convert::Error, "Could not open file for writing: #{target}"
|
|
115
|
+
end
|
|
116
|
+
|
|
86
117
|
def resolve_password(download_password)
|
|
87
118
|
download_password.nil? ? @download_password : download_password
|
|
88
119
|
end
|
|
89
120
|
|
|
90
121
|
def headers(password)
|
|
91
|
-
password.nil? ? {} : { "X-
|
|
122
|
+
password.nil? ? {} : { "X-Api2convert-Download-Password" => password }
|
|
92
123
|
end
|
|
93
124
|
|
|
94
125
|
def resolve_target(path_or_dir)
|