runconvert 0.1.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 (6) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +7 -0
  3. data/LICENSE +21 -0
  4. data/README.md +96 -0
  5. data/lib/runconvert.rb +390 -0
  6. metadata +50 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5c004ab09e9f5609e7a9af31221aadecbd99a315cd3bf2cb05245c4fd31174ec
4
+ data.tar.gz: dffa3d774535fe77f139e5982291af020748e21f9cf898999cc50bdbdb64397e
5
+ SHA512:
6
+ metadata.gz: f77fcaccb05f7773cf1ade697a947b0ae5bb94f4df1bc7ea4935f95da574fd936683ac48466d34ae76c2016d5ff8a6fee2d1a7df64c0273160dda379669a423a
7
+ data.tar.gz: e896b309f2d48eda0dde81d98b43d6637ed483d749f8d2cc7e6506c57e2671146def30fc5f59292112f553e536813f1448659d9153e0c03cea3c739a0af17a0c
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-11-15)
4
+
5
+ First version, following the v3 API spec: `convert` in one call; jobs (create, get, list, each, wait,
6
+ cancel, delete); file upload and download; operations; `me`; webhook signature checks; typed errors;
7
+ retries that never create a job twice. Ruby 2.7+, standard library only.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 runconvert
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # RunConvert SDK for Ruby
2
+
3
+ Convert files between 240+ formats (3,000+ conversions) with the [RunConvert API](https://www.runconvert.com/docs):
4
+ documents, images, video, audio, ebooks, archives, fonts and CAD. Ruby 2.7+, standard library only.
5
+
6
+ ```bash
7
+ gem install runconvert
8
+ ```
9
+
10
+ ```ruby
11
+ require "runconvert"
12
+
13
+ rc = RunConvert::Client.new # reads RUNCONVERT_API_KEY
14
+
15
+ # One call: upload, convert, wait, download.
16
+ rc.convert("report.docx", to: "pdf", out_dir: "out").saved # => ["out/report.pdf"]
17
+ ```
18
+
19
+ Get an API key in **Dashboard → API Keys** on [runconvert.com](https://www.runconvert.com). Keep keys on the server.
20
+
21
+ ## Convert
22
+
23
+ ```ruby
24
+ # From a URL (imported by RunConvert, nothing to upload)
25
+ rc.convert("https://example.com/talk.mp4", to: "mp3", options: { audioBitrate: "192k" })
26
+
27
+ # From an IO: give a filename (or from:)
28
+ rc.convert(File.open("photo.heic", "rb"), to: "jpg", out_dir: "out")
29
+
30
+ # Progress and a time limit (seconds)
31
+ rc.convert("movie.mov", to: "mp4", out_dir: "out", timeout: 600) { |job| puts job["status"] }
32
+ ```
33
+
34
+ ## Jobs with your own tasks
35
+
36
+ ```ruby
37
+ # Merge two PDFs
38
+ job = rc.jobs.create({
39
+ "a" => { operation: "import/url", source_url: "https://example.com/cover.pdf" },
40
+ "b" => { operation: "import/url", source_url: "https://example.com/body.pdf" },
41
+ "merge" => { operation: "convert", input: %w[a b], input_format: "pdf", output_format: "pdf" },
42
+ "export" => { operation: "export/url", input: "merge" }
43
+ }, tag: "merge")
44
+ done = rc.jobs.wait(job["id"])
45
+ RunConvert::Jobs.exported_files(done).each { |f| rc.files.download(f, "out") }
46
+
47
+ # Upload your own file
48
+ job = rc.jobs.create({
49
+ "upload" => { operation: "import/direct/upload" },
50
+ "convert" => { operation: "convert", input: "upload", input_format: "png", output_format: "webp", image_quality: 80 },
51
+ "export" => { operation: "export/url", input: "convert" }
52
+ })
53
+ rc.files.upload(job["tasks"].find { |t| t["name"] == "upload" }, "image.png")
54
+ ```
55
+
56
+ | Method | What it does |
57
+ |---|---|
58
+ | `rc.jobs.create / get / list / each / wait / cancel / delete` | Jobs, with paging (`list`, `each`) and polling (`wait`) |
59
+ | `rc.files.upload(task, path_or_io)` / `download(file, dir)` | Transfers |
60
+ | `rc.operations.list(category:)` / `for_input("mp4")` / `for_pair("mp4", "mp3")` | Formats, targets, engines (no key needed) |
61
+ | `rc.me` | Account, team and credit balance |
62
+
63
+ ## Webhooks
64
+
65
+ ```ruby
66
+ post "/webhooks/runconvert" do # Sinatra
67
+ body = request.body.read
68
+ event = RunConvert::Webhooks.construct_event(body, request.env["HTTP_X_RUNCONVERT_SIGNATURE"], ENV["RUNCONVERT_WEBHOOK_SECRET"])
69
+ puts "done #{event['job']['id']}" if event["event"] == "job.finished"
70
+ 200
71
+ rescue ArgumentError
72
+ 400
73
+ end
74
+ ```
75
+
76
+ ## Errors
77
+
78
+ | Error | When |
79
+ |---|---|
80
+ | `RunConvert::Error` | The API said no: `status`, `code` (e.g. `BILLING_INSUFFICIENT_CREDITS`), `message` |
81
+ | `RunConvert::JobFailedError` | The job ended in `error`; `task` has `code`, `reason`, `message` |
82
+ | `RunConvert::WaitTimeoutError` | `wait` ran past its `timeout` |
83
+
84
+ Network errors, `429` and `5xx` are retried twice with backoff (`max_retries:`). Creating a job is only
85
+ retried when the request never reached the API, so a job is never created twice.
86
+
87
+ ## Development
88
+
89
+ ```bash
90
+ ruby -Ilib test/test_unit.rb # no network
91
+ RUNCONVERT_API_KEY=rc_live_… ruby -Ilib test/test_live.rb # real API, about 2 credits
92
+ ```
93
+
94
+ ## License
95
+
96
+ MIT
data/lib/runconvert.rb ADDED
@@ -0,0 +1,390 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+ require "fileutils"
8
+
9
+ # Official RunConvert SDK for Ruby: convert files between 240+ formats.
10
+ #
11
+ # rc = RunConvert::Client.new # reads RUNCONVERT_API_KEY
12
+ # rc.convert("report.docx", to: "pdf", out_dir: "out").saved # => ["out/report.pdf"]
13
+ module RunConvert
14
+ VERSION = "0.1.0"
15
+ DEFAULT_BASE_URL = "https://api.runconvert.com/v3"
16
+
17
+ # The API said no: {"success": false, "error": {"code", "message"}}. New codes can appear,
18
+ # so branch on #status for anything you don't recognise.
19
+ class Error < StandardError
20
+ attr_reader :status, :code, :body
21
+
22
+ def initialize(message, status: 0, code: "", body: nil)
23
+ super(message)
24
+ @status = status
25
+ @code = code.to_s
26
+ @body = body
27
+ end
28
+ end
29
+
30
+ # The job ended with status "error". #task is the first failed task (code, reason, message).
31
+ class JobFailedError < Error
32
+ attr_reader :job, :task
33
+
34
+ def initialize(job, task = nil)
35
+ @job = job
36
+ @task = task
37
+ super((task && task["message"]).to_s.empty? ? "Job #{job['id']} failed" : task["message"], code: task ? task["code"].to_s : "", body: job)
38
+ end
39
+ end
40
+
41
+ # Client::Jobs#wait gave up before the job reached "finished" or "error".
42
+ class WaitTimeoutError < Error
43
+ attr_reader :job
44
+
45
+ def initialize(job, timeout)
46
+ @job = job
47
+ super("Job #{job['id']} still #{job['status']} after #{timeout.round}s", code: "WAIT_TIMEOUT", body: job)
48
+ end
49
+ end
50
+
51
+ # Result of Client#convert.
52
+ ConvertResult = Struct.new(:job, :files, :saved, keyword_init: true)
53
+
54
+ # A raw HTTP exchange (internal).
55
+ Response = Struct.new(:status, :headers, :body)
56
+
57
+ # Webhook signature checks: "sha256=" + hex HMAC-SHA256 of the raw body, keyed with the secret.
58
+ module Webhooks
59
+ module_function
60
+
61
+ # True when the X-RunConvert-Signature header matches the raw body.
62
+ def verify(raw_body, signature, secret)
63
+ return false if signature.nil? || signature.empty? || secret.nil? || secret.empty?
64
+
65
+ given = signature.strip.downcase.sub(/\Asha256=/, "")
66
+ return false unless given.match?(/\A[0-9a-f]{64}\z/)
67
+
68
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body.to_s)
69
+ secure_compare(expected, given)
70
+ end
71
+
72
+ # Constant-time comparison of two equal-length strings (OpenSSL's own needs Ruby 3.0+).
73
+ def secure_compare(a, b)
74
+ return OpenSSL.fixed_length_secure_compare(a, b) if OpenSSL.respond_to?(:fixed_length_secure_compare)
75
+ return false unless a.bytesize == b.bytesize
76
+
77
+ diff = 0
78
+ a.bytes.zip(b.bytes) { |x, y| diff |= x ^ y }
79
+ diff.zero?
80
+ end
81
+
82
+ # Verifies the signature, then parses the event. Raises ArgumentError if it doesn't match.
83
+ def construct_event(raw_body, signature, secret)
84
+ raise ArgumentError, "Invalid RunConvert webhook signature" unless verify(raw_body, signature, secret)
85
+
86
+ JSON.parse(raw_body)
87
+ end
88
+ end
89
+
90
+ # The RunConvert API client.
91
+ class Client
92
+ RETRY_STATUS = [408, 425, 429, 500, 502, 503, 504].freeze
93
+
94
+ attr_reader :jobs, :operations, :files, :base_url
95
+
96
+ # api_key: defaults to ENV["RUNCONVERT_API_KEY"]. timeout: per API request, seconds.
97
+ def initialize(api_key: nil, base_url: DEFAULT_BASE_URL, timeout: 60, max_retries: 2)
98
+ @api_key = api_key.nil? ? ENV.fetch("RUNCONVERT_API_KEY", "") : api_key
99
+ @base_url = base_url.sub(%r{/+\z}, "")
100
+ @timeout = timeout
101
+ @max_retries = max_retries
102
+ @user_agent = "runconvert-ruby/#{VERSION} ruby/#{RUBY_VERSION}"
103
+ @jobs = Jobs.new(self)
104
+ @operations = Operations.new(self)
105
+ @files = Files.new(self)
106
+ end
107
+
108
+ attr_reader :user_agent
109
+
110
+ def webhooks
111
+ Webhooks
112
+ end
113
+
114
+ # The account, team and credit balance behind the API key.
115
+ def me
116
+ request(:get, "/me")
117
+ end
118
+
119
+ # Converts one file in one call: upload (or import a URL), convert, wait, download.
120
+ # file is a path, an http(s) URL or an IO. Engine options go in options:.
121
+ def convert(file, to:, from: nil, options: {}, out_dir: nil, filename: nil, tag: nil, timeout: nil, &on_update)
122
+ is_url = file.is_a?(String) && file.match?(%r{\Ahttps?://}i)
123
+ name = filename || (is_url ? URI(file).path : (file.is_a?(String) ? file : (file.respond_to?(:path) ? file.path : "")))
124
+ source = (from || File.extname(name.to_s).delete_prefix(".")).downcase
125
+ raise ArgumentError, 'Can\'t tell the source format: pass from: "mp4" or filename: "video.mp4"' if source.empty?
126
+
127
+ import = is_url ? { operation: "import/url", source_url: file } : { operation: "import/direct/upload" }
128
+ convert_task = options.merge(operation: "convert", input: "import-1", input_format: source, output_format: to.to_s.downcase.delete_prefix("."))
129
+ job = jobs.create({ "import-1" => import, "convert-1" => convert_task, "export-1" => { operation: "export/url", input: "convert-1" } }, tag: tag)
130
+ unless is_url
131
+ task = (job["tasks"] || []).find { |t| t["name"] == "import-1" }
132
+ raise Error.new("The job has no upload URL", body: job) unless task && task["upload_url"]
133
+
134
+ files.upload(task, file, filename: File.basename(name.to_s))
135
+ end
136
+ job = jobs.wait(job["id"], timeout: timeout, &on_update)
137
+ exported = Jobs.exported_files(job)
138
+ saved = out_dir ? exported.map { |f| files.download(f, out_dir) } : []
139
+ ConvertResult.new(job: job, files: exported, saved: saved)
140
+ end
141
+
142
+ # Sends a request and returns "data" (internal; use the resource methods).
143
+ def request(method, path, body: nil, query: nil)
144
+ request_raw(method, path, body: body, query: query).first
145
+ end
146
+
147
+ # Like #request, returning [data, meta].
148
+ def request_raw(method, path, body: nil, query: nil)
149
+ params = (query || {}).reject { |_, v| v.nil? || v == "" }
150
+ url = @base_url + path + (params.empty? ? "" : "?#{URI.encode_www_form(params)}")
151
+ headers = { "Accept" => "application/json", "User-Agent" => @user_agent }
152
+ headers["Authorization"] = "Bearer #{@api_key}" unless @api_key.empty?
153
+ payload = nil
154
+ unless body.nil?
155
+ headers["Content-Type"] = "application/json"
156
+ payload = JSON.generate(body)
157
+ end
158
+ # POST /jobs is only repeated when it never reached the server: a job is never created twice.
159
+ idempotent = method != :post || path.end_with?("/cancel")
160
+ attempt = 0
161
+ loop do
162
+ begin
163
+ res = send_request(method, url, headers, payload, @timeout)
164
+ rescue Timeout::Error, SystemCallError, IOError, SocketError, OpenSSL::SSL::SSLError => e
165
+ reached = e.is_a?(Timeout::Error)
166
+ if attempt < @max_retries && (idempotent || !reached)
167
+ attempt += 1
168
+ sleep(backoff(attempt))
169
+ next
170
+ end
171
+ raise Error, "Request to RunConvert failed: #{e.message}"
172
+ end
173
+ if RETRY_STATUS.include?(res.status) && attempt < @max_retries && (idempotent || res.status == 429)
174
+ attempt += 1
175
+ sleep(retry_after(res) || backoff(attempt))
176
+ next
177
+ end
178
+ return [nil, {}] if res.status == 204
179
+
180
+ data = begin
181
+ res.body.to_s.empty? ? nil : JSON.parse(res.body)
182
+ rescue JSON::ParserError
183
+ nil
184
+ end
185
+ if res.status >= 400 || (data.is_a?(Hash) && data["success"] == false)
186
+ err = data.is_a?(Hash) ? (data["error"] || {}) : {}
187
+ raise Error.new(err["message"] || "RunConvert API error (HTTP #{res.status})", status: res.status, code: err["code"].to_s, body: data || res.body)
188
+ end
189
+ data ||= {}
190
+ return [data["data"], data["meta"] || {}]
191
+ end
192
+ end
193
+
194
+ # One HTTP exchange; overridden in tests. body may be a String or an IO (streamed).
195
+ def send_request(method, url, headers, body, timeout)
196
+ uri = URI(url)
197
+ http = Net::HTTP.new(uri.host, uri.port)
198
+ http.use_ssl = uri.scheme == "https"
199
+ if timeout
200
+ http.open_timeout = 15
201
+ http.read_timeout = timeout
202
+ http.write_timeout = timeout if http.respond_to?(:write_timeout=)
203
+ end
204
+ klass = { get: Net::HTTP::Get, post: Net::HTTP::Post, put: Net::HTTP::Put, delete: Net::HTTP::Delete }.fetch(method)
205
+ req = klass.new(uri.request_uri, headers)
206
+ if body.respond_to?(:read)
207
+ req.body_stream = body
208
+ elsif body
209
+ req.body = body
210
+ end
211
+ res = http.request(req)
212
+ Response.new(res.code.to_i, res.each_header.to_h, res.body.to_s)
213
+ end
214
+
215
+ # Downloads url into dest; returns the status. Overridden in tests.
216
+ def stream_to(url, dest)
217
+ uri = URI(url)
218
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
219
+ http.request(Net::HTTP::Get.new(uri.request_uri, "User-Agent" => @user_agent)) do |res|
220
+ return res.code.to_i if res.code.to_i >= 300
221
+
222
+ File.open(dest, "wb") { |f| res.read_body { |chunk| f.write(chunk) } }
223
+ return res.code.to_i
224
+ end
225
+ end
226
+ end
227
+
228
+ private
229
+
230
+ def backoff(attempt)
231
+ [8.0, 0.5 * (2**(attempt - 1))].min * (0.75 + rand / 2)
232
+ end
233
+
234
+ def retry_after(res)
235
+ value = res.headers.find { |k, _| k.to_s.casecmp("retry-after").zero? }&.last
236
+ seconds = value.to_f
237
+ seconds.positive? ? [seconds, 30.0].min : nil
238
+ end
239
+ end
240
+
241
+ # Create, read, wait for and stop jobs.
242
+ class Jobs
243
+ def initialize(client)
244
+ @c = client
245
+ end
246
+
247
+ # Creates a job from named tasks. Returned with its tasks (an upload task carries "upload_url").
248
+ def create(tasks, tag: nil, metadata: nil)
249
+ body = { tasks: tasks }
250
+ body[:tag] = tag if tag
251
+ body[:metadata] = metadata if metadata
252
+ @c.request(:post, "/jobs", body: body, query: { include: "tasks" })
253
+ end
254
+
255
+ # A job with its tasks.
256
+ def get(id)
257
+ @c.request(:get, "/jobs/#{escape(id)}", query: { include: "tasks" })
258
+ end
259
+
260
+ # One page of jobs, newest first: {"data" => [...], "meta" => {...}}.
261
+ def list(status: nil, tag: nil, page: nil, per_page: nil, include_tasks: false)
262
+ data, meta = @c.request_raw(:get, "/jobs", query: { "filter[status]" => status, "filter[tag]" => tag, page: page, per_page: per_page, include: include_tasks ? "tasks" : nil })
263
+ { "data" => data || [], "meta" => meta }
264
+ end
265
+
266
+ # Every job, page by page: rc.jobs.each(status: "finished") { |job| ... }
267
+ def each(**filters)
268
+ return enum_for(:each, **filters) unless block_given?
269
+
270
+ page = 1
271
+ loop do
272
+ result = list(page: page, **filters)
273
+ result["data"].each { |j| yield j }
274
+ total = result["meta"]["total_pages"]
275
+ break if result["data"].empty? || (total && page >= total)
276
+
277
+ page += 1
278
+ end
279
+ end
280
+
281
+ # Cancels pending tasks; credits for unfinished tasks are refunded.
282
+ def cancel(id)
283
+ @c.request(:post, "/jobs/#{escape(id)}/cancel")
284
+ end
285
+
286
+ # Deletes a job.
287
+ def delete(id)
288
+ @c.request(:delete, "/jobs/#{escape(id)}")
289
+ nil
290
+ end
291
+
292
+ # Polls until "finished" or "error" (0.7 s, slowing to 3 s). Raises JobFailedError for a failed job.
293
+ def wait(id, timeout: nil, raise_on_error: true)
294
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
295
+ delay = 0.7
296
+ loop do
297
+ job = get(id)
298
+ yield job if block_given?
299
+ return job if job["status"] == "finished"
300
+ if job["status"] == "error"
301
+ return job unless raise_on_error
302
+
303
+ raise JobFailedError.new(job, (job["tasks"] || []).find { |t| t["status"] == "error" })
304
+ end
305
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
306
+ raise WaitTimeoutError.new(job, timeout) if timeout && elapsed + delay > timeout
307
+
308
+ sleep(delay)
309
+ delay = [delay + 0.3, 3.0].min
310
+ end
311
+ end
312
+
313
+ # The files produced by the job's export/url tasks.
314
+ def self.exported_files(job)
315
+ (job["tasks"] || []).select { |t| t["operation"] == "export/url" }.flat_map { |t| (t["result"] || {})["files"] || [] }
316
+ end
317
+
318
+ private
319
+
320
+ def escape(id)
321
+ URI.encode_www_form_component(id.to_s)
322
+ end
323
+ end
324
+
325
+ # Formats, targets, engines and options. Public: no key needed.
326
+ class Operations
327
+ def initialize(client)
328
+ @c = client
329
+ end
330
+
331
+ # Every input format with its targets.
332
+ def list(category: nil)
333
+ @c.request(:get, "/operations", query: { category: category })
334
+ end
335
+
336
+ # What one format converts to, with credit costs.
337
+ def for_input(input)
338
+ @c.request(:get, "/operations", query: { input: input.to_s.downcase })
339
+ end
340
+
341
+ # Engines for one conversion and the options a convert task accepts.
342
+ def for_pair(input, output)
343
+ @c.request(:get, "/operations", query: { input: input.to_s.downcase, output: output.to_s.downcase })
344
+ end
345
+ end
346
+
347
+ # Uploads to import/direct/upload tasks and downloads of results.
348
+ class Files
349
+ def initialize(client)
350
+ @c = client
351
+ end
352
+
353
+ # PUTs a path or IO to the task's "upload_url" before its deadline.
354
+ def upload(task, file, filename: nil)
355
+ upload_url = task.is_a?(String) ? task : task["upload_url"]
356
+ raise ArgumentError, "This task has no upload_url (is it an import/direct/upload task?)" if upload_url.to_s.empty?
357
+
358
+ filename ||= File.basename(file) if file.is_a?(String)
359
+ uri = URI(upload_url)
360
+ query = URI.decode_www_form(uri.query || "").to_h
361
+ query["filename"] = filename if filename
362
+ uri.query = URI.encode_www_form(query)
363
+ headers = { "Content-Type" => "application/octet-stream", "User-Agent" => @c.user_agent }
364
+ res = if file.is_a?(String)
365
+ File.open(file, "rb") do |io|
366
+ headers["Content-Length"] = File.size(file).to_s
367
+ @c.send_request(:put, uri.to_s, headers, io, nil)
368
+ end
369
+ else
370
+ headers["Content-Length"] = file.size.to_s if file.respond_to?(:size)
371
+ @c.send_request(:put, uri.to_s, headers, file, nil)
372
+ end
373
+ raise Error.new("Upload failed (HTTP #{res.status}): #{res.body.to_s[0, 300]}", status: res.status) if res.status >= 300
374
+
375
+ nil
376
+ end
377
+
378
+ # Saves a result file (or a URL) into dir keeping its name; returns the path.
379
+ def download(file, dir, filename: nil)
380
+ url = file.is_a?(String) ? file : file["url"]
381
+ name = filename || (file.is_a?(String) ? File.basename(URI(url).path) : file["filename"])
382
+ FileUtils.mkdir_p(dir)
383
+ dest = File.join(dir, File.basename(name))
384
+ status = @c.stream_to(url, dest)
385
+ raise Error.new("Download failed (HTTP #{status})", status: status) if status >= 300
386
+
387
+ dest
388
+ end
389
+ end
390
+ end
metadata ADDED
@@ -0,0 +1,50 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: runconvert
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - RunConvert
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-11-15 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email:
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - CHANGELOG.md
20
+ - LICENSE
21
+ - README.md
22
+ - lib/runconvert.rb
23
+ homepage: https://www.runconvert.com/docs
24
+ licenses:
25
+ - MIT
26
+ metadata:
27
+ source_code_uri: https://github.com/runconvert/runconvert-ruby
28
+ documentation_uri: https://www.runconvert.com/docs
29
+ changelog_uri: https://github.com/runconvert/runconvert-ruby/blob/main/CHANGELOG.md
30
+ rubygems_mfa_required: 'true'
31
+ post_install_message:
32
+ rdoc_options: []
33
+ require_paths:
34
+ - lib
35
+ required_ruby_version: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.7'
40
+ required_rubygems_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: '0'
45
+ requirements: []
46
+ rubygems_version: 3.5.22
47
+ signing_key:
48
+ specification_version: 4
49
+ summary: 'Official RunConvert SDK for Ruby: convert files between 240+ formats'
50
+ test_files: []