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.
@@ -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