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