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::sse::{Event, KeepAlive, Sse};
17use axum::response::{IntoResponse, Response};
18use axum::routing::{delete, get, patch, post, put, MethodRouter};
19use axum::{Json, Router};
20use futures::StreamExt;
21use prost_reflect::{DescriptorPool, DynamicMessage, MethodDescriptor, SerializeOptions};
22use std::sync::Arc;
23use tonic::client::Grpc;
24
25use crate::config::AliasConfig;
26use error::{ErrorDetailsPolicy, StatusDetails};
27
28pub trait TranscodeState: Clone + Send + Sync + 'static {
33 fn grpc_channel(&self) -> tonic::transport::Channel;
35 fn forwarded_headers(&self) -> &[String];
37 fn sse_keep_alive_secs(&self) -> u64;
39}
40
41impl TranscodeState for crate::ProxyState {
42 fn grpc_channel(&self) -> tonic::transport::Channel {
43 self.grpc_channel.clone()
44 }
45 fn forwarded_headers(&self) -> &[String] {
46 &self.forwarded_headers
47 }
48 fn sse_keep_alive_secs(&self) -> u64 {
49 self.sse_keep_alive_secs
50 }
51}
52
53#[derive(Debug, Clone)]
55struct RouteEntry {
56 http_path: String,
58 http_method: HttpMethod,
60 grpc_path: axum::http::uri::PathAndQuery,
63 method: MethodDescriptor,
65 body: request::BodyMapping,
67 response_body: Option<String>,
69 error_details: Option<Arc<StatusDetails>>,
72 ndjson_envelope: bool,
74 response_has_required: bool,
77}
78
79#[derive(Debug, Clone, Default)]
93pub struct TranscodeOptions {
94 pub(crate) error_details: ErrorDetailsPolicy,
95 pub(crate) ndjson_envelope: bool,
96}
97
98impl TranscodeOptions {
99 pub fn with_error_details(mut self, policy: ErrorDetailsPolicy) -> Self {
102 self.error_details = policy;
103 self
104 }
105
106 pub fn with_ndjson_envelope(mut self, enabled: bool) -> Self {
114 self.ndjson_envelope = enabled;
115 self
116 }
117}
118
119#[derive(Debug, Clone, Copy)]
120enum HttpMethod {
121 Get,
122 Post,
123 Put,
124 Patch,
125 Delete,
126}
127
128impl HttpMethod {
129 fn as_str(self) -> &'static str {
131 match self {
132 HttpMethod::Get => "GET",
133 HttpMethod::Post => "POST",
134 HttpMethod::Put => "PUT",
135 HttpMethod::Patch => "PATCH",
136 HttpMethod::Delete => "DELETE",
137 }
138 }
139}
140
141pub fn routes<S: TranscodeState>(pool: &DescriptorPool, aliases: &[AliasConfig]) -> Router<S> {
148 routes_with_options(pool, aliases, &TranscodeOptions::default())
149}
150
151pub fn routes_with_options<S: TranscodeState>(
153 pool: &DescriptorPool,
154 aliases: &[AliasConfig],
155 options: &TranscodeOptions,
156) -> Router<S> {
157 let bindings = route_bindings(pool, aliases);
158 if bindings.is_empty() {
159 tracing::warn!("No HTTP-annotated RPCs found in proto descriptors");
160 return Router::new();
161 }
162
163 tracing::info!("Registering {} transcoded REST→gRPC routes", bindings.len());
164
165 let mut status_details: [Option<Arc<StatusDetails>>; 2] = [None, None];
169 let mut router: Router<S> = Router::new();
170 for mut binding in bindings {
171 let policy = &options.error_details;
172 if policy.enabled_for(&binding.axum_path) {
173 let opaque = policy.opaque_for(&binding.axum_path);
174 let slot = usize::from(opaque);
175 if status_details[slot].is_none() {
176 let base = status_details
177 .iter()
178 .flatten()
179 .next()
180 .map(|existing| existing.as_ref().clone())
181 .unwrap_or_else(|| StatusDetails::new(pool));
182 status_details[slot] = Some(Arc::new(base.with_opaque_details(opaque)));
183 }
184 binding.entry.error_details = status_details[slot].clone();
185 }
186 binding.entry.ndjson_envelope = options.ndjson_envelope;
187 let method = binding.entry.http_method;
188 let entry = Arc::new(binding.entry);
189 let method_router: MethodRouter<S> = if binding.streaming {
190 let handler = move |proxy_state: State<S>,
191 headers: HeaderMap,
192 path_params: Path<std::collections::HashMap<String, String>>,
193 raw_query: RawQuery,
194 body: axum::body::Bytes| {
195 streaming_handler(proxy_state, headers, path_params, raw_query, body, entry)
196 };
197 match method {
198 HttpMethod::Get => get(handler),
199 HttpMethod::Post => post(handler),
200 _ => unreachable!("streaming routes are GET/POST only"),
202 }
203 } else {
204 let handler = move |proxy_state: State<S>,
205 headers: HeaderMap,
206 path_params: Path<std::collections::HashMap<String, String>>,
207 raw_query: RawQuery,
208 body: axum::body::Bytes| {
209 transcode_handler(proxy_state, headers, path_params, raw_query, body, entry)
210 };
211 match method {
212 HttpMethod::Get => get(handler),
213 HttpMethod::Post => post(handler),
214 HttpMethod::Put => put(handler),
215 HttpMethod::Patch => patch(handler),
216 HttpMethod::Delete => delete(handler),
217 }
218 };
219 router = router.route(&binding.axum_path, method_router);
220 }
221
222 router
223}
224
225struct RouteBinding {
228 entry: RouteEntry,
229 axum_path: String,
230 streaming: bool,
231}
232
233fn route_bindings(pool: &DescriptorPool, aliases: &[AliasConfig]) -> Vec<RouteBinding> {
238 let mut bindings = Vec::new();
239 for entry in extract_routes(pool) {
240 bindings.push(RouteBinding {
241 axum_path: proto_path_to_axum(&entry.http_path),
242 entry: entry.clone(),
243 streaming: false,
244 });
245 for alias in aliases {
246 if let Some(suffix) = entry.http_path.strip_prefix(&alias.to) {
247 if alias.from.ends_with("/{path}") {
248 let prefix = alias.from.trim_end_matches("/{path}");
249 bindings.push(RouteBinding {
250 axum_path: format!("{prefix}{suffix}"),
251 entry: entry.clone(),
252 streaming: false,
253 });
254 }
255 }
256 }
257 }
258 for entry in extract_streaming_routes(pool) {
259 if matches!(entry.http_method, HttpMethod::Get | HttpMethod::Post) {
260 bindings.push(RouteBinding {
261 axum_path: proto_path_to_axum(&entry.http_path),
262 entry,
263 streaming: true,
264 });
265 }
266 }
267 bindings
268}
269
270pub fn route_paths(pool: &DescriptorPool, aliases: &[AliasConfig]) -> Vec<(String, String)> {
279 route_bindings(pool, aliases)
280 .into_iter()
281 .map(|b| (b.entry.http_method.as_str().to_string(), b.axum_path))
282 .collect()
283}
284
285fn response_serialize_options() -> SerializeOptions {
288 SerializeOptions::new()
289 .skip_default_fields(false)
290 .stringify_64_bit_integers(true)
291}
292
293fn message_to_json_string(msg: &DynamicMessage, opts: &SerializeOptions) -> Result<String, String> {
295 let value = msg
296 .serialize_with_options(serde_json::value::Serializer, opts)
297 .map_err(|e| e.to_string())?;
298 serde_json::to_string(&value).map_err(|e| e.to_string())
299}
300
301fn wants_sse(headers: &HeaderMap) -> bool {
309 headers
310 .get_all(axum::http::header::ACCEPT)
311 .iter()
312 .filter_map(|v| v.to_str().ok())
313 .flat_map(|accept| accept.split(','))
314 .any(accept_range_selects_sse)
315}
316
317fn accept_range_selects_sse(range: &str) -> bool {
320 let mut parts = range.split(';');
321 let media = parts.next().unwrap_or("").trim();
322 if !media.eq_ignore_ascii_case("text/event-stream") {
323 return false;
324 }
325 for param in parts {
328 let mut kv = param.splitn(2, '=');
329 if kv.next().unwrap_or("").trim().eq_ignore_ascii_case("q") {
330 let q: f32 = kv.next().unwrap_or("").trim().parse().unwrap_or(1.0);
331 return q > 0.0;
332 }
333 }
334 true
335}
336
337async fn streaming_handler<S: TranscodeState>(
344 State(proxy_state): State<S>,
345 headers: HeaderMap,
346 Path(path_params): Path<std::collections::HashMap<String, String>>,
347 RawQuery(raw_query): RawQuery,
348 body_bytes: axum::body::Bytes,
349 entry: std::sync::Arc<RouteEntry>,
350) -> Response {
351 let channel = proxy_state.grpc_channel();
352
353 let request_msg = match decode_request(
354 &entry,
355 &headers,
356 &path_params,
357 raw_query.as_deref(),
358 &body_bytes,
359 ) {
360 Ok(msg) => msg,
361 Err(message) => return bad_request(&entry, message),
362 };
363
364 let grpc_metadata =
365 metadata::http_headers_to_grpc_metadata(&headers, proxy_state.forwarded_headers());
366 let mut grpc_request = tonic::Request::new(request_msg);
367 *grpc_request.metadata_mut() = grpc_metadata;
368 metadata::apply_request_deadline(&mut grpc_request, &headers);
369
370 let output_desc = entry.method.output();
371 let grpc_codec =
372 codec::DynamicCodec::with_required_check(output_desc.clone(), entry.response_has_required);
373 let grpc_path = entry.grpc_path.clone();
374
375 let mut grpc_client = Grpc::new(channel);
376 if let Err(e) = grpc_client.ready().await {
377 let status = tonic::Status::unavailable(format!("gRPC upstream not ready: {e}"));
378 return error::status_to_response_with_details(&status, entry.error_details.as_deref());
379 }
380
381 let use_sse = wants_sse(&headers);
382
383 match grpc_client
384 .server_streaming(grpc_request, grpc_path, grpc_codec)
385 .await
386 {
387 Ok(response) => {
392 let stream = response.into_inner();
393 let envelope = entry.ndjson_envelope;
397 let render_error = move |status: &tonic::Status| {
398 error::error_body(status, entry.error_details.as_deref())
399 };
400 if use_sse {
401 sse_response(stream, render_error, proxy_state.sse_keep_alive_secs())
402 } else {
403 ndjson_response(stream, render_error, envelope)
404 }
405 }
406 Err(status) => {
407 error::status_to_response_with_details(&status, entry.error_details.as_deref())
408 }
409 }
410}
411
412enum StreamFrame {
420 Data(String),
421 Error(serde_json::Value),
422}
423
424const STATUS_TYPE_URL: &str = "type.googleapis.com/google.rpc.Status";
432
433fn json_frames<St, R>(
443 stream: St,
444 render_error: R,
445) -> impl futures::Stream<Item = StreamFrame> + Send + 'static
446where
447 St: futures::Stream<Item = Result<DynamicMessage, tonic::Status>> + Send + Unpin + 'static,
448 R: Fn(&tonic::Status) -> serde_json::Value + Send + 'static,
449{
450 let state = Some((stream, render_error, response_serialize_options()));
451 futures::stream::unfold(state, |state| async move {
452 let (mut stream, render_error, opts) = state?;
453 let error = match stream.next().await? {
454 Ok(msg) => match message_to_json_string(&msg, &opts) {
455 Ok(s) => return Some((StreamFrame::Data(s), Some((stream, render_error, opts)))),
456 Err(e) => tonic::Status::internal(format!("serialization error: {e}")),
457 },
458 Err(status) => status,
459 };
460 Some((StreamFrame::Error(render_error(&error)), None))
461 })
462}
463
464fn ndjson_response<St, R>(stream: St, render_error: R, envelope: bool) -> Response
471where
472 St: futures::Stream<Item = Result<DynamicMessage, tonic::Status>> + Send + Unpin + 'static,
473 R: Fn(&tonic::Status) -> serde_json::Value + Send + 'static,
474{
475 let byte_stream = json_frames(stream, render_error).map(move |frame| {
476 let mut line = match frame {
477 StreamFrame::Data(s) if envelope => {
480 let mut wrapped = String::with_capacity(s.len() + 12);
481 wrapped.push_str("{\"result\":");
482 wrapped.push_str(&s);
483 wrapped.push('}');
484 wrapped
485 }
486 StreamFrame::Data(s) => s,
487 StreamFrame::Error(body) if envelope => {
488 serde_json::json!({ "error": body }).to_string()
489 }
490 StreamFrame::Error(mut body) => {
491 if let Some(fields) = body.as_object_mut() {
492 fields.insert("@type".into(), STATUS_TYPE_URL.into());
493 }
494 body.to_string()
495 }
496 };
497 line.push('\n');
498 Ok::<axum::body::Bytes, std::io::Error>(axum::body::Bytes::from(line))
499 });
500
501 let body = axum::body::Body::from_stream(byte_stream);
502 Response::builder()
506 .status(StatusCode::OK)
507 .header("content-type", "application/x-ndjson")
508 .body(body)
509 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
510}
511
512fn sse_response<St, R>(stream: St, render_error: R, keep_alive_secs: u64) -> Response
514where
515 St: futures::Stream<Item = Result<DynamicMessage, tonic::Status>> + Send + Unpin + 'static,
516 R: Fn(&tonic::Status) -> serde_json::Value + Send + 'static,
517{
518 let event_stream = json_frames(stream, render_error).map(|frame| {
522 let event = match frame {
523 StreamFrame::Data(s) => Event::default().data(s),
524 StreamFrame::Error(body) => Event::default()
525 .event("stream-error")
526 .data(body.to_string()),
527 };
528 Ok::<Event, std::convert::Infallible>(event)
529 });
530
531 Sse::new(event_stream)
532 .keep_alive(KeepAlive::new().interval(std::time::Duration::from_secs(keep_alive_secs)))
533 .into_response()
534}
535
536fn decode_request(
541 entry: &RouteEntry,
542 headers: &HeaderMap,
543 path_params: &std::collections::HashMap<String, String>,
544 raw_query: Option<&str>,
545 body_bytes: &[u8],
546) -> Result<DynamicMessage, String> {
547 let json_body = match entry.body {
549 request::BodyMapping::None => serde_json::Value::Null,
550 _ => body::parse_body(body::content_type(headers), body_bytes)
551 .map_err(|e| format!("failed to parse request body: {e}"))?,
552 };
553
554 let query_pairs = request::parse_query(raw_query)?;
558
559 let input_desc = entry.method.input();
560 let request_json = request::build_request_json(
561 &input_desc,
562 &entry.body,
563 json_body,
564 path_params,
565 &query_pairs,
566 )?;
567
568 DynamicMessage::deserialize(input_desc, request_json)
569 .map_err(|e| format!("failed to decode request: {e}"))
570}
571
572fn bad_request(entry: &RouteEntry, message: String) -> Response {
575 error::status_to_response_with_details(
576 &tonic::Status::invalid_argument(message),
577 entry.error_details.as_deref(),
578 )
579}
580
581async fn transcode_handler<S: TranscodeState>(
583 State(proxy_state): State<S>,
584 headers: HeaderMap,
585 Path(path_params): Path<std::collections::HashMap<String, String>>,
586 RawQuery(raw_query): RawQuery,
587 body_bytes: axum::body::Bytes,
588 entry: std::sync::Arc<RouteEntry>,
589) -> Response {
590 let channel = proxy_state.grpc_channel();
591
592 let request_msg = match decode_request(
593 &entry,
594 &headers,
595 &path_params,
596 raw_query.as_deref(),
597 &body_bytes,
598 ) {
599 Ok(msg) => msg,
600 Err(message) => return bad_request(&entry, message),
601 };
602
603 let grpc_metadata =
604 metadata::http_headers_to_grpc_metadata(&headers, proxy_state.forwarded_headers());
605 let mut grpc_request = tonic::Request::new(request_msg);
606 *grpc_request.metadata_mut() = grpc_metadata;
607 metadata::apply_request_deadline(&mut grpc_request, &headers);
608
609 let output_desc = entry.method.output();
610 let grpc_codec =
611 codec::DynamicCodec::with_required_check(output_desc.clone(), entry.response_has_required);
612 let grpc_path = entry.grpc_path.clone();
613
614 let mut grpc_client = Grpc::new(channel);
615 if let Err(e) = grpc_client.ready().await {
616 let status = tonic::Status::unavailable(format!("gRPC upstream not ready: {e}"));
617 return error::status_to_response_with_details(&status, entry.error_details.as_deref());
618 }
619
620 match grpc_client.unary(grpc_request, grpc_path, grpc_codec).await {
621 Ok(response) => {
622 let response_msg = response.into_inner();
623 let serialize_opts = response_serialize_options();
624 match response_msg
625 .serialize_with_options(serde_json::value::Serializer, &serialize_opts)
626 {
627 Ok(json_value) => {
628 let out = match &entry.response_body {
630 Some(path) => request::extract_response_body(&json_value, path)
631 .unwrap_or_else(|| {
632 tracing::warn!(
633 response_body = %path,
634 "configured response_body path not found in response; \
635 returning null"
636 );
637 serde_json::Value::Null
638 }),
639 None => json_value,
640 };
641 (StatusCode::OK, Json(out)).into_response()
642 }
643 Err(e) => {
644 tracing::error!("Failed to serialize gRPC response: {e}");
645 error::status_to_response_with_details(
646 &tonic::Status::internal("failed to serialize response"),
647 entry.error_details.as_deref(),
648 )
649 }
650 }
651 }
652 Err(status) => {
653 error::status_to_response_with_details(&status, entry.error_details.as_deref())
654 }
655 }
656}
657
658fn extract_routes(pool: &DescriptorPool) -> Vec<RouteEntry> {
660 let http_ext = match pool.get_extension_by_name("google.api.http") {
661 Some(ext) => ext,
662 None => {
663 tracing::warn!("google.api.http extension not found in descriptor pool");
664 return Vec::new();
665 }
666 };
667
668 let mut entries = Vec::new();
669
670 for service in pool.services() {
671 for method in service.methods() {
672 if method.is_client_streaming() || method.is_server_streaming() {
673 continue;
674 }
675
676 let grpc_path = format!("/{}/{}", service.full_name(), method.name());
677 let grpc_path: axum::http::uri::PathAndQuery = match grpc_path.parse() {
678 Ok(p) => p,
679 Err(e) => {
680 tracing::error!("skipping route with invalid gRPC path '{grpc_path}': {e}");
681 continue;
682 }
683 };
684
685 let response_has_required = codec::has_required_fields(&method.output());
686 for binding in extract_http_bindings(&method, &http_ext) {
687 entries.push(RouteEntry {
688 http_path: binding.http_path,
689 http_method: binding.http_method,
690 grpc_path: grpc_path.clone(),
691 method: method.clone(),
692 body: binding.body,
693 response_body: binding.response_body,
694 error_details: None,
696 ndjson_envelope: false,
697 response_has_required,
698 });
699 }
700 }
701 }
702
703 entries
704}
705
706fn extract_streaming_routes(pool: &DescriptorPool) -> Vec<RouteEntry> {
708 let http_ext = match pool.get_extension_by_name("google.api.http") {
709 Some(ext) => ext,
710 None => return Vec::new(),
711 };
712
713 let mut entries = Vec::new();
714
715 for service in pool.services() {
716 for method in service.methods() {
717 if !method.is_server_streaming() || method.is_client_streaming() {
718 continue;
719 }
720
721 let grpc_path = format!("/{}/{}", service.full_name(), method.name());
722 let grpc_path: axum::http::uri::PathAndQuery = match grpc_path.parse() {
723 Ok(p) => p,
724 Err(e) => {
725 tracing::error!("skipping route with invalid gRPC path '{grpc_path}': {e}");
726 continue;
727 }
728 };
729
730 let response_has_required = codec::has_required_fields(&method.output());
731 for binding in extract_http_bindings(&method, &http_ext) {
732 tracing::info!(
733 "Registering streaming route: {} {} → {}",
734 match binding.http_method {
735 HttpMethod::Get => "GET",
736 HttpMethod::Post => "POST",
737 _ => "OTHER",
738 },
739 binding.http_path,
740 grpc_path
741 );
742 entries.push(RouteEntry {
743 http_path: binding.http_path,
744 http_method: binding.http_method,
745 grpc_path: grpc_path.clone(),
746 method: method.clone(),
747 body: binding.body,
748 response_body: binding.response_body,
749 error_details: None,
751 ndjson_envelope: false,
752 response_has_required,
753 });
754 }
755 }
756 }
757
758 entries
759}
760
761struct HttpBinding {
763 http_method: HttpMethod,
764 http_path: String,
765 body: request::BodyMapping,
766 response_body: Option<String>,
767}
768
769fn extract_http_bindings(
772 method: &MethodDescriptor,
773 http_ext: &prost_reflect::ExtensionDescriptor,
774) -> Vec<HttpBinding> {
775 let options = method.options();
776 if !options.has_extension(http_ext) {
777 return Vec::new();
778 }
779
780 let prost_reflect::Value::Message(rule_msg) = options.get_extension(http_ext).into_owned()
781 else {
782 return Vec::new();
783 };
784
785 collect_bindings(&rule_msg)
786}
787
788fn collect_bindings(rule_msg: &DynamicMessage) -> Vec<HttpBinding> {
791 let mut bindings = Vec::new();
792 if let Some(binding) = parse_http_rule(rule_msg) {
793 bindings.push(binding);
794 }
795
796 if let Some(field) = rule_msg.get_field_by_name("additional_bindings") {
799 if let prost_reflect::Value::List(list) = field.into_owned() {
800 for item in list {
801 if let prost_reflect::Value::Message(sub) = item {
802 if let Some(binding) = parse_http_rule(&sub) {
803 bindings.push(binding);
804 }
805 }
806 }
807 }
808 }
809
810 bindings
811}
812
813fn parse_http_rule(rule_msg: &DynamicMessage) -> Option<HttpBinding> {
815 let (http_method, http_path) = [
816 ("get", HttpMethod::Get),
817 ("post", HttpMethod::Post),
818 ("put", HttpMethod::Put),
819 ("delete", HttpMethod::Delete),
820 ("patch", HttpMethod::Patch),
821 ]
822 .into_iter()
823 .find_map(
824 |(name, http_method)| match rule_msg.get_field_by_name(name)?.into_owned() {
825 prost_reflect::Value::String(path) if !path.is_empty() => Some((http_method, path)),
826 _ => None,
827 },
828 )?;
829
830 let body = rule_msg
831 .get_field_by_name("body")
832 .and_then(|v| match v.into_owned() {
833 prost_reflect::Value::String(s) => Some(request::BodyMapping::parse(&s)),
834 _ => None,
835 })
836 .unwrap_or(request::BodyMapping::None);
837
838 let response_body =
839 rule_msg
840 .get_field_by_name("response_body")
841 .and_then(|v| match v.into_owned() {
842 prost_reflect::Value::String(s) if !s.is_empty() => Some(s),
843 _ => None,
844 });
845
846 Some(HttpBinding {
847 http_method,
848 http_path,
849 body,
850 response_body,
851 })
852}
853
854pub fn proto_path_to_axum(path: &str) -> String {
865 let mut out = String::with_capacity(path.len());
866
867 let segments = split_top_level(path);
868 let last = segments.len().saturating_sub(1);
869 for (idx, segment) in segments.iter().enumerate() {
870 if idx > 0 {
871 out.push('/');
872 }
873 out.push_str(&convert_segment(segment, idx, idx == last));
874 }
875
876 out
877}
878
879fn split_top_level(path: &str) -> Vec<&str> {
886 let mut segments = Vec::new();
887 let mut depth = 0usize;
888 let mut start = 0usize;
889
890 for (i, ch) in path.char_indices() {
891 match ch {
892 '{' => depth += 1,
893 '}' if depth > 0 => depth -= 1,
896 '/' if depth == 0 => {
897 segments.push(&path[start..i]);
898 start = i + 1;
899 }
900 _ => {}
901 }
902 }
903 segments.push(&path[start..]);
904 segments
905}
906
907fn convert_segment(segment: &str, idx: usize, is_last: bool) -> String {
912 if let Some(inner) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
913 if let Some((name, template)) = inner.split_once('=') {
915 return match template {
916 "*" => format!("{{{name}}}"),
918 "**" => catch_all(name, is_last),
920 _ => {
926 tracing::warn!(
927 template = %inner,
928 "google.api.http multi-segment field template is not fully \
929 supported; routing it as a catch-all capture"
930 );
931 catch_all(name, is_last)
932 }
933 };
934 }
935 return format!("{{{inner}}}");
937 }
938
939 match segment {
941 "**" => catch_all(&format!("wildcard{idx}"), is_last),
942 "*" => format!("{{wildcard{idx}}}"),
943 literal => literal.to_string(),
944 }
945}
946
947fn catch_all(name: &str, is_last: bool) -> String {
955 if is_last {
956 format!("{{*{name}}}")
957 } else {
958 tracing::warn!(
959 capture = %name,
960 "catch-all in a non-terminal path segment is unrepresentable in axum; \
961 degrading to a single-segment capture"
962 );
963 format!("{{{name}}}")
964 }
965}
966
967#[cfg(test)]
968mod tests;