Skip to main content

velesdb_server/
lib.rs

1// Server — triaged pedantic/nursery lints (Sprint 2 Wave 8, A.10).
2// Blanket `#![allow(clippy::pedantic)]` removed; each remaining lint is
3// justified below.  Axum handler signatures, utoipa derives, and
4// OpenAPI-documented error contracts drive most of these.
5#![allow(clippy::uninlined_format_args)] // readability in error messages
6#![allow(clippy::manual_let_else)] // pattern matching in handlers is clearer
7#![allow(clippy::cast_possible_truncation)] // u128→u64 timing casts are bounded
8#![allow(clippy::cast_sign_loss)] // Duration→u64 timing casts are non-negative
9#![allow(clippy::cast_precision_loss)] // byte-count→f64 display casts are fine
10#![allow(clippy::ref_option)] // utoipa-generated code triggers this
11#![allow(clippy::match_same_arms)] // explicit arms improve readability in routers
12#![allow(clippy::trivially_copy_pass_by_ref)] // Axum extractors require &
13#![allow(clippy::map_unwrap_or)] // readability preference
14#![allow(clippy::enum_glob_use)] // StatusCode::* in handlers
15#![allow(clippy::unused_async)] // Axum requires async signature even for sync handlers
16#![allow(clippy::needless_for_each)] // readability in metric recording loops
17#![allow(clippy::doc_markdown)] // backtick pedantry — docs use utoipa annotations
18#![allow(clippy::missing_errors_doc)] // errors documented in #[utoipa::path] responses
19#![allow(clippy::must_use_candidate)] // handlers return impl IntoResponse, not Option
20#![allow(clippy::similar_names)] // handler params are intentionally close (name/names)
21#![allow(clippy::needless_raw_string_hashes)] // cosmetic, low-value fix
22#![allow(clippy::needless_pass_by_value)] // Axum extractors consume by value
23#![allow(clippy::redundant_closure_for_method_calls)] // readability in map chains
24#![allow(clippy::single_match_else)] // pattern matching in handlers is clearer
25#![allow(clippy::assigning_clones)] // minor optimisation, not performance-critical
26//! `VelesDB` Server - REST API library for the `VelesDB` vector database.
27//!
28//! This module provides the HTTP handlers and types for the `VelesDB` REST API.
29//!
30//! ## OpenAPI Documentation
31//!
32//! The API is documented using OpenAPI 3.0. Access the interactive documentation at:
33//! - Swagger UI: `GET /swagger-ui`
34//! - OpenAPI JSON: `GET /api-docs/openapi.json`
35
36pub mod auth;
37pub mod config;
38mod handlers;
39pub mod onboarding;
40pub mod rate_limit;
41pub mod routes;
42mod security_addon;
43pub mod tls;
44mod types;
45
46use security_addon::SecurityAddon;
47use std::sync::atomic::AtomicBool;
48use std::sync::Arc;
49use utoipa::OpenApi;
50use velesdb_core::guardrails::QueryLimits;
51use velesdb_core::metrics::{DurationHistogram, OperationalMetrics, TraversalMetrics};
52use velesdb_core::Database;
53
54pub use onboarding::OnboardingMetrics;
55pub use types::*;
56
57pub use handlers::{
58    aggregate, analyze_collection, batch_search, bulk_delete_points, collection_sanity,
59    compact_collection, create_collection, create_index, delete_collection, delete_index,
60    delete_point, explain, flush_collection, get_collection, get_collection_config,
61    get_collection_stats, get_guardrails, get_point, health_check, hybrid_search, is_empty,
62    list_collections, list_indexes, match_query, multi_query_search, query, readiness_check,
63    rebuild_index, scroll_points, search, search_ids, stream_insert, stream_upsert_points,
64    text_search, update_guardrails, upsert_points, vacuum_collection,
65};
66
67pub use handlers::graph::{
68    add_edge, get_edge_count, get_edges, get_node_degree, get_node_edges, get_node_payload,
69    graph_search, list_nodes, remove_edge, stream_traverse, traverse_graph, traverse_parallel,
70    upsert_node_payload, DegreeResponse, EdgeCountResponse, GraphSearchRequest,
71    GraphSearchResponse, NodeEdgeQueryParams, NodeListResponse, NodePayloadResponse,
72    ParallelTraverseRequest, StreamDoneEvent, StreamNodeEvent, StreamStatsEvent,
73    StreamTraverseParams, TraversalResultItem, TraversalStats, TraverseRequest, TraverseResponse,
74    UpsertNodePayloadRequest,
75};
76
77#[cfg(feature = "prometheus")]
78pub use handlers::metrics::{health_metrics, prometheus_metrics};
79
80// ============================================================================
81// OpenAPI Documentation
82
83/// VelesDB API Documentation
84#[derive(OpenApi)]
85#[openapi(
86    info(
87        title = "VelesDB API",
88        version = env!("CARGO_PKG_VERSION"),
89        description = "High-performance vector database for AI applications. \
90            Supports semantic search, HNSW indexing, and multiple distance metrics. \
91            Authentication is optional — when API keys are configured via VELESDB_API_KEYS, \
92            all endpoints except /health and /ready require a valid Bearer token.",
93        license(name = "VelesDB Core License 1.0", url = "https://github.com/cyberlife-coder/VelesDB/blob/main/LICENSE"),
94        contact(name = "VelesDB Team", url = "https://github.com/cyberlife-coder/VelesDB")
95    ),
96    security(
97        ("bearer_auth" = [])
98    ),
99    modifiers(&SecurityAddon),
100    servers(
101        (url = "/", description = "Local server")
102    ),
103    tags(
104        (name = "health", description = "Health check endpoints"),
105        (name = "collections", description = "Collection management"),
106        (name = "points", description = "Vector point operations"),
107        (name = "search", description = "Vector similarity search"),
108        (name = "query", description = "VelesQL query execution"),
109        (name = "indexes", description = "Property index management (EPIC-009)"),
110        (name = "graph", description = "Graph traversal and edge operations"),
111        (name = "guardrails", description = "Query guard-rails configuration (EPIC-048)")
112    ),
113    paths(
114        handlers::health::health_check,
115        handlers::health::readiness_check,
116        handlers::collections::list_collections,
117        handlers::collections::create_collection,
118        handlers::collections::get_collection,
119        handlers::collections::delete_collection,
120        handlers::collections::collection_sanity,
121        handlers::collections::is_empty,
122        handlers::collections::flush_collection,
123        handlers::admin::analyze_collection,
124        handlers::admin::get_collection_stats,
125        handlers::admin::get_guardrails,
126        handlers::admin::update_guardrails,
127        handlers::points::upsert_points,
128        handlers::points::stream_upsert_points,
129        handlers::points::stream_insert,
130        handlers::points::get_point,
131        handlers::points::delete_point,
132        handlers::points::scroll_points,
133        handlers::search::search,
134        handlers::search::batch_search,
135        handlers::search::multi_query_search,
136        handlers::search::text_search,
137        handlers::search::hybrid_search,
138        handlers::search::search_ids,
139        handlers::admin::get_collection_config,
140        handlers::query::query,
141        handlers::query::aggregate,
142        handlers::query::explain,
143        handlers::indexes::create_index,
144        handlers::indexes::list_indexes,
145        handlers::indexes::delete_index,
146        handlers::graph::handlers::get_edges,
147        handlers::graph::handlers::add_edge,
148        handlers::graph::handlers_extended::remove_edge,
149        handlers::graph::handlers_extended::get_edge_count,
150        handlers::graph::handlers_extended::list_nodes,
151        handlers::graph::handlers_extended::get_node_edges,
152        handlers::graph::handlers_extended::get_node_payload,
153        handlers::graph::handlers_extended::upsert_node_payload,
154        handlers::graph::handlers::traverse_graph,
155        handlers::graph::handlers_extended::traverse_parallel,
156        handlers::graph::handlers::get_node_degree,
157        handlers::graph::handlers_extended::graph_search,
158        handlers::graph::stream::stream_traverse,
159        handlers::match_query::match_query,
160        handlers::admin::rebuild_index,
161        handlers::admin::vacuum_collection,
162        handlers::admin::compact_collection,
163        handlers::points::bulk_delete_points
164    ),
165    components(
166        schemas(
167            CreateCollectionRequest,
168            CollectionResponse,
169            UpsertPointsRequest,
170            PointRequest,
171            StreamInsertRequest,
172            SearchRequest,
173            BatchSearchRequest,
174            TextSearchRequest,
175            HybridSearchRequest,
176            MultiQuerySearchRequest,
177            SearchResponse,
178            BatchSearchResponse,
179            SearchResultResponse,
180            SearchIdsResponse,
181            IdScoreResult,
182            CollectionConfigResponse,
183            ErrorResponse,
184            QueryRequest,
185            QueryResponse,
186            QueryResponseMeta,
187            AggregationResponse,
188            QueryErrorResponse,
189            QueryErrorDetail,
190            VelesqlErrorResponse,
191            VelesqlErrorDetail,
192            ExplainRequest,
193            ExplainResponse,
194            ExplainStep,
195            ExplainCost,
196            ExplainFeatures,
197            ActualStatsResponse,
198            NodeStatsResponse,
199            CreateIndexRequest,
200            IndexResponse,
201            ListIndexesResponse,
202            CollectionStatsResponse,
203            ColumnStatsResponse,
204            IndexStatsResponse,
205            ScrollRequest,
206            ScrollResponse,
207            ScrollPoint,
208            GuardRailsConfigRequest,
209            GuardRailsConfigResponse,
210            handlers::graph::TraverseRequest,
211            handlers::graph::TraverseResponse,
212            handlers::graph::TraversalResultItem,
213            handlers::graph::TraversalStats,
214            handlers::graph::DegreeResponse,
215            handlers::graph::AddEdgeRequest,
216            handlers::graph::EdgesResponse,
217            handlers::graph::EdgeResponse,
218            handlers::graph::EdgeCountResponse,
219            handlers::graph::NodeListResponse,
220            handlers::graph::NodePayloadResponse,
221            handlers::graph::UpsertNodePayloadRequest,
222            handlers::graph::ParallelTraverseRequest,
223            handlers::graph::GraphSearchRequest,
224            handlers::graph::GraphSearchResponse,
225            handlers::graph::GraphSearchResultItem,
226            handlers::graph::StreamNodeEvent,
227            handlers::graph::StreamStatsEvent,
228            handlers::graph::StreamDoneEvent,
229            handlers::match_query::MatchQueryRequest,
230            handlers::match_query::MatchQueryResponse,
231            handlers::match_query::MatchQueryResultItem,
232            handlers::match_query::MatchQueryMeta,
233            handlers::match_query::MatchQueryError,
234            handlers::points::BulkDeleteRequest
235        )
236    )
237)]
238pub struct ApiDoc;
239
240// ============================================================================
241// Application State
242
243/// Application state shared across handlers.
244pub struct AppState {
245    /// The `VelesDB` database instance.
246    pub db: Database,
247    /// New-user onboarding diagnostics counters.
248    pub onboarding_metrics: onboarding::OnboardingMetrics,
249    /// Query guard-rails configuration (EPIC-048).
250    pub query_limits: parking_lot::RwLock<QueryLimits>,
251    /// Readiness flag — `true` once the database is fully loaded.
252    pub ready: AtomicBool,
253    /// Operational metrics: query throughput, connections, doc counts (EPIC-050).
254    pub operational_metrics: Arc<OperationalMetrics>,
255    /// Graph traversal metrics: nodes visited, depth, edges scanned.
256    pub traversal_metrics: Arc<TraversalMetrics>,
257    /// Query duration histogram for Prometheus export.
258    pub query_duration_histogram: Arc<DurationHistogram>,
259}
260
261// ============================================================================
262// Tests
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use utoipa::OpenApi;
268
269    #[test]
270    fn test_openapi_spec_generation() {
271        let openapi = ApiDoc::openapi();
272        let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
273        assert!(!json.is_empty(), "OpenAPI spec should not be empty");
274        assert!(json.contains("VelesDB API"), "Should contain API title");
275        assert!(
276            json.contains(env!("CARGO_PKG_VERSION")),
277            "Should contain version"
278        );
279    }
280
281    #[test]
282    fn test_openapi_has_all_endpoints() {
283        let openapi = ApiDoc::openapi();
284        let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
285        assert!(json.contains("/health"), "Should document /health");
286        assert!(
287            json.contains("/collections"),
288            "Should document /collections"
289        );
290        assert!(
291            json.contains(r"/collections/{name}"),
292            "Should document collections by name"
293        );
294        assert!(json.contains("/points"), "Should document points endpoint");
295        assert!(
296            json.contains(r"/collections/{name}/points/stream"),
297            "Should document points stream endpoint"
298        );
299        assert!(json.contains("/search"), "Should document search endpoint");
300        assert!(json.contains("/query"), "Should document /query");
301        assert!(json.contains("/aggregate"), "Should document /aggregate");
302        assert!(
303            json.contains("/query/explain"),
304            "Should document /query/explain"
305        );
306    }
307
308    #[test]
309    fn test_openapi_has_all_tags() {
310        let openapi = ApiDoc::openapi();
311        let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
312        assert!(json.contains("\"health\""), "Should have health tag");
313        assert!(
314            json.contains("\"collections\""),
315            "Should have collections tag"
316        );
317        assert!(json.contains("\"points\""), "Should have points tag");
318        assert!(json.contains("\"search\""), "Should have search tag");
319        assert!(json.contains("\"query\""), "Should have query tag");
320    }
321
322    #[test]
323    fn test_openapi_has_schemas() {
324        let openapi = ApiDoc::openapi();
325        let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
326        assert!(
327            json.contains("CreateCollectionRequest"),
328            "Should have CreateCollectionRequest schema"
329        );
330        assert!(
331            json.contains("CollectionResponse"),
332            "Should have CollectionResponse schema"
333        );
334        assert!(
335            json.contains("SearchRequest"),
336            "Should have SearchRequest schema"
337        );
338        assert!(
339            json.contains("SearchResponse"),
340            "Should have SearchResponse schema"
341        );
342        assert!(
343            json.contains("ErrorResponse"),
344            "Should have ErrorResponse schema"
345        );
346    }
347
348    #[test]
349    fn generate_openapi_spec_files() {
350        let openapi = ApiDoc::openapi();
351        let json = openapi
352            .to_pretty_json()
353            .expect("Failed to serialize OpenAPI JSON");
354        let yaml = serde_yaml::to_string(&openapi).expect("Failed to serialize OpenAPI YAML");
355
356        // Write to docs/ relative to workspace root
357        let docs_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
358            .parent()
359            .unwrap()
360            .parent()
361            .unwrap()
362            .join("docs");
363        std::fs::create_dir_all(&docs_dir).expect("Failed to create docs dir");
364
365        std::fs::write(docs_dir.join("openapi.json"), &json).expect("Failed to write openapi.json");
366        std::fs::write(docs_dir.join("openapi.yaml"), &yaml).expect("Failed to write openapi.yaml");
367
368        // Verify key endpoints are present
369        assert!(
370            json.contains("sparse"),
371            "OpenAPI spec should contain sparse endpoints"
372        );
373        assert!(
374            json.contains("/graph/edges"),
375            "Should contain graph edge endpoints"
376        );
377        assert!(
378            json.contains("/graph/traverse"),
379            "Should contain graph traverse endpoint"
380        );
381        assert!(
382            json.contains("/stream/insert"),
383            "Should contain stream insert endpoint"
384        );
385        assert!(
386            json.contains("/match"),
387            "Should contain match query endpoint"
388        );
389        assert!(
390            json.contains("/search/multi"),
391            "Should contain multi-query search endpoint"
392        );
393    }
394
395    #[test]
396    fn test_openapi_has_license() {
397        let openapi = ApiDoc::openapi();
398        let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
399        assert!(
400            json.contains("VelesDB Core License 1.0"),
401            "Should have VelesDB Core License 1.0"
402        );
403    }
404
405    #[test]
406    fn test_openapi_pretty_json() {
407        let openapi = ApiDoc::openapi();
408        let pretty_json = openapi
409            .to_pretty_json()
410            .expect("Failed to serialize pretty JSON");
411        assert!(
412            pretty_json.contains('\n'),
413            "Pretty JSON should have newlines"
414        );
415        assert!(
416            pretty_json.len() > 1000,
417            "OpenAPI spec should be substantial"
418        );
419    }
420
421    #[test]
422    fn test_openapi_has_all_metrics_documented() {
423        let openapi = ApiDoc::openapi();
424        let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
425        assert!(json.contains("cosine"), "Should document cosine metric");
426        assert!(
427            json.contains("euclidean"),
428            "Should document euclidean metric"
429        );
430        assert!(json.contains("dot"), "Should document dot product metric");
431        assert!(json.contains("hamming"), "Should document hamming metric");
432        assert!(json.contains("jaccard"), "Should document jaccard metric");
433    }
434
435    #[test]
436    fn test_openapi_has_storage_mode_documented() {
437        let openapi = ApiDoc::openapi();
438        let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
439        assert!(
440            json.contains("storage_mode"),
441            "Should document storage_mode parameter"
442        );
443    }
444
445    #[test]
446    fn test_openapi_has_search_types_documented() {
447        let openapi = ApiDoc::openapi();
448        let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
449        assert!(json.contains("text_search"), "Should document text search");
450        assert!(
451            json.contains("hybrid_search"),
452            "Should document hybrid search"
453        );
454        assert!(json.contains("batch"), "Should document batch search");
455    }
456
457    #[test]
458    fn test_create_collection_request_default_metric() {
459        let json = r#"{"name": "test", "dimension": 128}"#;
460        let req: CreateCollectionRequest = serde_json::from_str(json).unwrap();
461        assert_eq!(req.metric, "cosine");
462    }
463
464    #[test]
465    fn test_create_collection_request_with_hamming() {
466        let json = r#"{"name": "test", "dimension": 128, "metric": "hamming"}"#;
467        let req: CreateCollectionRequest = serde_json::from_str(json).unwrap();
468        assert_eq!(req.metric, "hamming");
469    }
470
471    #[test]
472    fn test_create_collection_request_with_jaccard() {
473        let json = r#"{"name": "test", "dimension": 128, "metric": "jaccard"}"#;
474        let req: CreateCollectionRequest = serde_json::from_str(json).unwrap();
475        assert_eq!(req.metric, "jaccard");
476    }
477
478    #[test]
479    fn test_create_collection_request_with_storage_mode() {
480        let json = r#"{"name": "test", "dimension": 128, "storage_mode": "sq8"}"#;
481        let req: CreateCollectionRequest = serde_json::from_str(json).unwrap();
482        assert_eq!(req.storage_mode, "sq8");
483    }
484
485    #[test]
486    fn test_search_request_deserialize() {
487        let json = r#"{"vector": [0.1, 0.2, 0.3], "top_k": 5}"#;
488        let req: SearchRequest = serde_json::from_str(json).unwrap();
489        assert_eq!(req.vector, vec![0.1, 0.2, 0.3]);
490        assert_eq!(req.top_k, 5);
491    }
492
493    #[test]
494    fn test_batch_search_request_deserialize() {
495        let json = r#"{"searches": [{"vector": [0.1, 0.2], "top_k": 3}]}"#;
496        let req: BatchSearchRequest = serde_json::from_str(json).unwrap();
497        assert_eq!(req.searches.len(), 1);
498        assert_eq!(req.searches[0].top_k, 3);
499    }
500
501    #[test]
502    fn test_text_search_request_deserialize() {
503        let json = r#"{"query": "machine learning", "top_k": 10}"#;
504        let req: TextSearchRequest = serde_json::from_str(json).unwrap();
505        assert_eq!(req.query, "machine learning");
506        assert_eq!(req.top_k, 10);
507    }
508
509    #[test]
510    fn test_hybrid_search_request_deserialize() {
511        let json = r#"{"vector": [0.1, 0.2], "query": "test", "top_k": 5}"#;
512        let req: HybridSearchRequest = serde_json::from_str(json).unwrap();
513        assert_eq!(req.vector, vec![0.1, 0.2]);
514        assert_eq!(req.query, "test");
515        assert_eq!(req.top_k, 5);
516    }
517
518    #[test]
519    fn test_upsert_points_request_deserialize() {
520        let json = r#"{"points": [{"id": 1, "vector": [0.1, 0.2]}]}"#;
521        let req: UpsertPointsRequest = serde_json::from_str(json).unwrap();
522        assert_eq!(req.points.len(), 1);
523        assert_eq!(req.points[0].id, 1);
524    }
525
526    #[test]
527    fn test_collection_response_serialize() {
528        let resp = CollectionResponse {
529            name: "test".to_string(),
530            dimension: 128,
531            metric: "cosine".to_string(),
532            storage_mode: "full".to_string(),
533            point_count: 100,
534        };
535        let json = serde_json::to_string(&resp).unwrap();
536        assert!(json.contains("\"name\":\"test\""));
537        assert!(json.contains("\"dimension\":128"));
538        assert!(json.contains("\"metric\":\"cosine\""));
539        assert!(json.contains("\"storage_mode\":\"full\""));
540        assert!(json.contains("\"point_count\":100"));
541    }
542
543    #[test]
544    fn test_search_response_serialize() {
545        let resp = SearchResponse {
546            results: vec![SearchResultResponse {
547                id: 1,
548                score: 0.95,
549                payload: None,
550            }],
551        };
552        let json = serde_json::to_string(&resp).unwrap();
553        assert!(json.contains("\"results\""));
554        // IDs are serialized as strings to prevent JavaScript precision loss (WP-0D).
555        assert!(json.contains("\"id\":\"1\""));
556    }
557
558    #[test]
559    fn test_error_response_serialize() {
560        let resp = ErrorResponse {
561            error: "Test error".to_string(),
562            code: None,
563        };
564        let json = serde_json::to_string(&resp).unwrap();
565        assert!(json.contains("\"error\":\"Test error\""));
566        // code: None is omitted from JSON output
567        assert!(!json.contains("\"code\""));
568    }
569
570    // ========================================================================
571    // OpenAPI <-> Router structural conformance
572    // ========================================================================
573
574    /// Extracts every `(path_template, HTTP method)` pair declared in the
575    /// OpenAPI spec. Returns a sorted `Vec` for deterministic assertions.
576    fn extract_openapi_operations() -> Vec<(String, axum::http::Method)> {
577        let openapi = ApiDoc::openapi();
578        let mut ops = Vec::new();
579        for (path, item) in &openapi.paths.paths {
580            if item.get.is_some() {
581                ops.push((path.clone(), axum::http::Method::GET));
582            }
583            if item.post.is_some() {
584                ops.push((path.clone(), axum::http::Method::POST));
585            }
586            if item.put.is_some() {
587                ops.push((path.clone(), axum::http::Method::PUT));
588            }
589            if item.delete.is_some() {
590                ops.push((path.clone(), axum::http::Method::DELETE));
591            }
592            if item.patch.is_some() {
593                ops.push((path.clone(), axum::http::Method::PATCH));
594            }
595        }
596        ops.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.as_str().cmp(b.1.as_str())));
597        ops
598    }
599
600    /// Converts an OpenAPI path template into a concrete URI by replacing
601    /// each `{param}` placeholder with a safe dummy value.
602    fn template_to_uri(template: &str) -> String {
603        template
604            .replace("{name}", "test_col")
605            .replace("{id}", "1")
606            .replace("{node_id}", "1")
607            .replace("{edge_id}", "1")
608            .replace("{label}", "test_label")
609            .replace("{property}", "test_prop")
610    }
611
612    /// Creates a minimal [`AppState`] backed by an ephemeral directory.
613    /// Returns both the state and the `TempDir` guard (must stay alive).
614    fn create_conformance_state() -> (std::sync::Arc<AppState>, tempfile::TempDir) {
615        let dir = tempfile::TempDir::new().expect("test: create temp dir");
616        let db = Database::open(dir.path()).expect("test: open database");
617        let state = std::sync::Arc::new(AppState {
618            db,
619            onboarding_metrics: OnboardingMetrics::default(),
620            query_limits: parking_lot::RwLock::new(QueryLimits::default()),
621            ready: AtomicBool::new(true),
622            operational_metrics: velesdb_core::metrics::OperationalMetrics::new_arc(),
623            traversal_metrics: Arc::new(velesdb_core::metrics::TraversalMetrics::new()),
624            query_duration_histogram: Arc::new(velesdb_core::metrics::DurationHistogram::new()),
625        });
626        (state, dir)
627    }
628
629    /// Returns `true` when the response is Axum's built-in fallback (route
630    /// not found), which is a `404` with an empty body. Handler-generated
631    /// 404s always carry a non-empty JSON body.
632    async fn is_axum_fallback(resp: axum::http::Response<axum::body::Body>) -> bool {
633        if resp.status() != axum::http::StatusCode::NOT_FOUND {
634            return false;
635        }
636        let body = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
637            .await
638            .expect("test: read response body");
639        body.is_empty()
640    }
641
642    /// Structural conformance: every `(path, method)` declared in the OpenAPI
643    /// spec must be reachable through the Axum router (must NOT hit Axum's
644    /// built-in fallback 404).
645    #[tokio::test]
646    async fn test_openapi_routes_match_router() {
647        let operations = extract_openapi_operations();
648        assert!(
649            !operations.is_empty(),
650            "OpenAPI spec should declare at least one operation"
651        );
652
653        let (state, _dir) = create_conformance_state();
654        let router = crate::routes::api_routes().with_state(state);
655
656        let mut failures: Vec<String> = Vec::new();
657        for (template, method) in &operations {
658            let uri = template_to_uri(template);
659            let req = axum::http::Request::builder()
660                .method(method)
661                .uri(&uri)
662                .header("content-type", "application/json")
663                .body(axum::body::Body::from("{}"))
664                .expect("test: build request");
665
666            let resp = tower::ServiceExt::oneshot(router.clone(), req)
667                .await
668                .expect("test: send request");
669
670            if is_axum_fallback(resp).await {
671                failures.push(format!("{method} {template}"));
672            }
673        }
674
675        assert!(
676            failures.is_empty(),
677            "OpenAPI operations with no matching router route:\n  {}",
678            failures.join("\n  ")
679        );
680    }
681}