enconvert 0.0.1
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/LICENSE +21 -0
- data/README.md +308 -0
- data/enconvert.gemspec +29 -0
- data/lib/enconvert/client.rb +419 -0
- data/lib/enconvert/errors.rb +41 -0
- data/lib/enconvert/formats.rb +195 -0
- data/lib/enconvert/internal.rb +153 -0
- data/lib/enconvert/results.rb +31 -0
- data/lib/enconvert/v2.rb +688 -0
- data/lib/enconvert/v2_results.rb +130 -0
- data/lib/enconvert/version.rb +5 -0
- data/lib/enconvert.rb +30 -0
- metadata +63 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
|
|
8
|
+
require_relative "formats"
|
|
9
|
+
|
|
10
|
+
# Shared internal helpers used by both the V1 client and the V2 namespace.
|
|
11
|
+
module Enconvert
|
|
12
|
+
module Internal
|
|
13
|
+
# Normalized `{ bytes, filename, content_type }` produced by
|
|
14
|
+
# {to_file_part}. Shared by the V1 file conversions (Client) and the
|
|
15
|
+
# V2 file-ingest path (V2#ingest_files).
|
|
16
|
+
FilePart = Struct.new(:bytes, :filename, :content_type, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
# Authenticated, timeout-wrapped Net::HTTP wrapper bound to the client's
|
|
19
|
+
# base URL. Always sends the `X-API-Key` header (the presigned-URL
|
|
20
|
+
# download path in Client deliberately bypasses this class).
|
|
21
|
+
class Transport
|
|
22
|
+
REQUEST_CLASSES = {
|
|
23
|
+
get: Net::HTTP::Get,
|
|
24
|
+
post: Net::HTTP::Post,
|
|
25
|
+
patch: Net::HTTP::Patch,
|
|
26
|
+
delete: Net::HTTP::Delete
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
def initialize(api_key:, base_url:, timeout:)
|
|
30
|
+
@api_key = api_key
|
|
31
|
+
@base_url = base_url
|
|
32
|
+
@timeout = timeout
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def request(method, path, headers: {}, body: nil)
|
|
36
|
+
uri = URI("#{@base_url}#{path}")
|
|
37
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
38
|
+
http.use_ssl = uri.scheme == "https"
|
|
39
|
+
http.open_timeout = @timeout
|
|
40
|
+
http.read_timeout = @timeout
|
|
41
|
+
|
|
42
|
+
request_class = REQUEST_CLASSES.fetch(method) do
|
|
43
|
+
raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
44
|
+
end
|
|
45
|
+
req = request_class.new(uri)
|
|
46
|
+
req["X-API-Key"] = @api_key
|
|
47
|
+
headers.each { |key, value| req[key] = value }
|
|
48
|
+
req.body = body unless body.nil?
|
|
49
|
+
|
|
50
|
+
http.request(req)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
module_function
|
|
55
|
+
|
|
56
|
+
# Shared error mapping for both V1 and V2. On HTTP >= 400, extracts a
|
|
57
|
+
# message from JSON `detail`/`error`/the raw body, then raises the
|
|
58
|
+
# matching typed error: 401/403 -> AuthenticationError, 402 -> QuotaError,
|
|
59
|
+
# 429 -> RateLimitError, else -> APIError(status, message).
|
|
60
|
+
def raise_for_status(resp)
|
|
61
|
+
status = resp.code.to_i
|
|
62
|
+
return if status < 400
|
|
63
|
+
|
|
64
|
+
message = extract_error_message(resp)
|
|
65
|
+
raise Enconvert::AuthenticationError, message if [401, 403].include?(status)
|
|
66
|
+
raise Enconvert::QuotaError, message if status == 402
|
|
67
|
+
raise Enconvert::RateLimitError, message if status == 429
|
|
68
|
+
|
|
69
|
+
raise Enconvert::APIError.new(status, message)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def extract_error_message(resp)
|
|
73
|
+
parsed = JSON.parse(resp.body.to_s)
|
|
74
|
+
if parsed.is_a?(Hash)
|
|
75
|
+
parsed["detail"] || parsed["error"] || JSON.generate(parsed)
|
|
76
|
+
else
|
|
77
|
+
JSON.generate(parsed)
|
|
78
|
+
end
|
|
79
|
+
rescue JSON::ParserError, TypeError
|
|
80
|
+
text = resp.body.to_s
|
|
81
|
+
text.empty? ? "HTTP #{resp.code}" : text
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# snake_case-only-if-set serialization of PDF options. Accepts a Hash
|
|
85
|
+
# with symbol keys: page_size, page_width, page_height, orientation,
|
|
86
|
+
# margins, scale, grayscale, header, footer.
|
|
87
|
+
def serialize_pdf_options(o)
|
|
88
|
+
return {} if o.nil?
|
|
89
|
+
|
|
90
|
+
out = {}
|
|
91
|
+
out[:page_size] = o[:page_size] if o.key?(:page_size)
|
|
92
|
+
out[:page_width] = o[:page_width] if o.key?(:page_width)
|
|
93
|
+
out[:page_height] = o[:page_height] if o.key?(:page_height)
|
|
94
|
+
out[:orientation] = o[:orientation] if o.key?(:orientation)
|
|
95
|
+
out[:margins] = o[:margins] if o.key?(:margins)
|
|
96
|
+
out[:scale] = o[:scale] if o.key?(:scale)
|
|
97
|
+
out[:grayscale] = o[:grayscale] if o.key?(:grayscale)
|
|
98
|
+
out[:header] = o[:header] if o.key?(:header)
|
|
99
|
+
out[:footer] = o[:footer] if o.key?(:footer)
|
|
100
|
+
out
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A UUID4 with dashes removed (32 hex chars).
|
|
104
|
+
def new_job_id
|
|
105
|
+
SecureRandom.uuid.delete("-")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Convert a FileInput into a normalized FilePart. Accepts:
|
|
109
|
+
# - a filesystem path (String)
|
|
110
|
+
# - raw bytes via any IO-like/readable object (File, StringIO, ...)
|
|
111
|
+
# - a Hash wrapper { data:, filename:, content_type: }
|
|
112
|
+
def to_file_part(file)
|
|
113
|
+
if file.is_a?(Hash)
|
|
114
|
+
data = file[:data] || file["data"]
|
|
115
|
+
filename = file[:filename] || file["filename"]
|
|
116
|
+
content_type = file[:content_type] || file["content_type"]
|
|
117
|
+
bytes = data.respond_to?(:read) ? data.read : data.to_s
|
|
118
|
+
FilePart.new(bytes: bytes, filename: filename, content_type: content_type || Formats.mime_for(filename))
|
|
119
|
+
elsif file.is_a?(String)
|
|
120
|
+
bytes = File.binread(file)
|
|
121
|
+
filename = File.basename(file)
|
|
122
|
+
FilePart.new(bytes: bytes, filename: filename, content_type: Formats.mime_for(filename))
|
|
123
|
+
elsif file.respond_to?(:read)
|
|
124
|
+
FilePart.new(bytes: file.read, filename: "upload.bin", content_type: "application/octet-stream")
|
|
125
|
+
else
|
|
126
|
+
raise Enconvert::Error,
|
|
127
|
+
"Unsupported file input. Pass a path string, an IO/readable object, or { data:, filename: }."
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Build a multipart/form-data body from a flat list of field hashes
|
|
132
|
+
# (`{ name:, value:, filename?:, content_type?: }`). Multiple entries
|
|
133
|
+
# sharing the same `name` produce repeated parts — e.g. ingest_files'
|
|
134
|
+
# `files` field, one part per uploaded file.
|
|
135
|
+
def build_multipart(fields)
|
|
136
|
+
boundary = "EnconvertFormBoundary#{SecureRandom.hex(16)}"
|
|
137
|
+
crlf = "\r\n".b
|
|
138
|
+
buffer = +"".b
|
|
139
|
+
fields.each do |f|
|
|
140
|
+
buffer << "--#{boundary}".b << crlf
|
|
141
|
+
if f[:filename]
|
|
142
|
+
buffer << "Content-Disposition: form-data; name=\"#{f[:name]}\"; filename=\"#{f[:filename]}\"".b << crlf
|
|
143
|
+
buffer << "Content-Type: #{f[:content_type] || 'application/octet-stream'}".b << crlf << crlf
|
|
144
|
+
else
|
|
145
|
+
buffer << "Content-Disposition: form-data; name=\"#{f[:name]}\"".b << crlf << crlf
|
|
146
|
+
end
|
|
147
|
+
buffer << f[:value].to_s.b << crlf
|
|
148
|
+
end
|
|
149
|
+
buffer << "--#{boundary}--".b << crlf
|
|
150
|
+
[buffer, boundary]
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# V1 response types, returned as plain read-only Structs with snake_case
|
|
4
|
+
# readers (e.g. `result.presigned_url`).
|
|
5
|
+
module Enconvert
|
|
6
|
+
# Response of every single-file / single-page conversion.
|
|
7
|
+
ConversionResult = Struct.new(
|
|
8
|
+
:presigned_url, :object_key, :filename, :file_size, :conversion_time_seconds, :job_id,
|
|
9
|
+
keyword_init: true
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
# Response of {Client#get_job_status}. `status` is one of
|
|
13
|
+
# "processing", "success", "failed".
|
|
14
|
+
JobStatus = Struct.new(:status, :presigned_url, :object_key, :error, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
# 202 response from an async batch submission (website conversions).
|
|
17
|
+
BatchSubmission = Struct.new(
|
|
18
|
+
:batch_id, :status, :url_count, :total_discovered, :discovery_method, :output_format,
|
|
19
|
+
keyword_init: true
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Per-URL entry in a batch status response.
|
|
23
|
+
BatchItem = Struct.new(:source_url, :status, :download_url, :output_file_size, :duration, keyword_init: true)
|
|
24
|
+
|
|
25
|
+
# Response of {Client#get_batch_status} / {Client#wait_for_batch}.
|
|
26
|
+
# `status` is one of "processing", "completed", "partial", "failed".
|
|
27
|
+
BatchStatus = Struct.new(
|
|
28
|
+
:batch_id, :status, :total, :completed, :failed, :in_progress, :output_mode, :zip_download_url, :items,
|
|
29
|
+
keyword_init: true
|
|
30
|
+
)
|
|
31
|
+
end
|