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,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Api2Convert
4
+ module Model
5
+ # A single conversion within a job: the target format plus its options.
6
+ class Conversion
7
+ attr_reader :target, :id, :category, :options, :metadata
8
+
9
+ def initialize(target: "", id: nil, category: nil, options: {}, metadata: {})
10
+ @target = target
11
+ @id = id
12
+ @category = category
13
+ @options = options
14
+ @metadata = metadata
15
+ freeze
16
+ end
17
+
18
+ def self.from_hash(data)
19
+ d = Support::Data.as_object(data)
20
+ new(
21
+ target: Support::Data.as_str(d["target"]),
22
+ id: Support::Data.nullable_str(d["id"]),
23
+ category: Support::Data.nullable_str(d["category"]),
24
+ options: Support::Data.as_object(d["options"]),
25
+ metadata: Support::Data.as_object(d["metadata"])
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Api2Convert
4
+ module Model
5
+ # An input file attached to a job.
6
+ class InputFile
7
+ attr_reader :id, :type, :source, :status, :filename, :size, :content_type, :options
8
+
9
+ def initialize(id: nil, type: "", source: nil, status: nil, filename: nil,
10
+ size: nil, content_type: nil, options: {})
11
+ @id = id
12
+ @type = type
13
+ @source = source
14
+ @status = status
15
+ @filename = filename
16
+ @size = size
17
+ @content_type = content_type
18
+ @options = options
19
+ freeze
20
+ end
21
+
22
+ def self.from_hash(data)
23
+ d = Support::Data.as_object(data)
24
+ new(
25
+ id: Support::Data.nullable_str(d["id"]),
26
+ type: Support::Data.as_str(d["type"]),
27
+ source: Support::Data.nullable_str(d["source"]),
28
+ status: Support::Data.nullable_str(d["status"]),
29
+ filename: Support::Data.nullable_str(d["filename"]),
30
+ size: Support::Data.nullable_int(d["size"]),
31
+ content_type: Support::Data.nullable_str(d["content_type"]),
32
+ options: Support::Data.as_object(d["options"])
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Api2Convert
4
+ module Model
5
+ # A conversion job — the central API2Convert resource.
6
+ #
7
+ # {#server} and {#token} are needed to upload local files; {#output} holds the
8
+ # produced files once {#completed?}. {#raw} keeps the full decoded response for
9
+ # fields not surfaced as typed attributes.
10
+ class Job
11
+ attr_reader :id, :status, :token, :server, :callback,
12
+ :conversion, :input, :output, :errors, :warnings, :raw
13
+
14
+ def initialize(id:, status:, token:, server:, callback:,
15
+ conversion:, input:, output:, errors:, warnings:, raw:)
16
+ @id = id
17
+ @status = status
18
+ @token = token
19
+ @server = server
20
+ @callback = callback
21
+ @conversion = conversion
22
+ @input = input
23
+ @output = output
24
+ @errors = errors
25
+ @warnings = warnings
26
+ @raw = raw
27
+ freeze
28
+ end
29
+
30
+ def self.from_hash(data)
31
+ d = Support::Data.as_object(data)
32
+ new(
33
+ id: Support::Data.as_str(d["id"]),
34
+ status: Status.from_hash(d["status"]),
35
+ token: Support::Data.nullable_str(d["token"]),
36
+ server: Support::Data.nullable_str(d["server"]),
37
+ callback: Support::Data.nullable_str(d["callback"]),
38
+ conversion: Support::Data.map_objects(d["conversion"]) { |x| Conversion.from_hash(x) },
39
+ input: Support::Data.map_objects(d["input"]) { |x| InputFile.from_hash(x) },
40
+ output: Support::Data.map_objects(d["output"]) { |x| OutputFile.from_hash(x) },
41
+ errors: Support::Data.map_objects(d["errors"]) { |x| JobMessage.from_hash(x) },
42
+ warnings: Support::Data.map_objects(d["warnings"]) { |x| JobMessage.from_hash(x) },
43
+ raw: d
44
+ )
45
+ end
46
+
47
+ def completed?
48
+ @status.code == JobStatus::COMPLETED
49
+ end
50
+
51
+ def failed?
52
+ @status.code == JobStatus::FAILED
53
+ end
54
+
55
+ # The job was canceled server-side — terminal, and produced no output.
56
+ def canceled?
57
+ @status.code == JobStatus::CANCELED
58
+ end
59
+
60
+ # Finished (completed, failed or canceled) and will not change further.
61
+ def terminal?
62
+ JobStatus.terminal?(@status.code)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Api2Convert
4
+ module Model
5
+ # An error or warning attached to a job (the `errors[]` / `warnings[]` entries).
6
+ class JobMessage
7
+ attr_reader :code, :message, :source, :id_source, :details
8
+
9
+ def initialize(code: nil, message: "", source: nil, id_source: nil, details: {})
10
+ @code = code
11
+ @message = message
12
+ @source = source
13
+ @id_source = id_source
14
+ @details = details
15
+ freeze
16
+ end
17
+
18
+ def self.from_hash(data)
19
+ d = Support::Data.as_object(data)
20
+ new(
21
+ code: Support::Data.nullable_int(d["code"]),
22
+ message: Support::Data.as_str(d["message"]),
23
+ source: Support::Data.nullable_str(d["source"]),
24
+ id_source: Support::Data.nullable_str(d["id_source"]),
25
+ details: Support::Data.as_object(d["details"])
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Api2Convert
4
+ module Model
5
+ # A produced output file.
6
+ #
7
+ # {#uri} is a self-contained download URL (no auth), valid for a limited time
8
+ # (24h by default).
9
+ class OutputFile
10
+ attr_reader :id, :uri, :filename, :size, :status, :content_type, :checksum, :metadata
11
+
12
+ def initialize(id: nil, uri: "", filename: nil, size: nil, status: nil,
13
+ content_type: nil, checksum: nil, metadata: {})
14
+ @id = id
15
+ @uri = uri
16
+ @filename = filename
17
+ @size = size
18
+ @status = status
19
+ @content_type = content_type
20
+ @checksum = checksum
21
+ @metadata = metadata
22
+ freeze
23
+ end
24
+
25
+ def self.from_hash(data)
26
+ d = Support::Data.as_object(data)
27
+ new(
28
+ id: Support::Data.nullable_str(d["id"]),
29
+ uri: Support::Data.as_str(d["uri"]),
30
+ filename: Support::Data.nullable_str(d["filename"]),
31
+ size: Support::Data.nullable_int(d["size"]),
32
+ status: Support::Data.nullable_str(d["status"]),
33
+ content_type: Support::Data.nullable_str(d["content_type"]),
34
+ checksum: Support::Data.nullable_str(d["checksum"]),
35
+ metadata: Support::Data.as_object(d["metadata"])
36
+ )
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Api2Convert
4
+ module Model
5
+ # A saved conversion preset (a reusable named set of target + options).
6
+ class Preset
7
+ attr_reader :id, :name, :target, :category, :scope, :options
8
+
9
+ def initialize(id: nil, name: "", target: nil, category: nil, scope: nil, options: {})
10
+ @id = id
11
+ @name = name
12
+ @target = target
13
+ @category = category
14
+ @scope = scope
15
+ @options = options
16
+ freeze
17
+ end
18
+
19
+ def self.from_hash(data)
20
+ d = Support::Data.as_object(data)
21
+ new(
22
+ id: Support::Data.nullable_str(d["id"]),
23
+ name: Support::Data.as_str(d["name"]),
24
+ target: Support::Data.nullable_str(d["target"]),
25
+ category: Support::Data.nullable_str(d["category"]),
26
+ scope: Support::Data.nullable_str(d["scope"]),
27
+ options: Support::Data.as_object(d["options"])
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Api2Convert
4
+ module Model
5
+ # A job's status: a machine-readable {#code} plus optional human {#info}.
6
+ class Status
7
+ attr_reader :code, :info
8
+
9
+ def initialize(code: "", info: nil)
10
+ @code = code
11
+ @info = info
12
+ freeze
13
+ end
14
+
15
+ def self.from_hash(data)
16
+ d = Support::Data.as_object(data)
17
+ new(
18
+ code: Support::Data.as_str(d["code"]),
19
+ info: Support::Data.nullable_str(d["info"])
20
+ )
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Api2Convert
4
+ module Resource
5
+ # Information about the account's active contracts (free-form response).
6
+ class Contracts
7
+ def initialize(transport)
8
+ @transport = transport
9
+ end
10
+
11
+ def get
12
+ @transport.request("GET", "/contracts")
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Api2Convert
4
+ module Resource
5
+ # The conversions catalog (`GET /conversions`).
6
+ #
7
+ # The source of truth for which targets exist and which options each accepts.
8
+ class Conversions
9
+ def initialize(transport)
10
+ @transport = transport
11
+ end
12
+
13
+ # List supported conversions, optionally filtered by category/target. Each
14
+ # entry: `{ id, category, target, options }`.
15
+ def list(category = nil, target = nil, page = 1)
16
+ query = { "page" => page.to_s }
17
+ query["category"] = category unless category.nil?
18
+ query["target"] = target unless target.nil?
19
+ rows = @transport.request("GET", "/conversions", nil, query)
20
+ Support::Data.as_list(rows).grep(Hash)
21
+ end
22
+
23
+ # The option schema (type / enum / default / range) for a single target.
24
+ # +category+ is optional — pass it only to disambiguate an ambiguous target.
25
+ def options(target, category = nil)
26
+ rows = list(category, target)
27
+ first = rows.first || {}
28
+ opts = first["options"]
29
+ opts.is_a?(Hash) ? opts : {}
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Api2Convert
4
+ module Resource
5
+ # Full control over the job lifecycle.
6
+ #
7
+ # Most users only need `client.convert`, which is built on top of these
8
+ # methods. Reach for this resource for compound jobs, merges, presets, custom
9
+ # polling or job chaining. Methods are thin: build the request, call the
10
+ # transport, hydrate a model.
11
+ class Jobs
12
+ def initialize(transport, uploader)
13
+ @transport = transport
14
+ @uploader = uploader
15
+ end
16
+
17
+ # Create a job. Pass `{ "process" => false }` to stage it for uploads, then
18
+ # call {#start} once inputs are attached. +idempotency_key+ makes the create
19
+ # retry-safe (sent as the `Idempotency-Key` header).
20
+ def create(payload, idempotency_key = nil)
21
+ headers = idempotency_key.nil? ? nil : { "Idempotency-Key" => idempotency_key }
22
+ Model::Job.from_hash(@transport.request("POST", "/jobs", payload, nil, headers))
23
+ end
24
+
25
+ def get(job_id)
26
+ Model::Job.from_hash(@transport.request("GET", "/jobs/#{Support::Data.encode_segment(job_id)}"))
27
+ end
28
+
29
+ # List the current key's jobs (paginated, 50 per page).
30
+ def list(status = nil, page = 1)
31
+ query = { "page" => page.to_s }
32
+ query["status"] = status unless status.nil?
33
+ rows = @transport.request("GET", "/jobs", nil, query)
34
+ hashes(rows).map { |row| Model::Job.from_hash(row) }
35
+ end
36
+
37
+ def update(job_id, payload)
38
+ Model::Job.from_hash(
39
+ @transport.request("PATCH", "/jobs/#{Support::Data.encode_segment(job_id)}", payload)
40
+ )
41
+ end
42
+
43
+ # Start processing a staged job (`process => true`).
44
+ def start(job_id)
45
+ update(job_id, { "process" => true })
46
+ end
47
+
48
+ # Cancel a job (whether staged or processing).
49
+ def cancel(job_id)
50
+ @transport.request("DELETE", "/jobs/#{Support::Data.encode_segment(job_id)}")
51
+ nil
52
+ end
53
+
54
+ # Attach an input by descriptor, e.g. a remote URL:
55
+ # `add_input(job_id, { "type" => "remote", "source" => "https://..." })`.
56
+ def add_input(job_id, descriptor)
57
+ Model::InputFile.from_hash(
58
+ @transport.request("POST", "/jobs/#{Support::Data.encode_segment(job_id)}/input", descriptor)
59
+ )
60
+ end
61
+
62
+ # Upload a local file (path or IO) to the job's upload server.
63
+ def upload(job, file, filename = nil)
64
+ @uploader.upload(job, file, filename)
65
+ end
66
+
67
+ # Block until the job reaches a terminal status, polling with backoff.
68
+ #
69
+ # Raises {ConversionFailedError} on a failed/canceled job (unless
70
+ # +throw_on_failure+ is false) and {ConversionTimeoutError} past the
71
+ # deadline. The interval is floored and the total wait capped, so no
72
+ # configuration can busy-loop or poll unbounded.
73
+ def wait(job_id, timeout_seconds = nil, throw_on_failure = true)
74
+ config = @transport.config
75
+ # Clamp again here (Config.create already clamps) so a directly-built
76
+ # Config or a per-call override can never busy-loop or poll unbounded.
77
+ requested = timeout_seconds.nil? ? config.poll_timeout : timeout_seconds
78
+ timeout = requested.clamp(0, Config::MAX_POLL_TIMEOUT)
79
+ max_interval = [Config::MIN_POLL_INTERVAL, config.poll_max_interval].max
80
+ interval = [Config::MIN_POLL_INTERVAL, config.poll_interval].max
81
+ deadline = monotonic + timeout
82
+
83
+ loop do
84
+ job = get(job_id)
85
+
86
+ raise ConversionFailedError.new(job) if (job.failed? || job.canceled?) && throw_on_failure
87
+ return job if job.terminal?
88
+ raise ConversionTimeoutError.new(job, timeout) if monotonic >= deadline
89
+
90
+ @transport.pause(interval)
91
+ interval = [max_interval, interval * 1.5].min
92
+ end
93
+ end
94
+
95
+ # Outputs produced by the job (use {#get} or {#wait} first).
96
+ def outputs(job_id)
97
+ rows = @transport.request("GET", "/jobs/#{Support::Data.encode_segment(job_id)}/output")
98
+ hashes(rows).map { |row| Model::OutputFile.from_hash(row) }
99
+ end
100
+
101
+ private
102
+
103
+ def hashes(rows)
104
+ Support::Data.as_list(rows).grep(Hash)
105
+ end
106
+
107
+ def monotonic
108
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Api2Convert
4
+ module Resource
5
+ # Saved conversion presets (reusable named target + options).
6
+ class Presets
7
+ def initialize(transport)
8
+ @transport = transport
9
+ end
10
+
11
+ def list(category: nil, target: nil, filter: nil)
12
+ query = {}
13
+ query["category"] = category unless category.nil?
14
+ query["target"] = target unless target.nil?
15
+ query["filter"] = filter unless filter.nil?
16
+ rows = @transport.request("GET", "/presets", nil, query)
17
+ Support::Data.as_list(rows).grep(Hash).map { |row| Model::Preset.from_hash(row) }
18
+ end
19
+
20
+ # Create a preset from `{ name, target, options, scope?, category? }`.
21
+ def create(payload)
22
+ Model::Preset.from_hash(@transport.request("POST", "/presets", payload))
23
+ end
24
+
25
+ def get(preset_id)
26
+ Model::Preset.from_hash(
27
+ @transport.request("GET", "/presets/#{Support::Data.encode_segment(preset_id)}")
28
+ )
29
+ end
30
+
31
+ def update(preset_id, payload)
32
+ Model::Preset.from_hash(
33
+ @transport.request("PATCH", "/presets/#{Support::Data.encode_segment(preset_id)}", payload)
34
+ )
35
+ end
36
+
37
+ def delete(preset_id)
38
+ @transport.request("DELETE", "/presets/#{Support::Data.encode_segment(preset_id)}")
39
+ nil
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Api2Convert
4
+ module Resource
5
+ # API usage statistics. The response shape is free-form (returned as-is).
6
+ #
7
+ # +filter+ is either an API key to scope to, or `"all"`.
8
+ class Stats
9
+ def initialize(transport)
10
+ @transport = transport
11
+ end
12
+
13
+ # +day+ format `yyyy-mm-dd`.
14
+ def day(day, filter = "all")
15
+ @transport.request("GET", "/stats/day/#{seg(day)}/#{seg(filter)}")
16
+ end
17
+
18
+ # +month+ format `yyyy-mm`.
19
+ def month(month, filter = "all")
20
+ @transport.request("GET", "/stats/month/#{seg(month)}/#{seg(filter)}")
21
+ end
22
+
23
+ # +year+ format `yyyy`.
24
+ def year(year, filter = "all")
25
+ @transport.request("GET", "/stats/year/#{seg(year)}/#{seg(filter)}")
26
+ end
27
+
28
+ private
29
+
30
+ def seg(value)
31
+ Support::Data.encode_segment(value)
32
+ end
33
+ end
34
+ end
35
+ end