scanii-ruby 1.0.1 → 1.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0e3c62870b928231999c133d889594f002682b74b34769c5741dbfe9a9c12693
4
- data.tar.gz: a1673812fa7c2e46fa12657a3898fb01fdf56399c04146e95dfdf048d12ef185
3
+ metadata.gz: 2b4cc56c7cdba5d949e11dd004f7dbaa29ada9dab5e7b64173f5a523dc4f4e51
4
+ data.tar.gz: 37b10fb0ee6aa5dfbabf6b9c097fbf837f297fd557e27f0dc97e49278ed355d6
5
5
  SHA512:
6
- metadata.gz: 1afa93f5a49873f5813dbf9701d5886b173a3e3d5dbdda9d214690308eb77d92b8ff96bde66b6fa78b2f455af32d317bf8e1ce1338824fa0a5801f2dbe4786cd
7
- data.tar.gz: 6ed39afbb9846cec5916f8a77c8119a717f6e5c6e6da81dd17756a456fa069735063a20762a772090400d09b1e4fd85c883a92176beecf36479fe10d38ae7bd4
6
+ metadata.gz: 97c6569f83a40beb529c5d4a2189a57443201d17eafc9be2c75e693316ddf6d98e3306a9819d20346c6f573b0d3fbb182eea3493ad8269f4d8bdeef640b79096
7
+ data.tar.gz: 70cc50536847e7f83fce477098f6feb780ce6bf9ed3571d1ed92b0f8cd6b929eb5dd2fd2bd4cbdc5afc9d4b989f3df07ffdb86ae901e698e66de2884c95811c6
data/CHANGELOG.md CHANGED
@@ -2,6 +2,59 @@
2
2
 
