Skip to main content

structured_proxy/transcode/
mod.rs

1//! REST→gRPC transcoding layer.
2//!
3//! Reads `google.api.http` annotations from proto service descriptors
4//! and builds axum routes that proxy JSON/form requests to gRPC upstream.
5//! The upstream decides the HTTP answer beyond the JSON body where it needs
6//! to: its response metadata becomes response headers, `x-http-code` sets the
7//! status of a successful unary call, and `google.api.HttpBody` carries a raw
8//! body in either direction.
9//!
10//! Generic: works with ANY proto descriptor set. No product-specific code.
11
12pub mod body;
13pub mod codec;
14pub mod error;
15pub(crate) mod httpbody;
16pub mod metadata;
17pub mod request;
18pub(crate) mod response;
19pub(crate) mod rule;
20
21use axum::body::{Body, Bytes};
22use axum::extract::{Path, RawQuery, State};
23use axum::http::header::{ALLOW, CONTENT_TYPE};
24use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
25use axum::response::sse::{Event, KeepAlive, Sse};
26use axum::response::{IntoResponse, Response};
27use axum::routing::{MethodFilter, MethodRouter};
28use axum::Router;
29use futures::{StreamExt, TryStreamExt};
30use prost_reflect::{
31    DescriptorPool, DynamicMessage, FieldDescriptor, MessageDescriptor, MethodDescriptor,
32    SerializeOptions,
33};
34use std::collections::HashMap;
35use std::sync::Arc;
36use tonic::client::Grpc;
37use tonic::metadata::MetadataMap;
38
39use crate::config::AliasConfig;
40use error::{ErrorDetailsPolicy, StatusDetails};
41use response::UpstreamHeaders;
42use rule::RouteMethod;
43
44/// Trait for state types that support REST→gRPC transcoding.
45///
46/// Implement this for your application's state type to use `transcode::routes()`.
47/// Provides the minimal interface needed by transcode handlers.
48pub trait TranscodeState: Clone + Send + Sync + 'static {
49    /// Lazy gRPC channel to upstream service.
50    fn grpc_channel(&self) -> tonic::transport::Channel;
51    /// Headers to forward from HTTP to gRPC metadata.
52    fn forwarded_headers(&self) -> &[String];
53    /// SSE keep-alive interval (seconds) for server-streaming responses.
54    fn sse_keep_alive_secs(&self) -> u64;
55}
56
57impl TranscodeState for crate::ProxyState {
58    fn grpc_channel(&self) -> tonic::transport::Channel {
59        self.grpc_channel.clone()
60    }
61    fn forwarded_headers(&self) -> &[String] {
62        &self.forwarded_headers
63    }
64    fn sse_keep_alive_secs(&self) -> u64 {
65        self.sse_keep_alive_secs
66    }
67}
68
69/// Path parameters of a matched route.
70type PathParams = HashMap<String, String>;
71
72/// How the HTTP request body reaches the RPC's input message.
73#[derive(Debug, Clone)]
74enum RequestBody {
75    /// JSON or a form, mapped as the rule's `body` says.
76    Parsed(request::BodyMapping),
77    /// Raw bytes and `Content-Type` into the input message, a `google.api.HttpBody`.
78    RawRoot,
79    /// Raw bytes and `Content-Type` into the HttpBody field `body` names; the
80    /// other fields still come from path and query.
81    RawField {
82        /// The rule's `body` mapping, which keeps the field out of query binding.
83        mapping: request::BodyMapping,
84        field: FieldDescriptor,
85        /// The field's type, a `google.api.HttpBody`.
86        http_body: MessageDescriptor,
87    },
88}
89
90/// What the HTTP response body is made of.
91#[derive(Debug, Clone)]
92enum ResponseShape {
93    /// ProtoJSON of the response message, or of its `response_body` subfield.
94    Json(Option<String>),
95    /// The raw body of a `google.api.HttpBody`: the response message itself
96    /// (empty path) or the field chain `response_body` names.
97    HttpBody(Vec<FieldDescriptor>),
98}
99
100/// Route entry extracted from proto HTTP annotations.
101#[derive(Debug, Clone)]
102struct RouteEntry {
103    /// HTTP path pattern (e.g., "/v1/auth/opaque/login/start").
104    http_path: String,
105    /// The HTTP method(s) the binding answers.
106    http_method: RouteMethod,
107    /// gRPC path (e.g., "/sid.v1.AuthService/OpaqueLoginStart"), parsed once at
108    /// route-build time so each request clones a cheap `Bytes` refcount.
109    grpc_path: axum::http::uri::PathAndQuery,
110    /// Method descriptor for input/output message resolution.
111    method: MethodDescriptor,
112    /// Server-streaming RPC (NDJSON / SSE, or chunked HttpBody).
113    streaming: bool,
114    /// How the request body maps onto the gRPC request message.
115    request_body: RequestBody,
116    /// What the HTTP response body is made of.
117    response: ResponseShape,
118    /// Renderer for the status details of this route's errors; `None` when the
119    /// error-details policy switches them off for the route.
120    error_details: Option<Arc<StatusDetails>>,
121    /// Wrap NDJSON stream lines in `{"result"}` / `{"error"}` envelopes.
122    ndjson_envelope: bool,
123    /// [`codec::has_required_fields`] of the response type, computed once so a
124    /// request does not walk the descriptor.
125    response_has_required: bool,
126    /// Response metadata keys the operator keeps off the HTTP response, on top
127    /// of the ones that never go there.
128    denied_headers: Arc<[HeaderName]>,
129}
130
131impl RouteEntry {
132    fn codec(&self) -> codec::DynamicCodec {
133        codec::DynamicCodec::with_required_check(self.method.output(), self.response_has_required)
134    }
135
136    /// The raw body of `message`, on a route that answers with an HttpBody.
137    fn raw_body(&self, message: DynamicMessage) -> Option<httpbody::RawBody> {
138        match &self.response {
139            ResponseShape::HttpBody(path) => Some(httpbody::take(message, path)),
140            ResponseShape::Json(_) => None,
141        }
142    }
143}
144
145/// How [`routes_with_options`] builds the transcoded routes.
146///
147/// # Examples
148///
149/// ```
150/// use axum::http::HeaderName;
151/// use structured_proxy::transcode::error::ErrorDetailsPolicy;
152/// use structured_proxy::transcode::TranscodeOptions;
153///
154/// let options = TranscodeOptions::default()
155///     .with_error_details(ErrorDetailsPolicy::default().route("/v1/admin/**", false).unwrap())
156///     .with_ndjson_envelope(true)
157///     .with_denied_response_headers([HeaderName::from_static("x-debug-trace")]);
158/// # let _ = options;
159/// ```
160#[derive(Debug, Clone, Default)]
161pub struct TranscodeOptions {
162    pub(crate) error_details: ErrorDetailsPolicy,
163    pub(crate) ndjson_envelope: bool,
164    pub(crate) denied_response_headers: Arc<[HeaderName]>,
165}
166
167impl TranscodeOptions {
168    /// Which routes return `google.rpc.Status` details in their error bodies
169    /// (all of them by default).
170    pub fn with_error_details(mut self, policy: ErrorDetailsPolicy) -> Self {
171        self.error_details = policy;
172        self
173    }
174
175    /// Wrap every NDJSON line of a server-streaming response in an envelope:
176    /// `{"result": <message>}` for data, `{"error": <error body>}` for the
177    /// terminal error (the grpc-gateway stream shape). Off by default, when a
178    /// data line is the bare message and the error line carries an
179    /// `@type: google.rpc.Status` marker instead. Only the envelope keeps the
180    /// two apart for RPCs that stream `google.protobuf.Any`, `Struct`, `Value`
181    /// or `ListValue`, whose messages can carry any keys. SSE is unaffected.
182    pub fn with_ndjson_envelope(mut self, enabled: bool) -> Self {
183        self.ndjson_envelope = enabled;
184        self
185    }
186
187    /// Response metadata keys that never become HTTP response headers, on top
188    /// of gRPC's own keys, hop-by-hop fields and `x-http-code`, which never do.
189    /// Replaces any list set before. Use it to keep internal headers (debug
190    /// traces, backend names) off a public edge; an upstream key not listed
191    /// here reaches the client.
192    pub fn with_denied_response_headers(
193        mut self,
194        names: impl IntoIterator<Item = HeaderName>,
195    ) -> Self {
196        self.denied_response_headers = names.into_iter().collect();
197        self
198    }
199}
200
201/// Build transcoded REST→gRPC routes from a descriptor pool.
202///
203/// Takes a `DescriptorPool` and optional path aliases from config.
204/// Returns an axum Router that transcodes REST requests to gRPC calls. Error
205/// bodies carry `google.rpc.Status` details on every route; use
206/// [`routes_with_options`] to choose per route or to frame NDJSON streams.
207pub fn routes<S: TranscodeState>(pool: &DescriptorPool, aliases: &[AliasConfig]) -> Router<S> {
208    routes_with_options(pool, aliases, &TranscodeOptions::default())
209}
210
211/// [`routes`], built as `options` describe.
212///
213/// A second binding for a method and path already taken (or any binding on a
214/// path a `custom` `*` rule takes, which answers every method) is skipped with
215/// an error in the log.
216pub fn routes_with_options<S: TranscodeState>(
217    pool: &DescriptorPool,
218    aliases: &[AliasConfig],
219    options: &TranscodeOptions,
220) -> Router<S> {
221    let bindings = route_bindings(pool, aliases);
222    if bindings.is_empty() {
223        tracing::warn!("No HTTP-annotated RPCs found in proto descriptors");
224        return Router::new();
225    }
226
227    tracing::info!("Registering {} transcoded REST→gRPC routes", bindings.len());
228
229    // At most two renderers, without and with the opaque-detail extension,
230    // each shared by every route that uses it and built only when one does.
231    // The second is a copy of the first: the descriptor pool inside is shared.
232    let mut status_details: [Option<Arc<StatusDetails>>; 2] = [None, None];
233    // Every binding of one path goes into the same method router, in binding
234    // order.
235    let mut paths: Vec<PathRoutes> = Vec::new();
236    let mut path_index: HashMap<String, usize> = HashMap::new();
237    for mut binding in bindings {
238        let policy = &options.error_details;
239        if policy.enabled_for(&binding.axum_path) {
240            let opaque = policy.opaque_for(&binding.axum_path);
241            let slot = usize::from(opaque);
242            if status_details[slot].is_none() {
243                let base = status_details
244                    .iter()
245                    .flatten()
246                    .next()
247                    .map(|existing| existing.as_ref().clone())
248                    .unwrap_or_else(|| StatusDetails::new(pool));
249                status_details[slot] = Some(Arc::new(base.with_opaque_details(opaque)));
250            }
251            binding.entry.error_details = status_details[slot].clone();
252        }
253        binding.entry.ndjson_envelope = options.ndjson_envelope;
254        binding.entry.denied_headers = options.denied_response_headers.clone();
255        let index = match path_index.get(&binding.axum_path) {
256            Some(&index) => index,
257            None => {
258                path_index.insert(binding.axum_path.clone(), paths.len());
259                paths.push(PathRoutes {
260                    path: binding.axum_path,
261                    methods: Vec::new(),
262                });
263                paths.len() - 1
264            }
265        };
266        paths[index].add(Arc::new(binding.entry));
267    }
268
269    let mut router: Router<S> = Router::new();
270    for path in &paths {
271        router = router.route(&path.path, path.method_router());
272    }
273    router
274}
275
276/// The axum handler serving the route entry `$entry`, for the state type `S`
277/// in scope. A macro because the closure's handler type cannot be named.
278macro_rules! endpoint {
279    ($entry:expr) => {{
280        let entry: Arc<RouteEntry> = $entry;
281        move |state: State<S>,
282              headers: HeaderMap,
283              path_params: Path<PathParams>,
284              raw_query: RawQuery,
285              body: Bytes| handle(state, headers, path_params, raw_query, body, entry)
286    }};
287}
288
289/// The bindings mounted at one axum path.
290struct PathRoutes {
291    path: String,
292    methods: Vec<Arc<RouteEntry>>,
293}
294
295impl PathRoutes {
296    /// Add `entry` unless its method is already answered on this path.
297    fn add(&mut self, entry: Arc<RouteEntry>) {
298        let taken = self.methods.iter().any(|existing| {
299            existing.http_method == entry.http_method
300                || existing.http_method == RouteMethod::Any
301                || entry.http_method == RouteMethod::Any
302        });
303        if taken {
304            tracing::error!(
305                method = entry.http_method.as_str(),
306                path = %self.path,
307                rpc = %entry.grpc_path,
308                "HTTP method and path already bound to another RPC; skipping this binding"
309            );
310            return;
311        }
312        self.methods.push(entry);
313    }
314
315    /// One method router for every binding of the path. Methods axum routes by
316    /// itself are registered directly; any other token (a `custom` rule such
317    /// as `PROPFIND`) is dispatched by a fallback that answers `405` with the
318    /// full `Allow` list (RFC 9110 §15.5.6) for a method nobody binds.
319    fn method_router<S: TranscodeState>(&self) -> MethodRouter<S> {
320        let mut router = MethodRouter::new();
321        let mut extension: Vec<(Method, Arc<RouteEntry>)> = Vec::new();
322        let mut allow: Vec<&str> = Vec::new();
323        for entry in &self.methods {
324            match &entry.http_method {
325                // `add` keeps a `*` binding alone on its path.
326                RouteMethod::Any => return axum::routing::any(endpoint!(entry.clone())),
327                RouteMethod::One(method) => {
328                    allow.push(method.as_str());
329                    match MethodFilter::try_from(method.clone()) {
330                        Ok(filter) => router = router.on(filter, endpoint!(entry.clone())),
331                        Err(_) => extension.push((method.clone(), entry.clone())),
332                    }
333                }
334            }
335        }
336        if extension.is_empty() {
337            return router;
338        }
339        // A GET route answers HEAD too.
340        if allow.contains(&"GET") && !allow.contains(&"HEAD") {
341            allow.push("HEAD");
342        }
343        let allow = HeaderValue::from_str(&allow.join(", "))
344            .expect("method tokens are valid header value characters");
345        let extension: Arc<[(Method, Arc<RouteEntry>)]> = extension.into();
346        // The method decides before anything is extracted, so a request no
347        // binding answers gets its 405 without its body being read.
348        router.fallback(
349            move |State(state): State<S>, request: axum::extract::Request| async move {
350                match extension
351                    .iter()
352                    .find(|(bound, _)| bound == request.method())
353                {
354                    Some((_, entry)) => {
355                        axum::handler::Handler::call(endpoint!(entry.clone()), request, state).await
356                    }
357                    None => (StatusCode::METHOD_NOT_ALLOWED, [(ALLOW, allow)]).into_response(),
358                }
359            },
360        )
361    }
362}
363
364/// One transcode route to mount: the RPC entry that serves it and the axum path
365/// to register it at.
366struct RouteBinding {
367    entry: RouteEntry,
368    axum_path: String,
369}
370
371/// The single source of truth for what [`routes`] mounts: every binding of
372/// every unary and server-streaming RPC, plus its config aliases. Both
373/// [`routes`] (to build handlers) and [`route_paths`] (to enumerate paths for
374/// collision checks) consume this, so the mounted set and the enumerated set
375/// cannot drift apart.
376fn route_bindings(pool: &DescriptorPool, aliases: &[AliasConfig]) -> Vec<RouteBinding> {
377    let mut bindings = Vec::new();
378    for entry in extract_routes(pool) {
379        for alias in aliases {
380            if let Some(suffix) = entry.http_path.strip_prefix(&alias.to) {
381                if alias.from.ends_with("/{path}") {
382                    let prefix = alias.from.trim_end_matches("/{path}");
383                    bindings.push(RouteBinding {
384                        axum_path: proto_path_to_axum(&format!("{prefix}{suffix}")),
385                        entry: entry.clone(),
386                    });
387                }
388            }
389        }
390        bindings.push(RouteBinding {
391            axum_path: proto_path_to_axum(&entry.http_path),
392            entry,
393        });
394    }
395    bindings
396}
397
398/// The axum paths [`routes`] would register for this pool and aliases.
399///
400/// Mirrors the registration in [`routes`] (every binding of every unary and
401/// server-streaming RPC, and their config aliases) without building handlers,
402/// so callers can detect route collisions before mounting additional routes
403/// (e.g. a forward-auth endpoint).
404///
405/// Each entry is `(method, path)` where `method` is the uppercase HTTP token,
406/// or `*` for a `custom` rule that answers every method, so callers can
407/// distinguish same-path/different-method routes from real conflicts.
408pub fn route_paths(pool: &DescriptorPool, aliases: &[AliasConfig]) -> Vec<(String, String)> {
409    route_bindings(pool, aliases)
410        .into_iter()
411        .map(|b| (b.entry.http_method.as_str().to_string(), b.axum_path))
412        .collect()
413}
414
415/// JSON serialization options shared by the unary and streaming response paths,
416/// so a given message serializes identically regardless of RPC kind.
417fn response_serialize_options() -> SerializeOptions {
418    SerializeOptions::new()
419        .skip_default_fields(false)
420        .stringify_64_bit_integers(true)
421}
422
423/// Serialize a message straight to JSON bytes, without an intermediate tree.
424fn message_to_json_bytes(
425    msg: &DynamicMessage,
426    opts: &SerializeOptions,
427) -> Result<Vec<u8>, serde_json::Error> {
428    let mut buf = Vec::with_capacity(128);
429    msg.serialize_with_options(&mut serde_json::Serializer::new(&mut buf), opts)?;
430    Ok(buf)
431}
432
433/// Serialize one streamed gRPC message to a compact JSON string.
434fn message_to_json_string(msg: &DynamicMessage, opts: &SerializeOptions) -> Result<String, String> {
435    let buf = message_to_json_bytes(msg, opts).map_err(|e| e.to_string())?;
436    // SAFETY: serde_json's serializer writes only valid UTF-8, the same
437    // guarantee `serde_json::to_string` relies on.
438    Ok(unsafe { String::from_utf8_unchecked(buf) })
439}
440
441/// The unary response body as JSON: the whole message, or the subfield
442/// `response_body` names (JSON `null` when the path does not exist).
443fn json_body(
444    msg: &DynamicMessage,
445    response_body: Option<&str>,
446) -> Result<Vec<u8>, serde_json::Error> {
447    let opts = response_serialize_options();
448    let Some(path) = response_body else {
449        return message_to_json_bytes(msg, &opts);
450    };
451    // Walk the tree by moving each subtree out, so nothing is copied.
452    // `response_body` names proto fields, while the tree is keyed by their
453    // JSON names, so each segment is resolved through the descriptor.
454    let mut value = Some(msg.serialize_with_options(serde_json::value::Serializer, &opts)?);
455    let mut desc = Some(prost_reflect::ReflectMessage::descriptor(msg));
456    for segment in path.split('.') {
457        let field = desc.as_ref().and_then(|d| d.get_field_by_name(segment));
458        desc = match field.as_ref().map(FieldDescriptor::kind) {
459            Some(prost_reflect::Kind::Message(inner)) => Some(inner),
460            _ => None,
461        };
462        value = match (value, field) {
463            (Some(serde_json::Value::Object(mut fields)), Some(field)) => {
464                fields.remove(field.json_name())
465            }
466            _ => None,
467        };
468    }
469    let value = value.unwrap_or_else(|| {
470        tracing::warn!(
471            response_body = %path,
472            "configured response_body path not found in response; returning null"
473        );
474        serde_json::Value::Null
475    });
476    serde_json::to_vec(&value)
477}
478
479/// Whether the client negotiated a Server-Sent Events response via `Accept`.
480///
481/// Considers every `Accept` header line (a client may send more than one) and
482/// every comma-separated media range within each. Matches `text/event-stream`
483/// case-insensitively and honors the quality factor: per RFC 7231 §5.3.1 a
484/// `q=0` weight means the type is explicitly not acceptable, so it does not
485/// select the SSE path.
486fn wants_sse(headers: &HeaderMap) -> bool {
487    headers
488        .get_all(axum::http::header::ACCEPT)
489        .iter()
490        .filter_map(|v| v.to_str().ok())
491        .flat_map(|accept| accept.split(','))
492        .any(accept_range_selects_sse)
493}
494
495/// Whether a single `Accept` media range selects `text/event-stream` with a
496/// non-zero quality factor.
497fn accept_range_selects_sse(range: &str) -> bool {
498    let mut parts = range.split(';');
499    let media = parts.next().unwrap_or("").trim();
500    if !media.eq_ignore_ascii_case("text/event-stream") {
501        return false;
502    }
503    // Default weight is 1.0; only an explicit `q=0` (or unparseable-as-positive)
504    // disqualifies the match. A malformed weight falls back to acceptable.
505    for param in parts {
506        let mut kv = param.splitn(2, '=');
507        if kv.next().unwrap_or("").trim().eq_ignore_ascii_case("q") {
508            let q: f32 = kv.next().unwrap_or("").trim().parse().unwrap_or(1.0);
509            return q > 0.0;
510        }
511    }
512    true
513}
514
515/// Serve one request on a transcoded route.
516async fn handle<S: TranscodeState>(
517    State(proxy_state): State<S>,
518    headers: HeaderMap,
519    Path(path_params): Path<PathParams>,
520    RawQuery(raw_query): RawQuery,
521    body: Bytes,
522    entry: Arc<RouteEntry>,
523) -> Response {
524    let prepared = prepare(
525        &proxy_state,
526        &headers,
527        &path_params,
528        raw_query.as_deref(),
529        body,
530        &entry,
531    )
532    .await;
533    let (client, request) = match prepared {
534        Ok(prepared) => prepared,
535        Err(rejection) => return rejection.into_response(&entry),
536    };
537    if entry.streaming {
538        let keep_alive_secs = proxy_state.sse_keep_alive_secs();
539        streaming_call(client, request, entry, wants_sse(&headers), keep_alive_secs).await
540    } else {
541        unary_call(client, request, &entry).await
542    }
543}
544
545/// Why a request ends before the upstream is called.
546enum Rejection {
547    /// It cannot be mapped onto the RPC (`INVALID_ARGUMENT`, 400).
548    Unmappable(String),
549    /// The upstream channel is not ready (`UNAVAILABLE`, 503).
550    NotReady(String),
551}
552
553impl Rejection {
554    /// The answer, in the error body the upstream's own errors get on the route.
555    fn into_response(self, entry: &RouteEntry) -> Response {
556        let status = match self {
557            Self::Unmappable(message) => tonic::Status::invalid_argument(message),
558            Self::NotReady(message) => tonic::Status::unavailable(message),
559        };
560        error::status_to_response_with_details(&status, entry.error_details.as_deref())
561    }
562}
563
564/// Map the request onto the RPC's input message and get a client whose
565/// channel is ready.
566async fn prepare<S: TranscodeState>(
567    proxy_state: &S,
568    headers: &HeaderMap,
569    path_params: &PathParams,
570    raw_query: Option<&str>,
571    body: Bytes,
572    entry: &RouteEntry,
573) -> Result<
574    (
575        Grpc<tonic::transport::Channel>,
576        tonic::Request<DynamicMessage>,
577    ),
578    Rejection,
579> {
580    let request_metadata =
581        metadata::try_http_headers_to_grpc_metadata(headers, proxy_state.forwarded_headers())
582            .map_err(|e| Rejection::Unmappable(e.to_string()))?;
583    let message = decode_request(entry, headers, path_params, raw_query, body)
584        .map_err(Rejection::Unmappable)?;
585    let mut request = tonic::Request::new(message);
586    *request.metadata_mut() = request_metadata;
587    metadata::apply_request_deadline(&mut request, headers);
588
589    let mut client = Grpc::new(proxy_state.grpc_channel());
590    if let Err(e) = client.ready().await {
591        return Err(Rejection::NotReady(format!("gRPC upstream not ready: {e}")));
592    }
593    Ok((client, request))
594}
595
596/// A successful unary answer with its initial metadata and trailers kept
597/// apart.
598struct UnaryAnswer {
599    initial: MetadataMap,
600    message: DynamicMessage,
601    trailers: Option<MetadataMap>,
602}
603
604/// Call a unary RPC. tonic's `Grpc::unary` merges the trailers over the
605/// initial metadata, so a key sent in both keeps only its trailer value; the
606/// HTTP response carries both, so the call is made as a one-message stream
607/// instead, reading exactly what `Grpc::unary` reads.
608async fn call_unary(
609    client: &mut Grpc<tonic::transport::Channel>,
610    request: tonic::Request<DynamicMessage>,
611    entry: &RouteEntry,
612) -> Result<UnaryAnswer, tonic::Status> {
613    let response = client
614        .server_streaming(request, entry.grpc_path.clone(), entry.codec())
615        .await?;
616    let (initial, mut stream, _) = response.into_parts();
617    let message = match stream.message().await {
618        Ok(Some(message)) => message,
619        Ok(None) => {
620            let status = tonic::Status::internal("Missing response message.");
621            return Err(with_initial(status, initial));
622        }
623        Err(status) => return Err(with_initial(status, initial)),
624    };
625    let trailers = match stream.trailers().await {
626        Ok(trailers) => trailers,
627        Err(status) => return Err(with_initial(status, initial)),
628    };
629    Ok(UnaryAnswer {
630        initial,
631        message,
632        trailers,
633    })
634}
635
636/// A failure that came after the upstream's response headers, with their
637/// metadata put ahead of the failure's own: both belong to the error
638/// response, as tonic's `Grpc::unary` keeps them.
639fn with_initial(mut status: tonic::Status, initial: MetadataMap) -> tonic::Status {
640    let own = std::mem::take(status.metadata_mut()).into_headers();
641    let mut merged = initial.into_headers();
642    let mut last: Option<HeaderName> = None;
643    // A header map yields a name only with the first value of each key.
644    for (name, value) in own {
645        if let Some(name) = name {
646            merged.append(&name, value);
647            last = Some(name);
648        } else if let Some(name) = &last {
649            merged.append(name, value);
650        }
651    }
652    *status.metadata_mut() = MetadataMap::from_headers(merged);
653    status
654}
655
656/// Serve a unary RPC.
657async fn unary_call(
658    mut client: Grpc<tonic::transport::Channel>,
659    request: tonic::Request<DynamicMessage>,
660    entry: &RouteEntry,
661) -> Response {
662    match call_unary(&mut client, request, entry).await {
663        Ok(answer) => unary_success(entry, answer),
664        Err(status) => upstream_error(status, entry),
665    }
666}
667
668/// The HTTP response to a successful unary call: the status `x-http-code`
669/// sets (200 otherwise), the forwarded response metadata, and the body.
670fn unary_success(entry: &RouteEntry, answer: UnaryAnswer) -> Response {
671    let mut upstream = UpstreamHeaders::default();
672    upstream.absorb(answer.initial, &entry.denied_headers);
673    if let Some(trailers) = answer.trailers {
674        upstream.absorb(trailers, &entry.denied_headers);
675    }
676    let status = match upstream.status() {
677        Ok(status) => status.unwrap_or(StatusCode::OK),
678        Err(response::InvalidHttpCode) => {
679            tracing::error!(
680                rpc = %entry.grpc_path,
681                "upstream set x-http-code to something other than one integer in 200-599"
682            );
683            return error::malformed_response(entry.error_details.as_deref());
684        }
685    };
686    let (content_type, body) = match &entry.response {
687        ResponseShape::HttpBody(path) => {
688            let raw = httpbody::take(answer.message, path);
689            match content_type_header(&raw.content_type) {
690                Ok(content_type) => (content_type, Body::from(raw.data)),
691                Err(()) => return invalid_content_type(entry),
692            }
693        }
694        ResponseShape::Json(response_body) => {
695            match json_body(&answer.message, response_body.as_deref()) {
696                Ok(json) => (
697                    Some(HeaderValue::from_static("application/json")),
698                    Body::from(json),
699                ),
700                Err(e) => {
701                    tracing::error!("Failed to serialize gRPC response: {e}");
702                    return error::status_to_response_with_details(
703                        &tonic::Status::internal("failed to serialize response"),
704                        entry.error_details.as_deref(),
705                    );
706                }
707            }
708        }
709    };
710    response::build(status, upstream.into_headers(), content_type, body)
711}
712
713/// The `Content-Type` an HttpBody asks for: none when it left the field empty.
714fn content_type_header(content_type: &str) -> Result<Option<HeaderValue>, ()> {
715    if content_type.is_empty() {
716        return Ok(None);
717    }
718    HeaderValue::from_str(content_type)
719        .map(Some)
720        .map_err(|_| ())
721}
722
723/// The answer to an HttpBody whose content type cannot be a header value.
724fn invalid_content_type(entry: &RouteEntry) -> Response {
725    tracing::error!(
726        rpc = %entry.grpc_path,
727        "upstream HttpBody content_type is not a valid header value"
728    );
729    error::malformed_response(entry.error_details.as_deref())
730}
731
732/// The HTTP response to a failed call, carrying the failure's metadata as
733/// headers (a trailers-only response, or the response headers and trailers of
734/// a call that failed after them, see [`with_initial`]) unless its details
735/// were malformed and the answer is the generic `INTERNAL`, which then carries
736/// nothing of the upstream's answer.
737fn upstream_error(mut status: tonic::Status, entry: &RouteEntry) -> Response {
738    let (response, faithful) = error::render_response(&status, entry.error_details.as_deref());
739    let metadata = std::mem::take(status.metadata_mut());
740    if !faithful || metadata.is_empty() {
741        return response;
742    }
743    let mut upstream = UpstreamHeaders::default();
744    upstream.absorb(metadata, &entry.denied_headers);
745    response::with_upstream_headers(response, upstream.into_headers())
746}
747
748/// Serve a server-streaming RPC.
749///
750/// A JSON stream is Server-Sent Events when the client sends
751/// `Accept: text/event-stream`, otherwise newline-delimited JSON (NDJSON); a
752/// gRPC error mid-stream is delivered as an explicit terminal frame before the
753/// stream is closed cleanly, rather than truncating the HTTP body. An HttpBody
754/// stream is the concatenated `data` of its messages. The upstream's initial
755/// metadata becomes response headers; trailers arrive after the headers are
756/// sent and are not forwarded.
757async fn streaming_call(
758    mut client: Grpc<tonic::transport::Channel>,
759    request: tonic::Request<DynamicMessage>,
760    entry: Arc<RouteEntry>,
761    use_sse: bool,
762    keep_alive_secs: u64,
763) -> Response {
764    let response = match client
765        .server_streaming(request, entry.grpc_path.clone(), entry.codec())
766        .await
767    {
768        Ok(response) => response,
769        // Only a trailers-only rejection lands here.
770        Err(status) => return upstream_error(status, &entry),
771    };
772    let (initial, stream, _) = response.into_parts();
773    if matches!(entry.response, ResponseShape::HttpBody(_)) {
774        return http_body_stream(stream, entry, initial).await;
775    }
776    let mut upstream = UpstreamHeaders::default();
777    upstream.absorb(initial, &entry.denied_headers);
778    let headers = upstream.into_headers();
779    // Once the upstream accepted the call, the response starts at once rather
780    // than waiting for the first item, so headers and SSE keep-alives are not
781    // held back; an error that comes before the first message is a terminal
782    // frame. The terminal frame renders like the unary error body. The
783    // closure takes over this request's route handle, so the stream keeps it
784    // alive without another refcount.
785    let envelope = entry.ndjson_envelope;
786    let render_error =
787        move |status: &tonic::Status| error::error_body(status, entry.error_details.as_deref());
788    let response = if use_sse {
789        sse_response(stream, render_error, keep_alive_secs)
790    } else {
791        ndjson_response(stream, render_error, envelope)
792    };
793    response::with_upstream_headers(response, headers)
794}
795
796/// A server-streaming HttpBody response: `Content-Type` from the first
797/// message, so the headers wait for it, then every message's `data` as it
798/// arrives. An error before the first message is an ordinary error response,
799/// with the initial metadata the upstream sent before it; after the first
800/// message, the raw body has no in-band error frame, so the body is aborted
801/// and the client sees a truncated transfer instead of a clean end.
802async fn http_body_stream(
803    mut stream: tonic::Streaming<DynamicMessage>,
804    entry: Arc<RouteEntry>,
805    initial: MetadataMap,
806) -> Response {
807    let first = match stream.message().await {
808        Ok(Some(message)) => entry.raw_body(message).unwrap_or_default(),
809        Ok(None) => httpbody::RawBody::default(),
810        Err(status) => return upstream_error(with_initial(status, initial), &entry),
811    };
812    let mut upstream = UpstreamHeaders::default();
813    upstream.absorb(initial, &entry.denied_headers);
814    let headers = upstream.into_headers();
815    let content_type = match content_type_header(&first.content_type) {
816        Ok(content_type) => content_type,
817        Err(()) => return invalid_content_type(&entry),
818    };
819    let rest = stream.map(move |item| match item {
820        Ok(message) => Ok(entry.raw_body(message).unwrap_or_default().data),
821        Err(status) => {
822            tracing::error!(
823                rpc = %entry.grpc_path,
824                code = ?status.code(),
825                "server-streaming HttpBody failed after the response started; aborting the body"
826            );
827            Err(std::io::Error::other(status))
828        }
829    });
830    let chunks = futures::stream::once(futures::future::ready(Ok(first.data)))
831        .chain(rest)
832        .try_filter(|chunk| futures::future::ready(!chunk.is_empty()));
833    response::build(
834        StatusCode::OK,
835        headers,
836        content_type,
837        Body::from_stream(chunks),
838    )
839}
840
841/// One frame of a streaming response: a serialized message, or the error body
842/// (see [`error::error_body`]) that ends the stream.
843///
844/// `Error` is terminal: [`json_frames`] stops the stream right after yielding
845/// it, so an error frame is always the last thing a client sees regardless of
846/// whether it came from a gRPC status or a serialization failure. It stays a
847/// JSON value so each format can add its own framing before serializing it.
848enum StreamFrame {
849    Data(String),
850    Error(serde_json::Value),
851}
852
853/// Type URL marking the terminal error line of an unenveloped NDJSON stream. A
854/// data line is the ProtoJSON of a response message, which carries a top-level
855/// `@type` only when the RPC streams `google.protobuf.Any`; `Struct`, `Value`
856/// and `ListValue` can carry any key at all. For those RPCs no in-band marker
857/// is collision-free, which is what the opt-in envelope
858/// ([`TranscodeOptions::with_ndjson_envelope`]) is for; the unenveloped shape
859/// stays the default so existing NDJSON readers keep working.
860const STATUS_TYPE_URL: &str = "type.googleapis.com/google.rpc.Status";
861
862/// Turn a gRPC message stream into a stream of serialized JSON frames, stopping
863/// after the first error so error frames are unambiguously terminal.
864///
865/// Both a gRPC `Status` (rendered by `render_error`) and a per-message
866/// serialization failure become a terminal [`StreamFrame::Error`]; downstream
867/// messages the upstream might still emit are dropped rather than streamed past
868/// the error. The terminal frame drops the upstream along with the stream
869/// state, so the body ends at once instead of polling an upstream that may
870/// stay open.
871fn json_frames<St, R>(
872    stream: St,
873    render_error: R,
874) -> impl futures::Stream<Item = StreamFrame> + Send + 'static
875where
876    St: futures::Stream<Item = Result<DynamicMessage, tonic::Status>> + Send + Unpin + 'static,
877    R: Fn(&tonic::Status) -> serde_json::Value + Send + 'static,
878{
879    let state = Some((stream, render_error, response_serialize_options()));
880    futures::stream::unfold(state, |state| async move {
881        let (mut stream, render_error, opts) = state?;
882        let error = match stream.next().await? {
883            Ok(msg) => match message_to_json_string(&msg, &opts) {
884                Ok(s) => return Some((StreamFrame::Data(s), Some((stream, render_error, opts)))),
885                Err(e) => tonic::Status::internal(format!("serialization error: {e}")),
886            },
887            Err(status) => status,
888        };
889        Some((StreamFrame::Error(render_error(&error)), None))
890    })
891}
892
893/// Build an NDJSON (`application/x-ndjson`) streaming response.
894///
895/// With `envelope`, every line is wrapped: `{"result": <message>}` for data and
896/// `{"error": <error body>}` for the terminal error. Without it, a data line is
897/// the bare message and the error line is the error body plus an
898/// `@type: google.rpc.Status` marker (see [`STATUS_TYPE_URL`]).
899fn ndjson_response<St, R>(stream: St, render_error: R, envelope: bool) -> Response
900where
901    St: futures::Stream<Item = Result<DynamicMessage, tonic::Status>> + Send + Unpin + 'static,
902    R: Fn(&tonic::Status) -> serde_json::Value + Send + 'static,
903{
904    let byte_stream = json_frames(stream, render_error).map(move |frame| {
905        let mut line = match frame {
906            // The message is already serialized; wrap the text instead of
907            // parsing it back into a value.
908            StreamFrame::Data(s) if envelope => {
909                let mut wrapped = String::with_capacity(s.len() + 12);
910                wrapped.push_str("{\"result\":");
911                wrapped.push_str(&s);
912                wrapped.push('}');
913                wrapped
914            }
915            StreamFrame::Data(s) => s,
916            StreamFrame::Error(body) if envelope => {
917                serde_json::json!({ "error": body }).to_string()
918            }
919            StreamFrame::Error(mut body) => {
920                if let Some(fields) = body.as_object_mut() {
921                    fields.insert("@type".into(), STATUS_TYPE_URL.into());
922                }
923                body.to_string()
924            }
925        };
926        line.push('\n');
927        Ok::<Bytes, std::io::Error>(Bytes::from(line))
928    });
929
930    let body = Body::from_stream(byte_stream);
931    // Body framing (chunked on HTTP/1.1, DATA frames on HTTP/2) is chosen by
932    // hyper from the protocol version; setting transfer-encoding by hand would
933    // be redundant on HTTP/1.1 and illegal on HTTP/2.
934    Response::builder()
935        .status(StatusCode::OK)
936        .header(CONTENT_TYPE, "application/x-ndjson")
937        .body(body)
938        .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
939}
940
941/// Build a Server-Sent Events (`text/event-stream`) streaming response.
942fn sse_response<St, R>(stream: St, render_error: R, keep_alive_secs: u64) -> Response
943where
944    St: futures::Stream<Item = Result<DynamicMessage, tonic::Status>> + Send + Unpin + 'static,
945    R: Fn(&tonic::Status) -> serde_json::Value + Send + 'static,
946{
947    // Terminal errors use the `stream-error` event type, not the reserved
948    // `error` type that the browser EventSource dispatches for transport
949    // failures — clients listen for it via addEventListener("stream-error").
950    let event_stream = json_frames(stream, render_error).map(|frame| {
951        let event = match frame {
952            StreamFrame::Data(s) => Event::default().data(s),
953            StreamFrame::Error(body) => Event::default()
954                .event("stream-error")
955                .data(body.to_string()),
956        };
957        Ok::<Event, std::convert::Infallible>(event)
958    });
959
960    Sse::new(event_stream)
961        .keep_alive(KeepAlive::new().interval(std::time::Duration::from_secs(keep_alive_secs)))
962        .into_response()
963}
964
965/// Request body mapping of the raw-body routes: nothing parsed.
966static NO_PARSED_BODY: request::BodyMapping = request::BodyMapping::None;
967
968/// Map the HTTP request onto the RPC's input message: path parameters, query
969/// parameters and the route's `body` rule, or the raw body for an HttpBody.
970/// Unary and server-streaming routes share it, so both bind a request the same
971/// way. The error is the message of the 400 the caller answers with, before
972/// the upstream is called.
973fn decode_request(
974    entry: &RouteEntry,
975    headers: &HeaderMap,
976    path_params: &PathParams,
977    raw_query: Option<&str>,
978    body_bytes: Bytes,
979) -> Result<DynamicMessage, String> {
980    // A raw field keeps its `body` mapping with a null placeholder, so query
981    // binding leaves that field to the body as it does for a parsed one.
982    let (mapping, json_body) = match &entry.request_body {
983        RequestBody::Parsed(request::BodyMapping::None) => {
984            (&NO_PARSED_BODY, serde_json::Value::Null)
985        }
986        RequestBody::Parsed(mapping) => (
987            mapping,
988            body::parse_body(body::content_type(headers), &body_bytes)
989                .map_err(|e| format!("failed to parse request body: {e}"))?,
990        ),
991        RequestBody::RawRoot => (&NO_PARSED_BODY, serde_json::Value::Null),
992        RequestBody::RawField { mapping, .. } => (mapping, serde_json::Value::Null),
993    };
994
995    // Query string → field bindings (fields not bound by path or body).
996    // A malformed query is a client error: reject it rather than silently
997    // dropping every query-bound field. An HttpBody input with `body: "*"`
998    // takes every field from the raw body, so its query binds nothing
999    // (google/api/http.proto: with `*` there are no HTTP parameters).
1000    let query_pairs = match entry.request_body {
1001        RequestBody::RawRoot => Vec::new(),
1002        _ => request::parse_query(raw_query)?,
1003    };
1004
1005    let input_desc = entry.method.input();
1006    let request_json =
1007        request::build_request_json(&input_desc, mapping, json_body, path_params, &query_pairs)?;
1008
1009    let mut message = DynamicMessage::deserialize(input_desc, request_json)
1010        .map_err(|e| format!("failed to decode request: {e}"))?;
1011    match &entry.request_body {
1012        RequestBody::Parsed(_) => {}
1013        RequestBody::RawRoot => {
1014            httpbody::fill(&mut message, request_content_type(headers)?, body_bytes);
1015        }
1016        RequestBody::RawField {
1017            field, http_body, ..
1018        } => {
1019            let mut inner = DynamicMessage::new(http_body.clone());
1020            httpbody::fill(&mut inner, request_content_type(headers)?, body_bytes);
1021            message.set_field(field, prost_reflect::Value::Message(inner));
1022        }
1023    }
1024    Ok(message)
1025}
1026
1027/// The request's full `Content-Type` value (parameters included) for an
1028/// HttpBody, empty when absent. `HttpBody.content_type` is a proto string, so
1029/// a value that is not visible ASCII is rejected rather than altered.
1030fn request_content_type(headers: &HeaderMap) -> Result<String, String> {
1031    match headers.get(CONTENT_TYPE) {
1032        None => Ok(String::new()),
1033        Some(value) => value
1034            .to_str()
1035            .map(str::to_owned)
1036            .map_err(|_| "request Content-Type is not a visible ASCII string".to_string()),
1037    }
1038}
1039
1040/// Extract the route entries of every HTTP binding of every unary and
1041/// server-streaming RPC. Client-streaming RPCs have no HTTP mapping.
1042fn extract_routes(pool: &DescriptorPool) -> Vec<RouteEntry> {
1043    let http_ext = match pool.get_extension_by_name("google.api.http") {
1044        Some(ext) => ext,
1045        None => {
1046            tracing::warn!("google.api.http extension not found in descriptor pool");
1047            return Vec::new();
1048        }
1049    };
1050
1051    let mut entries = Vec::new();
1052
1053    for service in pool.services() {
1054        for method in service.methods() {
1055            if method.is_client_streaming() {
1056                continue;
1057            }
1058
1059            let grpc_path = format!("/{}/{}", service.full_name(), method.name());
1060            let grpc_path: axum::http::uri::PathAndQuery = match grpc_path.parse() {
1061                Ok(p) => p,
1062                Err(e) => {
1063                    tracing::error!("skipping route with invalid gRPC path '{grpc_path}': {e}");
1064                    continue;
1065                }
1066            };
1067
1068            let input = method.input();
1069            let output = method.output();
1070            let streaming = method.is_server_streaming();
1071            let response_has_required = codec::has_required_fields(&output);
1072            for binding in rule::http_bindings(&method, &http_ext) {
1073                if streaming {
1074                    tracing::info!(
1075                        "Registering streaming route: {} {} → {}",
1076                        binding.method.as_str(),
1077                        binding.path,
1078                        grpc_path
1079                    );
1080                }
1081                entries.push(RouteEntry {
1082                    http_path: binding.path,
1083                    http_method: binding.method,
1084                    grpc_path: grpc_path.clone(),
1085                    method: method.clone(),
1086                    streaming,
1087                    request_body: request_body(&input, binding.body),
1088                    response: response_shape(&output, binding.response_body),
1089                    // Decided per mounted path in `routes_with_options`.
1090                    error_details: None,
1091                    ndjson_envelope: false,
1092                    response_has_required,
1093                    denied_headers: Arc::default(),
1094                });
1095            }
1096        }
1097    }
1098
1099    entries
1100}
1101
1102/// How a binding's `body` rule reaches `input`: raw into an HttpBody (the
1103/// input itself with `body: "*"`, or the HttpBody field `body` names), parsed
1104/// otherwise. `body` names a field of the input itself, never a dotted path:
1105/// `google/api/http.proto` requires "the referred field must not be a repeated
1106/// field and must be present at the top-level of request message type".
1107fn request_body(input: &MessageDescriptor, mapping: request::BodyMapping) -> RequestBody {
1108    let raw_field = match &mapping {
1109        request::BodyMapping::Root if httpbody::is_http_body(input) => return RequestBody::RawRoot,
1110        request::BodyMapping::Field(name) => httpbody::http_body_field(input, name),
1111        _ => None,
1112    };
1113    match raw_field {
1114        Some((field, http_body)) => RequestBody::RawField {
1115            mapping,
1116            field,
1117            http_body,
1118        },
1119        None => RequestBody::Parsed(mapping),
1120    }
1121}
1122
1123/// What a binding answers with: the raw body of an HttpBody (the output itself,
1124/// or the HttpBody `response_body` names), JSON otherwise.
1125fn response_shape(output: &MessageDescriptor, response_body: Option<String>) -> ResponseShape {
1126    match response_body {
1127        None if httpbody::is_http_body(output) => ResponseShape::HttpBody(Vec::new()),
1128        None => ResponseShape::Json(None),
1129        Some(path) => match httpbody::http_body_path(output, &path) {
1130            Some(fields) => ResponseShape::HttpBody(fields),
1131            None => ResponseShape::Json(Some(path)),
1132        },
1133    }
1134}
1135
1136/// Convert a `google.api.http` path template to axum 0.8 path syntax.
1137///
1138/// The proto `{param}` form IS axum 0.8's native capture syntax, so plain
1139/// single-segment params pass through verbatim. Only field-path templates and
1140/// bare wildcards need rewriting (axum 0.7 used `:param`; 0.8 uses `{param}`
1141/// and rejects any segment starting with `:`):
1142/// - `{name=*}`  (single segment)      -> `{name}`
1143/// - `{name=**}` (multi-segment) -> `{*name}` (axum catch-all)
1144/// - bare `*` segment            -> `{wildcardN}`
1145/// - bare `**` segment           -> `{*wildcardN}` (axum catch-all)
1146pub fn proto_path_to_axum(path: &str) -> String {
1147    let mut out = String::with_capacity(path.len());
1148
1149    let segments = split_top_level(path);
1150    let last = segments.len().saturating_sub(1);
1151    for (idx, segment) in segments.iter().enumerate() {
1152        if idx > 0 {
1153            out.push('/');
1154        }
1155        out.push_str(&convert_segment(segment, idx, idx == last));
1156    }
1157
1158    out
1159}
1160
1161/// Split a path on `/` boundaries that are NOT inside a `{...}` brace span.
1162///
1163/// google.api.http field templates can embed slashes inside a single capture
1164/// (e.g. the AIP-127 resource name `{name=shelves/*/books/*}`), so a naive
1165/// `str::split('/')` would fracture the brace span into invalid fragments.
1166/// Tracking brace depth keeps each capture intact.
1167fn split_top_level(path: &str) -> Vec<&str> {
1168    let mut segments = Vec::new();
1169    let mut depth = 0usize;
1170    let mut start = 0usize;
1171
1172    for (i, ch) in path.char_indices() {
1173        match ch {
1174            '{' => depth += 1,
1175            // Decrement only on a matched brace; a stray `}` (malformed input)
1176            // is treated as a literal rather than driving depth negative.
1177            '}' if depth > 0 => depth -= 1,
1178            '/' if depth == 0 => {
1179                segments.push(&path[start..i]);
1180                start = i + 1;
1181            }
1182            _ => {}
1183        }
1184    }
1185    segments.push(&path[start..]);
1186    segments
1187}
1188
1189/// Convert a single top-level path segment from proto template to axum 0.8 form.
1190///
1191/// `is_last` indicates the terminal segment: axum permits a catch-all capture
1192/// (`{*name}`) only there, so catch-alls in any other position must degrade.
1193fn convert_segment(segment: &str, idx: usize, is_last: bool) -> String {
1194    if let Some(inner) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
1195        // Brace capture, possibly with a `name=template` field path.
1196        if let Some((name, template)) = inner.split_once('=') {
1197            return match template {
1198                // Single-segment field path collapses to a plain capture.
1199                "*" => format!("{{{name}}}"),
1200                // Multi-segment catch-all maps to axum's `{*name}` (terminal only).
1201                "**" => catch_all(name, is_last),
1202                // Templates with interspersed literals (`{name=shelves/*/books/*}`)
1203                // have no faithful axum form: axum cannot bind literal segments
1204                // into one capture. Collapse to a catch-all so routing stays
1205                // deterministic and the field still binds to the matched tail,
1206                // and warn so the limitation surfaces instead of mis-routing.
1207                _ => {
1208                    tracing::warn!(
1209                        template = %inner,
1210                        "google.api.http multi-segment field template is not fully \
1211                         supported; routing it as a catch-all capture"
1212                    );
1213                    catch_all(name, is_last)
1214                }
1215            };
1216        }
1217        // Plain `{name}` is already valid axum 0.8 syntax.
1218        return format!("{{{inner}}}");
1219    }
1220
1221    // Bare wildcards: name them by position so multiple wildcards never collide.
1222    match segment {
1223        "**" => catch_all(&format!("wildcard{idx}"), is_last),
1224        "*" => format!("{{wildcard{idx}}}"),
1225        literal => literal.to_string(),
1226    }
1227}
1228
1229/// Emit an axum catch-all `{*name}` when `is_last`, else degrade to a
1230/// single-segment `{name}` capture.
1231///
1232/// axum accepts a catch-all only in the final path segment; a mid-path
1233/// `{*name}` is rejected at `Router::route()`. A non-terminal catch-all comes
1234/// from a malformed or unsupported google.api.http template, so we degrade
1235/// (capturing one segment) and warn rather than panic the whole router.
1236fn catch_all(name: &str, is_last: bool) -> String {
1237    if is_last {
1238        format!("{{*{name}}}")
1239    } else {
1240        tracing::warn!(
1241            capture = %name,
1242            "catch-all in a non-terminal path segment is unrepresentable in axum; \
1243             degrading to a single-segment capture"
1244        );
1245        format!("{{{name}}}")
1246    }
1247}
1248
1249#[cfg(test)]
1250mod tests;