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