Skip to main content

velesdb_server/
lib.rs

1// Server — triaged pedantic/nursery lints (Sprint 2 Wave 8, A.10).
2// Blanket `#![allow(clippy::pedantic)]` removed; each remaining lint is
3// justified below.  Axum handler signatures, utoipa derives, and
4// OpenAPI-documented error contracts drive most of these.
5#![allow(clippy::uninlined_format_args)] // readability in error messages
6#![allow(clippy::manual_let_else)] // pattern matching in handlers is clearer
7#![allow(clippy::cast_possible_truncation)] // u128→u64 timing casts are bounded
8#![allow(clippy::cast_sign_loss)] // Duration→u64 timing casts are non-negative
9#![allow(clippy::cast_precision_loss)] // byte-count→f64 display casts are fine
10#![allow(clippy::ref_option)] // utoipa-generated code triggers this
11#![allow(clippy::match_same_arms)] // explicit arms improve readability in routers
12#![allow(clippy::trivially_copy_pass_by_ref)] // Axum extractors require &
13#![allow(clippy::map_unwrap_or)] // readability preference
14#![allow(clippy::enum_glob_use)] // StatusCode::* in handlers
15#![allow(clippy::unused_async)] // Axum requires async signature even for sync handlers
16#![allow(clippy::needless_for_each)] // readability in metric recording loops
17#![allow(clippy::doc_markdown)] // backtick pedantry — docs use utoipa annotations
18#![allow(clippy::missing_errors_doc)] // errors documented in #[utoipa::path] responses
19#![allow(clippy::must_use_candidate)] // handlers return impl IntoResponse, not Option
20#![allow(clippy::similar_names)] // handler params are intentionally close (name/names)
21#![allow(clippy::needless_raw_string_hashes)] // cosmetic, low-value fix
22#![allow(clippy::needless_pass_by_value)] // Axum extractors consume by value
23#![allow(clippy::redundant_closure_for_method_calls)] // readability in map chains
24#![allow(clippy::single_match_else)] // pattern matching in handlers is clearer
25#![allow(clippy::assigning_clones)]
26// minor optimisation, not performance-critical
27// The crate README is pulled into the crate documentation verbatim, so that
28// `cargo test --doc --package velesdb-server` type-checks every ```rust block it
29// contains. Today `README.md` only holds `bash`, `json` and `toml` blocks, which
30// rustdoc never compiles; the include is what makes any future Rust snippet
31// checked by the compiler instead of drifting away from the API unnoticed.
32// Blocks that must not be compiled or executed have to carry an explicit
33// rustdoc attribute in the README (`rust,no_run`, `rust,ignore`).
34#![doc = include_str!("../README.md")]
35//!
36//! ---
37//!
38//! # Crate-level notes
39//!
40//! `VelesDB` Server - REST API library for the `VelesDB` vector database.
41//!
42//! This module provides the HTTP handlers and types for the `VelesDB` REST API.
43//!
44//! ## OpenAPI Documentation
45//!
46//! The API is documented using OpenAPI 3.0. Access the interactive documentation at:
47//! - Swagger UI: `GET /swagger-ui`
48//! - OpenAPI JSON: `GET /api-docs/openapi.json`
49
50pub 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// ============================================================================
97// OpenAPI Documentation
98
99/// VelesDB API Documentation (paths that exist regardless of build features).
100///
101/// The `/metrics` path lives in [`MetricsApiDoc`] because `utoipa`'s `paths(...)`
102/// list is a fixed macro argument list — individual entries can't carry a
103/// `#[cfg(...)]`, so a handler gated behind the `prometheus` feature can't be
104/// listed here unconditionally without breaking `--no-default-features` builds.
105#[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/// OpenAPI doc fragment for the `/metrics` endpoint, only compiled when the
281/// `prometheus` feature is enabled (see [`ApiDocBase`] for why this is split out).
282#[cfg(feature = "prometheus")]
283#[derive(OpenApi)]
284#[openapi(paths(handlers::metrics::prometheus_metrics))]
285struct MetricsApiDoc;
286
287/// Public entry point for the full OpenAPI document. Merges in the
288/// `prometheus`-gated `/metrics` path when that feature is enabled.
289pub 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
303// ============================================================================
304// Application State
305
306/// Application state shared across handlers.
307pub struct AppState {
308    /// The `VelesDB` database instance.
309    pub db: Database,
310    /// New-user onboarding diagnostics counters.
311    pub onboarding_metrics: onboarding::OnboardingMetrics,
312    /// Query guard-rails configuration (EPIC-048).
313    pub query_limits: parking_lot::RwLock<QueryLimits>,
314    /// Readiness flag — `true` once the database is fully loaded.
315    pub ready: AtomicBool,
316    /// Operational metrics: query throughput, connections, doc counts (EPIC-050).
317    pub operational_metrics: Arc<OperationalMetrics>,
318    /// Graph traversal metrics: nodes visited, depth, edges scanned.
319    pub traversal_metrics: Arc<TraversalMetrics>,
320    /// Query duration histogram for Prometheus export.
321    pub query_duration_histogram: Arc<DurationHistogram>,
322}
323
324// ============================================================================
325// Tests
326
327#[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    /// Regenerates `docs/openapi.{json,yaml}` in place instead of only
411    /// comparing against them. Opt-in via `UPDATE_OPENAPI_SNAPSHOT=1` so that
412    /// a plain `cargo test` — including the default parallel test threads —
413    /// never mutates the working tree; see `generate_openapi_spec_files`.
414    fn update_openapi_snapshot_requested() -> bool {
415        std::env::var_os("UPDATE_OPENAPI_SNAPSHOT").is_some()
416    }
417
418    // #[ignore]: excludes this from the general `cargo test --workspace`
419    // sweep (the "Tests" CI job), which runs with a DIFFERENT feature set
420    // (persistence,gpu,update-check, no `openapi`/`prometheus`) than the one
421    // the committed docs/openapi.{json,yaml} were generated under. Run under
422    // that other feature set, the assert-equal below fails on a real (but
423    // benign) schema difference -- not staleness, a feature-combination
424    // mismatch. Only the dedicated `openapi-drift` CI step, which targets
425    // this test by exact name with `--ignored` under the canonical feature
426    // set, should ever run it.
427    #[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        // docs/ relative to workspace root
437        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        // Verify key endpoints are present
466        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        // IDs are serialized as strings to prevent JavaScript precision loss (WP-0D).
661        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        // code: None is omitted from JSON output
673        assert!(!json.contains("\"code\""));
674    }
675
676    // ========================================================================
677    // OpenAPI <-> Router structural conformance
678    // ========================================================================
679
680    /// Extracts every `(path_template, HTTP method)` pair declared in the
681    /// OpenAPI spec. Returns a sorted `Vec` for deterministic assertions.
682    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    /// Converts an OpenAPI path template into a concrete URI by replacing
707    /// each `{param}` placeholder with a safe dummy value.
708    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    /// Creates a minimal [`AppState`] backed by an ephemeral directory.
719    /// Returns both the state and the `TempDir` guard (must stay alive).
720    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    /// Returns `true` when the response is Axum's built-in fallback (route
736    /// not found), which is a `404` with an empty body. Handler-generated
737    /// 404s always carry a non-empty JSON body.
738    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    /// Structural conformance: every `(path, method)` declared in the OpenAPI
749    /// spec must be reachable through the Axum router (must NOT hit Axum's
750    /// built-in fallback 404).
751    #[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}