api2convert 10.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/AGENTS.md +99 -0
- data/LICENSE +21 -0
- data/README.md +164 -0
- data/docs/CHANGELOG.md +9 -0
- data/docs/SDK_CONTRACT.md +99 -0
- data/examples/convert.rb +20 -0
- data/examples/webhook.rb +32 -0
- data/lib/api2convert/client.rb +139 -0
- data/lib/api2convert/config.rb +66 -0
- data/lib/api2convert/errors.rb +121 -0
- data/lib/api2convert/http/net_http_sender.rb +148 -0
- data/lib/api2convert/http/request.rb +47 -0
- data/lib/api2convert/http/response.rb +23 -0
- data/lib/api2convert/http/transport.rb +290 -0
- data/lib/api2convert/input_type.rb +16 -0
- data/lib/api2convert/job_status.rb +29 -0
- data/lib/api2convert/model/conversion.rb +30 -0
- data/lib/api2convert/model/input_file.rb +37 -0
- data/lib/api2convert/model/job.rb +66 -0
- data/lib/api2convert/model/job_message.rb +30 -0
- data/lib/api2convert/model/output_file.rb +40 -0
- data/lib/api2convert/model/preset.rb +32 -0
- data/lib/api2convert/model/status.rb +24 -0
- data/lib/api2convert/resource/contracts.rb +16 -0
- data/lib/api2convert/resource/conversions.rb +33 -0
- data/lib/api2convert/resource/jobs.rb +112 -0
- data/lib/api2convert/resource/presets.rb +43 -0
- data/lib/api2convert/resource/stats.rb +35 -0
- data/lib/api2convert/result.rb +185 -0
- data/lib/api2convert/support/data.rb +89 -0
- data/lib/api2convert/support/secret.rb +27 -0
- data/lib/api2convert/upload/file_uploader.rb +87 -0
- data/lib/api2convert/upload/multipart_stream.rb +74 -0
- data/lib/api2convert/version.rb +8 -0
- data/lib/api2convert/webhook/event.rb +23 -0
- data/lib/api2convert/webhook/verifier.rb +74 -0
- data/lib/api2convert.rb +62 -0
- data/openapi/api2convert.openapi.json +3325 -0
- metadata +88 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Api2Convert
|
|
6
|
+
module Result
|
|
7
|
+
# A downloadable output file.
|
|
8
|
+
#
|
|
9
|
+
# Returned by `client.download(output)` and used internally by
|
|
10
|
+
# {ConversionResult}. A password supplied at conversion time (or to
|
|
11
|
+
# `client.download(output, password)`) is remembered and sent automatically on
|
|
12
|
+
# every download; an explicit password argument to {#save}/{#contents}
|
|
13
|
+
# overrides the remembered one for that call.
|
|
14
|
+
class FileDownload
|
|
15
|
+
def initialize(transport, output, download_password = nil)
|
|
16
|
+
@transport = transport
|
|
17
|
+
@output = output
|
|
18
|
+
@download_password = download_password
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The self-contained download URL (no auth required).
|
|
22
|
+
def url
|
|
23
|
+
@output.uri
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# 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.
|
|
32
|
+
def save(path_or_dir, download_password = nil)
|
|
33
|
+
target = resolve_target(path_or_dir.to_s)
|
|
34
|
+
parent = File.dirname(target)
|
|
35
|
+
parent = "." if parent.empty?
|
|
36
|
+
begin
|
|
37
|
+
FileUtils.mkdir_p(parent)
|
|
38
|
+
rescue SystemCallError
|
|
39
|
+
raise Api2Convert::Error, "Could not create directory: #{parent}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
stream_to_file(target, resolve_password(download_password))
|
|
43
|
+
target
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Download the file and return its contents. This buffers the whole body in
|
|
47
|
+
# memory by design — use {#save} for a large file to stream it to disk.
|
|
48
|
+
def contents(download_password = nil)
|
|
49
|
+
password = resolve_password(download_password)
|
|
50
|
+
# 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
|
+
@transport.download(@output.uri, headers(password), follow_redirects: password.nil?)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Redacted representation — the remembered download password is masked.
|
|
56
|
+
def inspect
|
|
57
|
+
"#<#{self.class.name} url=#{@output.uri.inspect} " \
|
|
58
|
+
"download_password=#{Support::Secret.mask(@download_password)}>"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def to_s
|
|
62
|
+
inspect
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def stream_to_file(target, password)
|
|
68
|
+
success = false
|
|
69
|
+
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}"
|
|
78
|
+
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
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def resolve_password(download_password)
|
|
87
|
+
download_password.nil? ? @download_password : download_password
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def headers(password)
|
|
91
|
+
password.nil? ? {} : { "X-Oc-Download-Password" => password }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def resolve_target(path_or_dir)
|
|
95
|
+
looks_like_dir = File.directory?(path_or_dir) ||
|
|
96
|
+
path_or_dir.end_with?("/") ||
|
|
97
|
+
path_or_dir.end_with?(File::SEPARATOR)
|
|
98
|
+
if looks_like_dir
|
|
99
|
+
name = safe_name(@output.filename) || safe_name(@output.id) || "output"
|
|
100
|
+
return File.join(path_or_dir.sub(%r{[/\\]+\z}, ""), name)
|
|
101
|
+
end
|
|
102
|
+
path_or_dir
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Reduce an API-supplied name to a bare filename safe to append to a dir.
|
|
106
|
+
# `output.filename` / `output.id` come straight from the API JSON, so a value
|
|
107
|
+
# like `../../etc/cron.d/evil` (or one with separators or a NUL byte) must
|
|
108
|
+
# never escape the caller's chosen directory. Returns nil when nothing usable
|
|
109
|
+
# remains, so the caller can fall back.
|
|
110
|
+
def safe_name(name)
|
|
111
|
+
return nil if name.nil?
|
|
112
|
+
|
|
113
|
+
base = File.basename(name.delete("\x00").tr("\\", "/")).strip
|
|
114
|
+
# Reject a name that reduces to nothing usable. Note Ruby's File.basename("/")
|
|
115
|
+
# is "/" (unlike POSIX basename -> ""), so a pure-separator name must be
|
|
116
|
+
# caught here too, matching the sibling SDKs' fallback behavior.
|
|
117
|
+
return nil if base == "." || base == ".." || base.delete("/").empty?
|
|
118
|
+
|
|
119
|
+
base
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The result of a completed conversion.
|
|
124
|
+
#
|
|
125
|
+
# The common case is one output: `result.save("out.pdf")`. Jobs that produce
|
|
126
|
+
# several files expose them via {#outputs} and {#download}.
|
|
127
|
+
class ConversionResult
|
|
128
|
+
# @return [Model::Job] the completed job.
|
|
129
|
+
attr_reader :job
|
|
130
|
+
|
|
131
|
+
def initialize(job, transport, index = 0, download_password = nil)
|
|
132
|
+
@job = job
|
|
133
|
+
@transport = transport
|
|
134
|
+
@index = index
|
|
135
|
+
@download_password = download_password
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The selected output file (the first one by default).
|
|
139
|
+
def output
|
|
140
|
+
# Any index not present — including a negative one — raises rather than
|
|
141
|
+
# wrapping around.
|
|
142
|
+
if @index.negative? || @index >= @job.output.length
|
|
143
|
+
raise Api2Convert::Error, "The job produced no output files."
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
@job.output[@index]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# All output files produced by the job.
|
|
150
|
+
def outputs
|
|
151
|
+
@job.output
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The download URL of the selected output (self-contained, no auth).
|
|
155
|
+
def url
|
|
156
|
+
output.uri
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Download the selected output to disk. Returns the path written to.
|
|
160
|
+
def save(path_or_dir, download_password = nil)
|
|
161
|
+
download.save(path_or_dir, download_password)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Download the selected output and return its contents.
|
|
165
|
+
def contents(download_password = nil)
|
|
166
|
+
download.contents(download_password)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# A {FileDownload} for a specific output (defaults to the selected one).
|
|
170
|
+
def download(output_file = nil)
|
|
171
|
+
FileDownload.new(@transport, output_file.nil? ? output : output_file, @download_password)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Redacted representation — the remembered download password is masked.
|
|
175
|
+
def inspect
|
|
176
|
+
"#<#{self.class.name} job=#{@job.id.inspect} outputs=#{@job.output.length} " \
|
|
177
|
+
"download_password=#{Support::Secret.mask(@download_password)}>"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def to_s
|
|
181
|
+
inspect
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Api2Convert
|
|
4
|
+
module Support
|
|
5
|
+
# Typed, null-safe accessors over decoded JSON.
|
|
6
|
+
#
|
|
7
|
+
# Mirrors the PHP SDK's `Support\Data` helper: model hydration stays free of
|
|
8
|
+
# scattered casts and, crucially, **never raises** on a surprising payload — a
|
|
9
|
+
# missing or wrong-typed field falls back to a sensible default. Internal
|
|
10
|
+
# helper, not part of the public API.
|
|
11
|
+
module Data
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Return +value+ when it is a real String, else +default+. Does not
|
|
15
|
+
# stringify ints/floats/bools — only genuine strings pass through.
|
|
16
|
+
def as_str(value, default = "")
|
|
17
|
+
value.is_a?(String) ? value : default
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def nullable_str(value)
|
|
21
|
+
value.is_a?(String) ? value : nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Coerce numeric values to Integer (truncating toward zero), else nil.
|
|
25
|
+
#
|
|
26
|
+
# +true+/+false+ are rejected (they must not become 1/0). Numeric strings
|
|
27
|
+
# and floats are truncated ("3.9" -> 3), matching PHP's (int) cast.
|
|
28
|
+
def nullable_int(value)
|
|
29
|
+
return nil if [true, false].include?(value)
|
|
30
|
+
return value if value.is_a?(Integer)
|
|
31
|
+
|
|
32
|
+
if value.is_a?(Float)
|
|
33
|
+
return nil unless value.finite?
|
|
34
|
+
|
|
35
|
+
return value.to_i
|
|
36
|
+
end
|
|
37
|
+
if value.is_a?(String)
|
|
38
|
+
begin
|
|
39
|
+
return Float(value).to_i
|
|
40
|
+
rescue ArgumentError, TypeError
|
|
41
|
+
return nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def as_bool(value, default = false)
|
|
48
|
+
[true, false].include?(value) ? value : default
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Return +value+ when it is a Hash (a JSON object), else an empty Hash.
|
|
52
|
+
def as_object(value)
|
|
53
|
+
value.is_a?(Hash) ? value : {}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Return a list of values: an Array passes through; a Hash is reduced to its
|
|
57
|
+
# values (mirrors PHP `array_values`); anything else yields [].
|
|
58
|
+
def as_list(value)
|
|
59
|
+
return value if value.is_a?(Array)
|
|
60
|
+
return value.values if value.is_a?(Hash)
|
|
61
|
+
|
|
62
|
+
[]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Build a model from each Hash element of +value+; skip non-Hash elements.
|
|
66
|
+
def map_objects(value)
|
|
67
|
+
as_list(value).each_with_object([]) do |item, acc|
|
|
68
|
+
acc << yield(item) if item.is_a?(Hash)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def str_list(value)
|
|
73
|
+
as_list(value).grep(String)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Percent-encode a value for use as a single URL path segment.
|
|
77
|
+
#
|
|
78
|
+
# A job/preset id or a stats date/filter is interpolated into the request
|
|
79
|
+
# path; a value carrying `/`, `?`, `#`, a space or any other reserved
|
|
80
|
+
# character (e.g. `all/../contracts`) must be encoded so it can never break
|
|
81
|
+
# out of its segment or forge a different path. Only the RFC 3986 unreserved
|
|
82
|
+
# set survives; everything else becomes `%XX` over the UTF-8 bytes (mirrors
|
|
83
|
+
# Node's `encodeURIComponent` / Go's `url.PathEscape`).
|
|
84
|
+
def encode_segment(value)
|
|
85
|
+
value.to_s.b.gsub(/[^A-Za-z0-9\-_.~]/n) { |byte| format("%%%02X", byte.ord) }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Api2Convert
|
|
4
|
+
module Support
|
|
5
|
+
# Redaction helpers for `#inspect` / `#to_s`.
|
|
6
|
+
#
|
|
7
|
+
# Without these, `p client`, a `config.inspect` in a log line, or an object
|
|
8
|
+
# dumped in a backtrace would print `@api_key` / `@download_password` in
|
|
9
|
+
# cleartext. Every object that holds a secret overrides `#inspect` to route the
|
|
10
|
+
# secret through {mask}. Internal helper, not part of the public API.
|
|
11
|
+
module Secret
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Mask a secret for display. A `nil` renders as `nil`; a sufficiently long
|
|
15
|
+
# value keeps only its last 4 characters (so it stays recognizable in a log)
|
|
16
|
+
# with the rest hidden; a short value collapses to `[FILTERED]`.
|
|
17
|
+
def mask(value)
|
|
18
|
+
return "nil" if value.nil?
|
|
19
|
+
|
|
20
|
+
str = value.to_s
|
|
21
|
+
return "[FILTERED]" if str.length < 8
|
|
22
|
+
|
|
23
|
+
"[FILTERED:...#{str[-4..-1]}]"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Api2Convert
|
|
7
|
+
module Upload
|
|
8
|
+
# Uploads a local file to a job's per-job upload server.
|
|
9
|
+
#
|
|
10
|
+
# This step is intentionally hand-written — it is NOT described by the OpenAPI
|
|
11
|
+
# spec. It posts a `multipart/form-data` body (field `file`) to
|
|
12
|
+
# `{job.server}/upload-file/{job.id}` and authenticates with the per-job
|
|
13
|
+
# `X-Oc-Token` header — never the account API key. The body is streamed, so
|
|
14
|
+
# large files are not read into memory. Internal.
|
|
15
|
+
class FileUploader
|
|
16
|
+
def initialize(transport)
|
|
17
|
+
@transport = transport
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param job [Model::Job] a staged job (created with `process: false`).
|
|
21
|
+
# @param file [String, Pathname, IO] a path, or an IO/StringIO of bytes.
|
|
22
|
+
# @return [Model::InputFile]
|
|
23
|
+
def upload(job, file, filename = nil)
|
|
24
|
+
if job.server.nil? || job.server.empty? || job.token.nil?
|
|
25
|
+
raise Api2Convert::Error,
|
|
26
|
+
"Cannot upload: the job has no upload server/token. " \
|
|
27
|
+
"Create the job with process=false and upload before starting it."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
io, resolved_name, opened = resolve(file, filename)
|
|
31
|
+
seekable = io.respond_to?(:rewind)
|
|
32
|
+
url = "#{job.server.sub(%r{/+\z}, "")}/upload-file/#{job.id}"
|
|
33
|
+
token = job.token
|
|
34
|
+
boundary = "----Api2Convert#{SecureRandom.hex(16)}"
|
|
35
|
+
|
|
36
|
+
build = lambda do
|
|
37
|
+
io.rewind if seekable
|
|
38
|
+
body_stream, length = MultipartStream.build(boundary, "file", resolved_name, io)
|
|
39
|
+
@transport.build_request(
|
|
40
|
+
"POST", url,
|
|
41
|
+
headers: {
|
|
42
|
+
"X-Oc-Token" => token,
|
|
43
|
+
"Content-Type" => "multipart/form-data; boundary=#{boundary}"
|
|
44
|
+
},
|
|
45
|
+
body_stream: body_stream,
|
|
46
|
+
content_length: length
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
begin
|
|
51
|
+
response = @transport.send_request(build, replayable: seekable)
|
|
52
|
+
Model::InputFile.from_hash(@transport.interpret(response))
|
|
53
|
+
ensure
|
|
54
|
+
opened&.close
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Returns `[io, filename, opened]` where +opened+ is the handle to close
|
|
61
|
+
# afterwards (nil when the caller owns the IO). A path/Pathname is opened in
|
|
62
|
+
# binary mode; any other IO without a known byte size is buffered so the
|
|
63
|
+
# multipart Content-Length can be computed.
|
|
64
|
+
def resolve(file, filename)
|
|
65
|
+
if file.is_a?(String) || file.respond_to?(:to_path)
|
|
66
|
+
raw_path = file.respond_to?(:to_path) ? file.to_path : file
|
|
67
|
+
path = File.realpath(raw_path)
|
|
68
|
+
raise Api2Convert::Error, "Input file not found: #{file}" unless File.file?(path)
|
|
69
|
+
|
|
70
|
+
# Opened without a block on purpose: the handle is streamed by the
|
|
71
|
+
# transport and closed in upload's ensure clause.
|
|
72
|
+
handle = File.open(path, "rb") # rubocop:disable Style/FileOpen
|
|
73
|
+
# Mirror the null-coalesce: only nil falls back to the default, so an
|
|
74
|
+
# explicit "" filename is preserved as given.
|
|
75
|
+
name = filename.nil? ? File.basename(path) : filename
|
|
76
|
+
return [handle, name, handle]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
io = file
|
|
80
|
+
io = StringIO.new(io.read) unless io.respond_to?(:size) && io.respond_to?(:rewind)
|
|
81
|
+
[io, filename.nil? ? "file" : filename, nil]
|
|
82
|
+
rescue Errno::ENOENT
|
|
83
|
+
raise Api2Convert::Error, "Input file not found: #{file}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module Api2Convert
|
|
6
|
+
module Upload
|
|
7
|
+
# A read-only IO that concatenates a multipart preamble, the file body IO, and
|
|
8
|
+
# the trailing boundary — so the body is streamed to the socket rather than
|
|
9
|
+
# loaded into memory. `Net::HTTP#body_stream=` reads it via `#read(len)`.
|
|
10
|
+
class MultipartStream
|
|
11
|
+
# Build the streamed body for a single `file` field. Returns
|
|
12
|
+
# `[stream, content_length]`; the caller sets `Content-Length` from the
|
|
13
|
+
# length so `Net::HTTP` does not fall back to chunked encoding.
|
|
14
|
+
#
|
|
15
|
+
# +io+ must be positioned at the start and expose a byte {#size} (a File or
|
|
16
|
+
# a StringIO both do — the uploader coerces anything else into a StringIO).
|
|
17
|
+
def self.build(boundary, field, filename, io)
|
|
18
|
+
disposition = %(form-data; name="#{escape(field)}"; filename="#{escape(filename)}")
|
|
19
|
+
preamble = [
|
|
20
|
+
"--#{boundary}",
|
|
21
|
+
"Content-Disposition: #{disposition}",
|
|
22
|
+
"Content-Type: application/octet-stream",
|
|
23
|
+
"",
|
|
24
|
+
""
|
|
25
|
+
].join("\r\n").b
|
|
26
|
+
epilogue = "\r\n--#{boundary}--\r\n".b
|
|
27
|
+
|
|
28
|
+
body_size = io.size - (io.respond_to?(:pos) ? io.pos : 0)
|
|
29
|
+
length = preamble.bytesize + body_size + epilogue.bytesize
|
|
30
|
+
[new(preamble, io, epilogue), length]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Strip quotes and CR/LF from a header parameter so it can't break out of
|
|
34
|
+
# the multipart header (header-injection defense).
|
|
35
|
+
def self.escape(value)
|
|
36
|
+
value.to_s.gsub('"', "%22").gsub(/[\r\n]/, "")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(preamble, io, epilogue)
|
|
40
|
+
@parts = [StringIO.new(preamble), io, StringIO.new(epilogue)]
|
|
41
|
+
@index = 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Read up to +length+ bytes across the parts. With no length, reads all that
|
|
45
|
+
# remains. Returns nil at end of stream (the contract `Net::HTTP` expects).
|
|
46
|
+
def read(length = nil, outbuf = nil)
|
|
47
|
+
buffer = outbuf || "".b
|
|
48
|
+
buffer.clear
|
|
49
|
+
|
|
50
|
+
if length.nil?
|
|
51
|
+
while @index < @parts.length
|
|
52
|
+
chunk = @parts[@index].read
|
|
53
|
+
buffer << chunk unless chunk.nil?
|
|
54
|
+
@index += 1
|
|
55
|
+
end
|
|
56
|
+
return buffer
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
while buffer.bytesize < length && @index < @parts.length
|
|
60
|
+
chunk = @parts[@index].read(length - buffer.bytesize)
|
|
61
|
+
if chunk.nil?
|
|
62
|
+
@index += 1
|
|
63
|
+
else
|
|
64
|
+
buffer << chunk
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
return nil if buffer.empty? && @index >= @parts.length
|
|
69
|
+
|
|
70
|
+
buffer
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Api2Convert
|
|
4
|
+
module Webhook
|
|
5
|
+
# A verified webhook callback. The API posts the job whose status changed.
|
|
6
|
+
class Event
|
|
7
|
+
# @return [Model::Job] the job whose status changed.
|
|
8
|
+
attr_reader :job
|
|
9
|
+
# @return [Hash] the full decoded callback body.
|
|
10
|
+
attr_reader :payload
|
|
11
|
+
|
|
12
|
+
def initialize(job, payload)
|
|
13
|
+
@job = job
|
|
14
|
+
@payload = payload
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.from_hash(payload)
|
|
19
|
+
new(Model::Job.from_hash(payload), payload)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module Api2Convert
|
|
7
|
+
module Webhook
|
|
8
|
+
# Webhook callback verification and parsing.
|
|
9
|
+
#
|
|
10
|
+
# Pass the **raw** request body (the exact string received) so signature
|
|
11
|
+
# verification is byte-exact. Verification uses HMAC-SHA256 and matches the
|
|
12
|
+
# server's signed-webhooks scheme; until signed webhooks are enabled on your
|
|
13
|
+
# account no signature is sent — use {#parse} then, or call {#construct_event}
|
|
14
|
+
# with an empty secret to skip verification.
|
|
15
|
+
class Verifier
|
|
16
|
+
# Verify the signature (when a secret is given) and return the typed event.
|
|
17
|
+
#
|
|
18
|
+
# +payload+ must be the raw request body. +signature+ is the value of the
|
|
19
|
+
# signature header (`X-Oc-Signature`). Pass an empty +secret+ to skip
|
|
20
|
+
# verification. Raises {SignatureVerificationError} when the signature is
|
|
21
|
+
# missing or does not match.
|
|
22
|
+
def construct_event(payload, signature, secret)
|
|
23
|
+
unless secret.nil? || secret == ""
|
|
24
|
+
if signature.nil? || signature == ""
|
|
25
|
+
raise SignatureVerificationError, "Missing webhook signature header."
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
expected = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, to_bytes(payload))
|
|
29
|
+
unless secure_compare(expected, signature)
|
|
30
|
+
raise SignatureVerificationError, "Webhook signature verification failed."
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
parse(payload)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Parse a callback body into a typed event WITHOUT verifying a signature.
|
|
38
|
+
# Only use this when signed webhooks are not yet enabled for your account.
|
|
39
|
+
def parse(payload)
|
|
40
|
+
begin
|
|
41
|
+
decoded = JSON.parse(to_bytes(payload))
|
|
42
|
+
rescue JSON::ParserError => e
|
|
43
|
+
raise SignatureVerificationError, "Webhook payload is not valid JSON: #{e.message}"
|
|
44
|
+
end
|
|
45
|
+
raise SignatureVerificationError, "Webhook payload is not a JSON object." unless decoded.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
Event.from_hash(decoded)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def to_bytes(payload)
|
|
53
|
+
payload.is_a?(String) ? payload : payload.to_s
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Constant-time comparison, guarding the length check first. Uses OpenSSL's
|
|
57
|
+
# fixed-length compare when available (Ruby 2.6+); otherwise a portable
|
|
58
|
+
# branch-free fallback so old Rubies stay constant-time too.
|
|
59
|
+
def secure_compare(expected, actual)
|
|
60
|
+
a = expected.to_s.b
|
|
61
|
+
b = actual.to_s.b
|
|
62
|
+
return false unless a.bytesize == b.bytesize
|
|
63
|
+
|
|
64
|
+
if OpenSSL.respond_to?(:fixed_length_secure_compare)
|
|
65
|
+
OpenSSL.fixed_length_secure_compare(a, b)
|
|
66
|
+
else
|
|
67
|
+
result = 0
|
|
68
|
+
a.bytesize.times { |i| result |= a.getbyte(i) ^ b.getbyte(i) }
|
|
69
|
+
result.zero?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
data/lib/api2convert.rb
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Official Ruby SDK for the API2Convert file-conversion API.
|
|
4
|
+
#
|
|
5
|
+
# Convert, compress and transform images, documents, audio, video, ebooks,
|
|
6
|
+
# archives and CAD — and run operations like OCR, merge, thumbnail and website
|
|
7
|
+
# capture — in one line of code:
|
|
8
|
+
#
|
|
9
|
+
# require "api2convert"
|
|
10
|
+
#
|
|
11
|
+
# client = Api2Convert::Client.new("YOUR_API_KEY")
|
|
12
|
+
# client.convert("invoice.docx", "pdf").save("invoice.pdf")
|
|
13
|
+
#
|
|
14
|
+
# It is one of five official ports (PHP, Python, Java, Node.js, Ruby) that all
|
|
15
|
+
# implement the same language-agnostic contract in `docs/SDK_CONTRACT.md`.
|
|
16
|
+
module Api2Convert
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
require_relative "api2convert/version"
|
|
20
|
+
require_relative "api2convert/errors"
|
|
21
|
+
require_relative "api2convert/config"
|
|
22
|
+
require_relative "api2convert/support/data"
|
|
23
|
+
require_relative "api2convert/support/secret"
|
|
24
|
+
require_relative "api2convert/job_status"
|
|
25
|
+
require_relative "api2convert/input_type"
|
|
26
|
+
|
|
27
|
+
require_relative "api2convert/model/status"
|
|
28
|
+
require_relative "api2convert/model/conversion"
|
|
29
|
+
require_relative "api2convert/model/input_file"
|
|
30
|
+
require_relative "api2convert/model/output_file"
|
|
31
|
+
require_relative "api2convert/model/job_message"
|
|
32
|
+
require_relative "api2convert/model/preset"
|
|
33
|
+
require_relative "api2convert/model/job"
|
|
34
|
+
|
|
35
|
+
require_relative "api2convert/http/request"
|
|
36
|
+
require_relative "api2convert/http/response"
|
|
37
|
+
require_relative "api2convert/http/net_http_sender"
|
|
38
|
+
require_relative "api2convert/http/transport"
|
|
39
|
+
|
|
40
|
+
require_relative "api2convert/upload/multipart_stream"
|
|
41
|
+
require_relative "api2convert/upload/file_uploader"
|
|
42
|
+
|
|
43
|
+
require_relative "api2convert/resource/jobs"
|
|
44
|
+
require_relative "api2convert/resource/conversions"
|
|
45
|
+
require_relative "api2convert/resource/presets"
|
|
46
|
+
require_relative "api2convert/resource/stats"
|
|
47
|
+
require_relative "api2convert/resource/contracts"
|
|
48
|
+
|
|
49
|
+
require_relative "api2convert/webhook/event"
|
|
50
|
+
require_relative "api2convert/webhook/verifier"
|
|
51
|
+
|
|
52
|
+
require_relative "api2convert/result"
|
|
53
|
+
require_relative "api2convert/client"
|
|
54
|
+
|
|
55
|
+
module Api2Convert
|
|
56
|
+
# Webhook verifier — usable without a configured client.
|
|
57
|
+
#
|
|
58
|
+
# event = Api2Convert.webhooks.construct_event(raw_body, signature, secret)
|
|
59
|
+
def self.webhooks
|
|
60
|
+
Webhook::Verifier.new
|
|
61
|
+
end
|
|
62
|
+
end
|