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