api2convert 10.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +99 -0
  3. data/LICENSE +21 -0
  4. data/README.md +164 -0
  5. data/docs/CHANGELOG.md +9 -0
  6. data/docs/SDK_CONTRACT.md +99 -0
  7. data/examples/convert.rb +20 -0
  8. data/examples/webhook.rb +32 -0
  9. data/lib/api2convert/client.rb +139 -0
  10. data/lib/api2convert/config.rb +66 -0
  11. data/lib/api2convert/errors.rb +121 -0
  12. data/lib/api2convert/http/net_http_sender.rb +148 -0
  13. data/lib/api2convert/http/request.rb +47 -0
  14. data/lib/api2convert/http/response.rb +23 -0
  15. data/lib/api2convert/http/transport.rb +290 -0
  16. data/lib/api2convert/input_type.rb +16 -0
  17. data/lib/api2convert/job_status.rb +29 -0
  18. data/lib/api2convert/model/conversion.rb +30 -0
  19. data/lib/api2convert/model/input_file.rb +37 -0
  20. data/lib/api2convert/model/job.rb +66 -0
  21. data/lib/api2convert/model/job_message.rb +30 -0
  22. data/lib/api2convert/model/output_file.rb +40 -0
  23. data/lib/api2convert/model/preset.rb +32 -0
  24. data/lib/api2convert/model/status.rb +24 -0
  25. data/lib/api2convert/resource/contracts.rb +16 -0
  26. data/lib/api2convert/resource/conversions.rb +33 -0
  27. data/lib/api2convert/resource/jobs.rb +112 -0
  28. data/lib/api2convert/resource/presets.rb +43 -0
  29. data/lib/api2convert/resource/stats.rb +35 -0
  30. data/lib/api2convert/result.rb +185 -0
  31. data/lib/api2convert/support/data.rb +89 -0
  32. data/lib/api2convert/support/secret.rb +27 -0
  33. data/lib/api2convert/upload/file_uploader.rb +87 -0
  34. data/lib/api2convert/upload/multipart_stream.rb +74 -0
  35. data/lib/api2convert/version.rb +8 -0
  36. data/lib/api2convert/webhook/event.rb +23 -0
  37. data/lib/api2convert/webhook/verifier.rb +74 -0
  38. data/lib/api2convert.rb +62 -0
  39. data/openapi/api2convert.openapi.json +3325 -0
  40. metadata +88 -0
@@ -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