1pub mod body;
9pub mod codec;
10pub mod error;
11pub mod metadata;
12pub mod request;
13
14use axum::extract::{Path, RawQuery, State};
15use axum::http::{HeaderMap, StatusCode};
16use axum::response::{IntoResponse, Response};
17use axum::routing::{delete, get, patch, post, put, MethodRouter};
18use axum::{Json, Router};
19use futures::StreamExt;
20use prost_reflect::{DescriptorPool, DynamicMessage, MethodDescriptor, SerializeOptions};
21use tonic::client::Grpc;
22
23use crate::config::AliasConfig;
24
25pub trait TranscodeState: Clone + Send + Sync + 'static {
30 fn grpc_channel(&self) -> tonic::transport::Channel;
32 fn forwarded_headers(&self) -> &[String];
34}
35
36impl TranscodeState for crate::ProxyState {
37 fn grpc_channel(&self) -> tonic::transport::Channel {
38 self.grpc_channel.clone()
39 }
40 fn forwarded_headers(&self) -> &[String] {
41 &self.forwarded_headers
42 }
43}
44
45#[derive(Debug, Clone)]
47struct RouteEntry {
48 http_path: String,
50 http_method: HttpMethod,
52 grpc_path: String,
54 method: MethodDescriptor,
56 body: request::BodyMapping,
58 response_body: Option<String>,
60}
61
62#[derive(Debug, Clone, Copy)]
63enum HttpMethod {
64 Get,
65 Post,
66 Put,
67 Patch,
68 Delete,
69}
70
71pub fn routes<S: TranscodeState>(pool: &DescriptorPool, aliases: &[AliasConfig]) -> Router<S> {
76 let entries = extract_routes(pool);
77 if entries.is_empty() {
78 tracing::warn!("No HTTP-annotated RPCs found in proto descriptors");
79 return Router::new();
80 }
81
82 tracing::info!("Registering {} transcoded REST→gRPC routes", entries.len());
83
84 let mut router: Router<S> = Router::new();
85 for entry in &entries {
86 let entry_clone = entry.clone();
87
88 let handler = move |proxy_state: State<S>,
89 headers: HeaderMap,
90 path_params: Path<std::collections::HashMap<String, String>>,
91 raw_query: RawQuery,
92 body: axum::body::Bytes| {
93 transcode_handler(
94 proxy_state,
95 headers,
96 path_params,
97 raw_query,
98 body,
99 entry_clone,
100 )
101 };
102
103 let method_router: MethodRouter<S> = match entry.http_method {
104 HttpMethod::Get => get(handler),
105 HttpMethod::Post => post(handler),
106 HttpMethod::Put => put(handler),
107 HttpMethod::Patch => patch(handler),
108 HttpMethod::Delete => delete(handler),
109 };
110
111 let axum_path = proto_path_to_axum(&entry.http_path);
112 router = router.route(&axum_path, method_router);
113
114 for alias in aliases {
116 if let Some(suffix) = entry.http_path.strip_prefix(&alias.to) {
117 let alias_path = if alias.from.ends_with("/{path}") {
119 let prefix = alias.from.trim_end_matches("/{path}");
120 format!("{}{}", prefix, suffix)
121 } else {
122 continue;
123 };
124
125 let alias_entry = entry.clone();
126 let alias_handler =
127 move |proxy_state: State<S>,
128 headers: HeaderMap,
129 path_params: Path<std::collections::HashMap<String, String>>,
130 raw_query: RawQuery,
131 body: axum::body::Bytes| {
132 transcode_handler(
133 proxy_state,
134 headers,
135 path_params,
136 raw_query,
137 body,
138 alias_entry,
139 )
140 };
141 let alias_method: MethodRouter<S> = match entry.http_method {
142 HttpMethod::Get => get(alias_handler),
143 HttpMethod::Post => post(alias_handler),
144 HttpMethod::Put => put(alias_handler),
145 HttpMethod::Patch => patch(alias_handler),
146 HttpMethod::Delete => delete(alias_handler),
147 };
148 router = router.route(&alias_path, alias_method);
149 }
150 }
151 }
152
153 let streaming_entries = extract_streaming_routes(pool);
155 for entry in &streaming_entries {
156 let entry_clone = entry.clone();
157 let axum_path = proto_path_to_axum(&entry.http_path);
158
159 let handler = move |proxy_state: State<S>, headers: HeaderMap| {
160 streaming_handler(proxy_state, headers, entry_clone)
161 };
162
163 let method_router: MethodRouter<S> = match entry.http_method {
164 HttpMethod::Get => get(handler),
165 HttpMethod::Post => post(handler),
166 _ => continue,
167 };
168
169 router = router.route(&axum_path, method_router);
170 }
171
172 router
173}
174
175async fn streaming_handler<S: TranscodeState>(
177 State(proxy_state): State<S>,
178 headers: HeaderMap,
179 entry: RouteEntry,
180) -> Response {
181 let channel = proxy_state.grpc_channel();
182
183 let input_desc = entry.method.input();
184 let request_msg = DynamicMessage::new(input_desc);
185
186 let grpc_metadata =
187 metadata::http_headers_to_grpc_metadata(&headers, proxy_state.forwarded_headers());
188 let mut grpc_request = tonic::Request::new(request_msg);
189 *grpc_request.metadata_mut() = grpc_metadata;
190
191 let output_desc = entry.method.output();
192 let grpc_codec = codec::DynamicCodec::new(output_desc.clone());
193 let grpc_path: axum::http::uri::PathAndQuery = match entry.grpc_path.parse() {
194 Ok(p) => p,
195 Err(e) => {
196 tracing::error!("Invalid gRPC path '{}': {e}", entry.grpc_path);
197 return (
198 StatusCode::INTERNAL_SERVER_ERROR,
199 Json(serde_json::json!({
200 "error": "INTERNAL",
201 "message": "invalid gRPC path configuration",
202 })),
203 )
204 .into_response();
205 }
206 };
207
208 let mut grpc_client = Grpc::new(channel);
209 if let Err(e) = grpc_client.ready().await {
210 return (
211 StatusCode::SERVICE_UNAVAILABLE,
212 Json(serde_json::json!({
213 "error": "UNAVAILABLE",
214 "message": format!("gRPC upstream not ready: {e}"),
215 })),
216 )
217 .into_response();
218 }
219
220 match grpc_client
221 .server_streaming(grpc_request, grpc_path, grpc_codec)
222 .await
223 {
224 Ok(response) => {
225 let stream = response.into_inner();
226 let serialize_opts = SerializeOptions::new()
227 .skip_default_fields(false)
228 .stringify_64_bit_integers(true);
229
230 let byte_stream = stream.map(move |result| match result {
231 Ok(msg) => {
232 match msg.serialize_with_options(serde_json::value::Serializer, &serialize_opts)
233 {
234 Ok(json_value) => {
235 let mut bytes = serde_json::to_vec(&json_value).unwrap_or_default();
236 bytes.push(b'\n');
237 Ok::<axum::body::Bytes, std::io::Error>(axum::body::Bytes::from(bytes))
238 }
239 Err(e) => Err(std::io::Error::other(format!("serialization error: {e}"))),
240 }
241 }
242 Err(status) => Err(std::io::Error::other(format!(
243 "gRPC stream error: {status}"
244 ))),
245 });
246
247 let body = axum::body::Body::from_stream(byte_stream);
248 Response::builder()
249 .status(StatusCode::OK)
250 .header("content-type", "application/x-ndjson")
251 .header("transfer-encoding", "chunked")
252 .body(body)
253 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
254 }
255 Err(status) => error::status_to_response(status),
256 }
257}
258
259async fn transcode_handler<S: TranscodeState>(
261 State(proxy_state): State<S>,
262 headers: HeaderMap,
263 Path(path_params): Path<std::collections::HashMap<String, String>>,
264 RawQuery(raw_query): RawQuery,
265 body_bytes: axum::body::Bytes,
266 entry: RouteEntry,
267) -> Response {
268 let channel = proxy_state.grpc_channel();
269
270 let json_body = match entry.body {
272 request::BodyMapping::None => serde_json::Value::Null,
273 _ => {
274 let ct = body::content_type(&headers);
275 match body::parse_body(ct, &body_bytes) {
276 Ok(v) => v,
277 Err(e) => {
278 return (
279 StatusCode::BAD_REQUEST,
280 Json(serde_json::json!({
281 "error": "INVALID_ARGUMENT",
282 "message": format!("failed to parse request body: {e}"),
283 })),
284 )
285 .into_response();
286 }
287 }
288 }
289 };
290
291 let query_pairs = match request::parse_query(raw_query.as_deref()) {
295 Ok(pairs) => pairs,
296 Err(e) => {
297 return (
298 StatusCode::BAD_REQUEST,
299 Json(serde_json::json!({
300 "error": "INVALID_ARGUMENT",
301 "message": e,
302 })),
303 )
304 .into_response();
305 }
306 };
307
308 let input_desc = entry.method.input();
309 let request_json = match request::build_request_json(
310 &input_desc,
311 &entry.body,
312 json_body,
313 &path_params,
314 &query_pairs,
315 ) {
316 Ok(v) => v,
317 Err(e) => {
318 return (
319 StatusCode::BAD_REQUEST,
320 Json(serde_json::json!({
321 "error": "INVALID_ARGUMENT",
322 "message": e,
323 })),
324 )
325 .into_response();
326 }
327 };
328
329 let request_msg = match DynamicMessage::deserialize(input_desc, request_json) {
330 Ok(msg) => msg,
331 Err(e) => {
332 return (
333 StatusCode::BAD_REQUEST,
334 Json(serde_json::json!({
335 "error": "INVALID_ARGUMENT",
336 "message": format!("failed to decode request: {e}"),
337 })),
338 )
339 .into_response();
340 }
341 };
342
343 let grpc_metadata =
344 metadata::http_headers_to_grpc_metadata(&headers, proxy_state.forwarded_headers());
345 let mut grpc_request = tonic::Request::new(request_msg);
346 *grpc_request.metadata_mut() = grpc_metadata;
347
348 let output_desc = entry.method.output();
349 let grpc_codec = codec::DynamicCodec::new(output_desc.clone());
350 let grpc_path: axum::http::uri::PathAndQuery = match entry.grpc_path.parse() {
351 Ok(p) => p,
352 Err(e) => {
353 tracing::error!("Invalid gRPC path '{}': {e}", entry.grpc_path);
354 return (
355 StatusCode::INTERNAL_SERVER_ERROR,
356 Json(serde_json::json!({
357 "error": "INTERNAL",
358 "message": "invalid gRPC path configuration",
359 })),
360 )
361 .into_response();
362 }
363 };
364
365 let mut grpc_client = Grpc::new(channel);
366 if let Err(e) = grpc_client.ready().await {
367 return (
368 StatusCode::SERVICE_UNAVAILABLE,
369 Json(serde_json::json!({
370 "error": "UNAVAILABLE",
371 "message": format!("gRPC upstream not ready: {e}"),
372 })),
373 )
374 .into_response();
375 }
376
377 match grpc_client.unary(grpc_request, grpc_path, grpc_codec).await {
378 Ok(response) => {
379 let response_msg = response.into_inner();
380 let serialize_opts = SerializeOptions::new()
381 .skip_default_fields(false)
382 .stringify_64_bit_integers(true);
383 match response_msg
384 .serialize_with_options(serde_json::value::Serializer, &serialize_opts)
385 {
386 Ok(json_value) => {
387 let out = match &entry.response_body {
389 Some(path) => request::extract_response_body(&json_value, path)
390 .unwrap_or_else(|| {
391 tracing::warn!(
392 response_body = %path,
393 "configured response_body path not found in response; \
394 returning null"
395 );
396 serde_json::Value::Null
397 }),
398 None => json_value,
399 };
400 (StatusCode::OK, Json(out)).into_response()
401 }
402 Err(e) => {
403 tracing::error!("Failed to serialize gRPC response: {e}");
404 (
405 StatusCode::INTERNAL_SERVER_ERROR,
406 Json(serde_json::json!({
407 "error": "INTERNAL",
408 "message": "failed to serialize response",
409 })),
410 )
411 .into_response()
412 }
413 }
414 }
415 Err(status) => error::status_to_response(status),
416 }
417}
418
419fn extract_routes(pool: &DescriptorPool) -> Vec<RouteEntry> {
421 let http_ext = match pool.get_extension_by_name("google.api.http") {
422 Some(ext) => ext,
423 None => {
424 tracing::warn!("google.api.http extension not found in descriptor pool");
425 return Vec::new();
426 }
427 };
428
429 let mut entries = Vec::new();
430
431 for service in pool.services() {
432 for method in service.methods() {
433 if method.is_client_streaming() || method.is_server_streaming() {
434 continue;
435 }
436
437 let grpc_path = format!("/{}/{}", service.full_name(), method.name());
438
439 for binding in extract_http_bindings(&method, &http_ext) {
440 entries.push(RouteEntry {
441 http_path: binding.http_path,
442 http_method: binding.http_method,
443 grpc_path: grpc_path.clone(),
444 method: method.clone(),
445 body: binding.body,
446 response_body: binding.response_body,
447 });
448 }
449 }
450 }
451
452 entries
453}
454
455fn extract_streaming_routes(pool: &DescriptorPool) -> Vec<RouteEntry> {
457 let http_ext = match pool.get_extension_by_name("google.api.http") {
458 Some(ext) => ext,
459 None => return Vec::new(),
460 };
461
462 let mut entries = Vec::new();
463
464 for service in pool.services() {
465 for method in service.methods() {
466 if !method.is_server_streaming() || method.is_client_streaming() {
467 continue;
468 }
469
470 let grpc_path = format!("/{}/{}", service.full_name(), method.name());
471
472 for binding in extract_http_bindings(&method, &http_ext) {
473 tracing::info!(
474 "Registering streaming route: {} {} → {}",
475 match binding.http_method {
476 HttpMethod::Get => "GET",
477 HttpMethod::Post => "POST",
478 _ => "OTHER",
479 },
480 binding.http_path,
481 grpc_path
482 );
483 entries.push(RouteEntry {
484 http_path: binding.http_path,
485 http_method: binding.http_method,
486 grpc_path: grpc_path.clone(),
487 method: method.clone(),
488 body: binding.body,
489 response_body: binding.response_body,
490 });
491 }
492 }
493 }
494
495 entries
496}
497
498struct HttpBinding {
500 http_method: HttpMethod,
501 http_path: String,
502 body: request::BodyMapping,
503 response_body: Option<String>,
504}
505
506fn extract_http_bindings(
509 method: &MethodDescriptor,
510 http_ext: &prost_reflect::ExtensionDescriptor,
511) -> Vec<HttpBinding> {
512 let options = method.options();
513 if !options.has_extension(http_ext) {
514 return Vec::new();
515 }
516
517 let prost_reflect::Value::Message(rule_msg) = options.get_extension(http_ext).into_owned()
518 else {
519 return Vec::new();
520 };
521
522 collect_bindings(&rule_msg)
523}
524
525fn collect_bindings(rule_msg: &DynamicMessage) -> Vec<HttpBinding> {
528 let mut bindings = Vec::new();
529 if let Some(binding) = parse_http_rule(rule_msg) {
530 bindings.push(binding);
531 }
532
533 if let Some(field) = rule_msg.get_field_by_name("additional_bindings") {
536 if let prost_reflect::Value::List(list) = field.into_owned() {
537 for item in list {
538 if let prost_reflect::Value::Message(sub) = item {
539 if let Some(binding) = parse_http_rule(&sub) {
540 bindings.push(binding);
541 }
542 }
543 }
544 }
545 }
546
547 bindings
548}
549
550fn parse_http_rule(rule_msg: &DynamicMessage) -> Option<HttpBinding> {
552 let (http_method, http_path) = [
553 ("get", HttpMethod::Get),
554 ("post", HttpMethod::Post),
555 ("put", HttpMethod::Put),
556 ("delete", HttpMethod::Delete),
557 ("patch", HttpMethod::Patch),
558 ]
559 .into_iter()
560 .find_map(
561 |(name, http_method)| match rule_msg.get_field_by_name(name)?.into_owned() {
562 prost_reflect::Value::String(path) if !path.is_empty() => Some((http_method, path)),
563 _ => None,
564 },
565 )?;
566
567 let body = rule_msg
568 .get_field_by_name("body")
569 .and_then(|v| match v.into_owned() {
570 prost_reflect::Value::String(s) => Some(request::BodyMapping::parse(&s)),
571 _ => None,
572 })
573 .unwrap_or(request::BodyMapping::None);
574
575 let response_body =
576 rule_msg
577 .get_field_by_name("response_body")
578 .and_then(|v| match v.into_owned() {
579 prost_reflect::Value::String(s) if !s.is_empty() => Some(s),
580 _ => None,
581 });
582
583 Some(HttpBinding {
584 http_method,
585 http_path,
586 body,
587 response_body,
588 })
589}
590
591pub fn proto_path_to_axum(path: &str) -> String {
602 let mut out = String::with_capacity(path.len());
603
604 let segments = split_top_level(path);
605 let last = segments.len().saturating_sub(1);
606 for (idx, segment) in segments.iter().enumerate() {
607 if idx > 0 {
608 out.push('/');
609 }
610 out.push_str(&convert_segment(segment, idx, idx == last));
611 }
612
613 out
614}
615
616fn split_top_level(path: &str) -> Vec<&str> {
623 let mut segments = Vec::new();
624 let mut depth = 0usize;
625 let mut start = 0usize;
626
627 for (i, ch) in path.char_indices() {
628 match ch {
629 '{' => depth += 1,
630 '}' if depth > 0 => depth -= 1,
633 '/' if depth == 0 => {
634 segments.push(&path[start..i]);
635 start = i + 1;
636 }
637 _ => {}
638 }
639 }
640 segments.push(&path[start..]);
641 segments
642}
643
644fn convert_segment(segment: &str, idx: usize, is_last: bool) -> String {
649 if let Some(inner) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
650 if let Some((name, template)) = inner.split_once('=') {
652 return match template {
653 "*" => format!("{{{name}}}"),
655 "**" => catch_all(name, is_last),
657 _ => {
663 tracing::warn!(
664 template = %inner,
665 "google.api.http multi-segment field template is not fully \
666 supported; routing it as a catch-all capture"
667 );
668 catch_all(name, is_last)
669 }
670 };
671 }
672 return format!("{{{inner}}}");
674 }
675
676 match segment {
678 "**" => catch_all(&format!("wildcard{idx}"), is_last),
679 "*" => format!("{{wildcard{idx}}}"),
680 literal => literal.to_string(),
681 }
682}
683
684fn catch_all(name: &str, is_last: bool) -> String {
692 if is_last {
693 format!("{{*{name}}}")
694 } else {
695 tracing::warn!(
696 capture = %name,
697 "catch-all in a non-terminal path segment is unrepresentable in axum; \
698 degrading to a single-segment capture"
699 );
700 format!("{{{name}}}")
701 }
702}
703
704#[cfg(test)]
705mod tests {
706 use super::*;
707
708 fn http_rule_descriptor() -> prost_reflect::MessageDescriptor {
712 use prost_reflect::prost::Message;
713 use prost_reflect::prost_types::{
714 field_descriptor_proto::{Label, Type},
715 DescriptorProto, FieldDescriptorProto, FileDescriptorProto, FileDescriptorSet,
716 };
717
718 let str_field = |name: &str, num: i32| FieldDescriptorProto {
719 name: Some(name.to_string()),
720 number: Some(num),
721 label: Some(Label::Optional as i32),
722 r#type: Some(Type::String as i32),
723 ..Default::default()
724 };
725 let rule = DescriptorProto {
726 name: Some("HttpRule".to_string()),
727 field: vec![
728 str_field("get", 2),
729 str_field("put", 3),
730 str_field("post", 4),
731 str_field("delete", 5),
732 str_field("patch", 6),
733 str_field("body", 7),
734 str_field("response_body", 12),
735 FieldDescriptorProto {
736 name: Some("additional_bindings".to_string()),
737 number: Some(11),
738 label: Some(Label::Repeated as i32),
739 r#type: Some(Type::Message as i32),
740 type_name: Some(".gapi.HttpRule".to_string()),
741 ..Default::default()
742 },
743 ],
744 ..Default::default()
745 };
746 let file = FileDescriptorProto {
747 name: Some("http.proto".to_string()),
748 package: Some("gapi".to_string()),
749 message_type: vec![rule],
750 syntax: Some("proto3".to_string()),
751 ..Default::default()
752 };
753 let fds = FileDescriptorSet { file: vec![file] };
754 let pool = DescriptorPool::decode(fds.encode_to_vec().as_slice()).unwrap();
755 pool.get_message_by_name("gapi.HttpRule").unwrap()
756 }
757
758 #[test]
759 fn collect_bindings_reads_body_response_and_additional() {
760 let desc = http_rule_descriptor();
761
762 let mut extra = DynamicMessage::new(desc.clone());
764 extra.set_field_by_name("post", prost_reflect::Value::String("/v1/items".into()));
765 extra.set_field_by_name("body", prost_reflect::Value::String("*".into()));
766
767 let mut rule = DynamicMessage::new(desc);
769 rule.set_field_by_name("get", prost_reflect::Value::String("/v1/items/{id}".into()));
770 rule.set_field_by_name(
771 "response_body",
772 prost_reflect::Value::String("result".into()),
773 );
774 rule.set_field_by_name(
775 "additional_bindings",
776 prost_reflect::Value::List(vec![prost_reflect::Value::Message(extra)]),
777 );
778
779 let bindings = collect_bindings(&rule);
780 assert_eq!(bindings.len(), 2);
781
782 assert!(matches!(bindings[0].http_method, HttpMethod::Get));
784 assert_eq!(bindings[0].http_path, "/v1/items/{id}");
785 assert_eq!(bindings[0].body, request::BodyMapping::None);
786 assert_eq!(bindings[0].response_body.as_deref(), Some("result"));
787
788 assert!(matches!(bindings[1].http_method, HttpMethod::Post));
790 assert_eq!(bindings[1].http_path, "/v1/items");
791 assert_eq!(bindings[1].body, request::BodyMapping::Root);
792 assert_eq!(bindings[1].response_body, None);
793 }
794
795 #[test]
796 fn test_proto_path_to_axum() {
797 assert_eq!(proto_path_to_axum("/v1/profiles/{id}"), "/v1/profiles/{id}");
799 assert_eq!(
800 proto_path_to_axum("/v1/admin/profiles/{profile_id}/metadata/{key}"),
801 "/v1/admin/profiles/{profile_id}/metadata/{key}"
802 );
803 assert_eq!(proto_path_to_axum("/v1/auth/login"), "/v1/auth/login");
804 }
805
806 #[test]
807 fn test_proto_path_to_axum_wildcards() {
808 assert_eq!(proto_path_to_axum("/v1/{name=*}"), "/v1/{name}");
810 assert_eq!(
812 proto_path_to_axum("/v1/files/{path=**}"),
813 "/v1/files/{*path}"
814 );
815 assert_eq!(proto_path_to_axum("/v1/*/items"), "/v1/{wildcard2}/items");
818 assert_eq!(proto_path_to_axum("/v1/files/**"), "/v1/files/{*wildcard3}");
819 }
820
821 #[test]
822 fn non_terminal_catch_all_degrades_to_single_capture() {
823 assert_eq!(
829 proto_path_to_axum("/v1/{name=projects/*}/topics"),
830 "/v1/{name}/topics"
831 );
832 let path = proto_path_to_axum("/v1/{name=projects/*}/topics");
833 let _router: Router<()> = Router::new().route(&path, get(|| async { "ok" }));
834
835 assert_eq!(proto_path_to_axum("/v1/{rest=**}/tail"), "/v1/{rest}/tail");
838 assert_eq!(
839 proto_path_to_axum("/v1/files/{rest=**}"),
840 "/v1/files/{*rest}"
841 );
842 }
843
844 #[test]
845 fn multi_segment_field_template_does_not_fracture() {
846 assert_eq!(
852 proto_path_to_axum("/v1/{name=shelves/*/books/*}"),
853 "/v1/{*name}"
854 );
855 let path = proto_path_to_axum("/v1/{name=shelves/*/books/*}");
857 let _router: Router<()> = Router::new().route(&path, get(|| async { "ok" }));
858 }
859
860 #[test]
865 fn router_builds_with_brace_path_params_on_axum_0_8() {
866 let axum_path = proto_path_to_axum("/v1/profiles/{id}");
867 let _router: Router<()> = Router::new().route(&axum_path, get(|| async { "ok" }));
868
869 let nested = proto_path_to_axum("/v1/admin/profiles/{profile_id}/metadata/{key}");
871 let catch_all = proto_path_to_axum("/v1/files/{path=**}");
872 let _router: Router<()> = Router::new()
873 .route(&nested, get(|| async { "ok" }))
874 .route(&catch_all, get(|| async { "ok" }));
875 }
876}