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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +1 -1
  3. data/README.md +43 -0
  4. data/docs/CHANGELOG.md +4 -2
  5. data/docs/SDK_CONTRACT.md +57 -6
  6. data/examples/add_watermark.rb +36 -0
  7. data/examples/audio_operations.rb +21 -0
  8. data/examples/authentication.rb +13 -0
  9. data/examples/capture_website.rb +29 -0
  10. data/examples/compare_files.rb +34 -0
  11. data/examples/compress_files.rb +18 -0
  12. data/examples/convert_files.rb +24 -0
  13. data/examples/create_archives.rb +29 -0
  14. data/examples/create_hashes.rb +19 -0
  15. data/examples/create_thumbnails.rb +22 -0
  16. data/examples/extract_assets.rb +16 -0
  17. data/examples/file_analysis.rb +17 -0
  18. data/examples/image_operations.rb +21 -0
  19. data/examples/job_lifecycle.rb +29 -0
  20. data/examples/presets.rb +14 -0
  21. data/examples/quickstart.rb +22 -0
  22. data/examples/rate_limits.rb +12 -0
  23. data/examples/statistics.rb +12 -0
  24. data/examples/uploading_files.rb +27 -0
  25. data/examples/webhooks.rb +24 -0
  26. data/lib/api2convert/client.rb +29 -5
  27. data/lib/api2convert/cloud_provider.rb +29 -0
  28. data/lib/api2convert/http/net_http_sender.rb +31 -3
  29. data/lib/api2convert/http/request.rb +7 -5
  30. data/lib/api2convert/http/transport.rb +18 -3
  31. data/lib/api2convert/model/cloud_input.rb +108 -0
  32. data/lib/api2convert/model/conversion.rb +7 -3
  33. data/lib/api2convert/model/input_file.rb +8 -3
  34. data/lib/api2convert/model/output_target.rb +78 -0
  35. data/lib/api2convert/resource/jobs.rb +6 -2
  36. data/lib/api2convert/resource/stats.rb +2 -1
  37. data/lib/api2convert/result.rb +51 -20
  38. data/lib/api2convert/support/data.rb +8 -1
  39. data/lib/api2convert/support/redactor.rb +83 -0
  40. data/lib/api2convert/upload/file_uploader.rb +2 -2
  41. data/lib/api2convert/version.rb +1 -1
  42. data/lib/api2convert.rb +4 -0
  43. data/openapi/api2convert.openapi.json +51 -51
  44. metadata +26 -3
  45. 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"
@@ -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
- job = start_conversion(source, to, options, category, nil, filename, download_password)
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, download_password)
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, download_password)
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-Oc-*` secret header can never be
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.body || "")
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-Oc-*
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-Oc-*` header,
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-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.
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
- [key, key.to_s.downcase.start_with?("x-oc-") ? Support::Secret.mask(value) : value]
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-Oc-Api-Key" => @config.api_key }
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-Oc-*` header, which a redirect could leak to another host.
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 = decode_safe(response)
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", descriptor)
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 either an API key to scope to, or `"all"`.
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
@@ -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 straight to the file
28
- # chunk-by-chunk (never buffered whole in memory), so an arbitrarily large
29
- # output cannot exhaust memory. A mid-stream failure removes the partial file
30
- # so a truncated download never masquerades as a complete result. Returns the
31
- # path written to.
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-Oc-Download-Password header could leak on a redirect).
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
- success = false
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
- File.open(target, "wb") do |file|
71
- @transport.download(
72
- @output.uri, headers(password), follow_redirects: password.nil?, sink: file
73
- )
74
- success = true
75
- end
76
- rescue SystemCallError
77
- raise Api2Convert::Error, "Could not open file for writing: #{target}"
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
- # Any failure (open error, network break, a refused-redirect NetworkError,
80
- # a mid-stream break) leaves a partial or empty file remove it so a
81
- # truncated download can never masquerade as a complete result.
82
- FileUtils.rm_f(target) unless success
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-Oc-Download-Password" => password }
122
+ password.nil? ? {} : { "X-Api2convert-Download-Password" => password }
92
123
  end
93
124
 
94
125
  def resolve_target(path_or_dir)