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::sse::{Event, KeepAlive, Sse};
17use axum::response::{IntoResponse, Response};
18use axum::routing::{delete, get, patch, post, put, MethodRouter};
19use axum::{Json, Router};
20use futures::StreamExt;
21use prost_reflect::{DescriptorPool, DynamicMessage, MethodDescriptor, SerializeOptions};
22use tonic::client::Grpc;
23
24use crate::config::AliasConfig;
25
26/// Trait for state types that support REST→gRPC transcoding.
27///
28/// Implement this for your application's state type to use `transcode::routes()`.
29/// Provides the minimal interface needed by transcode handlers.
30pub trait TranscodeState: Clone + Send + Sync + 'static {
31    /// Lazy gRPC channel to upstream service.
32    fn grpc_channel(&self) -> tonic::transport::Channel;
33    /// Headers to forward from HTTP to gRPC metadata.
34    fn forwarded_headers(&self) -> &[String];
35    /// SSE keep-alive interval (seconds) for server-streaming responses.
36    fn sse_keep_alive_secs(&self) -> u64;
37}
38
39impl TranscodeState for crate::ProxyState {
40    fn grpc_channel(&self) -> tonic::transport::Channel {
41        self.grpc_channel.clone()
42    }
43    fn forwarded_headers(&self) -> &[String] {
44        &self.forwarded_headers
45    }
46    fn sse_keep_alive_secs(&self) -> u64 {
47        self.sse_keep_alive_secs
48    }
49}
50
51/// Route entry extracted from proto HTTP annotations.
52#[derive(Debug, Clone)]
53struct RouteEntry {
54    /// HTTP path pattern (e.g., "/v1/auth/opaque/login/start").
55    http_path: String,
56    /// HTTP method (GET, POST, PUT, PATCH, DELETE).
57    http_method: HttpMethod,
58    /// gRPC path (e.g., "/sid.v1.AuthService/OpaqueLoginStart"), parsed once at
59    /// route-build time so each request clones a cheap `Bytes` refcount.
60    grpc_path: axum::http::uri::PathAndQuery,
61    /// Method descriptor for input/output message resolution.
62    method: MethodDescriptor,
63    /// How the request body maps onto the gRPC request message.
64    body: request::BodyMapping,
65    /// Optional response subfield to return as the HTTP body (`response_body`).
66    response_body: Option<String>,
67}
68
69#[derive(Debug, Clone, Copy)]
70enum HttpMethod {
71    Get,
72    Post,
73    Put,
74    Patch,
75    Delete,
76}
77
78impl HttpMethod {
79    /// The uppercase HTTP method token (e.g. `"GET"`).
80    fn as_str(self) -> &'static str {
81        match self {
82            HttpMethod::Get => "GET",
83            HttpMethod::Post => "POST",
84            HttpMethod::Put => "PUT",
85            HttpMethod::Patch => "PATCH",
86            HttpMethod::Delete => "DELETE",
87        }
88    }
89}
90
91/// Build transcoded REST→gRPC routes from a descriptor pool.
92///
93/// Takes a `DescriptorPool` and optional path aliases from config.
94/// Returns an axum Router that transcodes REST requests to gRPC calls.
95pub fn routes<S: TranscodeState>(pool: &DescriptorPool, aliases: &[AliasConfig]) -> Router<S> {
96    let bindings = route_bindings(pool, aliases);
97    if bindings.is_empty() {
98        tracing::warn!("No HTTP-annotated RPCs found in proto descriptors");
99        return Router::new();
100    }
101
102    tracing::info!("Registering {} transcoded REST→gRPC routes", bindings.len());
103
104    let mut router: Router<S> = Router::new();
105    for binding in bindings {
106        let method = binding.entry.http_method;
107        let entry = std::sync::Arc::new(binding.entry);
108        let method_router: MethodRouter<S> = if binding.streaming {
109            let handler = move |proxy_state: State<S>, headers: HeaderMap| {
110                streaming_handler(proxy_state, headers, entry)
111            };
112            match method {
113                HttpMethod::Get => get(handler),
114                HttpMethod::Post => post(handler),
115                // route_bindings only yields GET/POST streaming bindings.
116                _ => unreachable!("streaming routes are GET/POST only"),
117            }
118        } else {
119            let handler = move |proxy_state: State<S>,
120                                headers: HeaderMap,
121                                path_params: Path<std::collections::HashMap<String, String>>,
122                                raw_query: RawQuery,
123                                body: axum::body::Bytes| {
124                transcode_handler(proxy_state, headers, path_params, raw_query, body, entry)
125            };
126            match method {
127                HttpMethod::Get => get(handler),
128                HttpMethod::Post => post(handler),
129                HttpMethod::Put => put(handler),
130                HttpMethod::Patch => patch(handler),
131                HttpMethod::Delete => delete(handler),
132            }
133        };
134        router = router.route(&binding.axum_path, method_router);
135    }
136
137    router
138}
139
140/// One transcode route to mount: the RPC entry that serves it, the axum path to
141/// register it at, and whether it is the server-streaming variant.
142struct RouteBinding {
143    entry: RouteEntry,
144    axum_path: String,
145    streaming: bool,
146}
147
148/// The single source of truth for what [`routes`] mounts: unary RPCs, their
149/// config aliases, and server-streaming RPCs. Both [`routes`] (to build handlers)
150/// and [`route_paths`] (to enumerate paths for collision checks) consume this, so
151/// the mounted set and the enumerated set cannot drift apart.
152fn route_bindings(pool: &DescriptorPool, aliases: &[AliasConfig]) -> Vec<RouteBinding> {
153    let mut bindings = Vec::new();
154    for entry in extract_routes(pool) {
155        bindings.push(RouteBinding {
156            axum_path: proto_path_to_axum(&entry.http_path),
157            entry: entry.clone(),
158            streaming: false,
159        });
160        for alias in aliases {
161            if let Some(suffix) = entry.http_path.strip_prefix(&alias.to) {
162                if alias.from.ends_with("/{path}") {
163                    let prefix = alias.from.trim_end_matches("/{path}");
164                    bindings.push(RouteBinding {
165                        axum_path: format!("{prefix}{suffix}"),
166                        entry: entry.clone(),
167                        streaming: false,
168                    });
169                }
170            }
171        }
172    }
173    for entry in extract_streaming_routes(pool) {
174        if matches!(entry.http_method, HttpMethod::Get | HttpMethod::Post) {
175            bindings.push(RouteBinding {
176                axum_path: proto_path_to_axum(&entry.http_path),
177                entry,
178                streaming: true,
179            });
180        }
181    }
182    bindings
183}
184
185/// The axum paths [`routes`] would register for this pool and aliases.
186///
187/// Mirrors the registration in [`routes`] (unary RPCs, their config aliases, and
188/// server-streaming RPCs) without building handlers, so callers can detect route
189/// collisions before mounting additional routes (e.g. a forward-auth endpoint).
190///
191/// Each entry is `(method, path)` where `method` is the uppercase HTTP token, so
192/// callers can distinguish same-path/different-method routes from real conflicts.
193pub fn route_paths(pool: &DescriptorPool, aliases: &[AliasConfig]) -> Vec<(String, String)> {
194    route_bindings(pool, aliases)
195        .into_iter()
196        .map(|b| (b.entry.http_method.as_str().to_string(), b.axum_path))
197        .collect()
198}
199
200/// JSON serialization options shared by the unary and streaming response paths,
201/// so a given message serializes identically regardless of RPC kind.
202fn response_serialize_options() -> SerializeOptions {
203    SerializeOptions::new()
204        .skip_default_fields(false)
205        .stringify_64_bit_integers(true)
206}
207
208/// Serialize one streamed gRPC message to a compact JSON string.
209fn message_to_json_string(msg: &DynamicMessage, opts: &SerializeOptions) -> Result<String, String> {
210    let value = msg
211        .serialize_with_options(serde_json::value::Serializer, opts)
212        .map_err(|e| e.to_string())?;
213    serde_json::to_string(&value).map_err(|e| e.to_string())
214}
215
216/// Terminal error frame for a stream that failed mid-flight. Shared by the
217/// NDJSON and SSE paths so a client sees the same shape in either format.
218fn stream_error_json(status: &tonic::Status) -> serde_json::Value {
219    serde_json::json!({
220        "error": error::grpc_code_name(status.code()),
221        "message": status.message(),
222        "code": status.code() as i32,
223    })
224}
225
226/// Whether the client negotiated a Server-Sent Events response via `Accept`.
227///
228/// Considers every `Accept` header line (a client may send more than one) and
229/// every comma-separated media range within each. Matches `text/event-stream`
230/// case-insensitively and honors the quality factor: per RFC 7231 §5.3.1 a
231/// `q=0` weight means the type is explicitly not acceptable, so it does not
232/// select the SSE path.
233fn wants_sse(headers: &HeaderMap) -> bool {
234    headers
235        .get_all(axum::http::header::ACCEPT)
236        .iter()
237        .filter_map(|v| v.to_str().ok())
238        .flat_map(|accept| accept.split(','))
239        .any(accept_range_selects_sse)
240}
241
242/// Whether a single `Accept` media range selects `text/event-stream` with a
243/// non-zero quality factor.
244fn accept_range_selects_sse(range: &str) -> bool {
245    let mut parts = range.split(';');
246    let media = parts.next().unwrap_or("").trim();
247    if !media.eq_ignore_ascii_case("text/event-stream") {
248        return false;
249    }
250    // Default weight is 1.0; only an explicit `q=0` (or unparseable-as-positive)
251    // disqualifies the match. A malformed weight falls back to acceptable.
252    for param in parts {
253        let mut kv = param.splitn(2, '=');
254        if kv.next().unwrap_or("").trim().eq_ignore_ascii_case("q") {
255            let q: f32 = kv.next().unwrap_or("").trim().parse().unwrap_or(1.0);
256            return q > 0.0;
257        }
258    }
259    true
260}
261
262/// Handler for server-streaming RPCs.
263///
264/// Returns Server-Sent Events when the client sends `Accept: text/event-stream`,
265/// otherwise newline-delimited JSON (NDJSON). In both formats a gRPC error
266/// mid-stream is delivered as an explicit terminal frame before the stream is
267/// closed cleanly, rather than truncating the HTTP body.
268async fn streaming_handler<S: TranscodeState>(
269    State(proxy_state): State<S>,
270    headers: HeaderMap,
271    entry: std::sync::Arc<RouteEntry>,
272) -> Response {
273    let channel = proxy_state.grpc_channel();
274
275    let input_desc = entry.method.input();
276    let request_msg = DynamicMessage::new(input_desc);
277
278    let grpc_metadata =
279        metadata::http_headers_to_grpc_metadata(&headers, proxy_state.forwarded_headers());
280    let mut grpc_request = tonic::Request::new(request_msg);
281    *grpc_request.metadata_mut() = grpc_metadata;
282    metadata::apply_request_deadline(&mut grpc_request, &headers);
283
284    let output_desc = entry.method.output();
285    let grpc_codec = codec::DynamicCodec::new(output_desc.clone());
286    let grpc_path = entry.grpc_path.clone();
287
288    let mut grpc_client = Grpc::new(channel);
289    if let Err(e) = grpc_client.ready().await {
290        return (
291            StatusCode::SERVICE_UNAVAILABLE,
292            Json(serde_json::json!({
293                "error": "UNAVAILABLE",
294                "message": format!("gRPC upstream not ready: {e}"),
295            })),
296        )
297            .into_response();
298    }
299
300    let use_sse = wants_sse(&headers);
301
302    match grpc_client
303        .server_streaming(grpc_request, grpc_path, grpc_codec)
304        .await
305    {
306        Ok(response) => {
307            let stream = response.into_inner();
308            if use_sse {
309                sse_response(stream, proxy_state.sse_keep_alive_secs())
310            } else {
311                ndjson_response(stream)
312            }
313        }
314        Err(status) => error::status_to_response(status),
315    }
316}
317
318/// One JSON frame of a streaming response, already serialized.
319///
320/// `Error` is terminal: [`json_frames`] stops the stream right after yielding
321/// it, so an error frame is always the last thing a client sees regardless of
322/// whether it came from a gRPC status or a serialization failure.
323enum StreamFrame {
324    Data(String),
325    Error(String),
326}
327
328/// Turn a gRPC message stream into a stream of serialized JSON frames, stopping
329/// after the first error so error frames are unambiguously terminal.
330///
331/// Both a gRPC `Status` and a per-message serialization failure become a
332/// terminal [`StreamFrame::Error`]; downstream messages the upstream might
333/// still emit are dropped rather than streamed past the error.
334fn json_frames<St>(stream: St) -> impl futures::Stream<Item = StreamFrame> + Send + 'static
335where
336    St: futures::Stream<Item = Result<DynamicMessage, tonic::Status>> + Send + 'static,
337{
338    let opts = response_serialize_options();
339    stream.scan(false, move |stopped, result| {
340        if *stopped {
341            return futures::future::ready(None);
342        }
343        let frame = match result {
344            Ok(msg) => match message_to_json_string(&msg, &opts) {
345                Ok(s) => StreamFrame::Data(s),
346                Err(e) => {
347                    *stopped = true;
348                    StreamFrame::Error(
349                        serde_json::json!({
350                            "error": "INTERNAL",
351                            "message": format!("serialization error: {e}"),
352                        })
353                        .to_string(),
354                    )
355                }
356            },
357            Err(status) => {
358                *stopped = true;
359                StreamFrame::Error(stream_error_json(&status).to_string())
360            }
361        };
362        futures::future::ready(Some(frame))
363    })
364}
365
366/// Build an NDJSON (`application/x-ndjson`) streaming response.
367fn ndjson_response<St>(stream: St) -> Response
368where
369    St: futures::Stream<Item = Result<DynamicMessage, tonic::Status>> + Send + 'static,
370{
371    // Data and error frames are both JSON lines; an error is distinguished by
372    // its `error` field and by being the final line (see `json_frames`).
373    let byte_stream = json_frames(stream).map(|frame| {
374        let mut line = match frame {
375            StreamFrame::Data(s) | StreamFrame::Error(s) => s,
376        };
377        line.push('\n');
378        Ok::<axum::body::Bytes, std::io::Error>(axum::body::Bytes::from(line))
379    });
380
381    let body = axum::body::Body::from_stream(byte_stream);
382    // Body framing (chunked on HTTP/1.1, DATA frames on HTTP/2) is chosen by
383    // hyper from the protocol version; setting transfer-encoding by hand would
384    // be redundant on HTTP/1.1 and illegal on HTTP/2.
385    Response::builder()
386        .status(StatusCode::OK)
387        .header("content-type", "application/x-ndjson")
388        .body(body)
389        .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
390}
391
392/// Build a Server-Sent Events (`text/event-stream`) streaming response.
393fn sse_response<St>(stream: St, keep_alive_secs: u64) -> Response
394where
395    St: futures::Stream<Item = Result<DynamicMessage, tonic::Status>> + Send + 'static,
396{
397    // Terminal errors use the `stream-error` event type, not the reserved
398    // `error` type that the browser EventSource dispatches for transport
399    // failures — clients listen for it via addEventListener("stream-error").
400    let event_stream = json_frames(stream).map(|frame| {
401        let event = match frame {
402            StreamFrame::Data(s) => Event::default().data(s),
403            StreamFrame::Error(s) => Event::default().event("stream-error").data(s),
404        };
405        Ok::<Event, std::convert::Infallible>(event)
406    });
407
408    Sse::new(event_stream)
409        .keep_alive(KeepAlive::new().interval(std::time::Duration::from_secs(keep_alive_secs)))
410        .into_response()
411}
412
413/// Generic transcoding handler.
414async fn transcode_handler<S: TranscodeState>(
415    State(proxy_state): State<S>,
416    headers: HeaderMap,
417    Path(path_params): Path<std::collections::HashMap<String, String>>,
418    RawQuery(raw_query): RawQuery,
419    body_bytes: axum::body::Bytes,
420    entry: std::sync::Arc<RouteEntry>,
421) -> Response {
422    let channel = proxy_state.grpc_channel();
423
424    // Only read the body when the rule maps it onto the message.
425    let json_body = match entry.body {
426        request::BodyMapping::None => serde_json::Value::Null,
427        _ => {
428            let ct = body::content_type(&headers);
429            match body::parse_body(ct, &body_bytes) {
430                Ok(v) => v,
431                Err(e) => {
432                    return (
433                        StatusCode::BAD_REQUEST,
434                        Json(serde_json::json!({
435                            "error": "INVALID_ARGUMENT",
436                            "message": format!("failed to parse request body: {e}"),
437                        })),
438                    )
439                        .into_response();
440                }
441            }
442        }
443    };
444
445    // Query string → field bindings (fields not bound by path or body).
446    // A malformed query is a client error: reject it rather than silently
447    // dropping every query-bound field.
448    let query_pairs = match request::parse_query(raw_query.as_deref()) {
449        Ok(pairs) => pairs,
450        Err(e) => {
451            return (
452                StatusCode::BAD_REQUEST,
453                Json(serde_json::json!({
454                    "error": "INVALID_ARGUMENT",
455                    "message": e,
456                })),
457            )
458                .into_response();
459        }
460    };
461
462    let input_desc = entry.method.input();
463    let request_json = match request::build_request_json(
464        &input_desc,
465        &entry.body,
466        json_body,
467        &path_params,
468        &query_pairs,
469    ) {
470        Ok(v) => v,
471        Err(e) => {
472            return (
473                StatusCode::BAD_REQUEST,
474                Json(serde_json::json!({
475                    "error": "INVALID_ARGUMENT",
476                    "message": e,
477                })),
478            )
479                .into_response();
480        }
481    };
482
483    let request_msg = match DynamicMessage::deserialize(input_desc, request_json) {
484        Ok(msg) => msg,
485        Err(e) => {
486            return (
487                StatusCode::BAD_REQUEST,
488                Json(serde_json::json!({
489                    "error": "INVALID_ARGUMENT",
490                    "message": format!("failed to decode request: {e}"),
491                })),
492            )
493                .into_response();
494        }
495    };
496
497    let grpc_metadata =
498        metadata::http_headers_to_grpc_metadata(&headers, proxy_state.forwarded_headers());
499    let mut grpc_request = tonic::Request::new(request_msg);
500    *grpc_request.metadata_mut() = grpc_metadata;
501    metadata::apply_request_deadline(&mut grpc_request, &headers);
502
503    let output_desc = entry.method.output();
504    let grpc_codec = codec::DynamicCodec::new(output_desc.clone());
505    let grpc_path = entry.grpc_path.clone();
506
507    let mut grpc_client = Grpc::new(channel);
508    if let Err(e) = grpc_client.ready().await {
509        return (
510            StatusCode::SERVICE_UNAVAILABLE,
511            Json(serde_json::json!({
512                "error": "UNAVAILABLE",
513                "message": format!("gRPC upstream not ready: {e}"),
514            })),
515        )
516            .into_response();
517    }
518
519    match grpc_client.unary(grpc_request, grpc_path, grpc_codec).await {
520        Ok(response) => {
521            let response_msg = response.into_inner();
522            let serialize_opts = response_serialize_options();
523            match response_msg
524                .serialize_with_options(serde_json::value::Serializer, &serialize_opts)
525            {
526                Ok(json_value) => {
527                    // `response_body` returns just that subfield as the HTTP body.
528                    let out = match &entry.response_body {
529                        Some(path) => request::extract_response_body(&json_value, path)
530                            .unwrap_or_else(|| {
531                                tracing::warn!(
532                                    response_body = %path,
533                                    "configured response_body path not found in response; \
534                                     returning null"
535                                );
536                                serde_json::Value::Null
537                            }),
538                        None => json_value,
539                    };
540                    (StatusCode::OK, Json(out)).into_response()
541                }
542                Err(e) => {
543                    tracing::error!("Failed to serialize gRPC response: {e}");
544                    (
545                        StatusCode::INTERNAL_SERVER_ERROR,
546                        Json(serde_json::json!({
547                            "error": "INTERNAL",
548                            "message": "failed to serialize response",
549                        })),
550                    )
551                        .into_response()
552                }
553            }
554        }
555        Err(status) => error::status_to_response(status),
556    }
557}
558
559/// Extract HTTP route entries from proto descriptors.
560fn extract_routes(pool: &DescriptorPool) -> Vec<RouteEntry> {
561    let http_ext = match pool.get_extension_by_name("google.api.http") {
562        Some(ext) => ext,
563        None => {
564            tracing::warn!("google.api.http extension not found in descriptor pool");
565            return Vec::new();
566        }
567    };
568
569    let mut entries = Vec::new();
570
571    for service in pool.services() {
572        for method in service.methods() {
573            if method.is_client_streaming() || method.is_server_streaming() {
574                continue;
575            }
576
577            let grpc_path = format!("/{}/{}", service.full_name(), method.name());
578            let grpc_path: axum::http::uri::PathAndQuery = match grpc_path.parse() {
579                Ok(p) => p,
580                Err(e) => {
581                    tracing::error!("skipping route with invalid gRPC path '{grpc_path}': {e}");
582                    continue;
583                }
584            };
585
586            for binding in extract_http_bindings(&method, &http_ext) {
587                entries.push(RouteEntry {
588                    http_path: binding.http_path,
589                    http_method: binding.http_method,
590                    grpc_path: grpc_path.clone(),
591                    method: method.clone(),
592                    body: binding.body,
593                    response_body: binding.response_body,
594                });
595            }
596        }
597    }
598
599    entries
600}
601
602/// Extract server-streaming HTTP route entries.
603fn extract_streaming_routes(pool: &DescriptorPool) -> Vec<RouteEntry> {
604    let http_ext = match pool.get_extension_by_name("google.api.http") {
605        Some(ext) => ext,
606        None => return Vec::new(),
607    };
608
609    let mut entries = Vec::new();
610
611    for service in pool.services() {
612        for method in service.methods() {
613            if !method.is_server_streaming() || method.is_client_streaming() {
614                continue;
615            }
616
617            let grpc_path = format!("/{}/{}", service.full_name(), method.name());
618            let grpc_path: axum::http::uri::PathAndQuery = match grpc_path.parse() {
619                Ok(p) => p,
620                Err(e) => {
621                    tracing::error!("skipping route with invalid gRPC path '{grpc_path}': {e}");
622                    continue;
623                }
624            };
625
626            for binding in extract_http_bindings(&method, &http_ext) {
627                tracing::info!(
628                    "Registering streaming route: {} {} → {}",
629                    match binding.http_method {
630                        HttpMethod::Get => "GET",
631                        HttpMethod::Post => "POST",
632                        _ => "OTHER",
633                    },
634                    binding.http_path,
635                    grpc_path
636                );
637                entries.push(RouteEntry {
638                    http_path: binding.http_path,
639                    http_method: binding.http_method,
640                    grpc_path: grpc_path.clone(),
641                    method: method.clone(),
642                    body: binding.body,
643                    response_body: binding.response_body,
644                });
645            }
646        }
647    }
648
649    entries
650}
651
652/// A single HTTP binding parsed from a `google.api.http` rule.
653struct HttpBinding {
654    http_method: HttpMethod,
655    http_path: String,
656    body: request::BodyMapping,
657    response_body: Option<String>,
658}
659
660/// Extract all HTTP bindings (the primary rule plus any `additional_bindings`)
661/// from a method's `google.api.http` extension.
662fn extract_http_bindings(
663    method: &MethodDescriptor,
664    http_ext: &prost_reflect::ExtensionDescriptor,
665) -> Vec<HttpBinding> {
666    let options = method.options();
667    if !options.has_extension(http_ext) {
668        return Vec::new();
669    }
670
671    let prost_reflect::Value::Message(rule_msg) = options.get_extension(http_ext).into_owned()
672    else {
673        return Vec::new();
674    };
675
676    collect_bindings(&rule_msg)
677}
678
679/// Collect the primary binding plus every `additional_bindings` entry from an
680/// `HttpRule` message.
681fn collect_bindings(rule_msg: &DynamicMessage) -> Vec<HttpBinding> {
682    let mut bindings = Vec::new();
683    if let Some(binding) = parse_http_rule(rule_msg) {
684        bindings.push(binding);
685    }
686
687    // additional_bindings is a repeated HttpRule; each carries its own
688    // method/path/body. The proto forbids nesting them further.
689    if let Some(field) = rule_msg.get_field_by_name("additional_bindings") {
690        if let prost_reflect::Value::List(list) = field.into_owned() {
691            for item in list {
692                if let prost_reflect::Value::Message(sub) = item {
693                    if let Some(binding) = parse_http_rule(&sub) {
694                        bindings.push(binding);
695                    }
696                }
697            }
698        }
699    }
700
701    bindings
702}
703
704/// Parse a single `HttpRule` message into a binding (method+path required).
705fn parse_http_rule(rule_msg: &DynamicMessage) -> Option<HttpBinding> {
706    let (http_method, http_path) = [
707        ("get", HttpMethod::Get),
708        ("post", HttpMethod::Post),
709        ("put", HttpMethod::Put),
710        ("delete", HttpMethod::Delete),
711        ("patch", HttpMethod::Patch),
712    ]
713    .into_iter()
714    .find_map(
715        |(name, http_method)| match rule_msg.get_field_by_name(name)?.into_owned() {
716            prost_reflect::Value::String(path) if !path.is_empty() => Some((http_method, path)),
717            _ => None,
718        },
719    )?;
720
721    let body = rule_msg
722        .get_field_by_name("body")
723        .and_then(|v| match v.into_owned() {
724            prost_reflect::Value::String(s) => Some(request::BodyMapping::parse(&s)),
725            _ => None,
726        })
727        .unwrap_or(request::BodyMapping::None);
728
729    let response_body =
730        rule_msg
731            .get_field_by_name("response_body")
732            .and_then(|v| match v.into_owned() {
733                prost_reflect::Value::String(s) if !s.is_empty() => Some(s),
734                _ => None,
735            });
736
737    Some(HttpBinding {
738        http_method,
739        http_path,
740        body,
741        response_body,
742    })
743}
744
745/// Convert a `google.api.http` path template to axum 0.8 path syntax.
746///
747/// The proto `{param}` form IS axum 0.8's native capture syntax, so plain
748/// single-segment params pass through verbatim. Only field-path templates and
749/// bare wildcards need rewriting (axum 0.7 used `:param`; 0.8 uses `{param}`
750/// and rejects any segment starting with `:`):
751/// - `{name=*}`  (single segment)      -> `{name}`
752/// - `{name=**}` (multi-segment) -> `{*name}` (axum catch-all)
753/// - bare `*` segment            -> `{wildcardN}`
754/// - bare `**` segment           -> `{*wildcardN}` (axum catch-all)
755pub fn proto_path_to_axum(path: &str) -> String {
756    let mut out = String::with_capacity(path.len());
757
758    let segments = split_top_level(path);
759    let last = segments.len().saturating_sub(1);
760    for (idx, segment) in segments.iter().enumerate() {
761        if idx > 0 {
762            out.push('/');
763        }
764        out.push_str(&convert_segment(segment, idx, idx == last));
765    }
766
767    out
768}
769
770/// Split a path on `/` boundaries that are NOT inside a `{...}` brace span.
771///
772/// google.api.http field templates can embed slashes inside a single capture
773/// (e.g. the AIP-127 resource name `{name=shelves/*/books/*}`), so a naive
774/// `str::split('/')` would fracture the brace span into invalid fragments.
775/// Tracking brace depth keeps each capture intact.
776fn split_top_level(path: &str) -> Vec<&str> {
777    let mut segments = Vec::new();
778    let mut depth = 0usize;
779    let mut start = 0usize;
780
781    for (i, ch) in path.char_indices() {
782        match ch {
783            '{' => depth += 1,
784            // Decrement only on a matched brace; a stray `}` (malformed input)
785            // is treated as a literal rather than driving depth negative.
786            '}' if depth > 0 => depth -= 1,
787            '/' if depth == 0 => {
788                segments.push(&path[start..i]);
789                start = i + 1;
790            }
791            _ => {}
792        }
793    }
794    segments.push(&path[start..]);
795    segments
796}
797
798/// Convert a single top-level path segment from proto template to axum 0.8 form.
799///
800/// `is_last` indicates the terminal segment: axum permits a catch-all capture
801/// (`{*name}`) only there, so catch-alls in any other position must degrade.
802fn convert_segment(segment: &str, idx: usize, is_last: bool) -> String {
803    if let Some(inner) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
804        // Brace capture, possibly with a `name=template` field path.
805        if let Some((name, template)) = inner.split_once('=') {
806            return match template {
807                // Single-segment field path collapses to a plain capture.
808                "*" => format!("{{{name}}}"),
809                // Multi-segment catch-all maps to axum's `{*name}` (terminal only).
810                "**" => catch_all(name, is_last),
811                // Templates with interspersed literals (`{name=shelves/*/books/*}`)
812                // have no faithful axum form: axum cannot bind literal segments
813                // into one capture. Collapse to a catch-all so routing stays
814                // deterministic and the field still binds to the matched tail,
815                // and warn so the limitation surfaces instead of mis-routing.
816                _ => {
817                    tracing::warn!(
818                        template = %inner,
819                        "google.api.http multi-segment field template is not fully \
820                         supported; routing it as a catch-all capture"
821                    );
822                    catch_all(name, is_last)
823                }
824            };
825        }
826        // Plain `{name}` is already valid axum 0.8 syntax.
827        return format!("{{{inner}}}");
828    }
829
830    // Bare wildcards: name them by position so multiple wildcards never collide.
831    match segment {
832        "**" => catch_all(&format!("wildcard{idx}"), is_last),
833        "*" => format!("{{wildcard{idx}}}"),
834        literal => literal.to_string(),
835    }
836}
837
838/// Emit an axum catch-all `{*name}` when `is_last`, else degrade to a
839/// single-segment `{name}` capture.
840///
841/// axum accepts a catch-all only in the final path segment; a mid-path
842/// `{*name}` is rejected at `Router::route()`. A non-terminal catch-all comes
843/// from a malformed or unsupported google.api.http template, so we degrade
844/// (capturing one segment) and warn rather than panic the whole router.
845fn catch_all(name: &str, is_last: bool) -> String {
846    if is_last {
847        format!("{{*{name}}}")
848    } else {
849        tracing::warn!(
850            capture = %name,
851            "catch-all in a non-terminal path segment is unrepresentable in axum; \
852             degrading to a single-segment capture"
853        );
854        format!("{{{name}}}")
855    }
856}
857
858#[cfg(test)]
859mod tests {
860    use super::*;
861
862    /// Build a standalone `HttpRule`-shaped descriptor (self-referential
863    /// `additional_bindings`) so the binding parser can be tested without the
864    /// google.api extension wiring.
865    fn http_rule_descriptor() -> prost_reflect::MessageDescriptor {
866        use prost_reflect::prost::Message;
867        use prost_reflect::prost_types::{
868            field_descriptor_proto::{Label, Type},
869            DescriptorProto, FieldDescriptorProto, FileDescriptorProto, FileDescriptorSet,
870        };
871
872        let str_field = |name: &str, num: i32| FieldDescriptorProto {
873            name: Some(name.to_string()),
874            number: Some(num),
875            label: Some(Label::Optional as i32),
876            r#type: Some(Type::String as i32),
877            ..Default::default()
878        };
879        let rule = DescriptorProto {
880            name: Some("HttpRule".to_string()),
881            field: vec![
882                str_field("get", 2),
883                str_field("put", 3),
884                str_field("post", 4),
885                str_field("delete", 5),
886                str_field("patch", 6),
887                str_field("body", 7),
888                str_field("response_body", 12),
889                FieldDescriptorProto {
890                    name: Some("additional_bindings".to_string()),
891                    number: Some(11),
892                    label: Some(Label::Repeated as i32),
893                    r#type: Some(Type::Message as i32),
894                    type_name: Some(".gapi.HttpRule".to_string()),
895                    ..Default::default()
896                },
897            ],
898            ..Default::default()
899        };
900        let file = FileDescriptorProto {
901            name: Some("http.proto".to_string()),
902            package: Some("gapi".to_string()),
903            message_type: vec![rule],
904            syntax: Some("proto3".to_string()),
905            ..Default::default()
906        };
907        let fds = FileDescriptorSet { file: vec![file] };
908        let pool = DescriptorPool::decode(fds.encode_to_vec().as_slice()).unwrap();
909        pool.get_message_by_name("gapi.HttpRule").unwrap()
910    }
911
912    #[test]
913    fn collect_bindings_reads_body_response_and_additional() {
914        let desc = http_rule_descriptor();
915
916        // additional_bindings entry: POST /v1/items with whole-body mapping.
917        let mut extra = DynamicMessage::new(desc.clone());
918        extra.set_field_by_name("post", prost_reflect::Value::String("/v1/items".into()));
919        extra.set_field_by_name("body", prost_reflect::Value::String("*".into()));
920
921        // primary rule: GET /v1/items/{id}, returns only the `result` subfield.
922        let mut rule = DynamicMessage::new(desc);
923        rule.set_field_by_name("get", prost_reflect::Value::String("/v1/items/{id}".into()));
924        rule.set_field_by_name(
925            "response_body",
926            prost_reflect::Value::String("result".into()),
927        );
928        rule.set_field_by_name(
929            "additional_bindings",
930            prost_reflect::Value::List(vec![prost_reflect::Value::Message(extra)]),
931        );
932
933        let bindings = collect_bindings(&rule);
934        assert_eq!(bindings.len(), 2);
935
936        // Primary: GET, no body, response_body = result.
937        assert!(matches!(bindings[0].http_method, HttpMethod::Get));
938        assert_eq!(bindings[0].http_path, "/v1/items/{id}");
939        assert_eq!(bindings[0].body, request::BodyMapping::None);
940        assert_eq!(bindings[0].response_body.as_deref(), Some("result"));
941
942        // Additional: POST, whole-body mapping, no response_body.
943        assert!(matches!(bindings[1].http_method, HttpMethod::Post));
944        assert_eq!(bindings[1].http_path, "/v1/items");
945        assert_eq!(bindings[1].body, request::BodyMapping::Root);
946        assert_eq!(bindings[1].response_body, None);
947    }
948
949    #[test]
950    fn test_proto_path_to_axum() {
951        // axum 0.8: proto `{param}` IS the native capture syntax, pass through verbatim.
952        assert_eq!(proto_path_to_axum("/v1/profiles/{id}"), "/v1/profiles/{id}");
953        assert_eq!(
954            proto_path_to_axum("/v1/admin/profiles/{profile_id}/metadata/{key}"),
955            "/v1/admin/profiles/{profile_id}/metadata/{key}"
956        );
957        assert_eq!(proto_path_to_axum("/v1/auth/login"), "/v1/auth/login");
958    }
959
960    #[test]
961    fn test_proto_path_to_axum_wildcards() {
962        // `{name=*}` single-segment field path collapses to a plain capture.
963        assert_eq!(proto_path_to_axum("/v1/{name=*}"), "/v1/{name}");
964        // `{name=**}` multi-segment catch-all maps to axum's `{*name}`.
965        assert_eq!(
966            proto_path_to_axum("/v1/files/{path=**}"),
967            "/v1/files/{*path}"
968        );
969        // Bare wildcards get position-named captures so they never collide.
970        // Index is the segment position after splitting on `/` (leading "" = 0).
971        assert_eq!(proto_path_to_axum("/v1/*/items"), "/v1/{wildcard2}/items");
972        assert_eq!(proto_path_to_axum("/v1/files/**"), "/v1/files/{*wildcard3}");
973    }
974
975    #[test]
976    fn non_terminal_catch_all_degrades_to_single_capture() {
977        // A catch-all `{*name}` is only valid in axum's LAST path segment.
978        // An unsupported/multi-segment field template in a NON-terminal position
979        // (`/v1/{name=projects/*}/topics`) must NOT emit a mid-path catch-all —
980        // axum rejects `/v1/{*name}/topics` at `Router::route()`. It degrades to
981        // a single-segment capture instead.
982        assert_eq!(
983            proto_path_to_axum("/v1/{name=projects/*}/topics"),
984            "/v1/{name}/topics"
985        );
986        let path = proto_path_to_axum("/v1/{name=projects/*}/topics");
987        let _router: Router<()> = Router::new().route(&path, get(|| async { "ok" }));
988
989        // The same guard applies to an explicit `**` template in non-terminal
990        // position and a terminal one still yields a real catch-all.
991        assert_eq!(proto_path_to_axum("/v1/{rest=**}/tail"), "/v1/{rest}/tail");
992        assert_eq!(
993            proto_path_to_axum("/v1/files/{rest=**}"),
994            "/v1/files/{*rest}"
995        );
996    }
997
998    #[test]
999    fn multi_segment_field_template_does_not_fracture() {
1000        // google.api.http resource-name templates (AIP-127) embed slashes
1001        // inside a SINGLE brace span: `{name=shelves/*/books/*}`. Splitting on
1002        // `/` before brace parsing fractured this into invalid fragments and
1003        // produced a mangled axum path that panicked at `Router::route()`.
1004        // It must collapse to a single catch-all capture instead.
1005        assert_eq!(
1006            proto_path_to_axum("/v1/{name=shelves/*/books/*}"),
1007            "/v1/{*name}"
1008        );
1009        // And the produced path must actually register on axum 0.8.
1010        let path = proto_path_to_axum("/v1/{name=shelves/*/books/*}");
1011        let _router: Router<()> = Router::new().route(&path, get(|| async { "ok" }));
1012    }
1013
1014    /// Regression for the axum 0.7→0.8 migration bug: `proto_path_to_axum`
1015    /// emitted `:id` syntax, which axum 0.8 rejects at `Router::route()` with
1016    /// a startup panic ("Path segments must not start with `:`"). Building the
1017    /// router over a brace-param path must NOT panic. Pre-fix this panicked.
1018    #[test]
1019    fn router_builds_with_brace_path_params_on_axum_0_8() {
1020        let axum_path = proto_path_to_axum("/v1/profiles/{id}");
1021        let _router: Router<()> = Router::new().route(&axum_path, get(|| async { "ok" }));
1022
1023        // Deeper nesting and a catch-all also route without panicking.
1024        let nested = proto_path_to_axum("/v1/admin/profiles/{profile_id}/metadata/{key}");
1025        let catch_all = proto_path_to_axum("/v1/files/{path=**}");
1026        let _router: Router<()> = Router::new()
1027            .route(&nested, get(|| async { "ok" }))
1028            .route(&catch_all, get(|| async { "ok" }));
1029    }
1030
1031    /// `Item { name: "alice", count: 42 }` — default fixture for the
1032    /// serialization helpers.
1033    fn item_message() -> DynamicMessage {
1034        item_message_named("alice", 42)
1035    }
1036
1037    /// Build an `Item { name, count }` message from a freshly-decoded
1038    /// descriptor pool, used to exercise the streaming serialization helpers.
1039    fn item_message_named(name: &str, count: i64) -> DynamicMessage {
1040        use prost_reflect::prost::Message;
1041        use prost_reflect::prost_types::{
1042            field_descriptor_proto::{Label, Type},
1043            DescriptorProto, FieldDescriptorProto, FileDescriptorProto, FileDescriptorSet,
1044        };
1045
1046        let item = DescriptorProto {
1047            name: Some("Item".to_string()),
1048            field: vec![
1049                FieldDescriptorProto {
1050                    name: Some("name".to_string()),
1051                    number: Some(1),
1052                    label: Some(Label::Optional as i32),
1053                    r#type: Some(Type::String as i32),
1054                    ..Default::default()
1055                },
1056                FieldDescriptorProto {
1057                    name: Some("count".to_string()),
1058                    number: Some(2),
1059                    label: Some(Label::Optional as i32),
1060                    r#type: Some(Type::Int64 as i32),
1061                    ..Default::default()
1062                },
1063            ],
1064            ..Default::default()
1065        };
1066        let file = FileDescriptorProto {
1067            name: Some("item.proto".to_string()),
1068            package: Some("test.v1".to_string()),
1069            message_type: vec![item],
1070            syntax: Some("proto3".to_string()),
1071            ..Default::default()
1072        };
1073        let mut bytes = Vec::new();
1074        FileDescriptorSet { file: vec![file] }
1075            .encode(&mut bytes)
1076            .unwrap();
1077        let pool = DescriptorPool::decode(bytes.as_slice()).unwrap();
1078        let desc = pool.get_message_by_name("test.v1.Item").unwrap();
1079
1080        let mut msg = DynamicMessage::new(desc);
1081        msg.set_field_by_name("name", prost_reflect::Value::String(name.to_string()));
1082        msg.set_field_by_name("count", prost_reflect::Value::I64(count));
1083        msg
1084    }
1085
1086    /// Collect a streaming response body into a single UTF-8 string.
1087    async fn collect_body(resp: Response) -> String {
1088        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1089            .await
1090            .unwrap();
1091        String::from_utf8(bytes.to_vec()).unwrap()
1092    }
1093
1094    #[tokio::test]
1095    async fn ndjson_error_frame_is_terminal() {
1096        // A gRPC error mid-stream must be the LAST frame: messages the upstream
1097        // would yield after the error are dropped, so the error line is an
1098        // unambiguous end-of-stream signal rather than a mid-stream marker.
1099        let items = vec![
1100            Ok(item_message_named("alice", 1)),
1101            Err(tonic::Status::internal("boom")),
1102            Ok(item_message_named("bob", 2)),
1103        ];
1104        let body = collect_body(ndjson_response(futures::stream::iter(items))).await;
1105        let lines: Vec<&str> = body.lines().collect();
1106        assert_eq!(lines.len(), 2, "stream must stop after the error frame");
1107        assert!(lines[0].contains("alice"));
1108        assert!(lines[1].contains("INTERNAL") && lines[1].contains("boom"));
1109        assert!(!body.contains("bob"), "post-error message must be dropped");
1110    }
1111
1112    #[tokio::test]
1113    async fn sse_error_uses_distinct_event_name() {
1114        // The terminal error is sent as `event: stream-error`, not the reserved
1115        // `error` type that collides with the browser EventSource onerror.
1116        let items = vec![
1117            Ok(item_message_named("alice", 1)),
1118            Err(tonic::Status::permission_denied("nope")),
1119            Ok(item_message_named("bob", 2)),
1120        ];
1121        let body = collect_body(sse_response(futures::stream::iter(items), 15)).await;
1122        assert!(body.contains("stream-error"));
1123        assert!(body.contains("PERMISSION_DENIED"));
1124        assert!(!body.contains("bob"), "post-error message must be dropped");
1125    }
1126
1127    #[test]
1128    fn wants_sse_detects_event_stream_accept() {
1129        let mut headers = HeaderMap::new();
1130        headers.insert("accept", "text/event-stream".parse().unwrap());
1131        assert!(wants_sse(&headers));
1132    }
1133
1134    #[test]
1135    fn wants_sse_matches_within_list_and_ignores_params() {
1136        let mut headers = HeaderMap::new();
1137        headers.insert(
1138            "accept",
1139            "application/json, text/event-stream;q=0.9".parse().unwrap(),
1140        );
1141        assert!(wants_sse(&headers));
1142    }
1143
1144    #[test]
1145    fn wants_sse_false_for_json_and_missing() {
1146        let mut headers = HeaderMap::new();
1147        headers.insert("accept", "application/json".parse().unwrap());
1148        assert!(!wants_sse(&headers));
1149        assert!(!wants_sse(&HeaderMap::new()));
1150    }
1151
1152    #[test]
1153    fn wants_sse_rejects_explicit_q_zero() {
1154        // RFC 7231 §5.3.1: `q=0` means the media type is explicitly NOT
1155        // acceptable, so it must not select the SSE path.
1156        let mut headers = HeaderMap::new();
1157        headers.insert("accept", "text/event-stream;q=0".parse().unwrap());
1158        assert!(!wants_sse(&headers));
1159    }
1160
1161    #[test]
1162    fn wants_sse_honors_second_accept_header_line() {
1163        // A client may send multiple `Accept` header lines; the negotiation
1164        // must consider all of them, not just the first.
1165        let mut headers = HeaderMap::new();
1166        headers.append("accept", "application/json".parse().unwrap());
1167        headers.append("accept", "text/event-stream".parse().unwrap());
1168        assert!(wants_sse(&headers));
1169    }
1170
1171    #[test]
1172    fn message_to_json_string_stringifies_64bit() {
1173        let opts = response_serialize_options();
1174        let json = message_to_json_string(&item_message(), &opts).unwrap();
1175        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1176        assert_eq!(value["name"], "alice");
1177        // 64-bit integers are stringified to survive JS number precision limits.
1178        assert_eq!(value["count"], "42");
1179    }
1180
1181    #[test]
1182    fn ndjson_response_omits_manual_transfer_encoding() {
1183        // hyper picks the framing per protocol version; a hand-set
1184        // transfer-encoding would be illegal on HTTP/2.
1185        let resp = ndjson_response(futures::stream::empty::<
1186            Result<DynamicMessage, tonic::Status>,
1187        >());
1188        assert_eq!(
1189            resp.headers().get("content-type").unwrap(),
1190            "application/x-ndjson"
1191        );
1192        assert!(resp.headers().get("transfer-encoding").is_none());
1193    }
1194
1195    #[test]
1196    fn stream_error_json_carries_grpc_code_name() {
1197        let status = tonic::Status::permission_denied("nope");
1198        let value = stream_error_json(&status);
1199        assert_eq!(value["error"], "PERMISSION_DENIED");
1200        assert_eq!(value["message"], "nope");
1201        assert_eq!(value["code"], tonic::Code::PermissionDenied as i32);
1202    }
1203}