From 7ab0c4ea496eba51343cb9db82340ac6d15f5865 Mon Sep 17 00:00:00 2001 From: Rafael Ferreira <36054+rferreira@users.noreply.github.com> Date: Sat, 2 May 2026 09:32:21 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20add=20v2.2=20surface=20=E2=80=94=20retr?= =?UTF-8?q?ieve=5Ftrace,=20process=5Ffrom=5Furl,=20error=20field=20depreca?= =?UTF-8?q?tion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Propagates the v2.2 spec additions to scanii-ruby, following the naming conventions locked by scanii-java 8.1.0 and the translation patterns confirmed in scanii-dotnet 7.1.0 and scanii-node 1.2.0. - Add Scanii::TraceEvent and Scanii::TraceResult model classes - Add Client#retrieve_trace(id) — GET /v2.2/files/{id}/trace, returns nil on 404 - Add Client#process_from_url(location, ...) — POST /v2.2/files with location as multipart/form-data field (not urlencoded; cli rejects urlencoded with 405) - Deprecate ProcessingResult#error with YARD @deprecated + runtime warn - Bump version to 1.2.0 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 23 ++++++++++++ README.md | 2 ++ lib/scanii.rb | 2 ++ lib/scanii/client.rb | 64 +++++++++++++++++++++++++++++++++ lib/scanii/processing_result.rb | 14 ++++++-- lib/scanii/trace_event.rb | 20 +++++++++++ lib/scanii/trace_result.rb | 33 +++++++++++++++++ lib/scanii/version.rb | 2 +- test/integration/client_test.rb | 30 ++++++++++++++++ 9 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 lib/scanii/trace_event.rb create mode 100644 lib/scanii/trace_result.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index b406cf6..c42f037 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ All notable changes to `scanii-ruby` are documented here. Versions follow [SemVer](https://semver.org). +## [1.2.0] — v2.2 surface + +### New API + +- `Scanii::Client#retrieve_trace(id)` → `Scanii::TraceResult` or `nil` — retrieves the + ordered processing event trace for a scan via `GET /files/{id}/trace`. Returns `nil` on 404 + (no trace for that id). v2.2 preview surface; API shape may shift before marked stable. +- `Scanii::Client#process_from_url(location, callback: nil, metadata: nil)` → + `Scanii::ProcessingResult` — submits a URL for synchronous scanning via `POST /files` with + `location` as a multipart/form-data field. Distinct from `fetch`, which submits to + `/files/fetch` for asynchronous server-side fetching. `location` must be a String URL. + v2.2 preview surface. +- `Scanii::TraceResult` — new result class with `id`, `events`, `request_id`, `host_id`, + `raw_response`. +- `Scanii::TraceEvent` — new model with `timestamp` (String) and `message` (String). + +### Deprecations + +- `Scanii::ProcessingResult#error` — deprecated. The server never populates this field on + successful responses; errors arrive as non-2xx HTTP responses that raise `Scanii::Error` + subclasses. The field still exists and emits a runtime `warn` on access. Will be removed + in a future major version. + ## 1.1.0 — Streaming standardization Adds stream-based `process` and `process_async` methods, aligning scanii-ruby with the diff --git a/README.md b/README.md index 7fd9ef9..062a4e7 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,10 @@ puts "findings: #{result.findings.inspect}" | `process_file(path, metadata:, callback:)` | `POST /files` | `Scanii::ProcessingResult` | | `process_async(io, filename:, content_type:, metadata:, callback:)` | `POST /files/async` | `Scanii::PendingResult` | | `process_async_file(path, metadata:, callback:)` | `POST /files/async` | `Scanii::PendingResult` | +| `process_from_url(location, callback:, metadata:)` | `POST /files` | `Scanii::ProcessingResult` (v2.2 preview) | | `fetch(url, metadata:, callback:)` | `POST /files/fetch` | `Scanii::PendingResult` | | `retrieve(id)` | `GET /files/{id}` | `Scanii::ProcessingResult` | +| `retrieve_trace(id)` | `GET /files/{id}/trace` | `Scanii::TraceResult` or `nil` (v2.2 preview) | | `ping` | `GET /ping` | `true` | | `create_auth_token(timeout_seconds)` | `POST /auth/tokens` | `Scanii::AuthToken` | | `retrieve_auth_token(id)` | `GET /auth/tokens/{id}` | `Scanii::AuthToken` | diff --git a/lib/scanii.rb b/lib/scanii.rb index 2c61492..af67b53 100644 --- a/lib/scanii.rb +++ b/lib/scanii.rb @@ -3,6 +3,8 @@ require_relative "scanii/processing_result" require_relative "scanii/pending_result" require_relative "scanii/auth_token" +require_relative "scanii/trace_event" +require_relative "scanii/trace_result" require_relative "scanii/multipart" require_relative "scanii/client" diff --git a/lib/scanii/client.rb b/lib/scanii/client.rb index 872ecb4..8c567a4 100644 --- a/lib/scanii/client.rb +++ b/lib/scanii/client.rb @@ -184,6 +184,70 @@ def retrieve(id) ProcessingResult.from_response(resp_body, headers) end + # Retrieve the processing event trace for a previously submitted scan. + # + # Returns nil when no trace exists for the given id (HTTP 404). + # + # This is a v2.2 preview surface; the API shape may shift before it is + # marked stable. + # + # @param id [String] processing id returned by process or process_file + # @see https://scanii.github.io/openapi/v22/ GET /files/{id}/trace + # @return [Scanii::TraceResult, nil] + def retrieve_trace(id) + raise ArgumentError, "id must not be empty" if id.nil? || id.empty? + + status, resp_body, headers = request("GET", "/files/#{url_encode(id)}/trace") + return nil if status == 404 + + raise_for_status(status, resp_body, headers) unless status == 200 + TraceResult.from_response(resp_body, headers) + end + + # Submit a remote URL for synchronous scanning. + # + # Sends the URL as a +location+ field in a multipart/form-data POST to + # +/files+. The Scanii server fetches and scans the URL synchronously and + # returns a ProcessingResult. This is distinct from {#fetch}, which submits + # to +/files/fetch+ for asynchronous server-side fetching. + # + # +location+ must be a String URL. This matches the existing {#fetch} + # String-URL convention and the Java reference (processFromUrl(String)). + # + # This is a v2.2 preview surface; the API shape may shift before it is + # marked stable. + # + # @param location [String] URL of the content to scan + # @param callback [String, nil] URL to POST the result to on completion + # @param metadata [Hash{String=>String}, nil] arbitrary key/value pairs attached to the result + # @see https://scanii.github.io/openapi/v22/ POST /files + # @return [Scanii::ProcessingResult] + def process_from_url(location, callback: nil, metadata: nil) + raise ArgumentError, "location must not be empty" if location.nil? || location.to_s.empty? + + fields = build_text_fields(metadata, callback) + fields["location"] = location.to_s + + boundary = Multipart.make_boundary + body = String.new(encoding: Encoding::BINARY) + fields.each do |name, value| + body << "--#{boundary}\r\n".b + body << "Content-Disposition: form-data; name=\"#{name}\"\r\n".b + body << "Content-Type: text/plain; charset=UTF-8\r\n\r\n".b + body << value.to_s.b + body << "\r\n".b + end + body << "--#{boundary}--\r\n".b + + status, resp_body, headers = post( + "/files", + body: body, + content_type: Multipart.make_content_type(boundary) + ) + raise_for_status(status, resp_body, headers) unless status == 201 + ProcessingResult.from_response(resp_body, headers) + end + # Verify that the configured credentials reach the API. # # @see https://scanii.github.io/openapi/v22/ GET /ping diff --git a/lib/scanii/processing_result.rb b/lib/scanii/processing_result.rb index cdc66b3..62c10db 100644 --- a/lib/scanii/processing_result.rb +++ b/lib/scanii/processing_result.rb @@ -9,7 +9,7 @@ module Scanii # @see https://scanii.github.io/openapi/v22/ class ProcessingResult attr_reader :id, :findings, :checksum, :content_length, :content_type, - :metadata, :creation_date, :error, + :metadata, :creation_date, :request_id, :host_id, :resource_location, :raw_response def initialize(id:, findings:, checksum:, content_length:, content_type:, @@ -22,13 +22,23 @@ def initialize(id:, findings:, checksum:, content_length:, content_type:, @content_type = content_type @metadata = metadata @creation_date = creation_date - @error = error + @_error = error @request_id = request_id @host_id = host_id @resource_location = resource_location @raw_response = raw_response end + # @deprecated The server never populates this field on successful responses; + # errors arrive as non-2xx HTTP responses that raise Scanii::Error + # subclasses. Will be removed in a future major version. + def error + warn "[DEPRECATION] `Scanii::ProcessingResult#error` is deprecated; " \ + "rescue Scanii::Error (and its subclasses) to handle server-side errors. " \ + "Will be removed in a future major version." + @_error + end + def self.from_response(body, headers) json = body.nil? || body.empty? ? {} : JSON.parse(body) diff --git a/lib/scanii/trace_event.rb b/lib/scanii/trace_event.rb new file mode 100644 index 0000000..be70f68 --- /dev/null +++ b/lib/scanii/trace_event.rb @@ -0,0 +1,20 @@ +module Scanii + # A single processing event in a {Scanii::TraceResult}. + # + # @see https://scanii.github.io/openapi/v22/ + class TraceEvent + attr_reader :timestamp, :message + + def initialize(timestamp:, message:) + @timestamp = timestamp + @message = message + end + + def self.from_hash(hash) + new( + timestamp: hash["timestamp"]&.to_s, + message: hash["message"]&.to_s + ) + end + end +end diff --git a/lib/scanii/trace_result.rb b/lib/scanii/trace_result.rb new file mode 100644 index 0000000..34fc4e4 --- /dev/null +++ b/lib/scanii/trace_result.rb @@ -0,0 +1,33 @@ +require "json" + +module Scanii + # Result of Client#retrieve_trace — ordered processing events for a scan. + # + # This is a v2.2 preview surface; the API shape may shift before it is + # marked stable. + # + # @see https://scanii.github.io/openapi/v22/ + class TraceResult + attr_reader :id, :events, :request_id, :host_id, :raw_response + + def initialize(id:, events:, request_id:, host_id:, raw_response:) + @id = id + @events = events + @request_id = request_id + @host_id = host_id + @raw_response = raw_response + end + + def self.from_response(body, headers) + json = body.nil? || body.empty? ? {} : JSON.parse(body) + + new( + id: (json["id"] || "").to_s, + events: Array(json["events"]).map { |e| TraceEvent.from_hash(e) }, + request_id: headers["x-scanii-request-id"], + host_id: headers["x-scanii-host-id"], + raw_response: body + ) + end + end +end diff --git a/lib/scanii/version.rb b/lib/scanii/version.rb index 5ed5459..0f2107c 100644 --- a/lib/scanii/version.rb +++ b/lib/scanii/version.rb @@ -1,3 +1,3 @@ module Scanii - VERSION = "1.1.0".freeze + VERSION = "1.2.0".freeze end diff --git a/test/integration/client_test.rb b/test/integration/client_test.rb index 0254057..88909b3 100644 --- a/test/integration/client_test.rb +++ b/test/integration/client_test.rb @@ -162,6 +162,36 @@ def test_process_deprecated_path_still_works_and_warns cleanup(path) end + # -- retrieve_trace (v2.2 preview) ------------------------------------- + + def test_retrieve_trace_returns_non_empty_events_for_known_id + path = temp_file(LOCAL_MALWARE_UUID) + result = @client.process_file(path) + trace = @client.retrieve_trace(result.id) + refute_nil trace, "retrieve_trace must return a TraceResult for a known id" + assert_kind_of Scanii::TraceResult, trace + refute_empty trace.events, "events array must be non-empty for a known processing id" + assert(trace.events.all?(Scanii::TraceEvent)) + ensure + cleanup(path) + end + + def test_retrieve_trace_returns_nil_for_unknown_id + result = @client.retrieve_trace("does-not-exist-trace-#{Process.pid}") + assert_nil result + end + + # -- process_from_url (v2.2 preview) ----------------------------------- + + def test_process_from_url_returns_result_with_eicar_finding + url = "#{self.class.endpoint}/static/eicar.txt" + result = @client.process_from_url(url) + refute_nil result, "process_from_url must return a ProcessingResult" + assert_kind_of Scanii::ProcessingResult, result + assert_includes result.findings, "content.malicious.eicar-test-signature", + "expected EICAR finding; got: #{result.findings.inspect}" + end + # -- fetch -------------------------------------------------------------- def test_fetch_returns_pending_result