1#![allow(clippy::uninlined_format_args)] #![allow(clippy::manual_let_else)] #![allow(clippy::cast_possible_truncation)] #![allow(clippy::cast_sign_loss)] #![allow(clippy::cast_precision_loss)] #![allow(clippy::ref_option)] #![allow(clippy::match_same_arms)] #![allow(clippy::trivially_copy_pass_by_ref)] #![allow(clippy::map_unwrap_or)] #![allow(clippy::enum_glob_use)] #![allow(clippy::unused_async)] #![allow(clippy::needless_for_each)] #![allow(clippy::doc_markdown)] #![allow(clippy::missing_errors_doc)] #![allow(clippy::must_use_candidate)] #![allow(clippy::similar_names)] #![allow(clippy::needless_raw_string_hashes)] #![allow(clippy::needless_pass_by_value)] #![allow(clippy::redundant_closure_for_method_calls)] #![allow(clippy::single_match_else)] #![allow(clippy::assigning_clones)] pub 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#[derive(OpenApi)]
92#[openapi(
93 info(
94 title = "VelesDB API",
95 version = env!("CARGO_PKG_VERSION"),
96 description = "High-performance vector database for AI applications. \
97 Supports semantic search, HNSW indexing, and multiple distance metrics. \
98 Authentication is optional — when API keys are configured via VELESDB_API_KEYS, \
99 all endpoints except /health and /ready require a valid Bearer token.",
100 license(name = "VelesDB Core License 1.0", url = "https://github.com/cyberlife-coder/VelesDB/blob/main/LICENSE"),
101 contact(name = "VelesDB Team", url = "https://github.com/cyberlife-coder/VelesDB")
102 ),
103 security(
104 ("bearer_auth" = [])
105 ),
106 modifiers(&SecurityAddon),
107 servers(
108 (url = "/", description = "Local server")
109 ),
110 tags(
111 (name = "health", description = "Health check endpoints"),
112 (name = "collections", description = "Collection management"),
113 (name = "points", description = "Vector point operations"),
114 (name = "search", description = "Vector similarity search"),
115 (name = "query", description = "VelesQL query execution"),
116 (name = "indexes", description = "Property index management (EPIC-009)"),
117 (name = "graph", description = "Graph traversal and edge operations"),
118 (name = "guardrails", description = "Query guard-rails configuration (EPIC-048)"),
119 (name = "metrics", description = "Prometheus operational metrics")
120 ),
121 paths(
122 handlers::health::health_check,
123 handlers::health::readiness_check,
124 handlers::collections::list_collections,
125 handlers::collections::create_collection,
126 handlers::collections::get_collection,
127 handlers::collections::delete_collection,
128 handlers::collections::collection_sanity,
129 handlers::collections::is_empty,
130 handlers::collections::flush_collection,
131 handlers::admin::analyze_collection,
132 handlers::admin::get_collection_stats,
133 handlers::admin::collection_diagnostics,
134 handlers::admin::get_guardrails,
135 handlers::admin::update_guardrails,
136 handlers::points::upsert_points,
137 handlers::points::raw::upsert_points_raw,
138 handlers::points::stream_upsert_points,
139 handlers::points::stream_insert,
140 handlers::points::enable_streaming,
141 handlers::points::get_point,
142 handlers::points::delete_point,
143 handlers::points::scroll_points,
144 handlers::search::search,
145 handlers::search::batch_search,
146 handlers::search::multi_query_search,
147 handlers::search::multi_query_search_ids,
148 handlers::search::text_search,
149 handlers::search::hybrid_search,
150 handlers::search::search_ids,
151 handlers::admin::get_collection_config,
152 handlers::query::query,
153 handlers::query::aggregate,
154 handlers::query::explain,
155 handlers::indexes::create_index,
156 handlers::indexes::list_indexes,
157 handlers::indexes::delete_index,
158 handlers::graph::handlers::get_edges,
159 handlers::graph::handlers::add_edge,
160 handlers::graph::handlers::add_edges_batch,
161 handlers::graph::handlers_extended::remove_edge,
162 handlers::graph::handlers_extended::get_edge_count,
163 handlers::graph::handlers_extended::list_nodes,
164 handlers::graph::handlers_extended::get_node_edges,
165 handlers::graph::handlers_extended::get_node_payload,
166 handlers::graph::handlers_extended::upsert_node_payload,
167 handlers::graph::handlers::traverse_graph,
168 handlers::graph::handlers_extended::traverse_parallel,
169 handlers::graph::handlers::get_node_degree,
170 handlers::graph::handlers_extended::graph_search,
171 handlers::graph::stream::stream_traverse,
172 handlers::match_query::match_query,
173 handlers::admin::rebuild_index,
174 handlers::admin::vacuum_collection,
175 handlers::admin::compact_collection,
176 handlers::admin::reorder_for_locality,
177 handlers::points::bulk_delete_points,
178 handlers::points::relations::relate_points,
179 handlers::points::relations::unrelate_points,
180 handlers::points::relations::get_point_relations,
181 handlers::points::relations::set_point_ttl,
182 ),
183 components(
184 schemas(
185 CreateCollectionRequest,
186 CollectionResponse,
187 UpsertPointsRequest,
188 PointRequest,
189 StreamInsertRequest,
190 EnableStreamingRequest,
191 SearchRequest,
192 BatchSearchRequest,
193 TextSearchRequest,
194 HybridSearchRequest,
195 MultiQuerySearchRequest,
196 SearchResponse,
197 BatchSearchResponse,
198 SearchResultResponse,
199 SearchIdsResponse,
200 IdScoreResult,
201 CollectionConfigResponse,
202 ErrorResponse,
203 QueryRequest,
204 QueryResponse,
205 QueryResponseMeta,
206 AggregationResponse,
207 QueryErrorResponse,
208 QueryErrorDetail,
209 VelesqlErrorResponse,
210 VelesqlErrorDetail,
211 ExplainRequest,
212 ExplainResponse,
213 ExplainStep,
214 ExplainCost,
215 ExplainFeatures,
216 ActualStatsResponse,
217 NodeStatsResponse,
218 CreateIndexRequest,
219 IndexResponse,
220 ListIndexesResponse,
221 CollectionStatsResponse,
222 ColumnStatsResponse,
223 IndexStatsResponse,
224 ScrollRequest,
225 ScrollResponse,
226 ScrollPoint,
227 GuardRailsConfigRequest,
228 GuardRailsConfigResponse,
229 CollectionDiagnosticsResponse,
230 handlers::graph::TraverseRequest,
231 handlers::graph::TraverseResponse,
232 handlers::graph::TraversalResultItem,
233 handlers::graph::TraversalStats,
234 handlers::graph::DegreeResponse,
235 handlers::graph::AddEdgeRequest,
236 handlers::graph::AddEdgesBatchRequest,
237 handlers::graph::AddEdgesBatchResponse,
238 handlers::graph::EdgesResponse,
239 handlers::graph::EdgeResponse,
240 handlers::graph::EdgeCountResponse,
241 handlers::graph::NodeListResponse,
242 handlers::graph::NodePayloadResponse,
243 handlers::graph::UpsertNodePayloadRequest,
244 handlers::graph::ParallelTraverseRequest,
245 handlers::graph::GraphSearchRequest,
246 handlers::graph::GraphSearchResponse,
247 handlers::graph::GraphSearchResultItem,
248 handlers::graph::StreamNodeEvent,
249 handlers::graph::StreamStatsEvent,
250 handlers::graph::StreamDoneEvent,
251 handlers::match_query::MatchQueryRequest,
252 handlers::match_query::MatchQueryResponse,
253 handlers::match_query::MatchQueryResultItem,
254 handlers::match_query::MatchQueryMeta,
255 handlers::points::BulkDeleteRequest,
256 handlers::points::relations::RelateRequest,
257 handlers::points::relations::RelateResponse,
258 handlers::points::relations::RelationEdge,
259 handlers::points::relations::RelationsResponse,
260 handlers::points::relations::SetTtlRequest
261 )
262 )
263)]
264struct ApiDocBase;
265
266#[cfg(feature = "prometheus")]
269#[derive(OpenApi)]
270#[openapi(paths(handlers::metrics::prometheus_metrics))]
271struct MetricsApiDoc;
272
273pub struct ApiDoc;
276
277impl ApiDoc {
278 pub fn openapi() -> utoipa::openapi::OpenApi {
279 #[allow(unused_mut)]
280 let mut doc = ApiDocBase::openapi();
281 #[cfg(feature = "prometheus")]
282 {
283 doc = doc.merge_from(MetricsApiDoc::openapi());
284 }
285 doc
286 }
287}
288
289pub struct AppState {
294 pub db: Database,
296 pub onboarding_metrics: onboarding::OnboardingMetrics,
298 pub query_limits: parking_lot::RwLock<QueryLimits>,
300 pub ready: AtomicBool,
302 pub operational_metrics: Arc<OperationalMetrics>,
304 pub traversal_metrics: Arc<TraversalMetrics>,
306 pub query_duration_histogram: Arc<DurationHistogram>,
308}
309
310#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn test_openapi_spec_generation() {
319 let openapi = ApiDoc::openapi();
320 let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
321 assert!(!json.is_empty(), "OpenAPI spec should not be empty");
322 assert!(json.contains("VelesDB API"), "Should contain API title");
323 assert!(
324 json.contains(env!("CARGO_PKG_VERSION")),
325 "Should contain version"
326 );
327 }
328
329 #[test]
330 fn test_openapi_has_all_endpoints() {
331 let openapi = ApiDoc::openapi();
332 let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
333 assert!(json.contains("/health"), "Should document /health");
334 assert!(
335 json.contains("/collections"),
336 "Should document /collections"
337 );
338 assert!(
339 json.contains(r"/collections/{name}"),
340 "Should document collections by name"
341 );
342 assert!(json.contains("/points"), "Should document points endpoint");
343 assert!(
344 json.contains(r"/collections/{name}/points/stream"),
345 "Should document points stream endpoint"
346 );
347 assert!(json.contains("/search"), "Should document search endpoint");
348 assert!(json.contains("/query"), "Should document /query");
349 assert!(json.contains("/aggregate"), "Should document /aggregate");
350 assert!(
351 json.contains("/query/explain"),
352 "Should document /query/explain"
353 );
354 }
355
356 #[test]
357 fn test_openapi_has_all_tags() {
358 let openapi = ApiDoc::openapi();
359 let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
360 assert!(json.contains("\"health\""), "Should have health tag");
361 assert!(
362 json.contains("\"collections\""),
363 "Should have collections tag"
364 );
365 assert!(json.contains("\"points\""), "Should have points tag");
366 assert!(json.contains("\"search\""), "Should have search tag");
367 assert!(json.contains("\"query\""), "Should have query tag");
368 }
369
370 #[test]
371 fn test_openapi_has_schemas() {
372 let openapi = ApiDoc::openapi();
373 let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
374 assert!(
375 json.contains("CreateCollectionRequest"),
376 "Should have CreateCollectionRequest schema"
377 );
378 assert!(
379 json.contains("CollectionResponse"),
380 "Should have CollectionResponse schema"
381 );
382 assert!(
383 json.contains("SearchRequest"),
384 "Should have SearchRequest schema"
385 );
386 assert!(
387 json.contains("SearchResponse"),
388 "Should have SearchResponse schema"
389 );
390 assert!(
391 json.contains("ErrorResponse"),
392 "Should have ErrorResponse schema"
393 );
394 }
395
396 fn update_openapi_snapshot_requested() -> bool {
401 std::env::var_os("UPDATE_OPENAPI_SNAPSHOT").is_some()
402 }
403
404 #[test]
414 #[ignore = "run explicitly via the openapi-drift CI job; see comment above"]
415 fn generate_openapi_spec_files() {
416 let openapi = ApiDoc::openapi();
417 let json = openapi
418 .to_pretty_json()
419 .expect("Failed to serialize OpenAPI JSON");
420 let yaml = serde_yaml::to_string(&openapi).expect("Failed to serialize OpenAPI YAML");
421
422 let docs_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
424 .parent()
425 .expect("test: CARGO_MANIFEST_DIR has a parent (crates/)")
426 .parent()
427 .expect("test: crates/ has a parent (workspace root)")
428 .join("docs");
429 let json_path = docs_dir.join("openapi.json");
430 let yaml_path = docs_dir.join("openapi.yaml");
431
432 if update_openapi_snapshot_requested() {
433 std::fs::create_dir_all(&docs_dir).expect("Failed to create docs dir");
434 std::fs::write(&json_path, &json).expect("Failed to write openapi.json");
435 std::fs::write(&yaml_path, &yaml).expect("Failed to write openapi.yaml");
436 } else {
437 let committed_json = std::fs::read_to_string(&json_path)
438 .expect("Failed to read docs/openapi.json (run with UPDATE_OPENAPI_SNAPSHOT=1 to create it)");
439 let committed_yaml = std::fs::read_to_string(&yaml_path)
440 .expect("Failed to read docs/openapi.yaml (run with UPDATE_OPENAPI_SNAPSHOT=1 to create it)");
441 assert_eq!(
442 json, committed_json,
443 "docs/openapi.json is stale — rerun with UPDATE_OPENAPI_SNAPSHOT=1 to regenerate"
444 );
445 assert_eq!(
446 yaml, committed_yaml,
447 "docs/openapi.yaml is stale — rerun with UPDATE_OPENAPI_SNAPSHOT=1 to regenerate"
448 );
449 }
450
451 assert!(
453 json.contains("sparse"),
454 "OpenAPI spec should contain sparse endpoints"
455 );
456 assert!(
457 json.contains("/graph/edges"),
458 "Should contain graph edge endpoints"
459 );
460 assert!(
461 json.contains("/graph/traverse"),
462 "Should contain graph traverse endpoint"
463 );
464 assert!(
465 json.contains("/stream/insert"),
466 "Should contain stream insert endpoint"
467 );
468 assert!(
469 json.contains("/match"),
470 "Should contain match query endpoint"
471 );
472 assert!(
473 json.contains("/search/multi"),
474 "Should contain multi-query search endpoint"
475 );
476 }
477
478 #[test]
479 fn test_openapi_has_license() {
480 let openapi = ApiDoc::openapi();
481 let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
482 assert!(
483 json.contains("VelesDB Core License 1.0"),
484 "Should have VelesDB Core License 1.0"
485 );
486 }
487
488 #[test]
489 fn test_openapi_pretty_json() {
490 let openapi = ApiDoc::openapi();
491 let pretty_json = openapi
492 .to_pretty_json()
493 .expect("Failed to serialize pretty JSON");
494 assert!(
495 pretty_json.contains('\n'),
496 "Pretty JSON should have newlines"
497 );
498 assert!(
499 pretty_json.len() > 1000,
500 "OpenAPI spec should be substantial"
501 );
502 }
503
504 #[test]
505 fn test_openapi_has_all_metrics_documented() {
506 let openapi = ApiDoc::openapi();
507 let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
508 assert!(json.contains("cosine"), "Should document cosine metric");
509 assert!(
510 json.contains("euclidean"),
511 "Should document euclidean metric"
512 );
513 assert!(json.contains("dot"), "Should document dot product metric");
514 assert!(json.contains("hamming"), "Should document hamming metric");
515 assert!(json.contains("jaccard"), "Should document jaccard metric");
516 }
517
518 #[test]
519 fn test_openapi_has_storage_mode_documented() {
520 let openapi = ApiDoc::openapi();
521 let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
522 assert!(
523 json.contains("storage_mode"),
524 "Should document storage_mode parameter"
525 );
526 }
527
528 #[test]
529 fn test_openapi_has_search_types_documented() {
530 let openapi = ApiDoc::openapi();
531 let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
532 assert!(json.contains("text_search"), "Should document text search");
533 assert!(
534 json.contains("hybrid_search"),
535 "Should document hybrid search"
536 );
537 assert!(json.contains("batch"), "Should document batch search");
538 }
539
540 #[test]
541 fn test_create_collection_request_default_metric() {
542 let json = r#"{"name": "test", "dimension": 128}"#;
543 let req: CreateCollectionRequest =
544 serde_json::from_str(json).expect("test: valid CreateCollectionRequest JSON");
545 assert_eq!(req.metric, "cosine");
546 }
547
548 #[test]
549 fn test_create_collection_request_with_hamming() {
550 let json = r#"{"name": "test", "dimension": 128, "metric": "hamming"}"#;
551 let req: CreateCollectionRequest =
552 serde_json::from_str(json).expect("test: valid CreateCollectionRequest JSON");
553 assert_eq!(req.metric, "hamming");
554 }
555
556 #[test]
557 fn test_create_collection_request_with_jaccard() {
558 let json = r#"{"name": "test", "dimension": 128, "metric": "jaccard"}"#;
559 let req: CreateCollectionRequest =
560 serde_json::from_str(json).expect("test: valid CreateCollectionRequest JSON");
561 assert_eq!(req.metric, "jaccard");
562 }
563
564 #[test]
565 fn test_create_collection_request_with_storage_mode() {
566 let json = r#"{"name": "test", "dimension": 128, "storage_mode": "sq8"}"#;
567 let req: CreateCollectionRequest =
568 serde_json::from_str(json).expect("test: valid CreateCollectionRequest JSON");
569 assert_eq!(req.storage_mode, "sq8");
570 }
571
572 #[test]
573 fn test_search_request_deserialize() {
574 let json = r#"{"vector": [0.1, 0.2, 0.3], "top_k": 5}"#;
575 let req: SearchRequest =
576 serde_json::from_str(json).expect("test: valid SearchRequest JSON");
577 assert_eq!(req.vector, vec![0.1, 0.2, 0.3]);
578 assert_eq!(req.top_k, 5);
579 }
580
581 #[test]
582 fn test_batch_search_request_deserialize() {
583 let json = r#"{"searches": [{"vector": [0.1, 0.2], "top_k": 3}]}"#;
584 let req: BatchSearchRequest =
585 serde_json::from_str(json).expect("test: valid BatchSearchRequest JSON");
586 assert_eq!(req.searches.len(), 1);
587 assert_eq!(req.searches[0].top_k, 3);
588 }
589
590 #[test]
591 fn test_text_search_request_deserialize() {
592 let json = r#"{"query": "machine learning", "top_k": 10}"#;
593 let req: TextSearchRequest =
594 serde_json::from_str(json).expect("test: valid TextSearchRequest JSON");
595 assert_eq!(req.query, "machine learning");
596 assert_eq!(req.top_k, 10);
597 }
598
599 #[test]
600 fn test_hybrid_search_request_deserialize() {
601 let json = r#"{"vector": [0.1, 0.2], "query": "test", "top_k": 5}"#;
602 let req: HybridSearchRequest =
603 serde_json::from_str(json).expect("test: valid HybridSearchRequest JSON");
604 assert_eq!(req.vector, vec![0.1, 0.2]);
605 assert_eq!(req.query, "test");
606 assert_eq!(req.top_k, 5);
607 }
608
609 #[test]
610 fn test_upsert_points_request_deserialize() {
611 let json = r#"{"points": [{"id": 1, "vector": [0.1, 0.2]}]}"#;
612 let req: UpsertPointsRequest =
613 serde_json::from_str(json).expect("test: valid UpsertPointsRequest JSON");
614 assert_eq!(req.points.len(), 1);
615 assert_eq!(req.points[0].id, 1);
616 }
617
618 #[test]
619 fn test_collection_response_serialize() {
620 let resp = CollectionResponse {
621 name: "test".to_string(),
622 dimension: 128,
623 metric: "cosine".to_string(),
624 storage_mode: "full".to_string(),
625 point_count: 100,
626 };
627 let json = serde_json::to_string(&resp).expect("test: serialize CollectionResponse");
628 assert!(json.contains("\"name\":\"test\""));
629 assert!(json.contains("\"dimension\":128"));
630 assert!(json.contains("\"metric\":\"cosine\""));
631 assert!(json.contains("\"storage_mode\":\"full\""));
632 assert!(json.contains("\"point_count\":100"));
633 }
634
635 #[test]
636 fn test_search_response_serialize() {
637 let resp = SearchResponse {
638 results: vec![SearchResultResponse {
639 id: 1,
640 score: 0.95,
641 payload: None,
642 }],
643 };
644 let json = serde_json::to_string(&resp).expect("test: serialize SearchResponse");
645 assert!(json.contains("\"results\""));
646 assert!(json.contains("\"id\":\"1\""));
648 }
649
650 #[test]
651 fn test_error_response_serialize() {
652 let resp = ErrorResponse {
653 error: "Test error".to_string(),
654 code: None,
655 };
656 let json = serde_json::to_string(&resp).expect("test: serialize ErrorResponse");
657 assert!(json.contains("\"error\":\"Test error\""));
658 assert!(!json.contains("\"code\""));
660 }
661
662 fn extract_openapi_operations() -> Vec<(String, axum::http::Method)> {
669 let openapi = ApiDoc::openapi();
670 let mut ops = Vec::new();
671 for (path, item) in &openapi.paths.paths {
672 if item.get.is_some() {
673 ops.push((path.clone(), axum::http::Method::GET));
674 }
675 if item.post.is_some() {
676 ops.push((path.clone(), axum::http::Method::POST));
677 }
678 if item.put.is_some() {
679 ops.push((path.clone(), axum::http::Method::PUT));
680 }
681 if item.delete.is_some() {
682 ops.push((path.clone(), axum::http::Method::DELETE));
683 }
684 if item.patch.is_some() {
685 ops.push((path.clone(), axum::http::Method::PATCH));
686 }
687 }
688 ops.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.as_str().cmp(b.1.as_str())));
689 ops
690 }
691
692 fn template_to_uri(template: &str) -> String {
695 template
696 .replace("{name}", "test_col")
697 .replace("{id}", "1")
698 .replace("{node_id}", "1")
699 .replace("{edge_id}", "1")
700 .replace("{label}", "test_label")
701 .replace("{property}", "test_prop")
702 }
703
704 fn create_conformance_state() -> (std::sync::Arc<AppState>, tempfile::TempDir) {
707 let dir = tempfile::TempDir::new().expect("test: create temp dir");
708 let db = Database::open(dir.path()).expect("test: open database");
709 let state = std::sync::Arc::new(AppState {
710 db,
711 onboarding_metrics: OnboardingMetrics::default(),
712 query_limits: parking_lot::RwLock::new(QueryLimits::default()),
713 ready: AtomicBool::new(true),
714 operational_metrics: velesdb_core::metrics::OperationalMetrics::new_arc(),
715 traversal_metrics: Arc::new(velesdb_core::metrics::TraversalMetrics::new()),
716 query_duration_histogram: Arc::new(velesdb_core::metrics::DurationHistogram::new()),
717 });
718 (state, dir)
719 }
720
721 async fn is_axum_fallback(resp: axum::http::Response<axum::body::Body>) -> bool {
725 if resp.status() != axum::http::StatusCode::NOT_FOUND {
726 return false;
727 }
728 let body = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
729 .await
730 .expect("test: read response body");
731 body.is_empty()
732 }
733
734 #[tokio::test]
738 async fn test_openapi_routes_match_router() {
739 let operations = extract_openapi_operations();
740 assert!(
741 !operations.is_empty(),
742 "OpenAPI spec should declare at least one operation"
743 );
744
745 let (state, _dir) = create_conformance_state();
746 let router = crate::routes::api_routes().with_state(state);
747
748 let mut failures: Vec<String> = Vec::new();
749 for (template, method) in &operations {
750 let uri = template_to_uri(template);
751 let req = axum::http::Request::builder()
752 .method(method)
753 .uri(&uri)
754 .header("content-type", "application/json")
755 .body(axum::body::Body::from("{}"))
756 .expect("test: build request");
757
758 let resp = tower::ServiceExt::oneshot(router.clone(), req)
759 .await
760 .expect("test: send request");
761
762 if is_axum_fallback(resp).await {
763 failures.push(format!("{method} {template}"));
764 }
765 }
766
767 assert!(
768 failures.is_empty(),
769 "OpenAPI operations with no matching router route:\n {}",
770 failures.join("\n ")
771 );
772 }
773}