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