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