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//!
6//! Generic: works with ANY proto descriptor set. No product-specific code.
7
8pub mod body;
9pub mod codec;
10pub mod error;
11pub mod metadata;
12pub mod request;
13
14use axum::extract::{Path, RawQuery, State};
15use axum::http::{HeaderMap, StatusCode};
16use axum::response::{IntoResponse, Response};
17use axum::routing::{delete, get, patch, post, put, MethodRouter};
18use axum::{Json, Router};
19use futures::StreamExt;
20use prost_reflect::{DescriptorPool, DynamicMessage, MethodDescriptor, SerializeOptions};
21use tonic::client::Grpc;
22
23use crate::config::AliasConfig;
24
25/// Trait for state types that support REST→gRPC transcoding.
26///
27/// Implement this for your application's state type to use `transcode::routes()`.
28/// Provides the minimal interface needed by transcode handlers.
29pub trait TranscodeState: Clone + Send + Sync + 'static {
30    /// Lazy gRPC channel to upstream service.
31    fn grpc_channel(&self) -> tonic::transport::Channel;
32    /// Headers to forward from HTTP to gRPC metadata.
33    fn forwarded_headers(&self) -> &[String];
34}
35
36impl TranscodeState for crate::ProxyState {
37    fn grpc_channel(&self) -> tonic::transport::Channel {
38        self.grpc_channel.clone()
39    }
40    fn forwarded_headers(&self) -> &[String] {
41        &self.forwarded_headers
42    }
43}
44
45/// Route entry extracted from proto HTTP annotations.
46#[derive(Debug, Clone)]
47struct RouteEntry {
48    /// HTTP path pattern (e.g., "/v1/auth/opaque/login/start").
49    http_path: String,
50    /// HTTP method (GET, POST, PUT, PATCH, DELETE).
51    http_method: HttpMethod,
52    /// gRPC path (e.g., "/sid.v1.AuthService/OpaqueLoginStart").
53    grpc_path: String,
54    /// Method descriptor for input/output message resolution.
55    method: MethodDescriptor,
56    /// How the request body maps onto the gRPC request message.
57    body: request::BodyMapping,
58    /// Optional response subfield to return as the HTTP body (`response_body`).
59    response_body: Option<String>,
60}
61
62#[derive(Debug, Clone, Copy)]
63enum HttpMethod {
64    Get,
65    Post,
66    Put,
67    Patch,
68    Delete,
69}
70
71/// Build transcoded REST→gRPC routes from a descriptor pool.
72///
73/// Takes a `DescriptorPool` and optional path aliases from config.
74/// Returns an axum Router that transcodes REST requests to gRPC calls.
75pub fn routes<S: TranscodeState>(pool: &DescriptorPool, aliases: &[AliasConfig]) -> Router<S> {
76    let entries = extract_routes(pool);
77    if entries.is_empty() {
78        tracing::warn!("No HTTP-annotated RPCs found in proto descriptors");
79        return Router::new();
80    }
81
82    tracing::info!("Registering {} transcoded REST→gRPC routes", entries.len());
83
84    let mut router: Router<S> = Router::new();
85    for entry in &entries {
86        let entry_clone = entry.clone();
87
88        let handler = move |proxy_state: State<S>,
89                            headers: HeaderMap,
90                            path_params: Path<std::collections::HashMap<String, String>>,
91                            raw_query: RawQuery,
92                            body: axum::body::Bytes| {
93            transcode_handler(
94                proxy_state,
95                headers,
96                path_params,
97                raw_query,
98                body,
99                entry_clone,
100            )
101        };
102
103        let method_router: MethodRouter<S> = match entry.http_method {
104            HttpMethod::Get => get(handler),
105            HttpMethod::Post => post(handler),
106            HttpMethod::Put => put(handler),
107            HttpMethod::Patch => patch(handler),
108            HttpMethod::Delete => delete(handler),
109        };
110
111        let axum_path = proto_path_to_axum(&entry.http_path);
112        router = router.route(&axum_path, method_router);
113
114        // Register aliases from config
115        for alias in aliases {
116            if let Some(suffix) = entry.http_path.strip_prefix(&alias.to) {
117                // Build alias path: alias.from with the matched suffix
118                let alias_path = if alias.from.ends_with("/{path}") {
119                    let prefix = alias.from.trim_end_matches("/{path}");
120                    format!("{}{}", prefix, suffix)
121                } else {
122                    continue;
123                };
124
125                let alias_entry = entry.clone();
126                let alias_handler =
127                    move |proxy_state: State<S>,
128                          headers: HeaderMap,
129                          path_params: Path<std::collections::HashMap<String, String>>,
130                          raw_query: RawQuery,
131                          body: axum::body::Bytes| {
132                        transcode_handler(
133                            proxy_state,
134                            headers,
135                            path_params,
136                            raw_query,
137                            body,
138                            alias_entry,
139                        )
140                    };
141                let alias_method: MethodRouter<S> = match entry.http_method {
142                    HttpMethod::Get => get(alias_handler),
143                    HttpMethod::Post => post(alias_handler),
144                    HttpMethod::Put => put(alias_handler),
145                    HttpMethod::Patch => patch(alias_handler),
146                    HttpMethod::Delete => delete(alias_handler),
147                };
148                router = router.route(&alias_path, alias_method);
149            }
150        }
151    }
152
153    // Server-streaming RPCs
154    let streaming_entries = extract_streaming_routes(pool);
155    for entry in &streaming_entries {
156        let entry_clone = entry.clone();
157        let axum_path = proto_path_to_axum(&entry.http_path);
158
159        let handler = move |proxy_state: State<S>, headers: HeaderMap| {
160            streaming_handler(proxy_state, headers, entry_clone)
161        };
162
163        let method_router: MethodRouter<S> = match entry.http_method {
164            HttpMethod::Get => get(handler),
165            HttpMethod::Post => post(handler),
166            _ => continue,
167        };
168
169        router = router.route(&axum_path, method_router);
170    }
171
172    router
173}
174
175/// Handler for server-streaming RPCs (NDJSON response).
176async fn streaming_handler<S: TranscodeState>(
177    State(proxy_state): State<S>,
178    headers: HeaderMap,
179    entry: RouteEntry,
180) -> Response {
181    let channel = proxy_state.grpc_channel();
182
183    let input_desc = entry.method.input();
184    let request_msg = DynamicMessage::new(input_desc);
185
186    let grpc_metadata =
187        metadata::http_headers_to_grpc_metadata(&headers, proxy_state.forwarded_headers());
188    let mut grpc_request = tonic::Request::new(request_msg);
189    *grpc_request.metadata_mut() = grpc_metadata;
190
191    let output_desc = entry.method.output();
192    let grpc_codec = codec::DynamicCodec::new(output_desc.clone());
193    let grpc_path: axum::http::uri::PathAndQuery = match entry.grpc_path.parse() {
194        Ok(p) => p,
195        Err(e) => {
196            tracing::error!("Invalid gRPC path '{}': {e}", entry.grpc_path);
197            return (
198                StatusCode::INTERNAL_SERVER_ERROR,
199                Json(serde_json::json!({
200                    "error": "INTERNAL",
201                    "message": "invalid gRPC path configuration",
202                })),
203            )
204                .into_response();
205        }
206    };
207
208    let mut grpc_client = Grpc::new(channel);
209    if let Err(e) = grpc_client.ready().await {
210        return (
211            StatusCode::SERVICE_UNAVAILABLE,
212            Json(serde_json::json!({
213                "error": "UNAVAILABLE",
214                "message": format!("gRPC upstream not ready: {e}"),
215            })),
216        )
217            .into_response();
218    }
219
220    match grpc_client
221        .server_streaming(grpc_request, grpc_path, grpc_codec)
222        .await
223    {
224        Ok(response) => {
225            let stream = response.into_inner();
226            let serialize_opts = SerializeOptions::new()
227                .skip_default_fields(false)
228                .stringify_64_bit_integers(true);
229
230            let byte_stream = stream.map(move |result| match result {
231                Ok(msg) => {
232                    match msg.serialize_with_options(serde_json::value::Serializer, &serialize_opts)
233                    {
234                        Ok(json_value) => {
235                            let mut bytes = serde_json::to_vec(&json_value).unwrap_or_default();
236                            bytes.push(b'\n');
237                            Ok::<axum::body::Bytes, std::io::Error>(axum::body::Bytes::from(bytes))
238                        }
239                        Err(e) => Err(std::io::Error::other(format!("serialization error: {e}"))),
240                    }
241                }
242                Err(status) => Err(std::io::Error::other(format!(
243                    "gRPC stream error: {status}"
244                ))),
245            });
246
247            let body = axum::body::Body::from_stream(byte_stream);
248            Response::builder()
249                .status(StatusCode::OK)
250                .header("content-type", "application/x-ndjson")
251                .header("transfer-encoding", "chunked")
252                .body(body)
253                .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
254        }
255        Err(status) => error::status_to_response(status),
256    }
257}
258
259/// Generic transcoding handler.
260async fn transcode_handler<S: TranscodeState>(
261    State(proxy_state): State<S>,
262    headers: HeaderMap,
263    Path(path_params): Path<std::collections::HashMap<String, String>>,
264    RawQuery(raw_query): RawQuery,
265    body_bytes: axum::body::Bytes,
266    entry: RouteEntry,
267) -> Response {
268    let channel = proxy_state.grpc_channel();
269
270    // Only read the body when the rule maps it onto the message.
271    let json_body = match entry.body {
272        request::BodyMapping::None => serde_json::Value::Null,
273        _ => {
274            let ct = body::content_type(&headers);
275            match body::parse_body(ct, &body_bytes) {
276                Ok(v) => v,
277                Err(e) => {
278                    return (
279                        StatusCode::BAD_REQUEST,
280                        Json(serde_json::json!({
281                            "error": "INVALID_ARGUMENT",
282                            "message": format!("failed to parse request body: {e}"),
283                        })),
284                    )
285                        .into_response();
286                }
287            }
288        }
289    };
290
291    // Query string → field bindings (fields not bound by path or body).
292    // A malformed query is a client error: reject it rather than silently
293    // dropping every query-bound field.
294    let query_pairs = match request::parse_query(raw_query.as_deref()) {
295        Ok(pairs) => pairs,
296        Err(e) => {
297            return (
298                StatusCode::BAD_REQUEST,
299                Json(serde_json::json!({
300                    "error": "INVALID_ARGUMENT",
301                    "message": e,
302                })),
303            )
304                .into_response();
305        }
306    };
307
308    let input_desc = entry.method.input();
309    let request_json = match request::build_request_json(
310        &input_desc,
311        &entry.body,
312        json_body,
313        &path_params,
314        &query_pairs,
315    ) {
316        Ok(v) => v,
317        Err(e) => {
318            return (
319                StatusCode::BAD_REQUEST,
320                Json(serde_json::json!({
321                    "error": "INVALID_ARGUMENT",
322                    "message": e,
323                })),
324            )
325                .into_response();
326        }
327    };
328
329    let request_msg = match DynamicMessage::deserialize(input_desc, request_json) {
330        Ok(msg) => msg,
331        Err(e) => {
332            return (
333                StatusCode::BAD_REQUEST,
334                Json(serde_json::json!({
335                    "error": "INVALID_ARGUMENT",
336                    "message": format!("failed to decode request: {e}"),
337                })),
338            )
339                .into_response();
340        }
341    };
342
343    let grpc_metadata =
344        metadata::http_headers_to_grpc_metadata(&headers, proxy_state.forwarded_headers());
345    let mut grpc_request = tonic::Request::new(request_msg);
346    *grpc_request.metadata_mut() = grpc_metadata;
347
348    let output_desc = entry.method.output();
349    let grpc_codec = codec::DynamicCodec::new(output_desc.clone());
350    let grpc_path: axum::http::uri::PathAndQuery = match entry.grpc_path.parse() {
351        Ok(p) => p,
352        Err(e) => {
353            tracing::error!("Invalid gRPC path '{}': {e}", entry.grpc_path);
354            return (
355                StatusCode::INTERNAL_SERVER_ERROR,
356                Json(serde_json::json!({
357                    "error": "INTERNAL",
358                    "message": "invalid gRPC path configuration",
359                })),
360            )
361                .into_response();
362        }
363    };
364
365    let mut grpc_client = Grpc::new(channel);
366    if let Err(e) = grpc_client.ready().await {
367        return (
368            StatusCode::SERVICE_UNAVAILABLE,
369            Json(serde_json::json!({
370                "error": "UNAVAILABLE",
371                "message": format!("gRPC upstream not ready: {e}"),
372            })),
373        )
374            .into_response();
375    }
376
377    match grpc_client.unary(grpc_request, grpc_path, grpc_codec).await {
378        Ok(response) => {
379            let response_msg = response.into_inner();
380            let serialize_opts = SerializeOptions::new()
381                .skip_default_fields(false)
382                .stringify_64_bit_integers(true);
383            match response_msg
384                .serialize_with_options(serde_json::value::Serializer, &serialize_opts)
385            {
386                Ok(json_value) => {
387                    // `response_body` returns just that subfield as the HTTP body.
388                    let out = match &entry.response_body {
389                        Some(path) => request::extract_response_body(&json_value, path)
390                            .unwrap_or_else(|| {
391                                tracing::warn!(
392                                    response_body = %path,
393                                    "configured response_body path not found in response; \
394                                     returning null"
395                                );
396                                serde_json::Value::Null
397                            }),
398                        None => json_value,
399                    };
400                    (StatusCode::OK, Json(out)).into_response()
401                }
402                Err(e) => {
403                    tracing::error!("Failed to serialize gRPC response: {e}");
404                    (
405                        StatusCode::INTERNAL_SERVER_ERROR,
406                        Json(serde_json::json!({
407                            "error": "INTERNAL",
408                            "message": "failed to serialize response",
409                        })),
410                    )
411                        .into_response()
412                }
413            }
414        }
415        Err(status) => error::status_to_response(status),
416    }
417}
418
419/// Extract HTTP route entries from proto descriptors.
420fn extract_routes(pool: &DescriptorPool) -> Vec<RouteEntry> {
421    let http_ext = match pool.get_extension_by_name("google.api.http") {
422        Some(ext) => ext,
423        None => {
424            tracing::warn!("google.api.http extension not found in descriptor pool");
425            return Vec::new();
426        }
427    };
428
429    let mut entries = Vec::new();
430
431    for service in pool.services() {
432        for method in service.methods() {
433            if method.is_client_streaming() || method.is_server_streaming() {
434                continue;
435            }
436
437            let grpc_path = format!("/{}/{}", service.full_name(), method.name());
438
439            for binding in extract_http_bindings(&method, &http_ext) {
440                entries.push(RouteEntry {
441                    http_path: binding.http_path,
442                    http_method: binding.http_method,
443                    grpc_path: grpc_path.clone(),
444                    method: method.clone(),
445                    body: binding.body,
446                    response_body: binding.response_body,
447                });
448            }
449        }
450    }
451
452    entries
453}
454
455/// Extract server-streaming HTTP route entries.
456fn extract_streaming_routes(pool: &DescriptorPool) -> Vec<RouteEntry> {
457    let http_ext = match pool.get_extension_by_name("google.api.http") {
458        Some(ext) => ext,
459        None => return Vec::new(),
460    };
461
462    let mut entries = Vec::new();
463
464    for service in pool.services() {
465        for method in service.methods() {
466            if !method.is_server_streaming() || method.is_client_streaming() {
467                continue;
468            }
469
470            let grpc_path = format!("/{}/{}", service.full_name(), method.name());
471
472            for binding in extract_http_bindings(&method, &http_ext) {
473                tracing::info!(
474                    "Registering streaming route: {} {} → {}",
475                    match binding.http_method {
476                        HttpMethod::Get => "GET",
477                        HttpMethod::Post => "POST",
478                        _ => "OTHER",
479                    },
480                    binding.http_path,
481                    grpc_path
482                );
483                entries.push(RouteEntry {
484                    http_path: binding.http_path,
485                    http_method: binding.http_method,
486                    grpc_path: grpc_path.clone(),
487                    method: method.clone(),
488                    body: binding.body,
489                    response_body: binding.response_body,
490                });
491            }
492        }
493    }
494
495    entries
496}
497
498/// A single HTTP binding parsed from a `google.api.http` rule.
499struct HttpBinding {
500    http_method: HttpMethod,
501    http_path: String,
502    body: request::BodyMapping,
503    response_body: Option<String>,
504}
505
506/// Extract all HTTP bindings (the primary rule plus any `additional_bindings`)
507/// from a method's `google.api.http` extension.
508fn extract_http_bindings(
509    method: &MethodDescriptor,
510    http_ext: &prost_reflect::ExtensionDescriptor,
511) -> Vec<HttpBinding> {
512    let options = method.options();
513    if !options.has_extension(http_ext) {
514        return Vec::new();
515    }
516
517    let prost_reflect::Value::Message(rule_msg) = options.get_extension(http_ext).into_owned()
518    else {
519        return Vec::new();
520    };
521
522    collect_bindings(&rule_msg)
523}
524
525/// Collect the primary binding plus every `additional_bindings` entry from an
526/// `HttpRule` message.
527fn collect_bindings(rule_msg: &DynamicMessage) -> Vec<HttpBinding> {
528    let mut bindings = Vec::new();
529    if let Some(binding) = parse_http_rule(rule_msg) {
530        bindings.push(binding);
531    }
532
533    // additional_bindings is a repeated HttpRule; each carries its own
534    // method/path/body. The proto forbids nesting them further.
535    if let Some(field) = rule_msg.get_field_by_name("additional_bindings") {
536        if let prost_reflect::Value::List(list) = field.into_owned() {
537            for item in list {
538                if let prost_reflect::Value::Message(sub) = item {
539                    if let Some(binding) = parse_http_rule(&sub) {
540                        bindings.push(binding);
541                    }
542                }
543            }
544        }
545    }
546
547    bindings
548}
549
550/// Parse a single `HttpRule` message into a binding (method+path required).
551fn parse_http_rule(rule_msg: &DynamicMessage) -> Option<HttpBinding> {
552    let (http_method, http_path) = [
553        ("get", HttpMethod::Get),
554        ("post", HttpMethod::Post),
555        ("put", HttpMethod::Put),
556        ("delete", HttpMethod::Delete),
557        ("patch", HttpMethod::Patch),
558    ]
559    .into_iter()
560    .find_map(
561        |(name, http_method)| match rule_msg.get_field_by_name(name)?.into_owned() {
562            prost_reflect::Value::String(path) if !path.is_empty() => Some((http_method, path)),
563            _ => None,
564        },
565    )?;
566
567    let body = rule_msg
568        .get_field_by_name("body")
569        .and_then(|v| match v.into_owned() {
570            prost_reflect::Value::String(s) => Some(request::BodyMapping::parse(&s)),
571            _ => None,
572        })
573        .unwrap_or(request::BodyMapping::None);
574
575    let response_body =
576        rule_msg
577            .get_field_by_name("response_body")
578            .and_then(|v| match v.into_owned() {
579                prost_reflect::Value::String(s) if !s.is_empty() => Some(s),
580                _ => None,
581            });
582
583    Some(HttpBinding {
584        http_method,
585        http_path,
586        body,
587        response_body,
588    })
589}
590
591/// Convert a `google.api.http` path template to axum 0.8 path syntax.
592///
593/// The proto `{param}` form IS axum 0.8's native capture syntax, so plain
594/// single-segment params pass through verbatim. Only field-path templates and
595/// bare wildcards need rewriting (axum 0.7 used `:param`; 0.8 uses `{param}`
596/// and rejects any segment starting with `:`):
597/// - `{name=*}`  (single segment)      -> `{name}`
598/// - `{name=**}` (multi-segment) -> `{*name}` (axum catch-all)
599/// - bare `*` segment            -> `{wildcardN}`
600/// - bare `**` segment           -> `{*wildcardN}` (axum catch-all)
601pub fn proto_path_to_axum(path: &str) -> String {
602    let mut out = String::with_capacity(path.len());
603
604    let segments = split_top_level(path);
605    let last = segments.len().saturating_sub(1);
606    for (idx, segment) in segments.iter().enumerate() {
607        if idx > 0 {
608            out.push('/');
609        }
610        out.push_str(&convert_segment(segment, idx, idx == last));
611    }
612
613    out
614}
615
616/// Split a path on `/` boundaries that are NOT inside a `{...}` brace span.
617///
618/// google.api.http field templates can embed slashes inside a single capture
619/// (e.g. the AIP-127 resource name `{name=shelves/*/books/*}`), so a naive
620/// `str::split('/')` would fracture the brace span into invalid fragments.
621/// Tracking brace depth keeps each capture intact.
622fn split_top_level(path: &str) -> Vec<&str> {
623    let mut segments = Vec::new();
624    let mut depth = 0usize;
625    let mut start = 0usize;
626
627    for (i, ch) in path.char_indices() {
628        match ch {
629            '{' => depth += 1,
630            // Decrement only on a matched brace; a stray `}` (malformed input)
631            // is treated as a literal rather than driving depth negative.
632            '}' if depth > 0 => depth -= 1,
633            '/' if depth == 0 => {
634                segments.push(&path[start..i]);
635                start = i + 1;
636            }
637            _ => {}
638        }
639    }
640    segments.push(&path[start..]);
641    segments
642}
643
644/// Convert a single top-level path segment from proto template to axum 0.8 form.
645///
646/// `is_last` indicates the terminal segment: axum permits a catch-all capture
647/// (`{*name}`) only there, so catch-alls in any other position must degrade.
648fn convert_segment(segment: &str, idx: usize, is_last: bool) -> String {
649    if let Some(inner) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
650        // Brace capture, possibly with a `name=template` field path.
651        if let Some((name, template)) = inner.split_once('=') {
652            return match template {
653                // Single-segment field path collapses to a plain capture.
654                "*" => format!("{{{name}}}"),
655                // Multi-segment catch-all maps to axum's `{*name}` (terminal only).
656                "**" => catch_all(name, is_last),
657                // Templates with interspersed literals (`{name=shelves/*/books/*}`)
658                // have no faithful axum form: axum cannot bind literal segments
659                // into one capture. Collapse to a catch-all so routing stays
660                // deterministic and the field still binds to the matched tail,
661                // and warn so the limitation surfaces instead of mis-routing.
662                _ => {
663                    tracing::warn!(
664                        template = %inner,
665                        "google.api.http multi-segment field template is not fully \
666                         supported; routing it as a catch-all capture"
667                    );
668                    catch_all(name, is_last)
669                }
670            };
671        }
672        // Plain `{name}` is already valid axum 0.8 syntax.
673        return format!("{{{inner}}}");
674    }
675
676    // Bare wildcards: name them by position so multiple wildcards never collide.
677    match segment {
678        "**" => catch_all(&format!("wildcard{idx}"), is_last),
679        "*" => format!("{{wildcard{idx}}}"),
680        literal => literal.to_string(),
681    }
682}
683
684/// Emit an axum catch-all `{*name}` when `is_last`, else degrade to a
685/// single-segment `{name}` capture.
686///
687/// axum accepts a catch-all only in the final path segment; a mid-path
688/// `{*name}` is rejected at `Router::route()`. A non-terminal catch-all comes
689/// from a malformed or unsupported google.api.http template, so we degrade
690/// (capturing one segment) and warn rather than panic the whole router.
691fn catch_all(name: &str, is_last: bool) -> String {
692    if is_last {
693        format!("{{*{name}}}")
694    } else {
695        tracing::warn!(
696            capture = %name,
697            "catch-all in a non-terminal path segment is unrepresentable in axum; \
698             degrading to a single-segment capture"
699        );
700        format!("{{{name}}}")
701    }
702}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707
708    /// Build a standalone `HttpRule`-shaped descriptor (self-referential
709    /// `additional_bindings`) so the binding parser can be tested without the
710    /// google.api extension wiring.
711    fn http_rule_descriptor() -> prost_reflect::MessageDescriptor {
712        use prost_reflect::prost::Message;
713        use prost_reflect::prost_types::{
714            field_descriptor_proto::{Label, Type},
715            DescriptorProto, FieldDescriptorProto, FileDescriptorProto, FileDescriptorSet,
716        };
717
718        let str_field = |name: &str, num: i32| FieldDescriptorProto {
719            name: Some(name.to_string()),
720            number: Some(num),
721            label: Some(Label::Optional as i32),
722            r#type: Some(Type::String as i32),
723            ..Default::default()
724        };
725        let rule = DescriptorProto {
726            name: Some("HttpRule".to_string()),
727            field: vec![
728                str_field("get", 2),
729                str_field("put", 3),
730                str_field("post", 4),
731                str_field("delete", 5),
732                str_field("patch", 6),
733                str_field("body", 7),
734                str_field("response_body", 12),
735                FieldDescriptorProto {
736                    name: Some("additional_bindings".to_string()),
737                    number: Some(11),
738                    label: Some(Label::Repeated as i32),
739                    r#type: Some(Type::Message as i32),
740                    type_name: Some(".gapi.HttpRule".to_string()),
741                    ..Default::default()
742                },
743            ],
744            ..Default::default()
745        };
746        let file = FileDescriptorProto {
747            name: Some("http.proto".to_string()),
748            package: Some("gapi".to_string()),
749            message_type: vec![rule],
750            syntax: Some("proto3".to_string()),
751            ..Default::default()
752        };
753        let fds = FileDescriptorSet { file: vec![file] };
754        let pool = DescriptorPool::decode(fds.encode_to_vec().as_slice()).unwrap();
755        pool.get_message_by_name("gapi.HttpRule").unwrap()
756    }
757
758    #[test]
759    fn collect_bindings_reads_body_response_and_additional() {
760        let desc = http_rule_descriptor();
761
762        // additional_bindings entry: POST /v1/items with whole-body mapping.
763        let mut extra = DynamicMessage::new(desc.clone());
764        extra.set_field_by_name("post", prost_reflect::Value::String("/v1/items".into()));
765        extra.set_field_by_name("body", prost_reflect::Value::String("*".into()));
766
767        // primary rule: GET /v1/items/{id}, returns only the `result` subfield.
768        let mut rule = DynamicMessage::new(desc);
769        rule.set_field_by_name("get", prost_reflect::Value::String("/v1/items/{id}".into()));
770        rule.set_field_by_name(
771            "response_body",
772            prost_reflect::Value::String("result".into()),
773        );
774        rule.set_field_by_name(
775            "additional_bindings",
776            prost_reflect::Value::List(vec![prost_reflect::Value::Message(extra)]),
777        );
778
779        let bindings = collect_bindings(&rule);
780        assert_eq!(bindings.len(), 2);
781
782        // Primary: GET, no body, response_body = result.
783        assert!(matches!(bindings[0].http_method, HttpMethod::Get));
784        assert_eq!(bindings[0].http_path, "/v1/items/{id}");
785        assert_eq!(bindings[0].body, request::BodyMapping::None);
786        assert_eq!(bindings[0].response_body.as_deref(), Some("result"));
787
788        // Additional: POST, whole-body mapping, no response_body.
789        assert!(matches!(bindings[1].http_method, HttpMethod::Post));
790        assert_eq!(bindings[1].http_path, "/v1/items");
791        assert_eq!(bindings[1].body, request::BodyMapping::Root);
792        assert_eq!(bindings[1].response_body, None);
793    }
794
795    #[test]
796    fn test_proto_path_to_axum() {
797        // axum 0.8: proto `{param}` IS the native capture syntax, pass through verbatim.
798        assert_eq!(proto_path_to_axum("/v1/profiles/{id}"), "/v1/profiles/{id}");
799        assert_eq!(
800            proto_path_to_axum("/v1/admin/profiles/{profile_id}/metadata/{key}"),
801            "/v1/admin/profiles/{profile_id}/metadata/{key}"
802        );
803        assert_eq!(proto_path_to_axum("/v1/auth/login"), "/v1/auth/login");
804    }
805
806    #[test]
807    fn test_proto_path_to_axum_wildcards() {
808        // `{name=*}` single-segment field path collapses to a plain capture.
809        assert_eq!(proto_path_to_axum("/v1/{name=*}"), "/v1/{name}");
810        // `{name=**}` multi-segment catch-all maps to axum's `{*name}`.
811        assert_eq!(
812            proto_path_to_axum("/v1/files/{path=**}"),
813            "/v1/files/{*path}"
814        );
815        // Bare wildcards get position-named captures so they never collide.
816        // Index is the segment position after splitting on `/` (leading "" = 0).
817        assert_eq!(proto_path_to_axum("/v1/*/items"), "/v1/{wildcard2}/items");
818        assert_eq!(proto_path_to_axum("/v1/files/**"), "/v1/files/{*wildcard3}");
819    }
820
821    #[test]
822    fn non_terminal_catch_all_degrades_to_single_capture() {
823        // A catch-all `{*name}` is only valid in axum's LAST path segment.
824        // An unsupported/multi-segment field template in a NON-terminal position
825        // (`/v1/{name=projects/*}/topics`) must NOT emit a mid-path catch-all —
826        // axum rejects `/v1/{*name}/topics` at `Router::route()`. It degrades to
827        // a single-segment capture instead.
828        assert_eq!(
829            proto_path_to_axum("/v1/{name=projects/*}/topics"),
830            "/v1/{name}/topics"
831        );
832        let path = proto_path_to_axum("/v1/{name=projects/*}/topics");
833        let _router: Router<()> = Router::new().route(&path, get(|| async { "ok" }));
834
835        // The same guard applies to an explicit `**` template in non-terminal
836        // position and a terminal one still yields a real catch-all.
837        assert_eq!(proto_path_to_axum("/v1/{rest=**}/tail"), "/v1/{rest}/tail");
838        assert_eq!(
839            proto_path_to_axum("/v1/files/{rest=**}"),
840            "/v1/files/{*rest}"
841        );
842    }
843
844    #[test]
845    fn multi_segment_field_template_does_not_fracture() {
846        // google.api.http resource-name templates (AIP-127) embed slashes
847        // inside a SINGLE brace span: `{name=shelves/*/books/*}`. Splitting on
848        // `/` before brace parsing fractured this into invalid fragments and
849        // produced a mangled axum path that panicked at `Router::route()`.
850        // It must collapse to a single catch-all capture instead.
851        assert_eq!(
852            proto_path_to_axum("/v1/{name=shelves/*/books/*}"),
853            "/v1/{*name}"
854        );
855        // And the produced path must actually register on axum 0.8.
856        let path = proto_path_to_axum("/v1/{name=shelves/*/books/*}");
857        let _router: Router<()> = Router::new().route(&path, get(|| async { "ok" }));
858    }
859
860    /// Regression for the axum 0.7→0.8 migration bug: `proto_path_to_axum`
861    /// emitted `:id` syntax, which axum 0.8 rejects at `Router::route()` with
862    /// a startup panic ("Path segments must not start with `:`"). Building the
863    /// router over a brace-param path must NOT panic. Pre-fix this panicked.
864    #[test]
865    fn router_builds_with_brace_path_params_on_axum_0_8() {
866        let axum_path = proto_path_to_axum("/v1/profiles/{id}");
867        let _router: Router<()> = Router::new().route(&axum_path, get(|| async { "ok" }));
868
869        // Deeper nesting and a catch-all also route without panicking.
870        let nested = proto_path_to_axum("/v1/admin/profiles/{profile_id}/metadata/{key}");
871        let catch_all = proto_path_to_axum("/v1/files/{path=**}");
872        let _router: Router<()> = Router::new()
873            .route(&nested, get(|| async { "ok" }))
874            .route(&catch_all, get(|| async { "ok" }));
875    }
876}