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