From 32680a4046c117fd8d3cbb9baa67fd157c1fd29c Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Mon, 22 Jun 2026 11:01:25 +0200 Subject: [PATCH 1/6] Add plan for OpenAPI doc endpoints Co-authored-by: AI (copilot/full) --- doc/specs/openapi-doc-endpoints.md | 460 +++++++++++++++++++++++++++++ 1 file changed, 460 insertions(+) create mode 100644 doc/specs/openapi-doc-endpoints.md diff --git a/doc/specs/openapi-doc-endpoints.md b/doc/specs/openapi-doc-endpoints.md new file mode 100644 index 0000000..d386f84 --- /dev/null +++ b/doc/specs/openapi-doc-endpoints.md @@ -0,0 +1,460 @@ +# OpenAPI 3 Documentation Endpoints + +**Date**: June 22, 2026 +**Objective**: Add dynamically generated OpenAPI 3 documentation endpoints serving YAML and HTML (Swagger UI) as an optional, toggleable feature. + +## Overview + +Implement code-first OpenAPI specification generation using `utoipa` 5.x behind a Cargo feature flag. When enabled, the service will expose three documentation endpoints: + +- `GET /doc` → **Swagger UI** (interactive HTML documentation) +- `GET /doc/openapi.yml` → **OpenAPI 3.0 YAML spec** (machine-readable) +- `GET /doc/openapi.json` → **OpenAPI 3.0 JSON spec** (internal; Swagger UI consumes this) + +All assets are embedded in the binary and served locally — no CDN or external dependencies. + +### Feature Flag + +- **Name**: `api-docs` +- **Default**: Enabled +- **Dependencies**: `utoipa` 5.x (with `axum_extras` feature), `utoipa-swagger-ui` 9.x (with `axum` feature) +- **Usage**: + - Default build (docs enabled): `cargo build` + - Production build (docs disabled): `cargo build --release --no-default-features` + +## Technical Stack + +- **`utoipa` 5.x** — Code-first OpenAPI annotation framework for Rust + - Feature: `axum_extras` — enables automatic `Path` and `Query` parameter extraction + - Optional dependency; only compiled when `api-docs` feature is enabled +- **`utoipa-swagger-ui` 9.x** — Swagger UI Axum integration + - Feature: `axum` — Axum route integration + - Embeds Swagger UI assets directly into the binary + - Supports OpenAPI 3.0 + - Optional dependency; only compiled when `api-docs` feature is enabled +- **`serde_yaml`** — Already in dependencies; used to serialize the OpenAPI spec to YAML + +## Implementation Plan + +### Phase 1: Add Feature Flag and Optional Dependencies + +**File**: `Cargo.toml` + +Add to `[features]`: + +```toml +[features] +default = ["api-docs"] +api-docs = ["utoipa", "utoipa-swagger-ui"] +``` + +Add to `[dependencies]`: + +```toml +utoipa = { version = "5", features = ["axum_extras"], optional = true } +utoipa-swagger-ui = { version = "9", features = ["axum"], optional = true } +``` + +### Phase 2: Add `ToSchema` to DTO Types + +**Files**: +- `src/api/dto/scans.rs` +- `src/scan/status.rs` + +Add `#[cfg_attr(feature = "api-docs", derive(utoipa::ToSchema))]` to all public DTO types: + +**In `src/api/dto/scans.rs`:** +- `ScanRequest` +- `ScanActionRequest` +- `ScanAction` +- `ScanDetailResponse` +- `ScanStatusResponse` +- `ScanResultResponse` +- `PreferencesResponse` +- `Target` +- `Credential` +- `UsernamePasswordCredential` +- `ScannerPreference` +- `Vt` +- `Parameter` +- `ResultType` +- `HostInfo` + +**In `src/scan/status.rs`:** +- `ScanStatus` + +**Example:** + +```rust +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "api-docs", derive(utoipa::ToSchema))] +pub struct ScanRequest { + // ... +} +``` + +### Phase 3: Add Path Annotations to Handlers + +**Files**: +- `src/api/health.rs` +- `src/api/scans.rs` + +Add `#[cfg_attr(feature = "api-docs", utoipa::path(...))]` macro to all handler functions. Each annotation specifies: +- HTTP method (`get`, `post`, `delete`, `head`) +- Path pattern (`/health`, `/scans`, `/scans/{id}`, etc.) +- Path parameters (if any) +- Query parameters (with `Query` struct reference) +- Request body (with `request_body` clause) +- Response codes and schemas + +**Example structure:** + +```rust +#[cfg_attr( + feature = "api-docs", + utoipa::path( + post, + path = "/scans", + request_body = ScanRequest, + responses( + (status = 201, description = "Scan created", body = String), + (status = 400, description = "Bad Request"), + (status = 403, description = "Scan ID already in use"), + ), + tag = "scan" + ) +)] +pub async fn create_scan(/* ... */) { + // ... +} +``` + +**Handlers to annotate:** + +Health endpoints (4): +- `head_health` +- `get_health_alive` +- `get_health_ready` +- `get_health_started` + +Scan endpoints (10): +- `head_scans` +- `create_scan` +- `get_scan_preferences` +- `get_scan` +- `scan_action` +- `delete_scan` +- `get_scan_results` (with `Query`) +- `get_scan_result` (with `Path<{id, rid}>`) +- `get_scan_status` + +### Phase 4: Create OpenAPI Definition Module + +**File**: `src/api/openapi.rs` (new, only compiled when `api-docs` feature is enabled) + +Define the OpenAPI document aggregator and YAML handler: + +```rust +//! OpenAPI 3 documentation specification and handlers. +//! +//! This module is only compiled when the `api-docs` feature is enabled. + +use axum::{ + http::{StatusCode, header}, + response::IntoResponse, +}; +use utoipa::OpenApi; + +use crate::{api, scan::status::ScanStatus}; + +/// OpenAPI 3.0 specification for the Greenbone Web Application Scanner API. +#[derive(OpenApi)] +#[openapi( + info( + title = "Web Application Scanner", + description = "A wrapper for the Zed Attack Proxy (ZAP) web application scanner", + contact(name = "Greenbone AG", url = "https://www.greenbone.net/"), + license(name = "AGPL-3.0-or-later", url = "https://spdx.org/licenses/AGPL-3.0-or-later.html"), + version = "0.1", + ), + paths( + // Health endpoints + api::health::head_health, + api::health::get_health_alive, + api::health::get_health_ready, + api::health::get_health_started, + // Scan endpoints + api::scans::head_scans, + api::scans::create_scan, + api::scans::get_scan_preferences, + api::scans::get_scan, + api::scans::scan_action, + api::scans::delete_scan, + api::scans::get_scan_results, + api::scans::get_scan_result, + api::scans::get_scan_status, + ), + components(schemas( + // DTO types + api::dto::scans::ScanRequest, + api::dto::scans::ScanActionRequest, + api::dto::scans::ScanAction, + api::dto::scans::ScanDetailResponse, + api::dto::scans::ScanStatusResponse, + api::dto::scans::ScanResultResponse, + api::dto::scans::PreferencesResponse, + api::dto::scans::Target, + api::dto::scans::Credential, + api::dto::scans::UsernamePasswordCredential, + api::dto::scans::ScannerPreference, + api::dto::scans::Vt, + api::dto::scans::Parameter, + api::dto::scans::ResultType, + api::dto::scans::HostInfo, + // Status + ScanStatus, + )), +)] +pub struct ApiDoc; + +/// Serve the OpenAPI specification as YAML. +/// +/// Returns the complete OpenAPI 3.0 specification in YAML format. +pub async fn get_openapi_yaml() -> impl IntoResponse { + let openapi = ApiDoc::openapi(); + let yaml = serde_yaml::to_string(&openapi) + .unwrap_or_else(|_| "error: failed to serialize OpenAPI spec".to_string()); + + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/yaml; charset=utf-8")], + yaml, + ) +} +``` + +### Phase 5: Declare the New Module + +**File**: `src/api/mod.rs` + +Add: + +```rust +#[cfg(feature = "api-docs")] +pub mod openapi; +``` + +### Phase 6: Update Router + +**File**: `src/http/router.rs` + +Import and conditionally wire up Swagger UI and the YAML endpoint: + +```rust +#[cfg(feature = "api-docs")] +use utoipa_swagger_ui::SwaggerUi; + +pub fn build_router(state: AppState) -> Router { + // ... existing routes ... + + let mut router = Router::new() + .nest(&API_BASE_PATH, public_routes) + .nest(&API_BASE_PATH, private_routes) + .layer(TraceLayer::new_for_http()) + .with_state(state); + + #[cfg(feature = "api-docs")] + { + use crate::api::openapi::ApiDoc; + let swagger_ui = SwaggerUi::new("/doc") + .url("/doc/openapi.json", ApiDoc::openapi()); + router = router + .route("/doc/openapi.yml", get(api::openapi::get_openapi_yaml)) + .merge(swagger_ui); + } + + router +} +``` + +### Phase 7: Add Tests + +**File**: `src/api/openapi_tests.rs` (new sidecar, only compiled when `api-docs` feature is enabled) + +Add to top of file: + +```rust +#![cfg(feature = "api-docs")] +``` + +Test that the documentation endpoints are accessible and return valid content: + +```rust +//! Tests for OpenAPI documentation endpoints. + +#![cfg(feature = "api-docs")] + +use std::sync::Arc; + +use crate::{ + app::AppState, + http::router::build_router, + scan::DefaultScanService, + storage::test_support::temporary_sqlite_storage, +}; + +#[tokio::test] +async fn swagger_ui_endpoint_returns_html() { + let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); + let scan_service = Arc::new(DefaultScanService::new_storage_only(storage.clone())); + let state = AppState::new(storage, scan_service); + + let router = build_router(state); + let response = router + .oneshot( + axum::http::Request::builder() + .uri("/doc") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), 200); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("swagger-ui"), "Response should contain Swagger UI HTML"); +} + +#[tokio::test] +async fn openapi_yaml_endpoint_returns_valid_yaml() { + let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); + let scan_service = Arc::new(DefaultScanService::new_storage_only(storage.clone())); + let state = AppState::new(storage, scan_service); + + let router = build_router(state); + let response = router + .oneshot( + axum::http::Request::builder() + .uri("/doc/openapi.yml") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), 200); + let content_type = response + .headers() + .get("content-type") + .unwrap() + .to_str() + .unwrap(); + assert!( + content_type.starts_with("application/yaml"), + "Content-Type should be application/yaml" + ); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let yaml = String::from_utf8(body.to_vec()).unwrap(); + assert!( + yaml.contains("openapi: '3"), + "YAML should contain OpenAPI 3.0 version" + ); + assert!( + yaml.contains("Web Application Scanner"), + "YAML should contain API title" + ); +} + +#[tokio::test] +async fn openapi_json_endpoint_returns_valid_json() { + let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); + let scan_service = Arc::new(DefaultScanService::new_storage_only(storage.clone())); + let state = AppState::new(storage, scan_service); + + let router = build_router(state); + let response = router + .oneshot( + axum::http::Request::builder() + .uri("/doc/openapi.json") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), 200); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json_str = String::from_utf8(body.to_vec()).unwrap(); + let json: serde_json::Value = serde_json::from_str(&json_str) + .expect("Response should be valid JSON"); + + assert_eq!(json["openapi"], "3.0.3", "Should be OpenAPI 3.0.3"); + assert_eq!( + json["info"]["title"], + "Web Application Scanner", + "Should have correct API title" + ); +} +``` + +## Build Variants + +### Default Build (with API Docs) +```bash +cargo build +cargo run +# Endpoints available: +# GET /doc → Swagger UI +# GET /doc/openapi.yml → YAML spec +# GET /doc/openapi.json → JSON spec +``` + +### Production Build (without API Docs) +```bash +cargo build --release --no-default-features +# Documentation endpoints NOT available +# Smaller binary size, no utoipa/utoipa-swagger-ui code included +``` + +### Test All Variants +```bash +# Test with feature enabled (default) +cargo test + +# Test without feature +cargo test --no-default-features +``` + +## Verification Checklist + +- [ ] **Feature flag**: `[features]` section in `Cargo.toml` defines `api-docs` with default enabled +- [ ] **Optional deps**: `utoipa` and `utoipa-swagger-ui` marked as `optional = true` and linked in feature +- [ ] **Build**: `cargo build` compiles without errors (with `api-docs` feature) +- [ ] **No-feature build**: `cargo build --no-default-features` compiles without errors +- [ ] **Tests**: `cargo test` passes (tests conditional on `#[cfg(feature = "api-docs")]`) +- [ ] **Feature-gated module**: `src/api/openapi.rs` only compiled with feature +- [ ] **Feature-gated handlers**: `#[cfg_attr(...)]` used on all `ToSchema`, `#[utoipa::path(...)]` annotations +- [ ] **Feature-gated router**: SwaggerUi wiring only executed with feature enabled +- [ ] **Swagger UI**: `GET /doc` returns HTML with Swagger UI interface +- [ ] **YAML Spec**: `GET /doc/openapi.yml` returns valid YAML starting with `openapi: '3` +- [ ] **JSON Spec**: `GET /doc/openapi.json` returns valid JSON OpenAPI document +- [ ] **Completeness**: Generated spec includes all 14 handlers and all DTO schemas +- [ ] **Consistency**: Spot-check: generated paths and schemas align with [openapi-reference.yml](openapi-reference.yml) +- [ ] **Local Assets**: No network requests made during Swagger UI initialization + +## Notes + +- The `doc/openapi-reference.yml` file is kept as-is, serving as a reference/guideline for spec consistency +- All Swagger UI assets are embedded in the binary; zero CDN/external dependencies (when feature is enabled) +- The generated OpenAPI spec is derived from actual code annotations, ensuring it stays synchronized with implementation +- Health endpoints are included in the public API documentation (they are part of the spec) +- The feature flag allows the API docs feature to be completely excluded from production builds for security or size concerns +- When the `api-docs` feature is disabled, all documentation-related code is not compiled, eliminating the dependency entirely From cfeec2f13a3906c47eade172369a2c2df87c84ba Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Mon, 22 Jun 2026 13:55:32 +0200 Subject: [PATCH 2/6] Add `api-docs` feature flag and dependencies Co-authored-by: AI (copilot/full) --- Cargo.lock | 215 +++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 6 ++ README.md | 25 +++++++ 3 files changed, 246 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 5752a30..5f6718f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -23,6 +29,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -304,6 +319,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -371,6 +395,17 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -496,6 +531,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "flume" version = "0.12.0" @@ -706,6 +751,8 @@ dependencies = [ "tracing", "tracing-subscriber", "tracing-test", + "utoipa", + "utoipa-swagger-ui", "uuid", "wiremock", ] @@ -1176,6 +1223,26 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.0" @@ -1590,6 +1657,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2 0.10.9", + "walkdir", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -1666,6 +1767,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1861,6 +1971,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -2456,6 +2572,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -2525,6 +2647,48 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "9.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" +dependencies = [ + "axum", + "base64", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "url", + "utoipa", + "zip", +] + [[package]] name = "uuid" version = "1.23.3" @@ -2554,6 +2718,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2711,6 +2885,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -3119,8 +3302,40 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977347db8caa080403f6b6b7c1cda9479a8e869316f7e13a59b19076a40f94e3" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index 05a5292..4791cec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,10 @@ description = "Greenbone web application scanner" publish = false default-run = "greenbone-was" +[features] +default = ["api-docs"] +api-docs = ["utoipa", "utoipa-swagger-ui"] + [[bin]] name = "greenbone-was" path = "src/main.rs" @@ -35,6 +39,8 @@ tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["default", "env-filter", "fmt", "json"] } uuid = { version = "1", features = ["v4"] } regex = "1.12.3" +utoipa = { version = "5", features = ["axum_extras"], optional = true } +utoipa-swagger-ui = { version = "9", features = ["axum"], optional = true } [dev-dependencies] serial_test = "3.5.0" diff --git a/README.md b/README.md index a49145d..c0e3394 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,24 @@ make DESTDIR=path/to/install install The binary can be found at `path/to/install/usr/local/bin` afterwards. +### Build Features + +The project supports the following build features: + +#### `api-docs` (enabled by default) + +Enables dynamically generated OpenAPI 3.0 documentation endpoints served as Swagger UI and YAML/JSON specs. + +**Default build (with docs enabled):** +```sh +cargo build +``` + +**Production build (docs disabled):** +```sh +cargo build --release --no-default-features +``` + ## Configuration WAS reads configuration from environment variables prefixed with @@ -53,6 +71,13 @@ URL `sqlite:/var/lib/greenbone-was/scans.db`. Set `GREENBONE_WAS_SQLITE_URL` when a fully explicit file-backed SQLite URL is required. +## API documentation + +Unless the default `api-docs` flag is disabled, the Web Application Scanner will serve API documentation at the following endpoints: + +- `GET /doc` — Interactive Swagger UI documentation +- `GET /doc/openapi.yml` — OpenAPI 3.0 YAML specification +- `GET /doc/openapi.json` — OpenAPI 3.0 JSON specification ## Maintainer From baed779cc58bcbce11c2af672a4c9dd5ce21cd2d Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Mon, 22 Jun 2026 14:17:13 +0200 Subject: [PATCH 3/6] Add annotations for API doc Co-authored-by: AI (copilot/full) --- src/api/dto/scans.rs | 15 +++++ src/api/health.rs | 41 +++++++++++++ src/api/scans.rs | 141 +++++++++++++++++++++++++++++++++++++++++++ src/scan/status.rs | 1 + 4 files changed, 198 insertions(+) diff --git a/src/api/dto/scans.rs b/src/api/dto/scans.rs index 8e0808d..ececbc8 100644 --- a/src/api/dto/scans.rs +++ b/src/api/dto/scans.rs @@ -11,6 +11,7 @@ use crate::scan::ScanStatus; /// Request body for POST /scans — Create a new scan. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "api-docs", derive(utoipa::ToSchema))] pub struct ScanRequest { /// Optional scan ID. If not provided, a random UUID will be generated. #[serde(skip_serializing_if = "Option::is_none")] @@ -26,6 +27,7 @@ pub struct ScanRequest { /// Request body for POST /scans/{id} — Perform an action on a scan. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "api-docs", derive(utoipa::ToSchema))] pub struct ScanActionRequest { /// The action to perform (Start or Stop). pub action: ScanAction, @@ -33,6 +35,7 @@ pub struct ScanActionRequest { /// Scan action type. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "api-docs", derive(utoipa::ToSchema))] #[serde(rename_all = "lowercase")] pub enum ScanAction { /// Start the scan. @@ -43,6 +46,7 @@ pub enum ScanAction { /// Scan target specification. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "api-docs", derive(utoipa::ToSchema))] pub struct Target { /// Target hosts to scan. pub hosts: Vec, @@ -56,6 +60,7 @@ pub struct Target { /// Authentication credential for a service. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "api-docs", derive(utoipa::ToSchema))] pub struct Credential { /// Service type (must be "http" for web app scans). pub service: String, @@ -67,6 +72,7 @@ pub struct Credential { /// Username and password credential pair. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "api-docs", derive(utoipa::ToSchema))] pub struct UsernamePasswordCredential { /// Username for authentication. pub username: String, @@ -80,6 +86,7 @@ pub struct UsernamePasswordCredential { /// Scanner preference. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "api-docs", derive(utoipa::ToSchema))] pub struct ScannerPreference { /// Preference identifier. pub id: String, @@ -89,6 +96,7 @@ pub struct ScannerPreference { /// VT parameter. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "api-docs", derive(utoipa::ToSchema))] pub struct Parameter { /// Parameter identifier. pub id: i32, @@ -98,6 +106,7 @@ pub struct Parameter { /// Vulnerability test (VT) specification. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "api-docs", derive(utoipa::ToSchema))] pub struct Vt { /// VT OID (Object Identifier). pub oid: String, @@ -108,6 +117,7 @@ pub struct Vt { /// Type of a scan result, matching the OpenAPI Result.type enum. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "api-docs", derive(utoipa::ToSchema))] #[serde(rename_all = "snake_case")] pub enum ResultType { Alarm, @@ -120,10 +130,12 @@ pub enum ResultType { /// Response body for GET /scans/preferences – available scanner preferences. #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[cfg_attr(feature = "api-docs", derive(utoipa::ToSchema))] pub struct PreferencesResponse {} /// Response body for GET /scans/{id} – full scan details. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "api-docs", derive(utoipa::ToSchema))] pub struct ScanDetailResponse { /// The scan UUID. pub scan_id: String, @@ -137,6 +149,7 @@ pub struct ScanDetailResponse { /// Response body for GET /scans/{id}/status – scan lifecycle and timing. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "api-docs", derive(utoipa::ToSchema))] pub struct ScanStatusResponse { /// Current scan status. pub status: ScanStatus, @@ -153,6 +166,7 @@ pub struct ScanStatusResponse { /// Host-level progress summary exposed in the scan status response. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "api-docs", derive(utoipa::ToSchema))] pub struct HostInfo { /// Total number of hosts in the scan target scope. pub all: i32, @@ -174,6 +188,7 @@ pub struct HostInfo { /// /// Individual result record with optional fields omitted from JSON serialization. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "api-docs", derive(utoipa::ToSchema))] pub struct ScanResultResponse { /// 0-based result index within the scan. pub id: i64, diff --git a/src/api/health.rs b/src/api/health.rs index 151ca74..61ecf31 100644 --- a/src/api/health.rs +++ b/src/api/health.rs @@ -15,6 +15,20 @@ use axum::{ use crate::api; /// HEAD /health — Return API version and authentication info as headers. +#[cfg_attr( + feature = "api-docs", + utoipa::path( + head, + path = "/health", + tag = "health", + responses( + (status = 200, description = "Health endpoint metadata", headers( + ("api-version" = String, description = "API version"), + ("authentication" = String, description = "Authentication mode") + )) + ) + ) +)] pub async fn head_health() -> impl IntoResponse { let mut headers = HeaderMap::new(); headers.insert( @@ -30,16 +44,43 @@ pub async fn head_health() -> impl IntoResponse { } /// GET /health/alive — Check if the server is alive. +#[cfg_attr( + feature = "api-docs", + utoipa::path( + get, + path = "/health/alive", + tag = "health", + responses((status = 200, description = "Server is alive")) + ) +)] pub async fn get_health_alive() -> StatusCode { StatusCode::OK } /// GET /health/ready — Check if the server is ready to serve traffic. +#[cfg_attr( + feature = "api-docs", + utoipa::path( + get, + path = "/health/ready", + tag = "health", + responses((status = 200, description = "Server is ready")) + ) +)] pub async fn get_health_ready() -> StatusCode { StatusCode::OK } /// GET /health/started — Check if the server has started up. +#[cfg_attr( + feature = "api-docs", + utoipa::path( + get, + path = "/health/started", + tag = "health", + responses((status = 200, description = "Server has started")) + ) +)] pub async fn get_health_started() -> StatusCode { StatusCode::OK } diff --git a/src/api/scans.rs b/src/api/scans.rs index b59986a..2f46595 100644 --- a/src/api/scans.rs +++ b/src/api/scans.rs @@ -21,6 +21,9 @@ use crate::{ storage::interface::{StorageError, parse_range}, }; +#[cfg(feature = "api-docs")] +use crate::api::dto::scans::PreferencesResponse; + /// Query parameters for the GET `/scans/{id}/results` endpoint. #[derive(Debug, Deserialize)] pub struct ResultRangeQuery { @@ -80,6 +83,20 @@ fn result_response(r: crate::scan::ScanResult) -> ScanResultResponse { // ─── Handlers ──────────────────────────────────────────────────────────────── /// HEAD /scans — Return API and authentication metadata as headers. +#[cfg_attr( + feature = "api-docs", + utoipa::path( + head, + path = "/scans", + tag = "scan", + responses( + (status = 204, description = "Scan endpoint metadata", headers( + ("api-version" = String, description = "API version"), + ("authentication" = String, description = "Authentication mode") + )) + ) + ) +)] pub async fn head_scans() -> impl IntoResponse { let mut headers = HeaderMap::new(); headers.insert( @@ -96,6 +113,22 @@ pub async fn head_scans() -> impl IntoResponse { /// POST /scans — Create a new scan and return its UUID. /// /// Returns 201 Created with the generated or provided scan ID if successful. +#[cfg_attr( + feature = "api-docs", + utoipa::path( + post, + path = "/scans", + tag = "scan", + request_body = ScanRequest, + responses( + (status = 201, description = "Scan created", body = String, content_type = "application/json"), + (status = 400, description = "Bad request"), + (status = 403, description = "Scan already exists"), + (status = 406, description = "Invalid scan state"), + (status = 500, description = "Internal server error") + ) + ) +)] pub async fn create_scan( State(state): State, Json(req): Json, @@ -114,6 +147,18 @@ pub async fn create_scan( } /// GET /scans/preferences — Retrieve available scan preferences. +#[cfg_attr( + feature = "api-docs", + utoipa::path( + get, + path = "/scans/preferences", + tag = "scan", + responses( + (status = 200, description = "Available scan preferences", body = PreferencesResponse), + (status = 500, description = "Internal server error") + ) + ) +)] pub async fn get_scan_preferences(State(state): State) -> impl IntoResponse { match state.scan_service.get_default_preferences().await { Ok(preferences) => Json(preferences).into_response(), @@ -125,6 +170,20 @@ pub async fn get_scan_preferences(State(state): State) -> impl IntoRes /// /// Returns the target, scan preferences, and VTs for the requested scan. /// Returns 404 if the scan does not exist. +#[cfg_attr( + feature = "api-docs", + utoipa::path( + get, + path = "/scans/{id}", + tag = "scan", + params(("id" = String, Path, description = "Scan ID")), + responses( + (status = 200, description = "Scan details", body = ScanDetailResponse), + (status = 404, description = "Scan not found"), + (status = 500, description = "Internal server error") + ) + ) +)] pub async fn get_scan(State(state): State, Path(id): Path) -> impl IntoResponse { match state.scan_service.get_scan(&id).await { Ok(scan) => Json(ScanDetailResponse { @@ -141,6 +200,23 @@ pub async fn get_scan(State(state): State, Path(id): Path) -> /// POST /scans/{id} — Perform an action on a scan (start or stop). /// /// Enforces non-idempotent start and stop transitions. Returns 406 if invalid. +#[cfg_attr( + feature = "api-docs", + utoipa::path( + post, + path = "/scans/{id}", + tag = "scan", + params(("id" = String, Path, description = "Scan ID")), + request_body = ScanActionRequest, + responses( + (status = 204, description = "Scan action accepted"), + (status = 400, description = "Bad request"), + (status = 404, description = "Scan not found"), + (status = 406, description = "Invalid scan transition"), + (status = 500, description = "Internal server error") + ) + ) +)] pub async fn scan_action( State(state): State, Path(id): Path, @@ -160,6 +236,21 @@ pub async fn scan_action( /// DELETE /scans/{id} — Delete a scan and all its results. /// /// Returns 406 if the scan is not in `new` or a terminal status. +#[cfg_attr( + feature = "api-docs", + utoipa::path( + delete, + path = "/scans/{id}", + tag = "scan", + params(("id" = String, Path, description = "Scan ID")), + responses( + (status = 204, description = "Scan deleted"), + (status = 404, description = "Scan not found"), + (status = 406, description = "Scan cannot be deleted in the current state"), + (status = 500, description = "Internal server error") + ) + ) +)] pub async fn delete_scan( State(state): State, Path(id): Path, @@ -174,6 +265,24 @@ pub async fn delete_scan( /// /// Query parameter `range` accepts `N` (all from N onward) or `N-M` (inclusive range). /// Defaults to all results if not specified. +#[cfg_attr( + feature = "api-docs", + utoipa::path( + get, + path = "/scans/{id}/results", + tag = "scan", + params( + ("id" = String, Path, description = "Scan ID"), + ("range" = Option, Query, description = "Optional result range such as 5 or 0-10") + ), + responses( + (status = 200, description = "Scan results", body = [ScanResultResponse]), + (status = 400, description = "Bad range specification"), + (status = 404, description = "Scan not found"), + (status = 500, description = "Internal server error") + ) + ) +)] pub async fn get_scan_results( State(state): State, Path(id): Path, @@ -198,6 +307,24 @@ pub async fn get_scan_results( /// GET /scans/{id}/results/{rid} — Retrieve a single scan result by index. /// /// The `{rid}` parameter is a 0-based result index. Returns 404 if not found. +#[cfg_attr( + feature = "api-docs", + utoipa::path( + get, + path = "/scans/{id}/results/{rid}", + tag = "scan", + params( + ("id" = String, Path, description = "Scan ID"), + ("rid" = i64, Path, description = "Result index") + ), + responses( + (status = 200, description = "Scan result", body = ScanResultResponse), + (status = 400, description = "Invalid result index"), + (status = 404, description = "Scan result not found"), + (status = 500, description = "Internal server error") + ) + ) +)] pub async fn get_scan_result( State(state): State, Path((id, rid)): Path<(String, String)>, @@ -216,6 +343,20 @@ pub async fn get_scan_result( /// GET /scans/{id}/status — Retrieve the current status and timestamps of a scan. /// /// Returns the status, start time, and end time of the scan. +#[cfg_attr( + feature = "api-docs", + utoipa::path( + get, + path = "/scans/{id}/status", + tag = "scan", + params(("id" = String, Path, description = "Scan ID")), + responses( + (status = 200, description = "Scan status", body = ScanStatusResponse), + (status = 404, description = "Scan not found"), + (status = 500, description = "Internal server error") + ) + ) +)] pub async fn get_scan_status( State(state): State, Path(id): Path, diff --git a/src/scan/status.rs b/src/scan/status.rs index 02e5d6a..3e4502c 100644 --- a/src/scan/status.rs +++ b/src/scan/status.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; /// Lifecycle phase of a scan. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "api-docs", derive(utoipa::ToSchema))] #[serde(rename_all = "snake_case")] pub enum ScanStatus { Stored, From 540a550889fddb64fb250c9c773522385a7169d5 Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Mon, 22 Jun 2026 14:33:41 +0200 Subject: [PATCH 4/6] Add API doc endpoints to router Co-authored-by: AI (copilot/full) --- src/api/mod.rs | 3 ++ src/api/openapi.rs | 80 ++++++++++++++++++++++++++++++++++++++++++++++ src/http/router.rs | 20 ++++++++++-- 3 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 src/api/openapi.rs diff --git a/src/api/mod.rs b/src/api/mod.rs index ea747b2..0448306 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -7,3 +7,6 @@ pub const API_VERSION: &str = "v1"; pub mod dto; pub mod health; pub mod scans; + +#[cfg(feature = "api-docs")] +pub mod openapi; diff --git a/src/api/openapi.rs b/src/api/openapi.rs new file mode 100644 index 0000000..02ee560 --- /dev/null +++ b/src/api/openapi.rs @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: 2026 Greenbone AG +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! OpenAPI 3 documentation specification and handlers. +//! +//! This module is only compiled when the `api-docs` feature is enabled. + +use axum::{ + http::{StatusCode, header}, + response::IntoResponse, +}; +use utoipa::OpenApi; + +use crate::{api, scan::status::ScanStatus}; + +/// OpenAPI 3.0 specification for the Greenbone Web Application Scanner API. +#[derive(OpenApi)] +#[openapi( + info( + title = "Web Application Scanner", + description = "A wrapper for the Zed Attack Proxy (ZAP) web application scanner", + contact(name = "Greenbone AG", url = "https://www.greenbone.net/"), + license(name = "AGPL-3.0-or-later", url = "https://spdx.org/licenses/AGPL-3.0-or-later.html"), + version = "0.1", + ), + paths( + // Health endpoints + api::health::head_health, + api::health::get_health_alive, + api::health::get_health_ready, + api::health::get_health_started, + // Scan endpoints + api::scans::head_scans, + api::scans::create_scan, + api::scans::get_scan_preferences, + api::scans::get_scan, + api::scans::scan_action, + api::scans::delete_scan, + api::scans::get_scan_results, + api::scans::get_scan_result, + api::scans::get_scan_status, + ), + components(schemas( + // DTO types + api::dto::scans::ScanRequest, + api::dto::scans::ScanActionRequest, + api::dto::scans::ScanAction, + api::dto::scans::ScanDetailResponse, + api::dto::scans::ScanStatusResponse, + api::dto::scans::ScanResultResponse, + api::dto::scans::PreferencesResponse, + api::dto::scans::Target, + api::dto::scans::Credential, + api::dto::scans::UsernamePasswordCredential, + api::dto::scans::ScannerPreference, + api::dto::scans::Vt, + api::dto::scans::Parameter, + api::dto::scans::ResultType, + api::dto::scans::HostInfo, + // Status + ScanStatus, + )), +)] +pub struct ApiDoc; + +/// Serve the OpenAPI specification as YAML. +/// +/// Returns the complete OpenAPI 3.0 specification in YAML format. +pub async fn get_openapi_yaml() -> impl IntoResponse { + let openapi = ApiDoc::openapi(); + let yaml = serde_yaml::to_string(&openapi) + .unwrap_or_else(|_| "error: failed to serialize OpenAPI spec".to_string()); + + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/yaml; charset=utf-8")], + yaml, + ) +} diff --git a/src/http/router.rs b/src/http/router.rs index 74015d6..a844a0f 100644 --- a/src/http/router.rs +++ b/src/http/router.rs @@ -10,6 +10,9 @@ use axum::{ }; use tower_http::trace::TraceLayer; +#[cfg(feature = "api-docs")] +use {utoipa::OpenApi, utoipa_swagger_ui::SwaggerUi}; + use crate::{api, app::AppState}; /// Base path for all API endpoints including the API version. @@ -47,11 +50,24 @@ pub fn build_router(state: AppState) -> Router { ) .route("/scans/{id}/status", get(api::scans::get_scan_status)); - Router::new() + #[allow(unused_mut)] + let mut router = Router::new() .nest(&API_BASE_PATH, public_routes) .nest(&API_BASE_PATH, private_routes) .layer(TraceLayer::new_for_http()) - .with_state(state) + .with_state(state); + + #[cfg(feature = "api-docs")] + { + use crate::api::openapi::ApiDoc; + let swagger_ui = SwaggerUi::new("/doc") + .url("/doc/openapi.json", ApiDoc::openapi()); + router = router + .route("/doc/openapi.yml", get(api::openapi::get_openapi_yaml)) + .merge(swagger_ui); + } + + router } #[cfg(test)] From 50696554e4ed08a062f33d7416b3f2f1193554ea Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Mon, 22 Jun 2026 14:50:13 +0200 Subject: [PATCH 5/6] Add tests for OpenAPI doc Co-authored-by: AI (copilot/full) --- src/api/openapi.rs | 4 ++ src/api/openapi_tests.rs | 141 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 src/api/openapi_tests.rs diff --git a/src/api/openapi.rs b/src/api/openapi.rs index 02ee560..e856c93 100644 --- a/src/api/openapi.rs +++ b/src/api/openapi.rs @@ -78,3 +78,7 @@ pub async fn get_openapi_yaml() -> impl IntoResponse { yaml, ) } + +#[cfg(test)] +#[path = "openapi_tests.rs"] +mod openapi_tests; diff --git a/src/api/openapi_tests.rs b/src/api/openapi_tests.rs new file mode 100644 index 0000000..71df269 --- /dev/null +++ b/src/api/openapi_tests.rs @@ -0,0 +1,141 @@ +//! Tests for OpenAPI documentation endpoints. + +#![cfg(feature = "api-docs")] + +use std::sync::Arc; + +use axum::{body::to_bytes, response::IntoResponse}; + +use crate::{ + app::AppState, + http::router::build_router, + scan::DefaultScanService, + storage::test_support::temporary_sqlite_storage, +}; + +#[tokio::test] +async fn router_builds_successfully_with_openapi_endpoints() { + let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); + let scan_service = Arc::new(DefaultScanService::new_storage_only(storage.clone())); + let state = AppState::new(storage, scan_service); + + // Just verify the router builds without panicking + let _router = build_router(state); +} + +#[tokio::test] +async fn openapi_yaml_handler_returns_valid_yaml() { + use crate::api::openapi::get_openapi_yaml; + + let response = get_openapi_yaml().await.into_response(); + assert_eq!(response.status(), 200); + + let body = to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let yaml = String::from_utf8(body.to_vec()).unwrap(); + assert!( + yaml.contains("openapi:"), + "YAML should contain openapi version" + ); + assert!( + yaml.contains("Web Application Scanner"), + "YAML should contain API title" + ); + assert!( + yaml.contains("Greenbone AG"), + "YAML should contain contact information" + ); +} + +#[tokio::test] +async fn openapi_spec_includes_all_health_endpoints() { + use crate::api::openapi::ApiDoc; + use utoipa::OpenApi; + + let openapi = ApiDoc::openapi(); + let spec_json = serde_json::to_value(&openapi).unwrap(); + + // Verify all health endpoints are documented + let paths = &spec_json["paths"]; + assert!( + paths["/health"].is_object(), + "Spec should document /health endpoint" + ); + assert!( + paths["/health/alive"].is_object(), + "Spec should document /health/alive endpoint" + ); + assert!( + paths["/health/ready"].is_object(), + "Spec should document /health/ready endpoint" + ); + assert!( + paths["/health/started"].is_object(), + "Spec should document /health/started endpoint" + ); +} + +#[tokio::test] +async fn openapi_spec_includes_all_scan_endpoints() { + use crate::api::openapi::ApiDoc; + use utoipa::OpenApi; + + let openapi = ApiDoc::openapi(); + let spec_json = serde_json::to_value(&openapi).unwrap(); + + // Verify all scan endpoints are documented + let paths = &spec_json["paths"]; + assert!( + paths["/scans"].is_object(), + "Spec should document /scans endpoint" + ); + assert!( + paths["/scans/preferences"].is_object(), + "Spec should document /scans/preferences endpoint" + ); + assert!( + paths["/scans/{id}"].is_object(), + "Spec should document /scans/{{id}} endpoint" + ); + assert!( + paths["/scans/{id}/results"].is_object(), + "Spec should document /scans/{{id}}/results endpoint" + ); + assert!( + paths["/scans/{id}/results/{rid}"].is_object(), + "Spec should document /scans/{{id}}/results/{{rid}} endpoint" + ); + assert!( + paths["/scans/{id}/status"].is_object(), + "Spec should document /scans/{{id}}/status endpoint" + ); +} + +#[tokio::test] +async fn openapi_spec_includes_all_dto_schemas() { + use crate::api::openapi::ApiDoc; + use utoipa::OpenApi; + + let openapi = ApiDoc::openapi(); + let spec_json = serde_json::to_value(&openapi).unwrap(); + + // Verify all DTO schemas are documented + let components = &spec_json["components"]["schemas"]; + assert!(components["ScanRequest"].is_object()); + assert!(components["ScanActionRequest"].is_object()); + assert!(components["ScanAction"].is_object()); + assert!(components["ScanDetailResponse"].is_object()); + assert!(components["ScanStatusResponse"].is_object()); + assert!(components["ScanResultResponse"].is_object()); + assert!(components["PreferencesResponse"].is_object()); + assert!(components["Target"].is_object()); + assert!(components["Credential"].is_object()); + assert!(components["UsernamePasswordCredential"].is_object()); + assert!(components["ScannerPreference"].is_object()); + assert!(components["Vt"].is_object()); + assert!(components["Parameter"].is_object()); + assert!(components["ResultType"].is_object()); + assert!(components["HostInfo"].is_object()); + assert!(components["ScanStatus"].is_object()); +} From fb849561903ec5c00d9fcaf63baae1eb3b1cd25c Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Tue, 23 Jun 2026 08:56:53 +0200 Subject: [PATCH 6/6] Reformat with cargo fmt --- src/api/openapi_tests.rs | 8 ++------ src/http/router.rs | 3 +-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/api/openapi_tests.rs b/src/api/openapi_tests.rs index 71df269..d128b52 100644 --- a/src/api/openapi_tests.rs +++ b/src/api/openapi_tests.rs @@ -7,9 +7,7 @@ use std::sync::Arc; use axum::{body::to_bytes, response::IntoResponse}; use crate::{ - app::AppState, - http::router::build_router, - scan::DefaultScanService, + app::AppState, http::router::build_router, scan::DefaultScanService, storage::test_support::temporary_sqlite_storage, }; @@ -30,9 +28,7 @@ async fn openapi_yaml_handler_returns_valid_yaml() { let response = get_openapi_yaml().await.into_response(); assert_eq!(response.status(), 200); - let body = to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); let yaml = String::from_utf8(body.to_vec()).unwrap(); assert!( yaml.contains("openapi:"), diff --git a/src/http/router.rs b/src/http/router.rs index a844a0f..992c4b0 100644 --- a/src/http/router.rs +++ b/src/http/router.rs @@ -60,8 +60,7 @@ pub fn build_router(state: AppState) -> Router { #[cfg(feature = "api-docs")] { use crate::api::openapi::ApiDoc; - let swagger_ui = SwaggerUi::new("/doc") - .url("/doc/openapi.json", ApiDoc::openapi()); + let swagger_ui = SwaggerUi::new("/doc").url("/doc/openapi.json", ApiDoc::openapi()); router = router .route("/doc/openapi.yml", get(api::openapi::get_openapi_yaml)) .merge(swagger_ui);