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::match_query::MatchQueryError,
252            handlers::points::BulkDeleteRequest,
253            handlers::points::relations::RelateRequest,
254            handlers::points::relations::RelateResponse,
255            handlers::points::relations::RelationEdge,
256            handlers::points::relations::RelationsResponse,
257            handlers::points::relations::SetTtlRequest
258        )
259    )
260)]
261pub struct ApiDoc;
262
263// ============================================================================
264// Application State
265
266/// Application state shared across handlers.
267pub struct AppState {
268    /// The `VelesDB` database instance.
269    pub db: Database,
270    /// New-user onboarding diagnostics counters.
271    pub onboarding_metrics: onboarding::OnboardingMetrics,
272    /// Query guard-rails configuration (EPIC-048).
273    pub query_limits: parking_lot::RwLock<QueryLimits>,
274    /// Readiness flag — `true` once the database is fully loaded.
275    pub ready: AtomicBool,
276    /// Operational metrics: query throughput, connections, doc counts (EPIC-050).
277    pub operational_metrics: Arc<OperationalMetrics>,
278    /// Graph traversal metrics: nodes visited, depth, edges scanned.
279    pub traversal_metrics: Arc<TraversalMetrics>,
280    /// Query duration histogram for Prometheus export.
281    pub query_duration_histogram: Arc<DurationHistogram>,
282}
283
284// ============================================================================
285// Tests
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use utoipa::OpenApi;
291
292    #[test]
293    fn test_openapi_spec_generation() {
294        let openapi = ApiDoc::openapi();
295        let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
296        assert!(!json.is_empty(), "OpenAPI spec should not be empty");
297        assert!(json.contains("VelesDB API"), "Should contain API title");
298        assert!(
299            json.contains(env!("CARGO_PKG_VERSION")),
300            "Should contain version"
301        );
302    }
303
304    #[test]
305    fn test_openapi_has_all_endpoints() {
306        let openapi = ApiDoc::openapi();
307        let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
308        assert!(json.contains("/health"), "Should document /health");
309        assert!(
310            json.contains("/collections"),
311            "Should document /collections"
312        );
313        assert!(
314            json.contains(r"/collections/{name}"),
315            "Should document collections by name"
316        );
317        assert!(json.contains("/points"), "Should document points endpoint");
318        assert!(
319            json.contains(r"/collections/{name}/points/stream"),
320            "Should document points stream endpoint"
321        );
322        assert!(json.contains("/search"), "Should document search endpoint");
323        assert!(json.contains("/query"), "Should document /query");
324        assert!(json.contains("/aggregate"), "Should document /aggregate");
325        assert!(
326            json.contains("/query/explain"),
327            "Should document /query/explain"
328        );
329    }
330
331    #[test]
332    fn test_openapi_has_all_tags() {
333        let openapi = ApiDoc::openapi();
334        let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
335        assert!(json.contains("\"health\""), "Should have health tag");
336        assert!(
337            json.contains("\"collections\""),
338            "Should have collections tag"
339        );
340        assert!(json.contains("\"points\""), "Should have points tag");
341        assert!(json.contains("\"search\""), "Should have search tag");
342        assert!(json.contains("\"query\""), "Should have query tag");
343    }
344
345    #[test]
346    fn test_openapi_has_schemas() {
347        let openapi = ApiDoc::openapi();
348        let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
349        assert!(
350            json.contains("CreateCollectionRequest"),
351            "Should have CreateCollectionRequest schema"
352        );
353        assert!(
354            json.contains("CollectionResponse"),
355            "Should have CollectionResponse schema"
356        );
357        assert!(
358            json.contains("SearchRequest"),
359            "Should have SearchRequest schema"
360        );
361        assert!(
362            json.contains("SearchResponse"),
363            "Should have SearchResponse schema"
364        );
365        assert!(
366            json.contains("ErrorResponse"),
367            "Should have ErrorResponse schema"
368        );
369    }
370
371    #[test]
372    fn generate_openapi_spec_files() {
373        let openapi = ApiDoc::openapi();
374        let json = openapi
375            .to_pretty_json()
376            .expect("Failed to serialize OpenAPI JSON");
377        let yaml = serde_yaml::to_string(&openapi).expect("Failed to serialize OpenAPI YAML");
378
379        // Write to docs/ relative to workspace root
380        let docs_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
381            .parent()
382            .expect("test: CARGO_MANIFEST_DIR has a parent (crates/)")
383            .parent()
384            .expect("test: crates/ has a parent (workspace root)")
385            .join("docs");
386        std::fs::create_dir_all(&docs_dir).expect("Failed to create docs dir");
387
388        std::fs::write(docs_dir.join("openapi.json"), &json).expect("Failed to write openapi.json");
389        std::fs::write(docs_dir.join("openapi.yaml"), &yaml).expect("Failed to write openapi.yaml");
390
391        // Verify key endpoints are present
392        assert!(
393            json.contains("sparse"),
394            "OpenAPI spec should contain sparse endpoints"
395        );
396        assert!(
397            json.contains("/graph/edges"),
398            "Should contain graph edge endpoints"
399        );
400        assert!(
401            json.contains("/graph/traverse"),
402            "Should contain graph traverse endpoint"
403        );
404        assert!(
405            json.contains("/stream/insert"),
406            "Should contain stream insert endpoint"
407        );
408        assert!(
409            json.contains("/match"),
410            "Should contain match query endpoint"
411        );
412        assert!(
413            json.contains("/search/multi"),
414            "Should contain multi-query search endpoint"
415        );
416    }
417
418    #[test]
419    fn test_openapi_has_license() {
420        let openapi = ApiDoc::openapi();
421        let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
422        assert!(
423            json.contains("VelesDB Core License 1.0"),
424            "Should have VelesDB Core License 1.0"
425        );
426    }
427
428    #[test]
429    fn test_openapi_pretty_json() {
430        let openapi = ApiDoc::openapi();
431        let pretty_json = openapi
432            .to_pretty_json()
433            .expect("Failed to serialize pretty JSON");
434        assert!(
435            pretty_json.contains('\n'),
436            "Pretty JSON should have newlines"
437        );
438        assert!(
439            pretty_json.len() > 1000,
440            "OpenAPI spec should be substantial"
441        );
442    }
443
444    #[test]
445    fn test_openapi_has_all_metrics_documented() {
446        let openapi = ApiDoc::openapi();
447        let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
448        assert!(json.contains("cosine"), "Should document cosine metric");
449        assert!(
450            json.contains("euclidean"),
451            "Should document euclidean metric"
452        );
453        assert!(json.contains("dot"), "Should document dot product metric");
454        assert!(json.contains("hamming"), "Should document hamming metric");
455        assert!(json.contains("jaccard"), "Should document jaccard metric");
456    }
457
458    #[test]
459    fn test_openapi_has_storage_mode_documented() {
460        let openapi = ApiDoc::openapi();
461        let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
462        assert!(
463            json.contains("storage_mode"),
464            "Should document storage_mode parameter"
465        );
466    }
467
468    #[test]
469    fn test_openapi_has_search_types_documented() {
470        let openapi = ApiDoc::openapi();
471        let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
472        assert!(json.contains("text_search"), "Should document text search");
473        assert!(
474            json.contains("hybrid_search"),
475            "Should document hybrid search"
476        );
477        assert!(json.contains("batch"), "Should document batch search");
478    }
479
480    #[test]
481    fn test_create_collection_request_default_metric() {
482        let json = r#"{"name": "test", "dimension": 128}"#;
483        let req: CreateCollectionRequest =
484            serde_json::from_str(json).expect("test: valid CreateCollectionRequest JSON");
485        assert_eq!(req.metric, "cosine");
486    }
487
488    #[test]
489    fn test_create_collection_request_with_hamming() {
490        let json = r#"{"name": "test", "dimension": 128, "metric": "hamming"}"#;
491        let req: CreateCollectionRequest =
492            serde_json::from_str(json).expect("test: valid CreateCollectionRequest JSON");
493        assert_eq!(req.metric, "hamming");
494    }
495
496    #[test]
497    fn test_create_collection_request_with_jaccard() {
498        let json = r#"{"name": "test", "dimension": 128, "metric": "jaccard"}"#;
499        let req: CreateCollectionRequest =
500            serde_json::from_str(json).expect("test: valid CreateCollectionRequest JSON");
501        assert_eq!(req.metric, "jaccard");
502    }
503
504    #[test]
505    fn test_create_collection_request_with_storage_mode() {
506        let json = r#"{"name": "test", "dimension": 128, "storage_mode": "sq8"}"#;
507        let req: CreateCollectionRequest =
508            serde_json::from_str(json).expect("test: valid CreateCollectionRequest JSON");
509        assert_eq!(req.storage_mode, "sq8");
510    }
511
512    #[test]
513    fn test_search_request_deserialize() {
514        let json = r#"{"vector": [0.1, 0.2, 0.3], "top_k": 5}"#;
515        let req: SearchRequest =
516            serde_json::from_str(json).expect("test: valid SearchRequest JSON");
517        assert_eq!(req.vector, vec![0.1, 0.2, 0.3]);
518        assert_eq!(req.top_k, 5);
519    }
520
521    #[test]
522    fn test_batch_search_request_deserialize() {
523        let json = r#"{"searches": [{"vector": [0.1, 0.2], "top_k": 3}]}"#;
524        let req: BatchSearchRequest =
525            serde_json::from_str(json).expect("test: valid BatchSearchRequest JSON");
526        assert_eq!(req.searches.len(), 1);
527        assert_eq!(req.searches[0].top_k, 3);
528    }
529
530    #[test]
531    fn test_text_search_request_deserialize() {
532        let json = r#"{"query": "machine learning", "top_k": 10}"#;
533        let req: TextSearchRequest =
534            serde_json::from_str(json).expect("test: valid TextSearchRequest JSON");
535        assert_eq!(req.query, "machine learning");
536        assert_eq!(req.top_k, 10);
537    }
538
539    #[test]
540    fn test_hybrid_search_request_deserialize() {
541        let json = r#"{"vector": [0.1, 0.2], "query": "test", "top_k": 5}"#;
542        let req: HybridSearchRequest =
543            serde_json::from_str(json).expect("test: valid HybridSearchRequest JSON");
544        assert_eq!(req.vector, vec![0.1, 0.2]);
545        assert_eq!(req.query, "test");
546        assert_eq!(req.top_k, 5);
547    }
548
549    #[test]
550    fn test_upsert_points_request_deserialize() {
551        let json = r#"{"points": [{"id": 1, "vector": [0.1, 0.2]}]}"#;
552        let req: UpsertPointsRequest =
553            serde_json::from_str(json).expect("test: valid UpsertPointsRequest JSON");
554        assert_eq!(req.points.len(), 1);
555        assert_eq!(req.points[0].id, 1);
556    }
557
558    #[test]
559    fn test_collection_response_serialize() {
560        let resp = CollectionResponse {
561            name: "test".to_string(),
562            dimension: 128,
563            metric: "cosine".to_string(),
564            storage_mode: "full".to_string(),
565            point_count: 100,
566        };
567        let json = serde_json::to_string(&resp).expect("test: serialize CollectionResponse");
568        assert!(json.contains("\"name\":\"test\""));
569        assert!(json.contains("\"dimension\":128"));
570        assert!(json.contains("\"metric\":\"cosine\""));
571        assert!(json.contains("\"storage_mode\":\"full\""));
572        assert!(json.contains("\"point_count\":100"));
573    }
574
575    #[test]
576    fn test_search_response_serialize() {
577        let resp = SearchResponse {
578            results: vec![SearchResultResponse {
579                id: 1,
580                score: 0.95,
581                payload: None,
582            }],
583        };
584        let json = serde_json::to_string(&resp).expect("test: serialize SearchResponse");
585        assert!(json.contains("\"results\""));
586        // IDs are serialized as strings to prevent JavaScript precision loss (WP-0D).
587        assert!(json.contains("\"id\":\"1\""));
588    }
589
590    #[test]
591    fn test_error_response_serialize() {
592        let resp = ErrorResponse {
593            error: "Test error".to_string(),
594            code: None,
595        };
596        let json = serde_json::to_string(&resp).expect("test: serialize ErrorResponse");
597        assert!(json.contains("\"error\":\"Test error\""));
598        // code: None is omitted from JSON output
599        assert!(!json.contains("\"code\""));
600    }
601
602    // ========================================================================
603    // OpenAPI <-> Router structural conformance
604    // ========================================================================
605
606    /// Extracts every `(path_template, HTTP method)` pair declared in the
607    /// OpenAPI spec. Returns a sorted `Vec` for deterministic assertions.
608    fn extract_openapi_operations() -> Vec<(String, axum::http::Method)> {
609        let openapi = ApiDoc::openapi();
610        let mut ops = Vec::new();
611        for (path, item) in &openapi.paths.paths {
612            if item.get.is_some() {
613                ops.push((path.clone(), axum::http::Method::GET));
614            }
615            if item.post.is_some() {
616                ops.push((path.clone(), axum::http::Method::POST));
617            }
618            if item.put.is_some() {
619                ops.push((path.clone(), axum::http::Method::PUT));
620            }
621            if item.delete.is_some() {
622                ops.push((path.clone(), axum::http::Method::DELETE));
623            }
624            if item.patch.is_some() {
625                ops.push((path.clone(), axum::http::Method::PATCH));
626            }
627        }
628        ops.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.as_str().cmp(b.1.as_str())));
629        ops
630    }
631
632    /// Converts an OpenAPI path template into a concrete URI by replacing
633    /// each `{param}` placeholder with a safe dummy value.
634    fn template_to_uri(template: &str) -> String {
635        template
636            .replace("{name}", "test_col")
637            .replace("{id}", "1")
638            .replace("{node_id}", "1")
639            .replace("{edge_id}", "1")
640            .replace("{label}", "test_label")
641            .replace("{property}", "test_prop")
642    }
643
644    /// Creates a minimal [`AppState`] backed by an ephemeral directory.
645    /// Returns both the state and the `TempDir` guard (must stay alive).
646    fn create_conformance_state() -> (std::sync::Arc<AppState>, tempfile::TempDir) {
647        let dir = tempfile::TempDir::new().expect("test: create temp dir");
648        let db = Database::open(dir.path()).expect("test: open database");
649        let state = std::sync::Arc::new(AppState {
650            db,
651            onboarding_metrics: OnboardingMetrics::default(),
652            query_limits: parking_lot::RwLock::new(QueryLimits::default()),
653            ready: AtomicBool::new(true),
654            operational_metrics: velesdb_core::metrics::OperationalMetrics::new_arc(),
655            traversal_metrics: Arc::new(velesdb_core::metrics::TraversalMetrics::new()),
656            query_duration_histogram: Arc::new(velesdb_core::metrics::DurationHistogram::new()),
657        });
658        (state, dir)
659    }
660
661    /// Returns `true` when the response is Axum's built-in fallback (route
662    /// not found), which is a `404` with an empty body. Handler-generated
663    /// 404s always carry a non-empty JSON body.
664    async fn is_axum_fallback(resp: axum::http::Response<axum::body::Body>) -> bool {
665        if resp.status() != axum::http::StatusCode::NOT_FOUND {
666            return false;
667        }
668        let body = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
669            .await
670            .expect("test: read response body");
671        body.is_empty()
672    }
673
674    /// Structural conformance: every `(path, method)` declared in the OpenAPI
675    /// spec must be reachable through the Axum router (must NOT hit Axum's
676    /// built-in fallback 404).
677    #[tokio::test]
678    async fn test_openapi_routes_match_router() {
679        let operations = extract_openapi_operations();
680        assert!(
681            !operations.is_empty(),
682            "OpenAPI spec should declare at least one operation"
683        );
684
685        let (state, _dir) = create_conformance_state();
686        let router = crate::routes::api_routes().with_state(state);
687
688        let mut failures: Vec<String> = Vec::new();
689        for (template, method) in &operations {
690            let uri = template_to_uri(template);
691            let req = axum::http::Request::builder()
692                .method(method)
693                .uri(&uri)
694                .header("content-type", "application/json")
695                .body(axum::body::Body::from("{}"))
696                .expect("test: build request");
697
698            let resp = tower::ServiceExt::oneshot(router.clone(), req)
699                .await
700                .expect("test: send request");
701
702            if is_axum_fallback(resp).await {
703                failures.push(format!("{method} {template}"));
704            }
705        }
706
707        assert!(
708            failures.is_empty(),
709            "OpenAPI operations with no matching router route:\n  {}",
710            failures.join("\n  ")
711        );
712    }
713}