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_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#[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
252pub struct AppState {
257 pub db: Database,
259 pub onboarding_metrics: onboarding::OnboardingMetrics,
261 pub query_limits: parking_lot::RwLock<QueryLimits>,
263 pub ready: AtomicBool,
265 pub operational_metrics: Arc<OperationalMetrics>,
267 pub traversal_metrics: Arc<TraversalMetrics>,
269 pub query_duration_histogram: Arc<DurationHistogram>,
271}
272
273#[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 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 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 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 assert!(!json.contains("\"code\""));
589 }
590
591 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 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 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 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 #[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}