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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Api2Convert
|
|
4
|
+
# The typed exception hierarchy.
|
|
5
|
+
#
|
|
6
|
+
# Every failure raised by the SDK descends from {Error}. HTTP error responses
|
|
7
|
+
# (status >= 400) map to {ApiError} and its subclasses; transport failures,
|
|
8
|
+
# conversion failures, poll timeouts and webhook verification failures descend
|
|
9
|
+
# directly from the base.
|
|
10
|
+
#
|
|
11
|
+
# The class names use Ruby's `...Error` convention; they map 1:1 to the PHP
|
|
12
|
+
# SDK's `...Exception` classes.
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
|
|
15
|
+
# An HTTP error response (status >= 400).
|
|
16
|
+
#
|
|
17
|
+
# Used directly for a 4xx with no more specific subclass; specific statuses map
|
|
18
|
+
# to the dedicated subclasses below.
|
|
19
|
+
class ApiError < Error
|
|
20
|
+
# @return [Integer] the HTTP status code.
|
|
21
|
+
attr_reader :status_code
|
|
22
|
+
# @return [String, nil] the `X-Request-Id` response header, if any. Quote it
|
|
23
|
+
# in support requests.
|
|
24
|
+
attr_reader :request_id
|
|
25
|
+
# @return [Hash] the decoded JSON error body, or `{}` when absent/unparseable.
|
|
26
|
+
attr_reader :body
|
|
27
|
+
|
|
28
|
+
def initialize(message, status_code: 0, request_id: nil, body: nil)
|
|
29
|
+
super(message)
|
|
30
|
+
@status_code = status_code
|
|
31
|
+
@request_id = request_id
|
|
32
|
+
@body = body || {}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The API key is missing, invalid or not permitted (HTTP 401 / 403).
|
|
37
|
+
class AuthenticationError < ApiError; end
|
|
38
|
+
|
|
39
|
+
# The account has no remaining quota/credit (HTTP 402).
|
|
40
|
+
class PaymentRequiredError < ApiError; end
|
|
41
|
+
|
|
42
|
+
# The requested resource does not exist (HTTP 404).
|
|
43
|
+
class NotFoundError < ApiError; end
|
|
44
|
+
|
|
45
|
+
# The request was rejected as invalid, e.g. an unknown target (HTTP 400 / 422).
|
|
46
|
+
class ValidationError < ApiError; end
|
|
47
|
+
|
|
48
|
+
# Too many requests (HTTP 429), raised only once auto-retries are exhausted.
|
|
49
|
+
class RateLimitError < ApiError
|
|
50
|
+
# @return [Integer, nil] seconds to wait before retrying, parsed from the
|
|
51
|
+
# `Retry-After` header (raw, uncapped).
|
|
52
|
+
attr_reader :retry_after
|
|
53
|
+
|
|
54
|
+
def initialize(message, status_code: 429, request_id: nil, body: nil, retry_after: nil)
|
|
55
|
+
super(message, status_code: status_code, request_id: request_id, body: body)
|
|
56
|
+
@retry_after = retry_after
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A server-side error (HTTP >= 500), raised once auto-retries are exhausted.
|
|
61
|
+
class ServerError < ApiError; end
|
|
62
|
+
|
|
63
|
+
# A request did not yield a usable response.
|
|
64
|
+
#
|
|
65
|
+
# Raised for a transport-level failure (DNS/connection/TLS/read) once idempotent
|
|
66
|
+
# retries are exhausted, or for a 2xx response whose body is not valid JSON.
|
|
67
|
+
class NetworkError < Error; end
|
|
68
|
+
|
|
69
|
+
# A job reached the `failed` (or `canceled`) status.
|
|
70
|
+
#
|
|
71
|
+
# The originating {Model::Job} is attached so you can inspect its errors and
|
|
72
|
+
# warnings.
|
|
73
|
+
class ConversionFailedError < Error
|
|
74
|
+
# @return [Model::Job] the failed job.
|
|
75
|
+
attr_reader :job
|
|
76
|
+
|
|
77
|
+
def initialize(job, message = nil)
|
|
78
|
+
@job = job
|
|
79
|
+
super(message.nil? ? self.class.build_message(job) : message)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [Array<Model::JobMessage>] the failed job's errors (may be empty if
|
|
83
|
+
# the API gave no detail).
|
|
84
|
+
def errors
|
|
85
|
+
@job.errors
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.build_message(job)
|
|
89
|
+
first = job.errors.first
|
|
90
|
+
unless first.nil?
|
|
91
|
+
code = first.code.nil? ? "" : " (code #{first.code})"
|
|
92
|
+
return "Conversion failed: #{first.message}#{code}"
|
|
93
|
+
end
|
|
94
|
+
info = job.status.info
|
|
95
|
+
info.nil? ? "Conversion failed." : "Conversion failed: #{info}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A job did not reach a terminal status within the configured poll timeout.
|
|
100
|
+
#
|
|
101
|
+
# The job is still running server-side — re-fetch it later with
|
|
102
|
+
# `client.jobs.get(job.id)`. (Maps to the PHP SDK's `TimeoutException`; named to
|
|
103
|
+
# avoid shadowing Ruby's `Timeout::Error`.)
|
|
104
|
+
class ConversionTimeoutError < Error
|
|
105
|
+
# @return [Model::Job] the job that was still running when the wait timed out.
|
|
106
|
+
attr_reader :job
|
|
107
|
+
|
|
108
|
+
def initialize(job, timeout_seconds)
|
|
109
|
+
@job = job
|
|
110
|
+
super(
|
|
111
|
+
"Timed out after #{timeout_seconds}s waiting for job #{job.id} to finish " \
|
|
112
|
+
"(last status: #{job.status.code})."
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A webhook payload could not be verified against the provided signature/secret.
|
|
118
|
+
#
|
|
119
|
+
# Treat this as a security event: do not trust or process the payload.
|
|
120
|
+
class SignatureVerificationError < Error; end
|
|
121
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "timeout"
|
|
7
|
+
|
|
8
|
+
module Api2Convert
|
|
9
|
+
module Http
|
|
10
|
+
# The default {HttpSender}, built on Ruby's stdlib `Net::HTTP` (zero runtime
|
|
11
|
+
# dependencies).
|
|
12
|
+
#
|
|
13
|
+
# `Net::HTTP` does not follow redirects on its own — this SDK relies on that.
|
|
14
|
+
# A request only follows a 3xx when {Request#follow_redirects} is true (the
|
|
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-Oc-*` secret header can never be
|
|
17
|
+
# forwarded to the redirect target.
|
|
18
|
+
#
|
|
19
|
+
# An {HttpSender} is any object responding to `call(request) -> Response`. Unit
|
|
20
|
+
# tests inject a fake in its place; this real sender is exercised end-to-end by
|
|
21
|
+
# the independent security suite against loopback servers.
|
|
22
|
+
class NetHttpSender
|
|
23
|
+
MAX_REDIRECTS = 5
|
|
24
|
+
REDIRECT_CODES = [301, 302, 303, 307, 308].freeze
|
|
25
|
+
|
|
26
|
+
# Errors worth surfacing when they strike mid-stream (after bytes have
|
|
27
|
+
# already reached the sink). They are re-raised as {NetworkError} so the
|
|
28
|
+
# transport does NOT retry — replaying would re-stream the whole body and
|
|
29
|
+
# append it to the partial file, silently corrupting the download.
|
|
30
|
+
#
|
|
31
|
+
# This MUST stay a superset of {Transport::TRANSPORT_ERRORS}: any transport
|
|
32
|
+
# error the retry loop would otherwise catch (notably OpenSSL::SSL::SSLError
|
|
33
|
+
# on a truncated/reset TLS body) has to be intercepted here first, or an
|
|
34
|
+
# idempotent streamed GET gets retried and the file is corrupted.
|
|
35
|
+
STREAM_ERRORS = [
|
|
36
|
+
IOError, EOFError, SocketError, SystemCallError, Timeout::Error,
|
|
37
|
+
OpenSSL::SSL::SSLError, URI::Error,
|
|
38
|
+
Net::OpenTimeout, Net::ReadTimeout, Net::HTTPBadResponse, Net::ProtocolError
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
METHOD_CLASSES = {
|
|
42
|
+
"GET" => Net::HTTP::Get,
|
|
43
|
+
"HEAD" => Net::HTTP::Head,
|
|
44
|
+
"POST" => Net::HTTP::Post,
|
|
45
|
+
"PUT" => Net::HTTP::Put,
|
|
46
|
+
"PATCH" => Net::HTTP::Patch,
|
|
47
|
+
"DELETE" => Net::HTTP::Delete,
|
|
48
|
+
"OPTIONS" => Net::HTTP::Options
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
def initialize(timeout: 30)
|
|
52
|
+
@timeout = timeout
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def call(request)
|
|
56
|
+
perform(
|
|
57
|
+
request.method, request.url, request.headers,
|
|
58
|
+
request.body, request.body_stream, request.content_length,
|
|
59
|
+
request.follow_redirects, request.response_sink, 0
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def perform(method, url, headers, body, body_stream, content_length, follow, sink, hops)
|
|
66
|
+
uri = URI.parse(url)
|
|
67
|
+
raise Api2Convert::NetworkError, "Unsupported or non-HTTP URL: #{url}" unless uri.is_a?(URI::HTTP)
|
|
68
|
+
|
|
69
|
+
http = build_http(uri)
|
|
70
|
+
req = build_request(method, uri, headers, body, body_stream, content_length)
|
|
71
|
+
|
|
72
|
+
redirect_to = nil
|
|
73
|
+
result = nil
|
|
74
|
+
http.start do |conn|
|
|
75
|
+
conn.request(req) do |res|
|
|
76
|
+
status = res.code.to_i
|
|
77
|
+
if follow && REDIRECT_CODES.include?(status) && hops < MAX_REDIRECTS &&
|
|
78
|
+
!res["location"].to_s.empty?
|
|
79
|
+
redirect_to = res["location"]
|
|
80
|
+
elsif sink && status >= 200 && status < 300
|
|
81
|
+
# Stream the success body straight to the sink — never buffered whole
|
|
82
|
+
# in memory — and hand back an empty-body Response.
|
|
83
|
+
stream_body(res, sink)
|
|
84
|
+
result = to_response(res, "")
|
|
85
|
+
else
|
|
86
|
+
result = to_response(res, res.body || "")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if redirect_to
|
|
92
|
+
# Re-issue as a bare GET carrying only non-secret headers, so no X-Oc-*
|
|
93
|
+
# secret header is ever forwarded to the redirect target.
|
|
94
|
+
safe = {}
|
|
95
|
+
%w[Accept User-Agent].each { |k| safe[k] = headers[k] unless headers[k].nil? }
|
|
96
|
+
next_url = URI.join(url, redirect_to).to_s
|
|
97
|
+
return perform("GET", next_url, safe, nil, nil, nil, follow, sink, hops + 1)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
result
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def stream_body(res, sink)
|
|
104
|
+
res.read_body { |chunk| sink.write(chunk) }
|
|
105
|
+
rescue *STREAM_ERRORS => e
|
|
106
|
+
# A failure once bytes have flowed to the sink must not be retried: a replay
|
|
107
|
+
# would append to a partial file and corrupt it. Raise a NetworkError (not a
|
|
108
|
+
# retryable transport error) so the transport surfaces it directly and the
|
|
109
|
+
# caller can delete the partial target.
|
|
110
|
+
raise Api2Convert::NetworkError, "Download stream failed: #{e.message}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def build_http(uri)
|
|
114
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
115
|
+
http.use_ssl = uri.is_a?(URI::HTTPS)
|
|
116
|
+
http.open_timeout = @timeout
|
|
117
|
+
http.read_timeout = @timeout
|
|
118
|
+
http.write_timeout = @timeout if http.respond_to?(:write_timeout=)
|
|
119
|
+
# The SDK's own retry policy (in Transport) is the sole authority. Disable
|
|
120
|
+
# Net::HTTP's built-in idempotent connection-error retry so requests are
|
|
121
|
+
# never silently re-sent underneath us (double retries / duplicate hits).
|
|
122
|
+
http.max_retries = 0 if http.respond_to?(:max_retries=)
|
|
123
|
+
http
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build_request(method, uri, headers, body, body_stream, content_length)
|
|
127
|
+
klass = METHOD_CLASSES[method]
|
|
128
|
+
raise Api2Convert::NetworkError, "Unsupported HTTP method: #{method}" if klass.nil?
|
|
129
|
+
|
|
130
|
+
req = klass.new(uri.request_uri)
|
|
131
|
+
headers.each { |key, value| req[key] = value unless value.nil? }
|
|
132
|
+
if body_stream
|
|
133
|
+
req.body_stream = body_stream
|
|
134
|
+
req["Content-Length"] = content_length.to_s unless content_length.nil?
|
|
135
|
+
elsif body
|
|
136
|
+
req.body = body
|
|
137
|
+
end
|
|
138
|
+
req
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def to_response(res, body)
|
|
142
|
+
headers = {}
|
|
143
|
+
res.each_header { |key, value| headers[key.downcase] = value }
|
|
144
|
+
Response.new(res.code.to_i, headers, body, res.message.to_s)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Api2Convert
|
|
4
|
+
module Http
|
|
5
|
+
# A transport-agnostic HTTP request. The {Transport} builds these and hands
|
|
6
|
+
# them to an {HttpSender}; a retry rebuilds a fresh one so a seekable body is
|
|
7
|
+
# replayed from the start.
|
|
8
|
+
#
|
|
9
|
+
# Exactly one of {#body} (a String) or {#body_stream} (an IO whose byte length
|
|
10
|
+
# is {#content_length}) is set on a request with a payload.
|
|
11
|
+
class Request
|
|
12
|
+
attr_reader :method, :url, :headers, :body, :body_stream, :content_length, :response_sink
|
|
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-Oc-*` header,
|
|
15
|
+
# so they must NOT follow redirects (the header could leak to another host).
|
|
16
|
+
attr_accessor :follow_redirects
|
|
17
|
+
|
|
18
|
+
def initialize(method:, url:, headers: {}, body: nil, body_stream: nil,
|
|
19
|
+
content_length: nil, follow_redirects: false, response_sink: nil)
|
|
20
|
+
@method = method.to_s.upcase
|
|
21
|
+
@url = url
|
|
22
|
+
@headers = headers
|
|
23
|
+
@body = body
|
|
24
|
+
@body_stream = body_stream
|
|
25
|
+
@content_length = content_length
|
|
26
|
+
@follow_redirects = follow_redirects
|
|
27
|
+
# An IO-like object (responds to `write`) to stream a successful download
|
|
28
|
+
# body into, instead of buffering it whole. nil for a normal request.
|
|
29
|
+
@response_sink = response_sink
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Redacted representation — {#headers} carries the raw `X-Oc-Api-Key` /
|
|
33
|
+
# `X-Oc-Download-Password` secrets, so the default `#inspect` would print
|
|
34
|
+
# them in cleartext in a log line or a backtrace. Mask every `X-Oc-*` value.
|
|
35
|
+
def inspect
|
|
36
|
+
safe = @headers.to_h do |key, value|
|
|
37
|
+
[key, key.to_s.downcase.start_with?("x-oc-") ? Support::Secret.mask(value) : value]
|
|
38
|
+
end
|
|
39
|
+
"#<#{self.class.name} method=#{@method.inspect} url=#{@url.inspect} headers=#{safe.inspect}>"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_s
|
|
43
|
+
inspect
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Api2Convert
|
|
4
|
+
module Http
|
|
5
|
+
# A transport-agnostic HTTP response. Headers are stored with downcased keys
|
|
6
|
+
# so {#header} lookups are case-insensitive regardless of the sender.
|
|
7
|
+
class Response
|
|
8
|
+
attr_reader :status, :headers, :body, :reason
|
|
9
|
+
|
|
10
|
+
def initialize(status, headers, body, reason = "")
|
|
11
|
+
@status = status
|
|
12
|
+
@headers = headers || {}
|
|
13
|
+
@body = body || ""
|
|
14
|
+
@reason = reason || ""
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Case-insensitive header lookup. Returns nil when absent.
|
|
18
|
+
def header(name)
|
|
19
|
+
@headers[name.to_s.downcase]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "socket"
|
|
7
|
+
require "time"
|
|
8
|
+
require "timeout"
|
|
9
|
+
require "uri"
|
|
10
|
+
|
|
11
|
+
module Api2Convert
|
|
12
|
+
module Http
|
|
13
|
+
# The HTTP layer: authenticated requests, transient-failure retries with
|
|
14
|
+
# exponential backoff, error-response mapping to typed exceptions, and JSON
|
|
15
|
+
# decoding.
|
|
16
|
+
#
|
|
17
|
+
# Resources talk to the API through {#request}; the uploader and the
|
|
18
|
+
# downloader use {#send_request} / {#interpret} directly because they need
|
|
19
|
+
# non-JSON bodies and per-job auth. Internal.
|
|
20
|
+
class Transport
|
|
21
|
+
RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
22
|
+
IDEMPOTENT_METHODS = %w[GET HEAD PUT DELETE OPTIONS TRACE].freeze
|
|
23
|
+
MAX_BACKOFF_SECONDS = 8.0
|
|
24
|
+
# Upper bound for an honored `Retry-After` — a hostile/misconfigured value
|
|
25
|
+
# asking for an absurd delay can never stall a worker for hours.
|
|
26
|
+
MAX_RETRY_AFTER_SECONDS = 120.0
|
|
27
|
+
|
|
28
|
+
# Transport-level failures worth wrapping as {NetworkError} (and retrying,
|
|
29
|
+
# for idempotent requests). `SystemCallError` covers the `Errno::*` family.
|
|
30
|
+
TRANSPORT_ERRORS = [
|
|
31
|
+
SocketError, SystemCallError, IOError, EOFError,
|
|
32
|
+
Timeout::Error, OpenSSL::SSL::SSLError, URI::Error,
|
|
33
|
+
Net::OpenTimeout, Net::ReadTimeout, Net::HTTPBadResponse, Net::ProtocolError
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
attr_reader :config
|
|
37
|
+
|
|
38
|
+
def initialize(sender, config, sleeper: nil, rng: nil)
|
|
39
|
+
@sender = sender
|
|
40
|
+
@config = config
|
|
41
|
+
@sleeper = sleeper || ->(seconds) { Kernel.sleep(seconds) }
|
|
42
|
+
@rng = rng || -> { Kernel.rand }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Redacted representation — the default `#inspect` would dump the config's
|
|
46
|
+
# API key in cleartext, so it is overridden to mask it.
|
|
47
|
+
def inspect
|
|
48
|
+
"#<#{self.class.name} base_url=#{@config.base_url.inspect} " \
|
|
49
|
+
"api_key=#{Support::Secret.mask(@config.api_key)}>"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def to_s
|
|
53
|
+
inspect
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Sleep for (at least) +seconds+ with a small upward jitter. Used by job
|
|
57
|
+
# polling; the jitter keeps a fleet from polling in lockstep.
|
|
58
|
+
def pause(seconds)
|
|
59
|
+
@sleeper.call(jitter(seconds))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Perform an authenticated JSON request and return the decoded body.
|
|
63
|
+
def request(method, path, body = nil, query = nil, headers = nil)
|
|
64
|
+
request_headers = { "X-Oc-Api-Key" => @config.api_key }
|
|
65
|
+
request_headers.merge!(headers) unless headers.nil?
|
|
66
|
+
content = nil
|
|
67
|
+
unless body.nil?
|
|
68
|
+
content = JSON.generate(body)
|
|
69
|
+
request_headers["Content-Type"] = "application/json"
|
|
70
|
+
end
|
|
71
|
+
url = build_url(path, query)
|
|
72
|
+
|
|
73
|
+
build = lambda do
|
|
74
|
+
Request.new(method: method, url: url, headers: request_headers.dup, body: content)
|
|
75
|
+
end
|
|
76
|
+
interpret(send_request(build))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Send a request (rebuilt fresh each attempt) with retry/backoff. +build+
|
|
80
|
+
# returns a fresh {Request} — a retry re-invokes it so a seekable body is
|
|
81
|
+
# replayed from the start. Adds the common `Accept`/`User-Agent` headers but
|
|
82
|
+
# no auth (callers add the header they need). +replayable+ must be false for
|
|
83
|
+
# a non-seekable body so it is sent once.
|
|
84
|
+
#
|
|
85
|
+
# +follow_redirects+ defaults to false: authenticated requests carry a secret
|
|
86
|
+
# in a custom `X-Oc-*` header, which a redirect could leak to another host.
|
|
87
|
+
# Only the self-contained download path (no account key) opts in.
|
|
88
|
+
def send_request(build, replayable: true, follow_redirects: false)
|
|
89
|
+
attempt = 0
|
|
90
|
+
loop do
|
|
91
|
+
request = build.call
|
|
92
|
+
request.headers["Accept"] = "application/json"
|
|
93
|
+
request.headers["User-Agent"] = user_agent
|
|
94
|
+
request.follow_redirects = follow_redirects
|
|
95
|
+
idempotent = idempotent?(request)
|
|
96
|
+
|
|
97
|
+
begin
|
|
98
|
+
response = @sender.call(request)
|
|
99
|
+
rescue *TRANSPORT_ERRORS => e
|
|
100
|
+
# A non-idempotent request must not be replayed on a network error:
|
|
101
|
+
# the backend may already have acted, so a blind retry could create a
|
|
102
|
+
# duplicate job (and a duplicate charge).
|
|
103
|
+
if replayable && idempotent && attempt < @config.max_retries
|
|
104
|
+
backoff(attempt)
|
|
105
|
+
attempt += 1
|
|
106
|
+
next
|
|
107
|
+
end
|
|
108
|
+
raise NetworkError, "Request to API2Convert failed: #{e.message}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
status = response.status
|
|
112
|
+
may_retry = RETRYABLE_STATUSES.include?(status) && replayable &&
|
|
113
|
+
attempt < @config.max_retries && (status == 429 || idempotent)
|
|
114
|
+
if may_retry
|
|
115
|
+
backoff(attempt, response.header("retry-after"))
|
|
116
|
+
attempt += 1
|
|
117
|
+
next
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
return response
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Raise a typed exception for error responses; otherwise decode JSON.
|
|
125
|
+
def interpret(response)
|
|
126
|
+
ensure_successful(response)
|
|
127
|
+
|
|
128
|
+
raw = response.body
|
|
129
|
+
return {} if raw.nil? || raw.empty?
|
|
130
|
+
|
|
131
|
+
begin
|
|
132
|
+
decoded = JSON.parse(raw)
|
|
133
|
+
rescue JSON::ParserError => e
|
|
134
|
+
# A 2xx carrying a non-JSON body (e.g. an intermediary HTML page) must
|
|
135
|
+
# surface as an SDK exception, not a raw parser error escaping the tree.
|
|
136
|
+
raise NetworkError, "API2Convert returned a non-JSON success response: #{e.message}"
|
|
137
|
+
end
|
|
138
|
+
decoded.is_a?(Hash) || decoded.is_a?(Array) ? decoded : {}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Raise the appropriate typed exception when +response+ is an HTTP error.
|
|
142
|
+
def ensure_successful(response)
|
|
143
|
+
status = response.status
|
|
144
|
+
return if status < 400
|
|
145
|
+
|
|
146
|
+
body = decode_safe(response)
|
|
147
|
+
api_message = body["message"]
|
|
148
|
+
message = api_message.is_a?(String) ? api_message : fallback_message(response)
|
|
149
|
+
request_id = response.header("x-request-id")
|
|
150
|
+
request_id = nil if request_id.nil? || request_id.empty?
|
|
151
|
+
|
|
152
|
+
case status
|
|
153
|
+
when 401, 403
|
|
154
|
+
raise AuthenticationError.new(message, status_code: status, request_id: request_id, body: body)
|
|
155
|
+
when 402
|
|
156
|
+
raise PaymentRequiredError.new(message, status_code: status, request_id: request_id, body: body)
|
|
157
|
+
when 404
|
|
158
|
+
raise NotFoundError.new(message, status_code: status, request_id: request_id, body: body)
|
|
159
|
+
when 429
|
|
160
|
+
raise RateLimitError.new(
|
|
161
|
+
message, status_code: status, request_id: request_id, body: body,
|
|
162
|
+
retry_after: parse_retry_after(response.header("retry-after"))
|
|
163
|
+
)
|
|
164
|
+
when 400, 422
|
|
165
|
+
raise ValidationError.new(message, status_code: status, request_id: request_id, body: body)
|
|
166
|
+
else
|
|
167
|
+
if status >= 500
|
|
168
|
+
raise ServerError.new(message, status_code: status, request_id: request_id, body: body)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
raise ApiError.new(message, status_code: status, request_id: request_id, body: body)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Download a (self-contained) URL. Used for output downloads — these URLs
|
|
176
|
+
# need no API key.
|
|
177
|
+
#
|
|
178
|
+
# When +sink+ is an IO-like object (responds to `write`), the body is streamed
|
|
179
|
+
# to it chunk-by-chunk and +nil+ is returned, so a large file never has to be
|
|
180
|
+
# buffered whole in memory. With no +sink+ the body is buffered and returned
|
|
181
|
+
# (the in-memory `contents` path).
|
|
182
|
+
#
|
|
183
|
+
# +follow_redirects+ is true for a passwordless download (storage URLs
|
|
184
|
+
# legitimately redirect, and no secret is carried) and false when a
|
|
185
|
+
# download-password header is present (so it can never leak on a redirect).
|
|
186
|
+
def download(uri, headers = {}, follow_redirects: true, sink: nil)
|
|
187
|
+
request_headers = headers.nil? ? {} : headers
|
|
188
|
+
build = lambda do
|
|
189
|
+
Request.new(method: "GET", url: uri, headers: request_headers.dup, response_sink: sink)
|
|
190
|
+
end
|
|
191
|
+
response = send_request(build, replayable: true, follow_redirects: follow_redirects)
|
|
192
|
+
ensure_successful(response)
|
|
193
|
+
# A no-follow download that resolved to a 3xx (e.g. a password-protected
|
|
194
|
+
# download whose redirect the policy refused to follow) must never be
|
|
195
|
+
# written as the file: the body is the redirect page, not the content.
|
|
196
|
+
# Guard the success path to 2xx so a 3xx can never silently corrupt output.
|
|
197
|
+
unless (200..299).cover?(response.status)
|
|
198
|
+
raise NetworkError,
|
|
199
|
+
"Unexpected HTTP #{response.status} on download: refusing to write a " \
|
|
200
|
+
"non-2xx (redirect) response as file content."
|
|
201
|
+
end
|
|
202
|
+
sink.nil? ? response.body : nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Build a request object without sending it (used by the uploader, which
|
|
206
|
+
# supplies a streamed multipart body).
|
|
207
|
+
def build_request(method, url, headers: {}, body_stream: nil, content_length: nil)
|
|
208
|
+
Request.new(
|
|
209
|
+
method: method, url: url, headers: headers,
|
|
210
|
+
body_stream: body_stream, content_length: content_length
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
def build_url(path, query)
|
|
217
|
+
url = "#{@config.base_url}/#{path.sub(%r{\A/+}, "")}"
|
|
218
|
+
url = "#{url}?#{URI.encode_www_form(query)}" if query && !query.empty?
|
|
219
|
+
url
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def decode_safe(response)
|
|
223
|
+
raw = response.body
|
|
224
|
+
return {} if raw.nil? || raw.empty?
|
|
225
|
+
|
|
226
|
+
decoded = JSON.parse(raw)
|
|
227
|
+
decoded.is_a?(Hash) ? decoded : {}
|
|
228
|
+
rescue JSON::ParserError
|
|
229
|
+
{}
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def fallback_message(response)
|
|
233
|
+
response.reason.to_s.empty? ? "Request failed" : response.reason
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def backoff(attempt, retry_after = nil)
|
|
237
|
+
retry_secs = parse_retry_after(retry_after)
|
|
238
|
+
seconds =
|
|
239
|
+
if retry_secs&.positive?
|
|
240
|
+
# Honor a positive Retry-After (capped so a hostile value can't stall
|
|
241
|
+
# us for hours). Not jittered: the server asked for this exact delay.
|
|
242
|
+
[MAX_RETRY_AFTER_SECONDS, retry_secs.to_f].min
|
|
243
|
+
else
|
|
244
|
+
# A zero/absent Retry-After falls through to jittered exponential
|
|
245
|
+
# backoff so we never retry-storm with no delay.
|
|
246
|
+
jitter([MAX_BACKOFF_SECONDS, 0.5 * (2**attempt)].min)
|
|
247
|
+
end
|
|
248
|
+
@sleeper.call(seconds)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Parse `Retry-After` (delay-seconds or HTTP-date) into whole seconds.
|
|
252
|
+
# Returns nil when absent/unparseable; never negative.
|
|
253
|
+
def parse_retry_after(value)
|
|
254
|
+
return nil if value.nil? || value.empty?
|
|
255
|
+
|
|
256
|
+
begin
|
|
257
|
+
number = Float(value)
|
|
258
|
+
# A non-finite value (e.g. "1e400" -> Infinity, "NaN") must not crash:
|
|
259
|
+
# Float::INFINITY.to_i raises FloatDomainError. Fall through so a hostile
|
|
260
|
+
# Retry-After can never stall a worker (matches Python's OverflowError guard).
|
|
261
|
+
return [0, number.to_i].max if number.finite?
|
|
262
|
+
rescue ArgumentError, TypeError
|
|
263
|
+
# not a plain number — fall through to the HTTP-date form
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
begin
|
|
267
|
+
parsed = Time.httpdate(value)
|
|
268
|
+
rescue ArgumentError
|
|
269
|
+
return nil
|
|
270
|
+
end
|
|
271
|
+
[0, (parsed - Time.now).to_i].max
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Add a small upward jitter (0-25%) so correlated clients don't lockstep.
|
|
275
|
+
def jitter(seconds)
|
|
276
|
+
seconds + (seconds * 0.25 * @rng.call)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def idempotent?(request)
|
|
280
|
+
return true if IDEMPOTENT_METHODS.include?(request.method)
|
|
281
|
+
|
|
282
|
+
!request.headers["Idempotency-Key"].to_s.empty?
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def user_agent
|
|
286
|
+
@user_agent ||= "api2convert-ruby/#{Api2Convert::VERSION} ruby/#{RUBY_VERSION}"
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Api2Convert
|
|
4
|
+
# The kinds of source an input file can be created from (the input `type`
|
|
5
|
+
# field). A typed reference for building input descriptors by hand, e.g.
|
|
6
|
+
# `add_input(job_id, { "type" => Api2Convert::InputType::REMOTE, "source" => ... })`.
|
|
7
|
+
module InputType
|
|
8
|
+
UPLOAD = "upload"
|
|
9
|
+
REMOTE = "remote"
|
|
10
|
+
OUTPUT = "output"
|
|
11
|
+
INPUT_ID = "input_id"
|
|
12
|
+
GDRIVE_PICKER = "gdrive_picker"
|
|
13
|
+
BASE64 = "base64"
|
|
14
|
+
CLOUD = "cloud"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Api2Convert
|
|
4
|
+
# Well-known job status codes (the `status.code` field).
|
|
5
|
+
#
|
|
6
|
+
# The API may introduce further codes; treat any code not listed here as
|
|
7
|
+
# non-terminal. Use {terminal?} for a raw status string rather than comparing
|
|
8
|
+
# by hand.
|
|
9
|
+
module JobStatus
|
|
10
|
+
CREATED = "created"
|
|
11
|
+
INCOMPLETE = "incomplete"
|
|
12
|
+
DOWNLOADING = "downloading"
|
|
13
|
+
QUEUED = "queued"
|
|
14
|
+
PROCESSING = "processing"
|
|
15
|
+
COMPLETED = "completed"
|
|
16
|
+
FAILED = "failed"
|
|
17
|
+
CANCELED = "canceled"
|
|
18
|
+
|
|
19
|
+
# The finished states — a job in one of these will not change further.
|
|
20
|
+
TERMINAL = [COMPLETED, FAILED, CANCELED].freeze
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Is the given raw status code terminal? Unknown codes are non-terminal.
|
|
25
|
+
def terminal?(code)
|
|
26
|
+
TERMINAL.include?(code)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|