3
3
  All notable changes to `scanii-ruby` are documented here. Versions follow [SemVer](https://semver.org).
4
4
 
5
+ ## [1.2.0] — v2.2 surface
6
+
7
+ ### New API
8
+
9
+ - `Scanii::Client#retrieve_trace(id)` → `Scanii::TraceResult` or `nil` — retrieves the
10
+ ordered processing event trace for a scan via `GET /files/{id}/trace`. Returns `nil` on 404
11
+ (no trace for that id). v2.2 preview surface; API shape may shift before marked stable.
12
+ - `Scanii::Client#process_from_url(location, callback: nil, metadata: nil)` →
13
+ `Scanii::ProcessingResult` — submits a URL for synchronous scanning via `POST /files` with
14
+ `location` as a multipart/form-data field. Distinct from `fetch`, which submits to
15
+ `/files/fetch` for asynchronous server-side fetching. `location` must be a String URL.
16
+ v2.2 preview surface.
17
+ - `Scanii::TraceResult` — new result class with `id`, `events`, `request_id`, `host_id`,
18
+ `raw_response`.
19
+ - `Scanii::TraceEvent` — new model with `timestamp` (String) and `message` (String).
20
+
21
+ ### Deprecations
22
+
23
+ - `Scanii::ProcessingResult#error` — deprecated. The server never populates this field on
24
+ successful responses; errors arrive as non-2xx HTTP responses that raise `Scanii::Error`
25
+ subclasses. The field still exists and emits a runtime `warn` on access. Will be removed
26
+ in a future major version.
27
+
28
+ ## 1.1.0 — Streaming standardization
29
+
30
+ Adds stream-based `process` and `process_async` methods, aligning scanii-ruby with the
31
+ cross-SDK streaming standard. File content is now truly streamed to the socket via
32
+ `Net::HTTP#body_stream=` rather than buffered into a single String.
33
+
34
+ ### New API
35
+
36
+ - `Scanii::Client#process(io, filename:, content_type: nil, metadata: nil, callback: nil)` →
37
+ `Scanii::ProcessingResult` — accepts any IO-like object (anything responding to `read(n)`).
38
+ Both `File` (opened with `File.open(path, "rb")`) and `StringIO` work.
39
+ - `Scanii::Client#process_file(path, metadata: nil, callback: nil)` →
40
+ `Scanii::ProcessingResult` — convenience wrapper that opens the file in binary mode and
41
+ delegates to `process`. This is the replacement for the old `process(path, ...)` form.
42
+ - Same shapes for `process_async` / `process_async_file`.
43
+
44
+ ### Deprecations
45
+
46
+ - `process(path_string, ...)` — passing a String path to `process` is deprecated; use
47
+ `process_file(path)` instead. The old form still works and emits a runtime `warn`. Will be
48
+ removed in a future major version.
49
+ - `process_async(path_string, ...)` — same; use `process_async_file(path)`. Will be removed
50
+ in a future major version.
51
+
52
+ ### Internals
53
+
54
+ - `Scanii::Multipart.stream_encode` replaces the old `encode`. Returns a `[ChainedIO,
55
+ content_type, content_length]` triple. `ChainedIO` reads prologue → caller IO → epilogue
56
+ without ever buffering the full body.
57
+
5
58
  ## 1.0.1 — Release infrastructure fix
6
59
 
7
60
  Wires up `bundler/gem_tasks` in the Rakefile so `bundle exec rake release` (invoked by `rubygems/release-gem@v1`) resolves correctly. v1.0.0 was tagged but never published to RubyGems because the release workflow failed at the `rake release` task lookup; v1.0.1 is functionally identical to that tag. No SDK behavior changes.
data/README.md CHANGED
@@ -30,12 +30,23 @@ Targets Ruby 3.4+. Zero runtime dependencies.
30
30
 
31
31
  ## Quickstart
32
32
 
33
+ Scan a file from disk:
34
+
33
35
  ```ruby
34
36
  require "scanii"
35
37
 
36
38
  client = Scanii::Client.new(key: "your-key", secret: "your-secret")
37
39
 
38
- result = client.process("./file.pdf")
40
+ result = client.process_file("./file.pdf")
41
+ puts "findings: #{result.findings.inspect}"
42
+ ```
43
+
44
+ Scan content already in memory (no temp file needed):
45
+
46
+ ```ruby
47
+ require "stringio"
48
+
49
+ result = client.process(StringIO.new(bytes), filename: "upload.bin")
39
50
  puts "findings: #{result.findings.inspect}"
40
51
  ```
41
52
 
@@ -45,10 +56,14 @@ puts "findings: #{result.findings.inspect}"
45
56
 
46
57
  | Method | REST | Returns |
47
58
  |---|---|---|
48
- | `process(file_path, metadata:, callback:)` | `POST /files` | `Scanii::ProcessingResult` |
49
- | `process_async(file_path, metadata:, callback:)` | `POST /files/async` | `Scanii::PendingResult` |
59
+ | `process(io, filename:, content_type:, metadata:, callback:)` | `POST /files` | `Scanii::ProcessingResult` |
60
+ | `process_file(path, metadata:, callback:)` | `POST /files` | `Scanii::ProcessingResult` |
61
+ | `process_async(io, filename:, content_type:, metadata:, callback:)` | `POST /files/async` | `Scanii::PendingResult` |
62
+ | `process_async_file(path, metadata:, callback:)` | `POST /files/async` | `Scanii::PendingResult` |
63
+ | `process_from_url(location, callback:, metadata:)` | `POST /files` | `Scanii::ProcessingResult` (v2.2 preview) |
50
64
  | `fetch(url, metadata:, callback:)` | `POST /files/fetch` | `Scanii::PendingResult` |
51
65
  | `retrieve(id)` | `GET /files/{id}` | `Scanii::ProcessingResult` |
66
+ | `retrieve_trace(id)` | `GET /files/{id}/trace` | `Scanii::TraceResult` or `nil` (v2.2 preview) |
52
67
  | `ping` | `GET /ping` | `true` |
53
68
  | `create_auth_token(timeout_seconds)` | `POST /auth/tokens` | `Scanii::AuthToken` |
54
69
  | `retrieve_auth_token(id)` | `GET /auth/tokens/{id}` | `Scanii::AuthToken` |
data/lib/scanii/client.rb CHANGED
@@ -13,10 +13,13 @@ module Scanii
13
13
  #
14
14
  # @see https://scanii.github.io/openapi/v22/
15
15
  #
16
- # @example
16
+ # @example Scan a file from disk
17
17
  # client = Scanii::Client.new(key: "your-key", secret: "your-secret")
18
- # result = client.process("./file.pdf")
18
+ # result = client.process_file("./file.pdf")
19
19
  # puts result.findings # [] when clean
20
+ #
21
+ # @example Scan content already in memory
22
+ # result = client.process(StringIO.new(bytes), filename: "upload.bin")
20
23
  class Client
21
24
  DEFAULT_ENDPOINT = "https://api.scanii.com".freeze
22
25
  DEFAULT_TIMEOUT = 60
@@ -44,34 +47,111 @@ module Scanii
44
47
  @user_agent = user_agent && !user_agent.empty? ? "#{user_agent} #{USER_AGENT}" : USER_AGENT
45
48
  end
46
49
 
47
- # Submit a file for synchronous scanning.
50
+ # Submit an IO-like object for synchronous scanning.
51
+ #
52
+ # +io+ is duck-typed: anything responding to +read(n)+ returning a String.
53
+ # Both +File+ (opened with +File.open(path, "rb")+) and +StringIO+ work.
54
+ # The body is streamed to the socket; file content is never fully buffered.
55
+ #
56
+ # Passing a String path is deprecated — use {#process_file} instead.
57
+ #
58
+ # @overload process(io, filename:, content_type: nil, metadata: nil, callback: nil)
59
+ # @param io [#read] IO-like object
60
+ # @param filename [String] filename sent in the multipart part
61
+ # @param content_type [String, nil] content-type of the file part; guessed from filename when nil
62
+ # @param metadata [Hash{String=>String}, nil] arbitrary key/value pairs attached to the result
63
+ # @param callback [String, nil] URL to POST the result to on completion
48
64
  #
49
65
  # @see https://scanii.github.io/openapi/v22/ POST /files
50
66
  # @return [Scanii::ProcessingResult]
51
- def process(file_path, metadata: nil, callback: nil)
52
- assert_readable(file_path)
67
+ def process(first_arg, filename: nil, content_type: nil, metadata: nil, callback: nil)
68
+ if first_arg.is_a?(String)
69
+ # @deprecated Use {#process_file} instead. Will be removed in a future major version.
70
+ warn "[DEPRECATION] `Scanii::Client#process(path)` is deprecated; " \
71
+ "use `process_file(path)` instead. Will be removed in a future major version."
72
+ return process_file(first_arg, metadata: metadata, callback: callback)
73
+ end
74
+
75
+ raise ArgumentError, "io must respond to read" unless first_arg.respond_to?(:read)
76
+ raise ArgumentError, "filename: is required" if filename.nil? || filename.to_s.empty?
77
+
53
78
  fields = build_text_fields(metadata, callback)
54
- body, content_type = Multipart.encode(fields, file_path)
55
- status, resp_body, headers = post("/files", body: body, content_type: content_type)
79
+ stream, ct, length = Multipart.stream_encode(fields, first_arg, filename.to_s, content_type)
80
+ status, resp_body, headers = post("/files", body_stream: stream, content_type: ct,
81
+ content_length: length)
56
82
  raise_for_status(status, resp_body, headers) unless status == 201
57
83
  ProcessingResult.from_response(resp_body, headers)
58
84
  end
59
85
 
60
- # Submit a file for server-side asynchronous scanning. Returns a pending
61
- # id; the final result is delivered to +callback+ (when supplied) or
62
- # fetched via #retrieve.
86
+ # Submit a file path for synchronous scanning.
87
+ #
88
+ # Opens the file in binary mode, streams it to Scanii, and closes it.
89
+ # Delegates to {#process} with +filename+ set to the basename.
90
+ #
91
+ # @param file_path [String] path to the file to upload
92
+ # @param metadata [Hash{String=>String}, nil]
93
+ # @param callback [String, nil]
94
+ # @see https://scanii.github.io/openapi/v22/ POST /files
95
+ # @return [Scanii::ProcessingResult]
96
+ def process_file(file_path, metadata: nil, callback: nil)
97
+ assert_readable(file_path)
98
+ File.open(file_path.to_s, "rb") do |f|
99
+ process(f, filename: File.basename(file_path.to_s), metadata: metadata, callback: callback)
100
+ end
101
+ end
102
+
103
+ # Submit an IO-like object for server-side asynchronous scanning.
104
+ #
105
+ # Returns a pending id; the final result is delivered to +callback+ (when
106
+ # supplied) or fetched via {#retrieve}.
107
+ #
108
+ # Passing a String path is deprecated — use {#process_async_file} instead.
109
+ #
110
+ # @overload process_async(io, filename:, content_type: nil, metadata: nil, callback: nil)
111
+ # @param io [#read] IO-like object
112
+ # @param filename [String] filename sent in the multipart part
113
+ # @param content_type [String, nil]
114
+ # @param metadata [Hash{String=>String}, nil]
115
+ # @param callback [String, nil]
63
116
  #
64
117
  # @see https://scanii.github.io/openapi/v22/ POST /files/async
65
118
  # @return [Scanii::PendingResult]
66
- def process_async(file_path, metadata: nil, callback: nil)
67
- assert_readable(file_path)
119
+ def process_async(first_arg, filename: nil, content_type: nil, metadata: nil, callback: nil)
120
+ if first_arg.is_a?(String)
121
+ # @deprecated Use {#process_async_file} instead. Will be removed in a future major version.
122
+ warn "[DEPRECATION] `Scanii::Client#process_async(path)` is deprecated; " \
123
+ "use `process_async_file(path)` instead. Will be removed in a future major version."
124
+ return process_async_file(first_arg, metadata: metadata, callback: callback)
125
+ end
126
+
127
+ raise ArgumentError, "io must respond to read" unless first_arg.respond_to?(:read)
128
+ raise ArgumentError, "filename: is required" if filename.nil? || filename.to_s.empty?
129
+
68
130
  fields = build_text_fields(metadata, callback)
69
- body, content_type = Multipart.encode(fields, file_path)
70
- status, resp_body, headers = post("/files/async", body: body, content_type: content_type)
131
+ stream, ct, length = Multipart.stream_encode(fields, first_arg, filename.to_s, content_type)
132
+ status, resp_body, headers = post("/files/async", body_stream: stream, content_type: ct,
133
+ content_length: length)
71
134
  raise_for_status(status, resp_body, headers) unless status == 202
72
135
  PendingResult.from_response(resp_body, headers)
73
136
  end
74
137
 
138
+ # Submit a file path for server-side asynchronous scanning.
139
+ #
140
+ # Opens the file in binary mode and delegates to {#process_async}.
141
+ #
142
+ # @param file_path [String] path to the file to upload
143
+ # @param metadata [Hash{String=>String}, nil]
144
+ # @param callback [String, nil]
145
+ # @see https://scanii.github.io/openapi/v22/ POST /files/async
146
+ # @return [Scanii::PendingResult]
147
+ def process_async_file(file_path, metadata: nil, callback: nil)
148
+ assert_readable(file_path)
149
+ File.open(file_path.to_s, "rb") do |f|
150
+ process_async(f, filename: File.basename(file_path.to_s), metadata: metadata,
151
+ callback: callback)
152
+ end
153
+ end
154
+
75
155
  # Ask Scanii to download a remote URL and scan it asynchronously.
76
156
  #
77
157
  # @see https://scanii.github.io/openapi/v22/ POST /files/fetch
@@ -104,6 +184,70 @@ module Scanii
104
184
  ProcessingResult.from_response(resp_body, headers)
105
185
  end
106
186
 
187
+ # Retrieve the processing event trace for a previously submitted scan.
188
+ #
189
+ # Returns nil when no trace exists for the given id (HTTP 404).
190
+ #
191
+ # This is a v2.2 preview surface; the API shape may shift before it is
192
+ # marked stable.
193
+ #
194
+ # @param id [String] processing id returned by process or process_file
195
+ # @see https://scanii.github.io/openapi/v22/ GET /files/{id}/trace
196
+ # @return [Scanii::TraceResult, nil]
197
+ def retrieve_trace(id)
198
+ raise ArgumentError, "id must not be empty" if id.nil? || id.empty?
199
+
200
+ status, resp_body, headers = request("GET", "/files/#{url_encode(id)}/trace")
201
+ return nil if status == 404
202
+
203
+ raise_for_status(status, resp_body, headers) unless status == 200
204
+ TraceResult.from_response(resp_body, headers)
205
+ end
206
+
207
+ # Submit a remote URL for synchronous scanning.
208
+ #
209
+ # Sends the URL as a +location+ field in a multipart/form-data POST to
210
+ # +/files+. The Scanii server fetches and scans the URL synchronously and
211
+ # returns a ProcessingResult. This is distinct from {#fetch}, which submits
212
+ # to +/files/fetch+ for asynchronous server-side fetching.
213
+ #
214
+ # +location+ must be a String URL. This matches the existing {#fetch}
215
+ # String-URL convention and the Java reference (processFromUrl(String)).
216
+ #
217
+ # This is a v2.2 preview surface; the API shape may shift before it is
218
+ # marked stable.
219
+ #
220
+ # @param location [String] URL of the content to scan
221
+ # @param callback [String, nil] URL to POST the result to on completion
222
+ # @param metadata [Hash{String=>String}, nil] arbitrary key/value pairs attached to the result
223
+ # @see https://scanii.github.io/openapi/v22/ POST /files
224
+ # @return [Scanii::ProcessingResult]
225
+ def process_from_url(location, callback: nil, metadata: nil)
226
+ raise ArgumentError, "location must not be empty" if location.nil? || location.to_s.empty?
227
+
228
+ fields = build_text_fields(metadata, callback)
229
+ fields["location"] = location.to_s
230
+
231
+ boundary = Multipart.make_boundary
232
+ body = String.new(encoding: Encoding::BINARY)
233
+ fields.each do |name, value|
234
+ body << "--#{boundary}\r\n".b
235
+ body << "Content-Disposition: form-data; name=\"#{name}\"\r\n".b
236
+ body << "Content-Type: text/plain; charset=UTF-8\r\n\r\n".b
237
+ body << value.to_s.b
238
+ body << "\r\n".b
239
+ end
240
+ body << "--#{boundary}--\r\n".b
241
+
242
+ status, resp_body, headers = post(
243
+ "/files",
244
+ body: body,
245
+ content_type: Multipart.make_content_type(boundary)
246
+ )
247
+ raise_for_status(status, resp_body, headers) unless status == 201
248
+ ProcessingResult.from_response(resp_body, headers)
249
+ end
250
+
107
251
  # Verify that the configured credentials reach the API.
108
252
  #
109
253
  # @see https://scanii.github.io/openapi/v22/ GET /ping
@@ -190,14 +334,16 @@ module Scanii
190
334
  fields
191
335
  end
192
336
 
193
- def post(path, body:, content_type:)
194
- request("POST", path, body: body, content_type: content_type)
337
+ def post(path, body: nil, content_type: nil, body_stream: nil, content_length: nil)
338
+ request("POST", path, body: body, content_type: content_type,
339
+ body_stream: body_stream, content_length: content_length)
195
340
  end
196
341
 
197
- def request(method, path, body: nil, content_type: nil)
342
+ def request(method, path, body: nil, content_type: nil, body_stream: nil, content_length: nil)
198
343
  uri = URI.parse("#{@base_uri}#{path}")
199
344
 
200
- req = build_request(method, uri, body, content_type)
345
+ req = build_request(method, uri, body: body, content_type: content_type,
346
+ body_stream: body_stream, content_length: content_length)
201
347
 
202
348
  Net::HTTP.start(uri.hostname, uri.port,
203
349
  use_ssl: uri.scheme == "https",
@@ -211,7 +357,7 @@ module Scanii
211
357
  raise Scanii::Error, "transport error: #{e.class}: #{e.message}"
212
358
  end
213
359
 
214
- def build_request(method, uri, body, content_type)
360
+ def build_request(method, uri, body: nil, content_type: nil, body_stream: nil, content_length: nil)
215
361
  klass = case method
216
362
  when "GET" then Net::HTTP::Get
217
363
  when "POST" then Net::HTTP::Post
@@ -224,7 +370,14 @@ module Scanii
224
370
  req["User-Agent"] = @user_agent
225
371
  req["Accept"] = "application/json"
226
372
  req["Content-Type"] = content_type if content_type
227
- req.body = body if body
373
+
374
+ if body_stream
375
+ req.body_stream = body_stream
376
+ req["Content-Length"] = content_length.to_s
377
+ elsif body
378
+ req.body = body
379
+ end
380
+
228
381
  req
229
382
  end
230
383
 
@@ -1,14 +1,11 @@
1
1
  require "securerandom"
2
+ require "stringio"
2
3
 
3
4
  module Scanii
4
5
  # Hand-rolled multipart/form-data encoder (RFC 7578).
5
6
  #
6
7
  # Ruby's stdlib Net::HTTP does not bundle a multipart encoder; this is the
7
8
  # smallest viable implementation that covers the Scanii POST /files payload.
8
- #
9
- # Body is assembled as a single binary-encoded String -- file contents are
10
- # read into memory rather than streamed. This matches the PHP SDK's approach;
11
- # callers scanning very large files should be aware.
12
9
  module Multipart
13
10
  module_function
14
11
 
@@ -22,42 +19,45 @@ module Scanii
22
19
  "multipart/form-data; boundary=#{boundary}"
23
20
  end
24
21
 
25
- # Encode a multipart body containing the bytes at file_path plus the given
26
- # text fields.
22
+ # Encode a multipart body as a streaming ChainedIO.
23
+ #
24
+ # Builds the RFC 7578 prologue and epilogue as binary Strings, chains them
25
+ # around the caller's IO, and returns the triple required for
26
+ # Net::HTTP body_stream= uploads. The caller's IO is never read here --
27
+ # only when Net::HTTP reads from the returned ChainedIO.
27
28
  #
28
- # @param fields [Hash{String=>String}] text form fields (e.g. metadata[k] => v, callback => url)
29
- # @param file_path [String] path to the file to upload
29
+ # @param fields [Hash{String=>String}] text form fields (e.g. metadata[k]=v, callback)
30
+ # @param io [#read, #size] IO-like object (anything responding to read(n))
31
+ # @param filename [String] filename for the file part
32
+ # @param content_type [String, nil] content-type of the file part; falls back to extension lookup
30
33
  # @param file_field [String] name of the file form field; default "file"
31
- # @return [Array(String, String)] tuple of [body, content_type]
32
- def encode(fields, file_path, file_field: "file")
34
+ # @return [Array(ChainedIO, String, Integer)] [body_stream, content_type_header, content_length]
35
+ def stream_encode(fields, io, filename, content_type = nil, file_field: "file")
33
36
  boundary = make_boundary
37
+ ct = content_type || guess_content_type(filename)
34
38
 
35
- filename = File.basename(file_path)
36
- content_type = guess_content_type(file_path)
37
- file_bytes = File.binread(file_path)
38
-
39
- body = String.new(encoding: Encoding::BINARY)
40
-
39
+ prologue = String.new(encoding: Encoding::BINARY)
41
40
  fields.each do |name, value|
42
- write_text_part(body, boundary, name.to_s, value.to_s)
41
+ write_text_part(prologue, boundary, name.to_s, value.to_s)
43
42
  end
43
+ prologue << "--#{boundary}\r\n".b
44
+ prologue << "Content-Disposition: form-data; name=\"#{file_field}\"; filename=\"#{filename}\"\r\n".b
45
+ prologue << "Content-Type: #{ct}\r\n\r\n".b
44
46
 
45
- body << "--#{boundary}\r\n".b
46
- body << "Content-Disposition: form-data; name=\"#{file_field}\"; filename=\"#{filename}\"\r\n".b
47
- body << "Content-Type: #{content_type}\r\n\r\n".b
48
- body << file_bytes.b
49
- body << "\r\n".b
50
- body << "--#{boundary}--\r\n".b
47
+ epilogue = "\r\n--#{boundary}--\r\n".b
51
48
 
52
- [body, make_content_type(boundary)]
49
+ io_size = io_remaining_bytes(io)
50
+ total_length = prologue.bytesize + io_size + epilogue.bytesize
51
+
52
+ [ChainedIO.new(prologue, io, epilogue), make_content_type(boundary), total_length]
53
53
  end
54
54
 
55
- # Best-effort content-type lookup by extension. Falls back to
55
+ # Best-effort content-type lookup by filename extension. Falls back to
56
56
  # application/octet-stream. The Scanii API does not require an accurate
57
57
  # content-type on the multipart part -- the server inspects the bytes -- so
58
58
  # a short table is sufficient.
59
- def guess_content_type(path)
60
- ext = File.extname(path).delete_prefix(".").downcase
59
+ def guess_content_type(filename)
60
+ ext = File.extname(filename.to_s).delete_prefix(".").downcase
61
61
  MIME_TYPES.fetch(ext, "application/octet-stream")
62
62
  end
63
63
 
@@ -96,5 +96,60 @@ module Scanii
96
96
  body << "\r\n".b
97
97
  end
98
98
  private_class_method :write_text_part
99
+
100
+ # Return the number of bytes remaining to be read from io.
101
+ # Requires io to respond to size (File and StringIO both do).
102
+ def io_remaining_bytes(io)
103
+ if io.respond_to?(:pos) && io.respond_to?(:size)
104
+ io.size - io.pos
105
+ elsif io.respond_to?(:size)
106
+ io.size
107
+ else
108
+ raise ArgumentError, "io must respond to size (File and StringIO do; got #{io.class})"
109
+ end
110
+ end
111
+ private_class_method :io_remaining_bytes
112
+
113
+ # Reads prologue_str, then io, then epilogue_str in sequence.
114
+ # Used as Net::HTTP body_stream for streaming multipart uploads.
115
+ class ChainedIO
116
+ def initialize(prologue, io, epilogue)
117
+ @parts = [StringIO.new(prologue), io, StringIO.new(epilogue)]
118
+ @idx = 0
119
+ end
120
+
121
+ def read(length = nil, buf = nil)
122
+ result = length.nil? ? read_all : read_n(length)
123
+ return nil if result.nil?
124
+
125
+ buf ? buf.replace(result) : result
126
+ end
127
+
128
+ private
129
+
130
+ def read_all
131
+ result = String.new(encoding: Encoding::BINARY)
132
+ @parts[@idx..].each do |part|
133
+ chunk = part.read
134
+ result << chunk.b if chunk
135
+ end
136
+ @idx = @parts.size
137
+ result
138
+ end
139
+
140
+ def read_n(length)
141
+ result = String.new(encoding: Encoding::BINARY)
142
+ while result.bytesize < length && @idx < @parts.size
143
+ chunk = @parts[@idx].read(length - result.bytesize)
144
+ if chunk.nil? || chunk.empty?
145
+ @idx += 1
146
+ else
147
+ result << chunk.b
148
+ end
149
+ end
150
+ result.empty? ? nil : result
151
+ end
152
+ end
153
+ private_constant :ChainedIO
99
154
  end
100
155
  end
@@ -9,7 +9,7 @@ module Scanii
9
9
  # @see https://scanii.github.io/openapi/v22/
10
10
  class ProcessingResult
11
11
  attr_reader :id, :findings, :checksum, :content_length, :content_type,
12
- :metadata, :creation_date, :error,
12
+ :metadata, :creation_date,
13
13
  :request_id, :host_id, :resource_location, :raw_response
14
14
 
15
15
  def initialize(id:, findings:, checksum:, content_length:, content_type:,
@@ -22,13 +22,23 @@ module Scanii
22
22
  @content_type = content_type
23
23
  @metadata = metadata
24
24
  @creation_date = creation_date
25
- @error = error
25
+ @_error = error
26
26
  @request_id = request_id
27
27
  @host_id = host_id
28
28
  @resource_location = resource_location
29
29
  @raw_response = raw_response
30
30
  end
31
31
 
32
+ # @deprecated The server never populates this field on successful responses;
33
+ # errors arrive as non-2xx HTTP responses that raise Scanii::Error
34
+ # subclasses. Will be removed in a future major version.
35
+ def error
36
+ warn "[DEPRECATION] `Scanii::ProcessingResult#error` is deprecated; " \
37
+ "rescue Scanii::Error (and its subclasses) to handle server-side errors. " \
38
+ "Will be removed in a future major version."
39
+ @_error
40
+ end
41
+
32
42
  def self.from_response(body, headers)
33
43
  json = body.nil? || body.empty? ? {} : JSON.parse(body)
34
44
 
@@ -0,0 +1,20 @@
1
+ module Scanii
2
+ # A single processing event in a {Scanii::TraceResult}.
3
+ #
4
+ # @see https://scanii.github.io/openapi/v22/
5
+ class TraceEvent
6
+ attr_reader :timestamp, :message
7
+
8
+ def initialize(timestamp:, message:)
9
+ @timestamp = timestamp
10
+ @message = message
11
+ end
12
+
13
+ def self.from_hash(hash)
14
+ new(
15
+ timestamp: hash["timestamp"]&.to_s,
16
+ message: hash["message"]&.to_s
17
+ )
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,33 @@
1
+ require "json"
2
+
3
+ module Scanii
4
+ # Result of Client#retrieve_trace — ordered processing events for a scan.
5
+ #
6
+ # This is a v2.2 preview surface; the API shape may shift before it is
7
+ # marked stable.
8
+ #
9
+ # @see https://scanii.github.io/openapi/v22/
10
+ class TraceResult
11
+ attr_reader :id, :events, :request_id, :host_id, :raw_response
12
+
13
+ def initialize(id:, events:, request_id:, host_id:, raw_response:)
14
+ @id = id
15
+ @events = events
16
+ @request_id = request_id
17
+ @host_id = host_id
18
+ @raw_response = raw_response
19
+ end
20
+
21
+ def self.from_response(body, headers)
22
+ json = body.nil? || body.empty? ? {} : JSON.parse(body)
23
+
24
+ new(
25
+ id: (json["id"] || "").to_s,
26
+ events: Array(json["events"]).map { |e| TraceEvent.from_hash(e) },
27
+ request_id: headers["x-scanii-request-id"],
28
+ host_id: headers["x-scanii-host-id"],
29
+ raw_response: body
30
+ )
31
+ end
32
+ end
33
+ end
@@ -1,3 +1,3 @@
1
1
  module Scanii
2
- VERSION = "1.0.1".freeze
2
+ VERSION = "1.2.0".freeze
3
3
  end
data/lib/scanii.rb CHANGED
@@ -3,6 +3,8 @@ require_relative "scanii/error"
3
3
  require_relative "scanii/processing_result"
4
4
  require_relative "scanii/pending_result"
5
5
  require_relative "scanii/auth_token"
6
+ require_relative "scanii/trace_event"
7
+ require_relative "scanii/trace_result"
6
8
  require_relative "scanii/multipart"
7
9
  require_relative "scanii/client"
8
10
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: scanii-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Scanii
@@ -80,6 +80,8 @@ files:
80
80
  - lib/scanii/multipart.rb
81
81
  - lib/scanii/pending_result.rb
82
82
  - lib/scanii/processing_result.rb
83
+ - lib/scanii/trace_event.rb
84
+ - lib/scanii/trace_result.rb
83
85
  - lib/scanii/version.rb
84
86
  homepage: https://github.com/scanii/scanii-ruby
85
87
  licenses: