bynn-decisionnode 0.4.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/CHANGELOG.md +128 -0
- data/LICENSE +21 -0
- data/README.md +908 -0
- data/lib/decisionnode/batches.rb +246 -0
- data/lib/decisionnode/boosters.rb +692 -0
- data/lib/decisionnode/checks.rb +722 -0
- data/lib/decisionnode/client.rb +1100 -0
- data/lib/decisionnode/collections.rb +535 -0
- data/lib/decisionnode/document.rb +179 -0
- data/lib/decisionnode/errors.rb +138 -0
- data/lib/decisionnode/image.rb +194 -0
- data/lib/decisionnode/image_header.rb +121 -0
- data/lib/decisionnode/keys.rb +383 -0
- data/lib/decisionnode/questions.rb +135 -0
- data/lib/decisionnode/results.rb +539 -0
- data/lib/decisionnode/session.rb +286 -0
- data/lib/decisionnode/session_stream.rb +255 -0
- data/lib/decisionnode/supervisor.rb +107 -0
- data/lib/decisionnode/transport.rb +249 -0
- data/lib/decisionnode/uploads.rb +422 -0
- data/lib/decisionnode/version.rb +9 -0
- data/lib/decisionnode/video.rb +128 -0
- data/lib/decisionnode.rb +39 -0
- metadata +85 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
class DecisionNode
|
|
6
|
+
# A document for a knowledge base (a collection of type "text"): the
|
|
7
|
+
# content of one item.
|
|
8
|
+
#
|
|
9
|
+
# DecisionNode::Document.new(text: "# Receipts\n\nReject any claim ...") # Markdown inline
|
|
10
|
+
# DecisionNode::Document.new(text: "Plain words.", format: "text")
|
|
11
|
+
# DecisionNode::Document.new(path: "claims-policy-2026.pdf")
|
|
12
|
+
# DecisionNode::Document.new(data: docx_bytes, filename: "rules.docx")
|
|
13
|
+
# DecisionNode::Document.new(io: $stdin, content_type: "text/markdown")
|
|
14
|
+
# DecisionNode::Document.new(upload_id: "upl_...") # from #upload_documents
|
|
15
|
+
#
|
|
16
|
+
# A text is sent inline, whatever its size. A file (a path, bytes or an IO)
|
|
17
|
+
# is read when the call is made: a text or Markdown file whose bytes total
|
|
18
|
+
# under the client's inline threshold and are UTF-8 without a NUL is sent
|
|
19
|
+
# inline as its text; every other file is uploaded first and named by its
|
|
20
|
+
# upload id. An IO is read to its end once, into memory, before anything is
|
|
21
|
+
# sent.
|
|
22
|
+
#
|
|
23
|
+
# content_type:: one of CONTENT_TYPES. Left out, it comes from the file
|
|
24
|
+
# name's extension, else from the first bytes (PDF, PNG, JPEG,
|
|
25
|
+
# TIFF, WebP). DOCX, PPTX, XLSX and text cannot be told from
|
|
26
|
+
# their bytes: they need content_type or a file name.
|
|
27
|
+
# filename:: a path's base name unless given; optional for bytes and IOs.
|
|
28
|
+
# Its stem is the item's title when none is given.
|
|
29
|
+
class Document
|
|
30
|
+
# The types a knowledge file may have, in the API's order.
|
|
31
|
+
CONTENT_TYPES = %w[
|
|
32
|
+
text/plain text/markdown application/pdf
|
|
33
|
+
application/vnd.openxmlformats-officedocument.wordprocessingml.document
|
|
34
|
+
application/vnd.openxmlformats-officedocument.presentationml.presentation
|
|
35
|
+
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
|
36
|
+
text/html image/png image/jpeg image/tiff image/webp
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
# The most bytes a knowledge file may have (50 MB).
|
|
40
|
+
MAX_BYTES = 52_428_800
|
|
41
|
+
|
|
42
|
+
FORMATS = %w[markdown text].freeze
|
|
43
|
+
|
|
44
|
+
EXTENSIONS = {
|
|
45
|
+
".txt" => "text/plain", ".md" => "text/markdown", ".markdown" => "text/markdown",
|
|
46
|
+
".pdf" => "application/pdf",
|
|
47
|
+
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
48
|
+
".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
49
|
+
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
50
|
+
".html" => "text/html", ".htm" => "text/html", ".png" => "image/png", ".jpg" => "image/jpeg",
|
|
51
|
+
".jpeg" => "image/jpeg", ".tif" => "image/tiff", ".tiff" => "image/tiff", ".webp" => "image/webp"
|
|
52
|
+
}.freeze
|
|
53
|
+
private_constant :EXTENSIONS
|
|
54
|
+
|
|
55
|
+
# What a call sends for a document, once its bytes are read: `kind` :text
|
|
56
|
+
# (inline), :file (to upload) or :upload (an upload id given).
|
|
57
|
+
Loaded = Struct.new(:kind, :text, :format, :data, :content_type, :filename, :upload_id) do
|
|
58
|
+
def md5
|
|
59
|
+
Digest::MD5.digest(data)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def sha256
|
|
63
|
+
Digest::SHA256.digest(data)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
attr_reader :text, :format, :path, :data, :io, :content_type, :filename, :upload_id
|
|
68
|
+
|
|
69
|
+
def initialize(text: nil, format: nil, path: nil, data: nil, io: nil, upload_id: nil, content_type: nil,
|
|
70
|
+
filename: nil)
|
|
71
|
+
sources = [text, path, data, io, upload_id].compact.size
|
|
72
|
+
raise ArgumentError, "give exactly one of text:, path:, data:, io: or upload_id:" unless sources == 1
|
|
73
|
+
raise ArgumentError, "text: must be a String" unless text.nil? || text.is_a?(String)
|
|
74
|
+
raise ArgumentError, "data: must be a String of bytes" unless data.nil? || data.is_a?(String)
|
|
75
|
+
raise ArgumentError, "io: must respond to read" unless io.nil? || io.respond_to?(:read)
|
|
76
|
+
raise ArgumentError, "format: goes with text:; a file's format comes from its type" unless format.nil? || !text.nil?
|
|
77
|
+
if text || upload_id
|
|
78
|
+
raise ArgumentError, "content_type: and filename: go with a file" unless content_type.nil? && filename.nil?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
@text = text
|
|
82
|
+
@format = format.is_a?(Symbol) ? format.to_s : format
|
|
83
|
+
@path = path&.to_s
|
|
84
|
+
@data = data
|
|
85
|
+
@io = io
|
|
86
|
+
@upload_id = upload_id&.to_s
|
|
87
|
+
@content_type = content_type.nil? || content_type.to_s.empty? ? nil : content_type.to_s
|
|
88
|
+
@filename = filename.nil? ? @path && File.basename(@path) : filename.to_s
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def inspect
|
|
92
|
+
what = if @text then "text"
|
|
93
|
+
elsif @upload_id then "upload_id=#{@upload_id}"
|
|
94
|
+
else "file#{@filename ? " #{@filename.inspect}" : ''}"
|
|
95
|
+
end
|
|
96
|
+
"#<#{self.class.name} #{what}>"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# True for a text given in memory.
|
|
100
|
+
def text?
|
|
101
|
+
!@text.nil?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# True for a file (a path, bytes or an IO).
|
|
105
|
+
def file?
|
|
106
|
+
@text.nil? && @upload_id.nil?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The file's bytes and type, read now, checked as the presign checks them
|
|
110
|
+
# (`index` is the file's place in it). A text and an upload id come back
|
|
111
|
+
# as they are.
|
|
112
|
+
def load(index = 0)
|
|
113
|
+
return Loaded.new(:text, @text, @format) if @text
|
|
114
|
+
return Loaded.new(:upload, nil, nil, nil, nil, nil, @upload_id) if @upload_id
|
|
115
|
+
|
|
116
|
+
bytes = read_bytes
|
|
117
|
+
type = @content_type || self.class.type_of(@filename, bytes)
|
|
118
|
+
if type.nil?
|
|
119
|
+
raise ArgumentError, "the document's type cannot be told from its name or its first bytes: give content_type: or filename:"
|
|
120
|
+
end
|
|
121
|
+
unless CONTENT_TYPES.include?(type)
|
|
122
|
+
usage("files[#{index}].content_type: must be one of #{CONTENT_TYPES.join(', ')}, got #{type}.")
|
|
123
|
+
end
|
|
124
|
+
usage("files[#{index}].byte_size: must be an integer from 1 to #{MAX_BYTES}.") if bytes.empty?
|
|
125
|
+
if bytes.bytesize > MAX_BYTES
|
|
126
|
+
usage("files[#{index}].byte_size: #{bytes.bytesize} is over the limit of #{MAX_BYTES} bytes.")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
Loaded.new(:file, nil, nil, bytes.b.freeze, type, @filename)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The type of a file: from its name's extension, else from its first bytes;
|
|
133
|
+
# nil when neither tells.
|
|
134
|
+
def self.type_of(filename, bytes)
|
|
135
|
+
ext = filename && File.extname(filename).downcase
|
|
136
|
+
return EXTENSIONS[ext] if ext && EXTENSIONS.key?(ext)
|
|
137
|
+
|
|
138
|
+
head = bytes.byteslice(0, 12).b
|
|
139
|
+
if head.start_with?("%PDF-") then "application/pdf"
|
|
140
|
+
elsif head.start_with?("\x89PNG\r\n\x1A\n".b) then "image/png"
|
|
141
|
+
elsif head.start_with?("\xFF\xD8\xFF".b) then "image/jpeg"
|
|
142
|
+
elsif head.start_with?("II*\0".b, "MM\0*".b) then "image/tiff"
|
|
143
|
+
elsif head.start_with?("RIFF") && head.byteslice(8, 4) == "WEBP" then "image/webp"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The stem of a file name: the name without its last extension.
|
|
148
|
+
def self.stem(filename)
|
|
149
|
+
base = File.basename(filename.to_s)
|
|
150
|
+
ext = File.extname(base)
|
|
151
|
+
ext.empty? ? base : base.delete_suffix(ext)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
# The file's bytes; a path or an IO is read to its end, or to one byte
|
|
157
|
+
# past the limit, which the size check then refuses.
|
|
158
|
+
def read_bytes
|
|
159
|
+
return @data if @data
|
|
160
|
+
return read_io(@io) if @io
|
|
161
|
+
|
|
162
|
+
File.open(@path, "rb") { |f| read_io(f) }
|
|
163
|
+
rescue SystemCallError, IOError => e
|
|
164
|
+
raise ArgumentError, "cannot read #{@path}: #{e.message}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def read_io(io)
|
|
168
|
+
out = +"".b
|
|
169
|
+
while out.bytesize <= MAX_BYTES && (chunk = io.read([1 << 20, MAX_BYTES + 1 - out.bytesize].min))
|
|
170
|
+
out << chunk.b
|
|
171
|
+
end
|
|
172
|
+
out
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def usage(message)
|
|
176
|
+
raise BadRequestError.new(message, error_type: "api_usage_error")
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class DecisionNode
|
|
4
|
+
# The base of every error this gem raises, and the class of an error for a
|
|
5
|
+
# status no subclass covers (a 3xx, 405, 409, or 408 once its retries are spent).
|
|
6
|
+
#
|
|
7
|
+
# The error's own `message` is the server's message (`detail.message`, or
|
|
8
|
+
# `detail` when it is a string), or the client's description when there was
|
|
9
|
+
# no response. It is an empty string when the server sent none. `reason` is
|
|
10
|
+
# the server's explanation in words, from the `x-decisionnode-error` header.
|
|
11
|
+
# `boosters` is the `x-decisionnode-boosters` header of a request that
|
|
12
|
+
# named boosters, as booster name to BoosterStatus in the request's order
|
|
13
|
+
# (an empty Hash when there is none), so a caller can see which required
|
|
14
|
+
# booster failed. `details` is the error body's `detail.details` when it is
|
|
15
|
+
# an object (an enrollment's 529 says why in its `reason`), else nil. The
|
|
16
|
+
# report Ruby prints for an uncaught error adds the class, status and
|
|
17
|
+
# reason.
|
|
18
|
+
class DecisionNodeError < StandardError
|
|
19
|
+
include DecisionResult
|
|
20
|
+
|
|
21
|
+
attr_reader :status, :error_type, :request_id, :reason, :body, :headers, :boosters, :details
|
|
22
|
+
|
|
23
|
+
def initialize(message = nil, status: nil, error_type: nil, request_id: nil, reason: nil, body: nil, headers: nil)
|
|
24
|
+
@status = status
|
|
25
|
+
@error_type = error_type
|
|
26
|
+
@request_id = request_id
|
|
27
|
+
@reason = reason
|
|
28
|
+
@body = body
|
|
29
|
+
@headers = headers || {}
|
|
30
|
+
@boosters = BoosterStatus.parse(@headers["x-decisionnode-boosters"])
|
|
31
|
+
detail = body.is_a?(Hash) ? body["detail"] : nil
|
|
32
|
+
details = detail.is_a?(Hash) ? detail["details"] : nil
|
|
33
|
+
@details = details.is_a?(Hash) ? details : nil
|
|
34
|
+
super(message.nil? ? "" : message.to_s)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def detailed_message(highlight: false, **)
|
|
38
|
+
parts = []
|
|
39
|
+
parts << message unless message.empty?
|
|
40
|
+
parts << "HTTP #{status}" if status
|
|
41
|
+
parts << "error_type: #{error_type}" if error_type
|
|
42
|
+
parts << "reason: #{reason}" if reason
|
|
43
|
+
parts << "request_id: #{request_id}" if request_id
|
|
44
|
+
"#{parts.empty? ? 'DecisionNode error' : parts.join('; ')} (#{self.class.name})"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# 400: the request is malformed or breaks a limit of the model. Also raised
|
|
49
|
+
# before any request when the SDK finds a fault the API would answer with a
|
|
50
|
+
# 400 (an image, an option or level count): such an error has no status and
|
|
51
|
+
# no request id.
|
|
52
|
+
class BadRequestError < DecisionNodeError; end
|
|
53
|
+
|
|
54
|
+
# 400 with `error_type` `max_tokens_exceeded`: the input is too long for the model.
|
|
55
|
+
class ContextLimitError < BadRequestError; end
|
|
56
|
+
|
|
57
|
+
# 401: the API key is missing or not valid.
|
|
58
|
+
class AuthenticationError < DecisionNodeError; end
|
|
59
|
+
|
|
60
|
+
# 402: the account has no credit left.
|
|
61
|
+
class InsufficientCreditError < DecisionNodeError; end
|
|
62
|
+
|
|
63
|
+
# 403 that is not a refusal: the workspace may not do this, for example use
|
|
64
|
+
# a booster turned off for it (`booster_not_enabled`), or it is suspended.
|
|
65
|
+
# `error_type` tells them apart.
|
|
66
|
+
class PermissionDeniedError < DecisionNodeError; end
|
|
67
|
+
|
|
68
|
+
# 403 with `error_type` `refusal_error`: the request was refused under the usage policy.
|
|
69
|
+
class RefusalError < DecisionNodeError
|
|
70
|
+
# "harm" or "self_harm".
|
|
71
|
+
attr_reader :category
|
|
72
|
+
# The parsed `x-decisionnode-safety` header, a Safety or nil.
|
|
73
|
+
attr_reader :safety
|
|
74
|
+
|
|
75
|
+
def initialize(message = nil, category: nil, safety: nil, **kw)
|
|
76
|
+
@category = category
|
|
77
|
+
@safety = safety
|
|
78
|
+
super(message, **kw)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# 404: the path or resource does not exist.
|
|
83
|
+
class NotFoundError < DecisionNodeError; end
|
|
84
|
+
|
|
85
|
+
# 413: the request body is over the API's limit of 26,214,400 bytes (25
|
|
86
|
+
# MiB). Never retried. Also raised before any request when the encoded body
|
|
87
|
+
# would be over it: such an error has no status and no request id.
|
|
88
|
+
class RequestTooLargeError < DecisionNodeError; end
|
|
89
|
+
|
|
90
|
+
# 422: the request body does not match the schema. Also raised before any
|
|
91
|
+
# request for a number grid the API would refuse, or a score with no levels.
|
|
92
|
+
class ValidationError < DecisionNodeError
|
|
93
|
+
# The server's list of validation entries.
|
|
94
|
+
attr_reader :entries
|
|
95
|
+
|
|
96
|
+
def initialize(message = nil, entries: nil, **kw)
|
|
97
|
+
@entries = entries
|
|
98
|
+
super(message, **kw)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# 429: too many requests.
|
|
103
|
+
class RateLimitError < DecisionNodeError
|
|
104
|
+
# Seconds to wait before trying again, or nil.
|
|
105
|
+
attr_reader :retry_after
|
|
106
|
+
|
|
107
|
+
def initialize(message = nil, retry_after: nil, **kw)
|
|
108
|
+
@retry_after = retry_after
|
|
109
|
+
super(message, **kw)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# 429 with `error_type` `session_limit_exceeded`: the key already holds as
|
|
114
|
+
# many open sessions as it may. Never retried.
|
|
115
|
+
class SessionLimitError < RateLimitError; end
|
|
116
|
+
|
|
117
|
+
# 529: the service is overloaded, or (`error_type` `booster_unavailable`) a
|
|
118
|
+
# booster the request required failed or did not answer in time. Retried
|
|
119
|
+
# like every 529.
|
|
120
|
+
class OverloadedError < DecisionNodeError
|
|
121
|
+
# Seconds to wait before trying again, or nil.
|
|
122
|
+
attr_reader :retry_after
|
|
123
|
+
|
|
124
|
+
def initialize(message = nil, retry_after: nil, **kw)
|
|
125
|
+
@retry_after = retry_after
|
|
126
|
+
super(message, **kw)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Any other 5xx, or a successful status with a body that is not JSON.
|
|
131
|
+
class ServerError < DecisionNodeError; end
|
|
132
|
+
|
|
133
|
+
# No response: the connection failed or was dropped.
|
|
134
|
+
class ConnectionError < DecisionNodeError; end
|
|
135
|
+
|
|
136
|
+
# No response within the timeout.
|
|
137
|
+
class TimeoutError < DecisionNodeError; end
|
|
138
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
class DecisionNode
|
|
6
|
+
# Local images whose bytes total less than this go inline as base64 `data`;
|
|
7
|
+
# at or above it they are uploaded once and sent by `upload_id`. Counted over
|
|
8
|
+
# all the local images of one request, one session frame or one batch line.
|
|
9
|
+
# The client's `inline_threshold:` option overrides it.
|
|
10
|
+
DEFAULT_INLINE_THRESHOLD = 524_288
|
|
11
|
+
|
|
12
|
+
# An image sent with a decision.
|
|
13
|
+
#
|
|
14
|
+
# DecisionNode::Image.new("scan", url: "https://example.com/scan.webp") # recommended
|
|
15
|
+
# DecisionNode::Image.new("receipt", path: "receipt.png")
|
|
16
|
+
# DecisionNode::Image.new("photo", data: jpeg_bytes)
|
|
17
|
+
# DecisionNode::Image.new("photo", data: File.open("photo.jpg", "rb"))
|
|
18
|
+
# DecisionNode::Image.new("photo", base64: "/9j/4AAQ...")
|
|
19
|
+
# DecisionNode::Image.new("receipt", upload_id: "upl_...")
|
|
20
|
+
#
|
|
21
|
+
# A URL is sent as it is and fetched by the API, so nothing is uploaded: the
|
|
22
|
+
# fastest form. A file, an IO, bytes or base64 text are read here, when the
|
|
23
|
+
# Image is made; `data:` also takes base64 text and decodes it when the text
|
|
24
|
+
# is not already image bytes. The bytes are checked locally before any
|
|
25
|
+
# request (format from the magic bytes, at most 10,485,760 bytes, at most
|
|
26
|
+
# 40,000,000 pixels read from the header), then sent inline or uploaded by
|
|
27
|
+
# the client's inline threshold. An image is never resized or re-encoded.
|
|
28
|
+
#
|
|
29
|
+
# The id is unique within a request. The model sees the image as
|
|
30
|
+
# "Picture <n>" (its place in the request's images, from 1) with this id
|
|
31
|
+
# beside it, so text in the state or the instructions can refer to it by
|
|
32
|
+
# either.
|
|
33
|
+
#
|
|
34
|
+
# An unreadable file or an argument of the wrong type raises ArgumentError
|
|
35
|
+
# here; a fault in the image itself raises BadRequestError when it is sent,
|
|
36
|
+
# before any request.
|
|
37
|
+
class Image
|
|
38
|
+
# The API's limits on one image, checked before any request.
|
|
39
|
+
MAX_BYTES = 10_485_760
|
|
40
|
+
MAX_PIXELS = 40_000_000
|
|
41
|
+
MAX_URL_CHARS = 2_048
|
|
42
|
+
MEDIA_TYPES = %w[image/jpeg image/png image/webp].freeze
|
|
43
|
+
|
|
44
|
+
attr_reader :id, :url, :upload_id
|
|
45
|
+
|
|
46
|
+
# The image's bytes (a frozen binary String), or nil for a URL or an upload id.
|
|
47
|
+
attr_reader :data
|
|
48
|
+
|
|
49
|
+
# "image/jpeg", "image/png" or "image/webp" from the magic bytes, or the
|
|
50
|
+
# media type given when the bytes are none of these.
|
|
51
|
+
def self.sniff_media_type(bytes)
|
|
52
|
+
ImageHeader.media_type(bytes.b)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def initialize(id, url: nil, path: nil, data: nil, base64: nil, upload_id: nil, media_type: nil)
|
|
56
|
+
sources = [url, path, data, base64, upload_id].compact.size
|
|
57
|
+
raise ArgumentError, "give exactly one of url:, path:, data:, base64: or upload_id:" unless sources == 1
|
|
58
|
+
|
|
59
|
+
@id = id.to_s
|
|
60
|
+
@declared = media_type.nil? || media_type.to_s.empty? ? nil : media_type.to_s
|
|
61
|
+
@url = url
|
|
62
|
+
@upload_id = upload_id&.to_s
|
|
63
|
+
@data = read_bytes(path, data, base64)&.freeze
|
|
64
|
+
@media_type = @data ? self.class.sniff_media_type(@data) || @declared : @declared
|
|
65
|
+
@data_type = @data ? self.class.sniff_media_type(@data) : nil
|
|
66
|
+
@lock = Mutex.new
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The image's media type: detected from the bytes, else as given.
|
|
70
|
+
attr_reader :media_type
|
|
71
|
+
|
|
72
|
+
def local?
|
|
73
|
+
!@data.nil?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def bytesize
|
|
77
|
+
@data ? @data.bytesize : 0
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# [width, height] read from the header, or nil.
|
|
81
|
+
def dimensions
|
|
82
|
+
return nil unless @data && @data_type
|
|
83
|
+
|
|
84
|
+
@lock.synchronize do
|
|
85
|
+
@dimensions = ImageHeader.dimensions(@data, @data_type) unless defined?(@dimensions)
|
|
86
|
+
@dimensions
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The SHA-256 of the bytes, raw.
|
|
91
|
+
def sha256
|
|
92
|
+
@sha256 ||= Digest::SHA256.digest(@data)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The MD5 of the bytes, raw.
|
|
96
|
+
def md5
|
|
97
|
+
@md5 ||= Digest::MD5.digest(@data)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Raises BadRequestError (`api_usage_error`), as the API would answer,
|
|
101
|
+
# when the image's bytes break one of the API's rules: the format from the
|
|
102
|
+
# magic bytes (and a declared media type that differs), the size, and the
|
|
103
|
+
# pixel count read from the header. Returns self otherwise. A URL or an
|
|
104
|
+
# upload id is checked with the request (Checks.images!).
|
|
105
|
+
def validate!(max_bytes: MAX_BYTES)
|
|
106
|
+
fault = find_fault(max_bytes)
|
|
107
|
+
raise BadRequestError.new(fault, error_type: "api_usage_error") if fault
|
|
108
|
+
|
|
109
|
+
self
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The image's wire form with its bytes inline, or its URL or upload id.
|
|
113
|
+
def to_h
|
|
114
|
+
return url_wire if @url
|
|
115
|
+
return upload_wire(@upload_id) if @upload_id
|
|
116
|
+
|
|
117
|
+
{ "id" => id, "media_type" => media_type, "data" => [@data].pack("m0") }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The wire form naming an upload of these bytes.
|
|
121
|
+
def upload_wire(upload_id)
|
|
122
|
+
wire = { "id" => id, "upload_id" => upload_id }
|
|
123
|
+
wire["media_type"] = media_type if @upload_id && media_type
|
|
124
|
+
wire
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def inspect
|
|
128
|
+
source = if @url then "url=#{@url.inspect}"
|
|
129
|
+
elsif @upload_id then "upload_id=#{@upload_id.inspect}"
|
|
130
|
+
else "#{media_type} #{bytesize} bytes"
|
|
131
|
+
end
|
|
132
|
+
"#<#{self.class.name} #{id.inspect} #{source}>"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def url_wire
|
|
138
|
+
wire = { "id" => id, "url" => @url }
|
|
139
|
+
wire["media_type"] = @declared if @declared
|
|
140
|
+
wire
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def read_bytes(path, data, base64)
|
|
144
|
+
if path
|
|
145
|
+
begin
|
|
146
|
+
File.binread(path.to_s)
|
|
147
|
+
rescue SystemCallError, IOError => e
|
|
148
|
+
raise ArgumentError, "image #{@id.inspect}: cannot read #{path}: #{e.message}"
|
|
149
|
+
end
|
|
150
|
+
elsif base64
|
|
151
|
+
decode_base64(base64.to_s) || raise(ArgumentError, "image #{@id.inspect}: base64: is not base64 text")
|
|
152
|
+
elsif data.respond_to?(:read)
|
|
153
|
+
data.read.to_s.b
|
|
154
|
+
elsif data
|
|
155
|
+
raise ArgumentError, "image #{@id.inspect}: data must be a String of bytes or base64 text, or an IO" unless data.is_a?(String)
|
|
156
|
+
|
|
157
|
+
bytes = data.b
|
|
158
|
+
if ImageHeader.media_type(bytes).nil? && (decoded = decode_base64(bytes))
|
|
159
|
+
ImageHeader.media_type(decoded) ? decoded : bytes
|
|
160
|
+
else
|
|
161
|
+
bytes
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The decoded bytes of base64 text (line breaks allowed), or nil.
|
|
167
|
+
def decode_base64(text)
|
|
168
|
+
text.delete("\r\n").unpack1("m0").b
|
|
169
|
+
rescue ArgumentError
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# The first rule the bytes break, or nil. The pixel size is read once.
|
|
174
|
+
def find_fault(max_bytes)
|
|
175
|
+
return nil unless @data
|
|
176
|
+
|
|
177
|
+
found = @data_type
|
|
178
|
+
return "Image #{@id} is not a JPEG, PNG or WebP image." if found.nil?
|
|
179
|
+
return "Image #{@id} could not be decoded as #{@declared}." if @declared && @declared != found
|
|
180
|
+
return "Image #{@id} is larger than #{max_bytes / 1_048_576} MB." if @data.bytesize > max_bytes
|
|
181
|
+
|
|
182
|
+
size = @lock.synchronize do
|
|
183
|
+
@dimensions = ImageHeader.dimensions(@data, found) unless defined?(@dimensions)
|
|
184
|
+
@dimensions
|
|
185
|
+
end
|
|
186
|
+
return "Image #{@id}: its header cannot be read." if size.nil?
|
|
187
|
+
|
|
188
|
+
width, height = size
|
|
189
|
+
return "Image #{@id} is #{width}x#{height} pixels, over 40 megapixels." if width * height > MAX_PIXELS
|
|
190
|
+
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class DecisionNode
|
|
4
|
+
# Reads an image's format and pixel size from its first bytes, without
|
|
5
|
+
# decoding it: the JPEG start-of-frame segment, the PNG IHDR chunk and the
|
|
6
|
+
# WebP VP8, VP8L or VP8X header.
|
|
7
|
+
#
|
|
8
|
+
# DecisionNode::ImageHeader.media_type(bytes) # => "image/png"
|
|
9
|
+
# DecisionNode::ImageHeader.dimensions(bytes, "image/png") # => [640, 480]
|
|
10
|
+
module ImageHeader
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
PNG_SIGNATURE = "\x89PNG\r\n\x1A\n".b.freeze
|
|
14
|
+
JPEG_SIGNATURE = "\xFF\xD8\xFF".b.freeze
|
|
15
|
+
RIFF = "RIFF".b.freeze
|
|
16
|
+
WEBP = "WEBP".b.freeze
|
|
17
|
+
IHDR = "IHDR".b.freeze
|
|
18
|
+
VP8_START_CODE = "\x9D\x01\x2A".b.freeze
|
|
19
|
+
|
|
20
|
+
# JPEG start-of-frame markers that carry the frame size: SOF0 to SOF15
|
|
21
|
+
# except DHT (C4), JPG (C8) and DAC (CC).
|
|
22
|
+
JPEG_SOF = ([0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF]).freeze
|
|
23
|
+
# Markers that stand alone, with no length: TEM, RST0 to RST7, SOI.
|
|
24
|
+
JPEG_STANDALONE = ([0x01, 0xD8] + (0xD0..0xD7).to_a).freeze
|
|
25
|
+
JPEG_SOS = 0xDA
|
|
26
|
+
JPEG_EOI = 0xD9
|
|
27
|
+
|
|
28
|
+
# "image/jpeg", "image/png" or "image/webp" from the magic bytes, else nil.
|
|
29
|
+
def media_type(bytes)
|
|
30
|
+
return "image/jpeg" if bytes.start_with?(JPEG_SIGNATURE)
|
|
31
|
+
return "image/png" if bytes.start_with?(PNG_SIGNATURE)
|
|
32
|
+
return "image/webp" if bytes.start_with?(RIFF) && bytes.byteslice(8, 4) == WEBP
|
|
33
|
+
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# [width, height] in pixels read from the header, or nil when the header
|
|
38
|
+
# is missing, truncated or not of this type.
|
|
39
|
+
def dimensions(bytes, media_type)
|
|
40
|
+
case media_type
|
|
41
|
+
when "image/png" then png(bytes)
|
|
42
|
+
when "image/jpeg" then jpeg(bytes)
|
|
43
|
+
when "image/webp" then webp(bytes)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The IHDR chunk is the first chunk: length (4), "IHDR", width (4), height (4).
|
|
48
|
+
def png(bytes)
|
|
49
|
+
return nil unless bytes.byteslice(12, 4) == IHDR
|
|
50
|
+
|
|
51
|
+
size = bytes.byteslice(16, 8)
|
|
52
|
+
size&.bytesize == 8 ? size.unpack("NN") : nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Walks the segments after SOI to the first start-of-frame segment:
|
|
56
|
+
# FF marker, length (2, counting itself), precision (1), height (2), width (2).
|
|
57
|
+
def jpeg(bytes)
|
|
58
|
+
i = 2
|
|
59
|
+
size = bytes.bytesize
|
|
60
|
+
while i + 1 < size
|
|
61
|
+
return nil unless bytes.getbyte(i) == 0xFF
|
|
62
|
+
|
|
63
|
+
marker = bytes.getbyte(i + 1)
|
|
64
|
+
if marker == 0xFF # fill byte before a marker
|
|
65
|
+
i += 1
|
|
66
|
+
next
|
|
67
|
+
end
|
|
68
|
+
if JPEG_STANDALONE.include?(marker)
|
|
69
|
+
i += 2
|
|
70
|
+
next
|
|
71
|
+
end
|
|
72
|
+
return nil if marker == JPEG_SOS || marker == JPEG_EOI || i + 4 > size
|
|
73
|
+
|
|
74
|
+
length = bytes.byteslice(i + 2, 2).unpack1("n")
|
|
75
|
+
return nil if length < 2
|
|
76
|
+
|
|
77
|
+
if JPEG_SOF.include?(marker)
|
|
78
|
+
frame = bytes.byteslice(i + 5, 4)
|
|
79
|
+
return nil unless frame&.bytesize == 4
|
|
80
|
+
|
|
81
|
+
height, width = frame.unpack("nn")
|
|
82
|
+
return [width, height]
|
|
83
|
+
end
|
|
84
|
+
i += 2 + length
|
|
85
|
+
end
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# RIFF (4), size (4), "WEBP", then the first chunk's fourcc at 12.
|
|
90
|
+
def webp(bytes)
|
|
91
|
+
case bytes.byteslice(12, 4)
|
|
92
|
+
when "VP8 ".b
|
|
93
|
+
# Frame tag (3) at 20, start code (3) at 23, then 14-bit width and height.
|
|
94
|
+
return nil unless bytes.byteslice(23, 3) == VP8_START_CODE
|
|
95
|
+
|
|
96
|
+
size = bytes.byteslice(26, 4)
|
|
97
|
+
return nil unless size&.bytesize == 4
|
|
98
|
+
|
|
99
|
+
width, height = size.unpack("vv")
|
|
100
|
+
[width & 0x3FFF, height & 0x3FFF]
|
|
101
|
+
when "VP8L".b
|
|
102
|
+
# Signature 0x2F at 20, then width - 1 and height - 1 in 14 bits each.
|
|
103
|
+
return nil unless bytes.getbyte(20) == 0x2F
|
|
104
|
+
|
|
105
|
+
bits = bytes.byteslice(21, 4)
|
|
106
|
+
return nil unless bits&.bytesize == 4
|
|
107
|
+
|
|
108
|
+
value = bits.unpack1("V")
|
|
109
|
+
[(value & 0x3FFF) + 1, ((value >> 14) & 0x3FFF) + 1]
|
|
110
|
+
when "VP8X".b
|
|
111
|
+
# Flags (4) at 20, then canvas width - 1 and height - 1 in 24 bits each.
|
|
112
|
+
canvas = bytes.byteslice(24, 6)
|
|
113
|
+
return nil unless canvas&.bytesize == 6
|
|
114
|
+
|
|
115
|
+
w = canvas.getbyte(0) | (canvas.getbyte(1) << 8) | (canvas.getbyte(2) << 16)
|
|
116
|
+
h = canvas.getbyte(3) | (canvas.getbyte(4) << 8) | (canvas.getbyte(5) << 16)
|
|
117
|
+
[w + 1, h + 1]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|