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