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::points::BulkDeleteRequest,
252 handlers::points::relations::RelateRequest,
253 handlers::points::relations::RelateResponse,
254 handlers::points::relations::RelationEdge,
255 handlers::points::relations::RelationsResponse,
256 handlers::points::relations::SetTtlRequest
257 )
258 )
259)]
260pub struct ApiDoc;
261
262pub struct AppState {
267 pub db: Database,
269 pub onboarding_metrics: onboarding::OnboardingMetrics,
271 pub query_limits: parking_lot::RwLock<QueryLimits>,
273 pub ready: AtomicBool,
275 pub operational_metrics: Arc<OperationalMetrics>,
277 pub traversal_metrics: Arc<TraversalMetrics>,
279 pub query_duration_histogram: Arc<DurationHistogram>,
281}
282
283#[cfg(test)]
287mod tests {
288 use super::*;
289 use utoipa::OpenApi;
290
291 #[test]
292 fn test_openapi_spec_generation() {
293 let openapi = ApiDoc::openapi();
294 let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
295 assert!(!json.is_empty(), "OpenAPI spec should not be empty");
296 assert!(json.contains("VelesDB API"), "Should contain API title");
297 assert!(
298 json.contains(env!("CARGO_PKG_VERSION")),
299 "Should contain version"
300 );
301 }
302
303 #[test]
304 fn test_openapi_has_all_endpoints() {
305 let openapi = ApiDoc::openapi();
306 let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
307 assert!(json.contains("/health"), "Should document /health");
308 assert!(
309 json.contains("/collections"),
310 "Should document /collections"
311 );
312 assert!(
313 json.contains(r"/collections/{name}"),
314 "Should document collections by name"
315 );
316 assert!(json.contains("/points"), "Should document points endpoint");
317 assert!(
318 json.contains(r"/collections/{name}/points/stream"),
319 "Should document points stream endpoint"
320 );
321 assert!(json.contains("/search"), "Should document search endpoint");
322 assert!(json.contains("/query"), "Should document /query");
323 assert!(json.contains("/aggregate"), "Should document /aggregate");
324 assert!(
325 json.contains("/query/explain"),
326 "Should document /query/explain"
327 );
328 }
329
330 #[test]
331 fn test_openapi_has_all_tags() {
332 let openapi = ApiDoc::openapi();
333 let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
334 assert!(json.contains("\"health\""), "Should have health tag");
335 assert!(
336 json.contains("\"collections\""),
337 "Should have collections tag"
338 );
339 assert!(json.contains("\"points\""), "Should have points tag");
340 assert!(json.contains("\"search\""), "Should have search tag");
341 assert!(json.contains("\"query\""), "Should have query tag");
342 }
343
344 #[test]
345 fn test_openapi_has_schemas() {
346 let openapi = ApiDoc::openapi();
347 let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
348 assert!(
349 json.contains("CreateCollectionRequest"),
350 "Should have CreateCollectionRequest schema"
351 );
352 assert!(
353 json.contains("CollectionResponse"),
354 "Should have CollectionResponse schema"
355 );
356 assert!(
357 json.contains("SearchRequest"),
358 "Should have SearchRequest schema"
359 );
360 assert!(
361 json.contains("SearchResponse"),
362 "Should have SearchResponse schema"
363 );
364 assert!(
365 json.contains("ErrorResponse"),
366 "Should have ErrorResponse schema"
367 );
368 }
369
370 #[test]
371 fn generate_openapi_spec_files() {
372 let openapi = ApiDoc::openapi();
373 let json = openapi
374 .to_pretty_json()
375 .expect("Failed to serialize OpenAPI JSON");
376 let yaml = serde_yaml::to_string(&openapi).expect("Failed to serialize OpenAPI YAML");
377
378 let docs_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
380 .parent()
381 .expect("test: CARGO_MANIFEST_DIR has a parent (crates/)")
382 .parent()
383 .expect("test: crates/ has a parent (workspace root)")
384 .join("docs");
385 std::fs::create_dir_all(&docs_dir).expect("Failed to create docs dir");
386
387 std::fs::write(docs_dir.join("openapi.json"), &json).expect("Failed to write openapi.json");
388 std::fs::write(docs_dir.join("openapi.yaml"), &yaml).expect("Failed to write openapi.yaml");
389
390 assert!(
392 json.contains("sparse"),
393 "OpenAPI spec should contain sparse endpoints"
394 );
395 assert!(
396 json.contains("/graph/edges"),
397 "Should contain graph edge endpoints"
398 );
399 assert!(
400 json.contains("/graph/traverse"),
401 "Should contain graph traverse endpoint"
402 );
403 assert!(
404 json.contains("/stream/insert"),
405 "Should contain stream insert endpoint"
406 );
407 assert!(
408 json.contains("/match"),
409 "Should contain match query endpoint"
410 );
411 assert!(
412 json.contains("/search/multi"),
413 "Should contain multi-query search endpoint"
414 );
415 }
416
417 #[test]
418 fn test_openapi_has_license() {
419 let openapi = ApiDoc::openapi();
420 let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
421 assert!(
422 json.contains("VelesDB Core License 1.0"),
423 "Should have VelesDB Core License 1.0"
424 );
425 }
426
427 #[test]
428 fn test_openapi_pretty_json() {
429 let openapi = ApiDoc::openapi();
430 let pretty_json = openapi
431 .to_pretty_json()
432 .expect("Failed to serialize pretty JSON");
433 assert!(
434 pretty_json.contains('\n'),
435 "Pretty JSON should have newlines"
436 );
437 assert!(
438 pretty_json.len() > 1000,
439 "OpenAPI spec should be substantial"
440 );
441 }
442
443 #[test]
444 fn test_openapi_has_all_metrics_documented() {
445 let openapi = ApiDoc::openapi();
446 let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
447 assert!(json.contains("cosine"), "Should document cosine metric");
448 assert!(
449 json.contains("euclidean"),
450 "Should document euclidean metric"
451 );
452 assert!(json.contains("dot"), "Should document dot product metric");
453 assert!(json.contains("hamming"), "Should document hamming metric");
454 assert!(json.contains("jaccard"), "Should document jaccard metric");
455 }
456
457 #[test]
458 fn test_openapi_has_storage_mode_documented() {
459 let openapi = ApiDoc::openapi();
460 let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
461 assert!(
462 json.contains("storage_mode"),
463 "Should document storage_mode parameter"
464 );
465 }
466
467 #[test]
468 fn test_openapi_has_search_types_documented() {
469 let openapi = ApiDoc::openapi();
470 let json = openapi.to_json().expect("Failed to serialize OpenAPI spec");
471 assert!(json.contains("text_search"), "Should document text search");
472 assert!(
473 json.contains("hybrid_search"),
474 "Should document hybrid search"
475 );
476 assert!(json.contains("batch"), "Should document batch search");
477 }
478
479 #[test]
480 fn test_create_collection_request_default_metric() {
481 let json = r#"{"name": "test", "dimension": 128}"#;
482 let req: CreateCollectionRequest =
483 serde_json::from_str(json).expect("test: valid CreateCollectionRequest JSON");
484 assert_eq!(req.metric, "cosine");
485 }
486
487 #[test]
488 fn test_create_collection_request_with_hamming() {
489 let json = r#"{"name": "test", "dimension": 128, "metric": "hamming"}"#;
490 let req: CreateCollectionRequest =
491 serde_json::from_str(json).expect("test: valid CreateCollectionRequest JSON");
492 assert_eq!(req.metric, "hamming");
493 }
494
495 #[test]
496 fn test_create_collection_request_with_jaccard() {
497 let json = r#"{"name": "test", "dimension": 128, "metric": "jaccard"}"#;
498 let req: CreateCollectionRequest =
499 serde_json::from_str(json).expect("test: valid CreateCollectionRequest JSON");
500 assert_eq!(req.metric, "jaccard");
501 }
502
503 #[test]
504 fn test_create_collection_request_with_storage_mode() {
505 let json = r#"{"name": "test", "dimension": 128, "storage_mode": "sq8"}"#;
506 let req: CreateCollectionRequest =
507 serde_json::from_str(json).expect("test: valid CreateCollectionRequest JSON");
508 assert_eq!(req.storage_mode, "sq8");
509 }
510
511 #[test]
512 fn test_search_request_deserialize() {
513 let json = r#"{"vector": [0.1, 0.2, 0.3], "top_k": 5}"#;
514 let req: SearchRequest =
515 serde_json::from_str(json).expect("test: valid SearchRequest JSON");
516 assert_eq!(req.vector, vec![0.1, 0.2, 0.3]);
517 assert_eq!(req.top_k, 5);
518 }
519
520 #[test]
521 fn test_batch_search_request_deserialize() {
522 let json = r#"{"searches": [{"vector": [0.1, 0.2], "top_k": 3}]}"#;
523 let req: BatchSearchRequest =
524 serde_json::from_str(json).expect("test: valid BatchSearchRequest JSON");
525 assert_eq!(req.searches.len(), 1);
526 assert_eq!(req.searches[0].top_k, 3);
527 }
528
529 #[test]
530 fn test_text_search_request_deserialize() {
531 let json = r#"{"query": "machine learning", "top_k": 10}"#;
532 let req: TextSearchRequest =
533 serde_json::from_str(json).expect("test: valid TextSearchRequest JSON");
534 assert_eq!(req.query, "machine learning");
535 assert_eq!(req.top_k, 10);
536 }
537
538 #[test]
539 fn test_hybrid_search_request_deserialize() {
540 let json = r#"{"vector": [0.1, 0.2], "query": "test", "top_k": 5}"#;
541 let req: HybridSearchRequest =
542 serde_json::from_str(json).expect("test: valid HybridSearchRequest JSON");
543 assert_eq!(req.vector, vec![0.1, 0.2]);
544 assert_eq!(req.query, "test");
545 assert_eq!(req.top_k, 5);
546 }
547
548 #[test]
549 fn test_upsert_points_request_deserialize() {
550 let json = r#"{"points": [{"id": 1, "vector": [0.1, 0.2]}]}"#;
551 let req: UpsertPointsRequest =
552 serde_json::from_str(json).expect("test: valid UpsertPointsRequest JSON");
553 assert_eq!(req.points.len(), 1);
554 assert_eq!(req.points[0].id, 1);
555 }
556
557 #[test]
558 fn test_collection_response_serialize() {
559 let resp = CollectionResponse {
560 name: "test".to_string(),
561 dimension: 128,
562 metric: "cosine".to_string(),
563 storage_mode: "full".to_string(),
564 point_count: 100,
565 };
566 let json = serde_json::to_string(&resp).expect("test: serialize CollectionResponse");
567 assert!(json.contains("\"name\":\"test\""));
568 assert!(json.contains("\"dimension\":128"));
569 assert!(json.contains("\"metric\":\"cosine\""));
570 assert!(json.contains("\"storage_mode\":\"full\""));
571 assert!(json.contains("\"point_count\":100"));
572 }
573
574 #[test]
575 fn test_search_response_serialize() {
576 let resp = SearchResponse {
577 results: vec![SearchResultResponse {
578 id: 1,
579 score: 0.95,
580 payload: None,
581 }],
582 };
583 let json = serde_json::to_string(&resp).expect("test: serialize SearchResponse");
584 assert!(json.contains("\"results\""));
585 assert!(json.contains("\"id\":\"1\""));
587 }
588
589 #[test]
590 fn test_error_response_serialize() {
591 let resp = ErrorResponse {
592 error: "Test error".to_string(),
593 code: None,
594 };
595 let json = serde_json::to_string(&resp).expect("test: serialize ErrorResponse");
596 assert!(json.contains("\"error\":\"Test error\""));
597 assert!(!json.contains("\"code\""));
599 }
600
601 fn extract_openapi_operations() -> Vec<(String, axum::http::Method)> {
608 let openapi = ApiDoc::openapi();
609 let mut ops = Vec::new();
610 for (path, item) in &openapi.paths.paths {
611 if item.get.is_some() {
612 ops.push((path.clone(), axum::http::Method::GET));
613 }
614 if item.post.is_some() {
615 ops.push((path.clone(), axum::http::Method::POST));
616 }
617 if item.put.is_some() {
618 ops.push((path.clone(), axum::http::Method::PUT));
619 }
620 if item.delete.is_some() {
621 ops.push((path.clone(), axum::http::Method::DELETE));
622 }
623 if item.patch.is_some() {
624 ops.push((path.clone(), axum::http::Method::PATCH));
625 }
626 }
627 ops.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.as_str().cmp(b.1.as_str())));
628 ops
629 }
630
631 fn template_to_uri(template: &str) -> String {
634 template
635 .replace("{name}", "test_col")
636 .replace("{id}", "1")
637 .replace("{node_id}", "1")
638 .replace("{edge_id}", "1")
639 .replace("{label}", "test_label")
640 .replace("{property}", "test_prop")
641 }
642
643 fn create_conformance_state() -> (std::sync::Arc<AppState>, tempfile::TempDir) {
646 let dir = tempfile::TempDir::new().expect("test: create temp dir");
647 let db = Database::open(dir.path()).expect("test: open database");
648 let state = std::sync::Arc::new(AppState {
649 db,
650 onboarding_metrics: OnboardingMetrics::default(),
651 query_limits: parking_lot::RwLock::new(QueryLimits::default()),
652 ready: AtomicBool::new(true),
653 operational_metrics: velesdb_core::metrics::OperationalMetrics::new_arc(),
654 traversal_metrics: Arc::new(velesdb_core::metrics::TraversalMetrics::new()),
655 query_duration_histogram: Arc::new(velesdb_core::metrics::DurationHistogram::new()),
656 });
657 (state, dir)
658 }
659
660 async fn is_axum_fallback(resp: axum::http::Response<axum::body::Body>) -> bool {
664 if resp.status() != axum::http::StatusCode::NOT_FOUND {
665 return false;
666 }
667 let body = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
668 .await
669 .expect("test: read response body");
670 body.is_empty()
671 }
672
673 #[tokio::test]
677 async fn test_openapi_routes_match_router() {
678 let operations = extract_openapi_operations();
679 assert!(
680 !operations.is_empty(),
681 "OpenAPI spec should declare at least one operation"
682 );
683
684 let (state, _dir) = create_conformance_state();
685 let router = crate::routes::api_routes().with_state(state);
686
687 let mut failures: Vec<String> = Vec::new();
688 for (template, method) in &operations {
689 let uri = template_to_uri(template);
690 let req = axum::http::Request::builder()
691 .method(method)
692 .uri(&uri)
693 .header("content-type", "application/json")
694 .body(axum::body::Body::from("{}"))
695 .expect("test: build request");
696
697 let resp = tower::ServiceExt::oneshot(router.clone(), req)
698 .await
699 .expect("test: send request");
700
701 if is_axum_fallback(resp).await {
702 failures.push(format!("{method} {template}"));
703 }
704 }
705
706 assert!(
707 failures.is_empty(),
708 "OpenAPI operations with no matching router route:\n {}",
709 failures.join("\n ")
710 );
711 }
712}