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