ironeye 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6ba74ebdbb15ba9cf99a51a33dce867568f31a3d7bd8fb74757d3348615b9da3
4
+ data.tar.gz: 046d3ce24e2a3c2ab5719eaef763631cbe749f362ad0c71fb2c008a7b569154c
5
+ SHA512:
6
+ metadata.gz: 0023cd6e36599c503993f4e240a239eb53a1e6ef41d97399666bbb156b336079669ec2e3463bc38885ec8ee92a6626931cb35943edb3d689e8939da4a35e51c7
7
+ data.tar.gz: c7b6ca607461c6b68b2f93601ae6019be00386e6df7ce92e0255731dd7b8d77c3f8c4066d01b1d19a7060770f1d06c0f98fbc6d8106721514d91671492d5d64b
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Direct Softworks SRL
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # IronEye for Ruby
2
+
3
+ The official Ruby client for the [IronEye](https://ironeye.org) API: document
4
+ analysis over bytes you send, and normalised collection from public sources,
5
+ behind one key.
6
+
7
+ ```sh
8
+ gem install ironeye
9
+ ```
10
+
11
+ ## Features
12
+
13
+ - Every analysis route, the async job path with `await_job`, the collection
14
+ catalogue and the data-subject-rights endpoints.
15
+ - One exception class per refusal family, each carrying the server's verdict.
16
+ - Retries on the server's own `retryable` flag, honouring `Retry-After`.
17
+ - Standard library only: `Net::HTTP` and `JSON`, no gem tree behind it.
18
+ - Takes any `Logger`. No credential, no payload.
19
+
20
+ Full documentation, including every endpoint and every option, is at
21
+ **https://ironeye.org/docs/sdk/ruby**.
22
+
23
+ ---
24
+
25
+ Direct Softworks · [MIT](LICENSE) · issues and pull requests welcome
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "logger"
5
+ require "net/http"
6
+ require "securerandom"
7
+ require "uri"
8
+
9
+ module IronEye
10
+ # The IronEye client.
11
+ #
12
+ # One instance per process is the intended shape: it keeps no per-request
13
+ # state, and Net::HTTP opens a connection per call.
14
+ class Client
15
+ DEFAULT_BASE_URL = "https://ironeye.org"
16
+ RETRYABLE_STATUS = [408, 425, 429, 500, 502, 503, 504].freeze
17
+
18
+ ANALYSIS_ROUTES = {
19
+ analyze: "/v1/analyze",
20
+ extract: "/v1/extract",
21
+ classify: "/v1/classify",
22
+ pii: "/v1/pii/analyze",
23
+ moderation: "/v1/moderation/analyze",
24
+ malware: "/v1/malware/scan",
25
+ secrets: "/v1/secrets/scan",
26
+ validate: "/v1/validate",
27
+ deduplicate: "/v1/deduplicate",
28
+ invoices: "/v1/invoices/parse"
29
+ }.freeze
30
+
31
+ DECLARATION_HEADERS = {
32
+ legal_basis: "X-Legal-Basis",
33
+ purpose: "X-Purpose",
34
+ controller: "X-Controller",
35
+ basis_evidence: "X-Basis-Evidence",
36
+ special_condition: "X-Special-Condition",
37
+ projection: "X-Projection"
38
+ }.freeze
39
+
40
+ attr_reader :base_url, :timeout, :max_retries
41
+
42
+ # The key comes from +api_key:+ or from IRONEYE_API_KEY; the base URL from
43
+ # +base_url:+, IRONEYE_BASE_URL, or the public host.
44
+ def initialize(api_key: nil, base_url: nil, timeout: 60, max_retries: 2, logger: nil)
45
+ @api_key = api_key || ENV["IRONEYE_API_KEY"]
46
+ raise ArgumentError, "An API key is required: pass api_key: or set IRONEYE_API_KEY." if @api_key.to_s.empty?
47
+
48
+ @base_url = (base_url || ENV["IRONEYE_BASE_URL"] || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
49
+ @timeout = timeout
50
+ @max_retries = max_retries
51
+ @logger = logger || Logger.new(File::NULL)
52
+ end
53
+
54
+ # -- analysis ------------------------------------------------------------
55
+ ANALYSIS_ROUTES.each do |name, path|
56
+ define_method(name) do |payload, idempotency_key: nil|
57
+ headers = idempotency_key ? { "Idempotency-Key" => idempotency_key } : {}
58
+ send(:request, :post, path, body: payload, headers: headers)
59
+ end
60
+ end
61
+
62
+ # Multipart, for bytes you hold already rather than base64 in a body.
63
+ def analyze_upload(file, filename: "document", content_type: "application/octet-stream",
64
+ features: nil, preset: nil, options: nil, output_mode: nil,
65
+ retention_seconds: nil, idempotency_key: nil)
66
+ fields = {
67
+ "features" => features&.join(","),
68
+ "preset" => preset,
69
+ "options" => (JSON.generate(options) if options),
70
+ "output_mode" => output_mode,
71
+ "retention_seconds" => retention_seconds&.to_s
72
+ }.compact
73
+ boundary = "ironeye-#{SecureRandom.hex(16)}"
74
+ headers = { "Content-Type" => "multipart/form-data; boundary=#{boundary}" }
75
+ headers["Idempotency-Key"] = idempotency_key if idempotency_key
76
+ request(:post, "/v1/analyze/upload",
77
+ raw: multipart(boundary, file, filename, content_type, fields),
78
+ headers: headers)
79
+ end
80
+
81
+ # -- jobs ----------------------------------------------------------------
82
+ def create_job(payload) = request(:post, "/v1/jobs", body: payload)
83
+ def job(job_id) = request(:get, "/v1/jobs/#{escape(job_id)}")
84
+ def delete_job(job_id) = request(:delete, "/v1/jobs/#{escape(job_id)}")
85
+
86
+ # Polls until the job settles. Nothing in the service dispatches to a
87
+ # callback URL, so polling is the whole asynchronous contract.
88
+ def await_job(job_id, interval: 2, timeout: 300)
89
+ deadline = monotonic + timeout
90
+ loop do
91
+ record = job(job_id)
92
+ return record if %w[completed failed].include?(record["status"])
93
+ raise Error, "Job #{job_id} was still #{record["status"]} after #{timeout}s." if monotonic + interval > deadline
94
+
95
+ sleep(interval)
96
+ end
97
+ end
98
+
99
+ # -- collection ----------------------------------------------------------
100
+ def catalogue = request(:get, "/v1/harvest/catalogue")
101
+ def operations(platform = nil) = request(:get, "/v1/harvest/operations", query: { platform: platform }.compact)
102
+ def operation(op_id) = request(:get, "/v1/harvest/operations/#{escape(op_id)}")
103
+
104
+ # Runs one operation, addressed by its own route as the catalogue gives it:
105
+ # "/v1/harvest/reddit/subreddit", say.
106
+ def collect(path, params = {}, declaration = {})
107
+ request(:get, path, query: params, headers: declaration_headers(declaration))
108
+ end
109
+
110
+ # +collect+ for the operations the registry declares as POST. The parameters
111
+ # are identical; only where they travel changes.
112
+ def collect_post(path, params = {}, declaration = {})
113
+ request(:post, path, body: params, headers: declaration_headers(declaration))
114
+ end
115
+
116
+ # -- data subject rights -------------------------------------------------
117
+ def gdpr_notice = request(:get, "/v1/gdpr/notice")
118
+ def erasure(subject) = request(:post, "/v1/gdpr/erasure", body: subject)
119
+ def objection(subject) = request(:post, "/v1/gdpr/objections", body: subject)
120
+ def access_request(subject) = request(:post, "/v1/gdpr/access", body: subject)
121
+ def suppression = request(:get, "/v1/gdpr/suppression")
122
+ def unsuppress(subject_key) = request(:delete, "/v1/gdpr/suppression/#{escape(subject_key)}")
123
+
124
+ # -- service -------------------------------------------------------------
125
+ def health = request(:get, "/healthz")
126
+ def ready = request(:get, "/readyz")
127
+ def features = request(:get, "/v1/features")
128
+ def status = request(:get, "/v1/status")
129
+ def audit_head = request(:get, "/v1/audit/head")
130
+
131
+ def inspect = "#<IronEye::Client base_url=#{@base_url.inspect} key=#{masked_key.inspect}>"
132
+
133
+ def to_s = "#{inspect} # everything is an object; the key is not one you get"
134
+
135
+ private
136
+
137
+ def request(method, path, query: {}, body: nil, raw: nil, headers: {})
138
+ uri = URI.parse(@base_url + path)
139
+ uri.query = URI.encode_www_form(query) unless query.nil? || query.empty?
140
+ attempt = 0
141
+ begin
142
+ started = monotonic
143
+ response = dispatch(method, uri, body, raw, headers)
144
+ interpret(response, method, path, monotonic - started)
145
+ rescue APIError => e
146
+ raise unless retry?(e, attempt)
147
+
148
+ wait(attempt, response, e.code, path)
149
+ attempt += 1
150
+ retry
151
+ rescue *NETWORK_ERRORS => e
152
+ raise ConnectionError, "#{method.upcase} #{path} failed: #{e.message}" if attempt >= @max_retries
153
+
154
+ wait(attempt, nil, "CONNECTION", path)
155
+ attempt += 1
156
+ retry
157
+ end
158
+ end
159
+
160
+ NETWORK_ERRORS = [
161
+ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, IOError,
162
+ Net::OpenTimeout, Net::ReadTimeout, SocketError
163
+ ].freeze
164
+ private_constant :NETWORK_ERRORS
165
+
166
+ def dispatch(method, uri, body, raw, headers)
167
+ klass = { get: Net::HTTP::Get, post: Net::HTTP::Post, delete: Net::HTTP::Delete }.fetch(method)
168
+ http_request = klass.new(uri)
169
+ http_request["Accept"] = "application/json"
170
+ http_request["Authorization"] = "Bearer #{@api_key}"
171
+ http_request["User-Agent"] = "ironeye-ruby/#{IronEye::VERSION}"
172
+ headers.each { |name, value| http_request[name] = value }
173
+ if raw
174
+ http_request.body = raw
175
+ elsif body
176
+ http_request["Content-Type"] = "application/json"
177
+ http_request.body = JSON.generate(body)
178
+ end
179
+
180
+ Net::HTTP.start(uri.hostname, uri.port,
181
+ use_ssl: uri.scheme == "https",
182
+ open_timeout: @timeout,
183
+ read_timeout: @timeout) { |http| http.request(http_request) }
184
+ end
185
+
186
+ def interpret(response, method, path, elapsed)
187
+ status = response.code.to_i
188
+ @logger.debug do
189
+ format("ironeye %<method>s %<path>s -> %<status>d in %<ms>dms (request_id=%<id>s)",
190
+ method: method.to_s.upcase, path: path, status: status,
191
+ ms: elapsed * 1000, id: response["x-request-id"] || "-")
192
+ end
193
+ return nil if [204, 205].include?(status)
194
+
195
+ payload = parse(response.body)
196
+ return payload if status < 400
197
+
198
+ raise IronEye.error_from(status, payload)
199
+ end
200
+
201
+ def parse(body)
202
+ return nil if body.nil? || body.empty?
203
+
204
+ JSON.parse(body)
205
+ rescue JSON::ParserError
206
+ { "error" => { "code" => "INTERNAL", "message" => body[0, 200] } }
207
+ end
208
+
209
+ def retry?(error, attempt)
210
+ attempt < @max_retries && error.retryable? && RETRYABLE_STATUS.include?(error.status)
211
+ end
212
+
213
+ # Retry-After is the server's own number, so it wins over the curve.
214
+ def wait(attempt, response, code, path)
215
+ advised = response && response["retry-after"].to_s
216
+ seconds = advised.to_s.match?(/\A\d+\z/) ? advised.to_i : (0.25 * (2**attempt)) + rand * 0.25
217
+ @logger.warn { format("ironeye %<path>s retrying after %<code>s in %<ms>dms", path: path, code: code, ms: seconds * 1000) }
218
+ sleep(seconds)
219
+ end
220
+
221
+ def declaration_headers(declaration)
222
+ declaration.each_with_object({}) do |(key, value), out|
223
+ name = DECLARATION_HEADERS[key.to_sym]
224
+ out[name] = value.to_s if name && value
225
+ end
226
+ end
227
+
228
+ def multipart(boundary, file, filename, content_type, fields)
229
+ parts = fields.map do |name, value|
230
+ "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{name}\"\r\n\r\n#{value}\r\n"
231
+ end
232
+ parts << "--#{boundary}\r\nContent-Disposition: form-data; name=\"file\"; " \
233
+ "filename=\"#{filename}\"\r\nContent-Type: #{content_type}\r\n\r\n"
234
+ "#{parts.join}#{file}\r\n--#{boundary}--\r\n".b
235
+ end
236
+
237
+ def escape(value) = URI.encode_www_form_component(value.to_s)
238
+
239
+ # Enough of the key to recognise it in a log, never enough to use it.
240
+ def masked_key = @api_key.length > 12 ? "#{@api_key[0, 9]}..." : "..."
241
+
242
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
243
+ end
244
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IronEye
4
+ # Base of every error this gem raises.
5
+ class Error < StandardError; end
6
+
7
+ # An error the server described in its response body.
8
+ #
9
+ # +retryable+ is the server's own verdict rather than an inference from the
10
+ # status code: a 429 from a spent monthly allowance is not the same wait as a
11
+ # 429 from a rate limiter, and only the body tells them apart.
12
+ class APIError < Error
13
+ attr_reader :status, :code, :retryable, :request_id, :suggested_action, :doc, :path, :meta
14
+
15
+ def initialize(status, body)
16
+ @status = status
17
+ @code = body["code"] || "INTERNAL"
18
+ @retryable = body.fetch("retryable", false)
19
+ @request_id = body["request_id"] || "-"
20
+ @suggested_action = body["suggested_action"].to_s
21
+ @doc = body["doc"].to_s
22
+ @path = body["path"]
23
+ @meta = body["meta"] || {}
24
+ super("#{@code}: #{body["message"]} (request_id=#{@request_id})")
25
+ end
26
+
27
+ alias retryable? retryable
28
+ end
29
+
30
+ class AuthenticationError < APIError; end
31
+ class PermissionError < APIError; end
32
+ class RateLimitError < APIError; end
33
+ class InvalidRequestError < APIError; end
34
+ class NotFoundError < APIError; end
35
+ class ComplianceError < APIError; end
36
+ class UpstreamError < APIError; end
37
+ class ServerError < APIError; end
38
+
39
+ # A transport failure, where there is no server verdict to read.
40
+ class ConnectionError < Error
41
+ def retryable? = true
42
+ end
43
+
44
+ FAMILIES = {
45
+ "UNAUTHENTICATED" => AuthenticationError,
46
+ "FORBIDDEN_SCOPE" => PermissionError,
47
+ "PLAN_LIMITED" => PermissionError,
48
+ "RATE_LIMITED" => RateLimitError,
49
+ "QUOTA_EXHAUSTED" => RateLimitError,
50
+ "TENANT_BUSY" => RateLimitError,
51
+ "NOT_FOUND" => NotFoundError,
52
+ "COMPLIANCE_REFUSED" => ComplianceError,
53
+ "COLLECTION_BLOCKED" => ComplianceError,
54
+ "SOURCE_NOT_CONFIGURED" => UpstreamError,
55
+ "UPSTREAM_REFUSED" => UpstreamError,
56
+ "UPSTREAM_THROTTLED" => UpstreamError,
57
+ "INTERNAL" => ServerError,
58
+ "DEPENDENCY_UNAVAILABLE" => ServerError,
59
+ "SERVER_DRAINING" => ServerError
60
+ }.freeze
61
+ private_constant :FAMILIES
62
+
63
+ # Builds the narrowest error class the response body justifies.
64
+ def self.error_from(status, payload)
65
+ body = payload.is_a?(Hash) ? payload["error"] : nil
66
+ unless body.is_a?(Hash) && body["code"]
67
+ return ServerError.new(status, {
68
+ "code" => "INTERNAL",
69
+ "message" => "The server returned #{status} with no error body.",
70
+ "retryable" => status >= 500,
71
+ "suggested_action" => "Retry, and quote the status if it persists."
72
+ })
73
+ end
74
+ FAMILIES.fetch(body["code"], InvalidRequestError).new(status, body)
75
+ end
76
+ end
data/lib/ironeye.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Official Ruby client for the IronEye API.
4
+ #
5
+ # eye = IronEye::Client.new # IRONEYE_API_KEY from the environment
6
+ # result = eye.secrets(input: { text: File.read("config.env") })
7
+ # result.dig("security", "secrets", "secret_count")
8
+ #
9
+ # Pass +logger:+ to see one line per request: method, route, status, duration
10
+ # and request id. No credential and no payload is ever written to it.
11
+ module IronEye
12
+ VERSION = "1.0.0"
13
+ end
14
+
15
+ require_relative "ironeye/errors"
16
+ require_relative "ironeye/client"
17
+
18
+ module IronEye
19
+ def self.matz
20
+ puts <<~CREED
21
+ Optimise for the reader, not the writer.
22
+ A finding without evidence is a rumour.
23
+ Refuse loudly rather than guess quietly.
24
+ Nothing touches disk.
25
+
26
+ ...forged at Direct Softworks.
27
+ CREED
28
+ end
29
+ end
metadata ADDED
@@ -0,0 +1,53 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ironeye
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Direct Softworks
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-05 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email:
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - LICENSE
20
+ - README.md
21
+ - lib/ironeye.rb
22
+ - lib/ironeye/client.rb
23
+ - lib/ironeye/errors.rb
24
+ homepage: https://ironeye.org
25
+ licenses:
26
+ - MIT
27
+ metadata:
28
+ documentation_uri: https://ironeye.org/docs/sdk/ruby
29
+ homepage_uri: https://ironeye.org
30
+ source_code_uri: https://github.com/IronEyeAPI/ironeye-ruby
31
+ bug_tracker_uri: https://github.com/IronEyeAPI/ironeye-ruby/issues
32
+ rubygems_mfa_required: 'true'
33
+ post_install_message:
34
+ rdoc_options: []
35
+ require_paths:
36
+ - lib
37
+ required_ruby_version: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '3.1'
42
+ required_rubygems_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ requirements: []
48
+ rubygems_version: 3.5.22
49
+ signing_key:
50
+ specification_version: 4
51
+ summary: Official Ruby client for the IronEye document intelligence and collection
52
+ API.
53
+ test_files: []