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