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 std::sync::Arc;
23use tonic::client::Grpc;
24
25use crate::config::AliasConfig;
26use error::{ErrorDetailsPolicy, StatusDetails};
27
28/// Trait for state types that support REST→gRPC transcoding.
29///
30/// Implement this for your application's state type to use `transcode::routes()`.
31/// Provides the minimal interface needed by transcode handlers.
32pub trait TranscodeState: Clone + Send + Sync + 'static {
33    /// Lazy gRPC channel to upstream service.
34    fn grpc_channel(&self) -> tonic::transport::Channel;
35    /// Headers to forward from HTTP to gRPC metadata.
36    fn forwarded_headers(&self) -> &[String];
37    /// SSE keep-alive interval (seconds) for server-streaming responses.
38    fn sse_keep_alive_secs(&self) -> u64;
39}
40
41impl TranscodeState for crate::ProxyState {
42    fn grpc_channel(&self) -> tonic::transport::Channel {
43        self.grpc_channel.clone()
44    }
45    fn forwarded_headers(&self) -> &[String] {
46        &self.forwarded_headers
47    }
48    fn sse_keep_alive_secs(&self) -> u64 {
49        self.sse_keep_alive_secs
50    }
51}
52
53/// Route entry extracted from proto HTTP annotations.
54#[derive(Debug, Clone)]
55struct RouteEntry {
56    /// HTTP path pattern (e.g., "/v1/auth/opaque/login/start").
57    http_path: String,
58    /// HTTP method (GET, POST, PUT, PATCH, DELETE).
59    http_method: HttpMethod,
60    /// gRPC path (e.g., "/sid.v1.AuthService/OpaqueLoginStart"), parsed once at
61    /// route-build time so each request clones a cheap `Bytes` refcount.
62    grpc_path: axum::http::uri::PathAndQuery,
63    /// Method descriptor for input/output message resolution.
64    method: MethodDescriptor,
65    /// How the request body maps onto the gRPC request message.
66    body: request::BodyMapping,
67    /// Optional response subfield to return as the HTTP body (`response_body`).
68    response_body: Option<String>,
69    /// Renderer for the status details of this route's errors; `None` when the
70    /// error-details policy switches them off for the route.
71    error_details: Option<Arc<StatusDetails>>,
72    /// Wrap NDJSON stream lines in `{"result"}` / `{"error"}` envelopes.
73    ndjson_envelope: bool,
74    /// [`codec::has_required_fields`] of the response type, computed once so a
75    /// request does not walk the descriptor.
76    response_has_required: bool,
77}
78
79/// How [`routes_with_options`] builds the transcoded routes.
80///
81/// # Examples
82///
83/// ```
84/// use structured_proxy::transcode::error::ErrorDetailsPolicy;
85/// use structured_proxy::transcode::TranscodeOptions;
86///
87/// let options = TranscodeOptions::default()
88///     .with_error_details(ErrorDetailsPolicy::default().route("/v1/admin/**", false).unwrap())
89///     .with_ndjson_envelope(true);
90/// # let _ = options;
91/// ```
92#[derive(Debug, Clone, Default)]
93pub struct TranscodeOptions {
94    pub(crate) error_details: ErrorDetailsPolicy,
95    pub(crate) ndjson_envelope: bool,
96}
97
98impl TranscodeOptions {
99    /// Which routes return `google.rpc.Status` details in their error bodies
100    /// (all of them by default).
101    pub fn with_error_details(mut self, policy: ErrorDetailsPolicy) -> Self {
102        self.error_details = policy;
103        self
104    }
105
106    /// Wrap every NDJSON line of a server-streaming response in an envelope:
107    /// `{"result": <message>}` for data, `{"error": <error body>}` for the
108    /// terminal error (the grpc-gateway stream shape). Off by default, when a
109    /// data line is the bare message and the error line carries an
110    /// `@type: google.rpc.Status` marker instead. Only the envelope keeps the
111    /// two apart for RPCs that stream `google.protobuf.Any`, `Struct`, `Value`
112    /// or `ListValue`, whose messages can carry any keys. SSE is unaffected.
113    pub fn with_ndjson_envelope(mut self, enabled: bool) -> Self {
114        self.ndjson_envelope = enabled;
115        self
116    }
117}
118
119#[derive(Debug, Clone, Copy)]
120enum HttpMethod {
121    Get,
122    Post,
123    Put,
124    Patch,
125    Delete,
126}
127
128impl HttpMethod {
129    /// The uppercase HTTP method token (e.g. `"GET"`).
130    fn as_str(self) -> &'static str {
131        match self {
132            HttpMethod::Get => "GET",
133            HttpMethod::Post => "POST",
134            HttpMethod::Put => "PUT",
135            HttpMethod::Patch => "PATCH",
136            HttpMethod::Delete => "DELETE",
137        }
138    }
139}
140
141/// Build transcoded REST→gRPC routes from a descriptor pool.
142///
143/// Takes a `DescriptorPool` and optional path aliases from config.
144/// Returns an axum Router that transcodes REST requests to gRPC calls. Error
145/// bodies carry `google.rpc.Status` details on every route; use
146/// [`routes_with_options`] to choose per route or to frame NDJSON streams.
147pub fn routes<S: TranscodeState>(pool: &DescriptorPool, aliases: &[AliasConfig]) -> Router<S> {
148    routes_with_options(pool, aliases, &TranscodeOptions::default())
149}
150
151/// [`routes`], built as `options` describe.
152pub fn routes_with_options<S: TranscodeState>(
153    pool: &DescriptorPool,
154    aliases: &[AliasConfig],
155    options: &TranscodeOptions,
156) -> Router<S> {
157    let bindings = route_bindings(pool, aliases);
158    if bindings.is_empty() {
159        tracing::warn!("No HTTP-annotated RPCs found in proto descriptors");
160        return Router::new();
161    }
162
163    tracing::info!("Registering {} transcoded REST→gRPC routes", bindings.len());
164
165    // At most two renderers, without and with the opaque-detail extension,
166    // each shared by every route that uses it and built only when one does.
167    // The second is a copy of the first: the descriptor pool inside is shared.
168    let mut status_details: [Option<Arc<StatusDetails>>; 2] = [None, None];
169    let mut router: Router<S> = Router::new();
170    for mut binding in bindings {
171        let policy = &options.error_details;
172        if policy.enabled_for(&binding.axum_path) {
173            let opaque = policy.opaque_for(&binding.axum_path);
174            let slot = usize::from(opaque);
175            if status_details[slot].is_none() {
176                let base = status_details
177                    .iter()
178                    .flatten()
179                    .next()
180                    .map(|existing| existing.as_ref().clone())
181                    .unwrap_or_else(|| StatusDetails::new(pool));
182                status_details[slot] = Some(Arc::new(base.with_opaque_details(opaque)));
183            }
184            binding.entry.error_details = status_details[slot].clone();
185        }
186        binding.entry.ndjson_envelope = options.ndjson_envelope;
187        let method = binding.entry.http_method;
188        let entry = Arc::new(binding.entry);
189        let method_router: MethodRouter<S> = if binding.streaming {
190            let handler = move |proxy_state: State<S>,
191                                headers: HeaderMap,
192                                path_params: Path<std::collections::HashMap<String, String>>,
193                                raw_query: RawQuery,
194                                body: axum::body::Bytes| {
195                streaming_handler(proxy_state, headers, path_params, raw_query, body, entry)
196            };
197            match method {
198                HttpMethod::Get => get(handler),
199                HttpMethod::Post => post(handler),
200                // route_bindings only yields GET/POST streaming bindings.
201                _ => unreachable!("streaming routes are GET/POST only"),
202            }
203        } else {
204            let handler = move |proxy_state: State<S>,
205                                headers: HeaderMap,
206                                path_params: Path<std::collections::HashMap<String, String>>,
207                                raw_query: RawQuery,
208                                body: axum::body::Bytes| {
209                transcode_handler(proxy_state, headers, path_params, raw_query, body, entry)
210            };
211            match method {
212                HttpMethod::Get => get(handler),
213                HttpMethod::Post => post(handler),
214                HttpMethod::Put => put(handler),
215                HttpMethod::Patch => patch(handler),
216                HttpMethod::Delete => delete(handler),
217            }
218        };
219        router = router.route(&binding.axum_path, method_router);
220    }
221
222    router
223}
224
225/// One transcode route to mount: the RPC entry that serves it, the axum path to
226/// register it at, and whether it is the server-streaming variant.
227struct RouteBinding {
228    entry: RouteEntry,
229    axum_path: String,
230    streaming: bool,
231}
232
233/// The single source of truth for what [`routes`] mounts: unary RPCs, their
234/// config aliases, and server-streaming RPCs. Both [`routes`] (to build handlers)
235/// and [`route_paths`] (to enumerate paths for collision checks) consume this, so
236/// the mounted set and the enumerated set cannot drift apart.
237fn route_bindings(pool: &DescriptorPool, aliases: &[AliasConfig]) -> Vec<RouteBinding> {
238    let mut bindings = Vec::new();
239    for entry in extract_routes(pool) {
240        bindings.push(RouteBinding {
241            axum_path: proto_path_to_axum(&entry.http_path),
242            entry: entry.clone(),
243            streaming: false,
244        });
245        for alias in aliases {
246            if let Some(suffix) = entry.http_path.strip_prefix(&alias.to) {
247                if alias.from.ends_with("/{path}") {
248                    let prefix = alias.from.trim_end_matches("/{path}");
249                    bindings.push(RouteBinding {
250                        axum_path: format!("{prefix}{suffix}"),
251                        entry: entry.clone(),
252                        streaming: false,
253                    });
254                }
255            }
256        }
257    }
258    for entry in extract_streaming_routes(pool) {
259        if matches!(entry.http_method, HttpMethod::Get | HttpMethod::Post) {
260            bindings.push(RouteBinding {
261                axum_path: proto_path_to_axum(&entry.http_path),
262                entry,
263                streaming: true,
264            });
265        }
266    }
267    bindings
268}
269
270/// The axum paths [`routes`] would register for this pool and aliases.
271///
272/// Mirrors the registration in [`routes`] (unary RPCs, their config aliases, and
273/// server-streaming RPCs) without building handlers, so callers can detect route
274/// collisions before mounting additional routes (e.g. a forward-auth endpoint).
275///
276/// Each entry is `(method, path)` where `method` is the uppercase HTTP token, so
277/// callers can distinguish same-path/different-method routes from real conflicts.
278pub fn route_paths(pool: &DescriptorPool, aliases: &[AliasConfig]) -> Vec<(String, String)> {
279    route_bindings(pool, aliases)
280        .into_iter()
281        .map(|b| (b.entry.http_method.as_str().to_string(), b.axum_path))
282        .collect()
283}
284
285/// JSON serialization options shared by the unary and streaming response paths,
286/// so a given message serializes identically regardless of RPC kind.
287fn response_serialize_options() -> SerializeOptions {
288    SerializeOptions::new()
289        .skip_default_fields(false)
290        .stringify_64_bit_integers(true)
291}
292
293/// Serialize one streamed gRPC message to a compact JSON string.
294fn message_to_json_string(msg: &DynamicMessage, opts: &SerializeOptions) -> Result<String, String> {
295    let value = msg
296        .serialize_with_options(serde_json::value::Serializer, opts)
297        .map_err(|e| e.to_string())?;
298    serde_json::to_string(&value).map_err(|e| e.to_string())
299}
300
301/// Whether the client negotiated a Server-Sent Events response via `Accept`.
302///
303/// Considers every `Accept` header line (a client may send more than one) and
304/// every comma-separated media range within each. Matches `text/event-stream`
305/// case-insensitively and honors the quality factor: per RFC 7231 §5.3.1 a
306/// `q=0` weight means the type is explicitly not acceptable, so it does not
307/// select the SSE path.
308fn wants_sse(headers: &HeaderMap) -> bool {
309    headers
310        .get_all(axum::http::header::ACCEPT)
311        .iter()
312        .filter_map(|v| v.to_str().ok())
313        .flat_map(|accept| accept.split(','))
314        .any(accept_range_selects_sse)
315}
316
317/// Whether a single `Accept` media range selects `text/event-stream` with a
318/// non-zero quality factor.
319fn accept_range_selects_sse(range: &str) -> bool {
320    let mut parts = range.split(';');
321    let media = parts.next().unwrap_or("").trim();
322    if !media.eq_ignore_ascii_case("text/event-stream") {
323        return false;
324    }
325    // Default weight is 1.0; only an explicit `q=0` (or unparseable-as-positive)
326    // disqualifies the match. A malformed weight falls back to acceptable.
327    for param in parts {
328        let mut kv = param.splitn(2, '=');
329        if kv.next().unwrap_or("").trim().eq_ignore_ascii_case("q") {
330            let q: f32 = kv.next().unwrap_or("").trim().parse().unwrap_or(1.0);
331            return q > 0.0;
332        }
333    }
334    true
335}
336
337/// Handler for server-streaming RPCs.
338///
339/// Returns Server-Sent Events when the client sends `Accept: text/event-stream`,
340/// otherwise newline-delimited JSON (NDJSON). In both formats a gRPC error
341/// mid-stream is delivered as an explicit terminal frame before the stream is
342/// closed cleanly, rather than truncating the HTTP body.
343async fn streaming_handler<S: TranscodeState>(
344    State(proxy_state): State<S>,
345    headers: HeaderMap,
346    Path(path_params): Path<std::collections::HashMap<String, String>>,
347    RawQuery(raw_query): RawQuery,
348    body_bytes: axum::body::Bytes,
349    entry: std::sync::Arc<RouteEntry>,
350) -> Response {
351    let channel = proxy_state.grpc_channel();
352
353    let request_msg = match decode_request(
354        &entry,
355        &headers,
356        &path_params,
357        raw_query.as_deref(),
358        &body_bytes,
359    ) {
360        Ok(msg) => msg,
361        Err(message) => return bad_request(&entry, message),
362    };
363
364    let grpc_metadata =
365        metadata::http_headers_to_grpc_metadata(&headers, proxy_state.forwarded_headers());
366    let mut grpc_request = tonic::Request::new(request_msg);
367    *grpc_request.metadata_mut() = grpc_metadata;
368    metadata::apply_request_deadline(&mut grpc_request, &headers);
369
370    let output_desc = entry.method.output();
371    let grpc_codec =
372        codec::DynamicCodec::with_required_check(output_desc.clone(), entry.response_has_required);
373    let grpc_path = entry.grpc_path.clone();
374
375    let mut grpc_client = Grpc::new(channel);
376    if let Err(e) = grpc_client.ready().await {
377        let status = tonic::Status::unavailable(format!("gRPC upstream not ready: {e}"));
378        return error::status_to_response_with_details(&status, entry.error_details.as_deref());
379    }
380
381    let use_sse = wants_sse(&headers);
382
383    match grpc_client
384        .server_streaming(grpc_request, grpc_path, grpc_codec)
385        .await
386    {
387        // Only a trailers-only rejection lands in `Err`. Once the upstream
388        // accepted the call, the response starts at once rather than waiting
389        // for the first item, so headers and SSE keep-alives are not held back;
390        // an error that comes before the first message is a terminal frame.
391        Ok(response) => {
392            let stream = response.into_inner();
393            // The terminal frame renders like the unary error body. The
394            // closure takes over this request's route handle, so the stream
395            // keeps it alive without another refcount.
396            let envelope = entry.ndjson_envelope;
397            let render_error = move |status: &tonic::Status| {
398                error::error_body(status, entry.error_details.as_deref())
399            };
400            if use_sse {
401                sse_response(stream, render_error, proxy_state.sse_keep_alive_secs())
402            } else {
403                ndjson_response(stream, render_error, envelope)
404            }
405        }
406        Err(status) => {
407            error::status_to_response_with_details(&status, entry.error_details.as_deref())
408        }
409    }
410}
411
412/// One frame of a streaming response: a serialized message, or the error body
413/// (see [`error::error_body`]) that ends the stream.
414///
415/// `Error` is terminal: [`json_frames`] stops the stream right after yielding
416/// it, so an error frame is always the last thing a client sees regardless of
417/// whether it came from a gRPC status or a serialization failure. It stays a
418/// JSON value so each format can add its own framing before serializing it.
419enum StreamFrame {
420    Data(String),
421    Error(serde_json::Value),
422}
423
424/// Type URL marking the terminal error line of an unenveloped NDJSON stream. A
425/// data line is the ProtoJSON of a response message, which carries a top-level
426/// `@type` only when the RPC streams `google.protobuf.Any`; `Struct`, `Value`
427/// and `ListValue` can carry any key at all. For those RPCs no in-band marker
428/// is collision-free, which is what the opt-in envelope
429/// ([`TranscodeOptions::with_ndjson_envelope`]) is for; the unenveloped shape
430/// stays the default so existing NDJSON readers keep working.
431const STATUS_TYPE_URL: &str = "type.googleapis.com/google.rpc.Status";
432
433/// Turn a gRPC message stream into a stream of serialized JSON frames, stopping
434/// after the first error so error frames are unambiguously terminal.
435///
436/// Both a gRPC `Status` (rendered by `render_error`) and a per-message
437/// serialization failure become a terminal [`StreamFrame::Error`]; downstream
438/// messages the upstream might still emit are dropped rather than streamed past
439/// the error. The terminal frame drops the upstream along with the stream
440/// state, so the body ends at once instead of polling an upstream that may
441/// stay open.
442fn json_frames<St, R>(
443    stream: St,
444    render_error: R,
445) -> impl futures::Stream<Item = StreamFrame> + Send + 'static
446where
447    St: futures::Stream<Item = Result<DynamicMessage, tonic::Status>> + Send + Unpin + 'static,
448    R: Fn(&tonic::Status) -> serde_json::Value + Send + 'static,
449{
450    let state = Some((stream, render_error, response_serialize_options()));
451    futures::stream::unfold(state, |state| async move {
452        let (mut stream, render_error, opts) = state?;
453        let error = match stream.next().await? {
454            Ok(msg) => match message_to_json_string(&msg, &opts) {
455                Ok(s) => return Some((StreamFrame::Data(s), Some((stream, render_error, opts)))),
456                Err(e) => tonic::Status::internal(format!("serialization error: {e}")),
457            },
458            Err(status) => status,
459        };
460        Some((StreamFrame::Error(render_error(&error)), None))
461    })
462}
463
464/// Build an NDJSON (`application/x-ndjson`) streaming response.
465///
466/// With `envelope`, every line is wrapped: `{"result": <message>}` for data and
467/// `{"error": <error body>}` for the terminal error. Without it, a data line is
468/// the bare message and the error line is the error body plus an
469/// `@type: google.rpc.Status` marker (see [`STATUS_TYPE_URL`]).
470fn ndjson_response<St, R>(stream: St, render_error: R, envelope: bool) -> Response
471where
472    St: futures::Stream<Item = Result<DynamicMessage, tonic::Status>> + Send + Unpin + 'static,
473    R: Fn(&tonic::Status) -> serde_json::Value + Send + 'static,
474{
475    let byte_stream = json_frames(stream, render_error).map(move |frame| {
476        let mut line = match frame {
477            // The message is already serialized; wrap the text instead of
478            // parsing it back into a value.
479            StreamFrame::Data(s) if envelope => {
480                let mut wrapped = String::with_capacity(s.len() + 12);
481                wrapped.push_str("{\"result\":");
482                wrapped.push_str(&s);
483                wrapped.push('}');
484                wrapped
485            }
486            StreamFrame::Data(s) => s,
487            StreamFrame::Error(body) if envelope => {
488                serde_json::json!({ "error": body }).to_string()
489            }
490            StreamFrame::Error(mut body) => {
491                if let Some(fields) = body.as_object_mut() {
492                    fields.insert("@type".into(), STATUS_TYPE_URL.into());
493                }
494                body.to_string()
495            }
496        };
497        line.push('\n');
498        Ok::<axum::body::Bytes, std::io::Error>(axum::body::Bytes::from(line))
499    });
500
501    let body = axum::body::Body::from_stream(byte_stream);
502    // Body framing (chunked on HTTP/1.1, DATA frames on HTTP/2) is chosen by
503    // hyper from the protocol version; setting transfer-encoding by hand would
504    // be redundant on HTTP/1.1 and illegal on HTTP/2.
505    Response::builder()
506        .status(StatusCode::OK)
507        .header("content-type", "application/x-ndjson")
508        .body(body)
509        .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
510}
511
512/// Build a Server-Sent Events (`text/event-stream`) streaming response.
513fn sse_response<St, R>(stream: St, render_error: R, keep_alive_secs: u64) -> Response
514where
515    St: futures::Stream<Item = Result<DynamicMessage, tonic::Status>> + Send + Unpin + 'static,
516    R: Fn(&tonic::Status) -> serde_json::Value + Send + 'static,
517{
518    // Terminal errors use the `stream-error` event type, not the reserved
519    // `error` type that the browser EventSource dispatches for transport
520    // failures — clients listen for it via addEventListener("stream-error").
521    let event_stream = json_frames(stream, render_error).map(|frame| {
522        let event = match frame {
523            StreamFrame::Data(s) => Event::default().data(s),
524            StreamFrame::Error(body) => Event::default()
525                .event("stream-error")
526                .data(body.to_string()),
527        };
528        Ok::<Event, std::convert::Infallible>(event)
529    });
530
531    Sse::new(event_stream)
532        .keep_alive(KeepAlive::new().interval(std::time::Duration::from_secs(keep_alive_secs)))
533        .into_response()
534}
535
536/// Map the HTTP request onto the RPC's input message: path parameters, query
537/// parameters and the route's `body` rule. Unary and server-streaming routes
538/// share it, so both bind a request the same way. The error is the message of
539/// the 400 the caller answers with, before the upstream is called.
540fn decode_request(
541    entry: &RouteEntry,
542    headers: &HeaderMap,
543    path_params: &std::collections::HashMap<String, String>,
544    raw_query: Option<&str>,
545    body_bytes: &[u8],
546) -> Result<DynamicMessage, String> {
547    // Only read the body when the rule maps it onto the message.
548    let json_body = match entry.body {
549        request::BodyMapping::None => serde_json::Value::Null,
550        _ => body::parse_body(body::content_type(headers), body_bytes)
551            .map_err(|e| format!("failed to parse request body: {e}"))?,
552    };
553
554    // Query string → field bindings (fields not bound by path or body).
555    // A malformed query is a client error: reject it rather than silently
556    // dropping every query-bound field.
557    let query_pairs = request::parse_query(raw_query)?;
558
559    let input_desc = entry.method.input();
560    let request_json = request::build_request_json(
561        &input_desc,
562        &entry.body,
563        json_body,
564        path_params,
565        &query_pairs,
566    )?;
567
568    DynamicMessage::deserialize(input_desc, request_json)
569        .map_err(|e| format!("failed to decode request: {e}"))
570}
571
572/// The 400 answer to a request [`decode_request`] could not map, in the same
573/// error body the upstream's own errors get on this route.
574fn bad_request(entry: &RouteEntry, message: String) -> Response {
575    error::status_to_response_with_details(
576        &tonic::Status::invalid_argument(message),
577        entry.error_details.as_deref(),
578    )
579}
580
581/// Generic transcoding handler.
582async fn transcode_handler<S: TranscodeState>(
583    State(proxy_state): State<S>,
584    headers: HeaderMap,
585    Path(path_params): Path<std::collections::HashMap<String, String>>,
586    RawQuery(raw_query): RawQuery,
587    body_bytes: axum::body::Bytes,
588    entry: std::sync::Arc<RouteEntry>,
589) -> Response {
590    let channel = proxy_state.grpc_channel();
591
592    let request_msg = match decode_request(
593        &entry,
594        &headers,
595        &path_params,
596        raw_query.as_deref(),
597        &body_bytes,
598    ) {
599        Ok(msg) => msg,
600        Err(message) => return bad_request(&entry, message),
601    };
602
603    let grpc_metadata =
604        metadata::http_headers_to_grpc_metadata(&headers, proxy_state.forwarded_headers());
605    let mut grpc_request = tonic::Request::new(request_msg);
606    *grpc_request.metadata_mut() = grpc_metadata;
607    metadata::apply_request_deadline(&mut grpc_request, &headers);
608
609    let output_desc = entry.method.output();
610    let grpc_codec =
611        codec::DynamicCodec::with_required_check(output_desc.clone(), entry.response_has_required);
612    let grpc_path = entry.grpc_path.clone();
613
614    let mut grpc_client = Grpc::new(channel);
615    if let Err(e) = grpc_client.ready().await {
616        let status = tonic::Status::unavailable(format!("gRPC upstream not ready: {e}"));
617        return error::status_to_response_with_details(&status, entry.error_details.as_deref());
618    }
619
620    match grpc_client.unary(grpc_request, grpc_path, grpc_codec).await {
621        Ok(response) => {
622            let response_msg = response.into_inner();
623            let serialize_opts = response_serialize_options();
624            match response_msg
625                .serialize_with_options(serde_json::value::Serializer, &serialize_opts)
626            {
627                Ok(json_value) => {
628                    // `response_body` returns just that subfield as the HTTP body.
629                    let out = match &entry.response_body {
630                        Some(path) => request::extract_response_body(&json_value, path)
631                            .unwrap_or_else(|| {
632                                tracing::warn!(
633                                    response_body = %path,
634                                    "configured response_body path not found in response; \
635                                     returning null"
636                                );
637                                serde_json::Value::Null
638                            }),
639                        None => json_value,
640                    };
641                    (StatusCode::OK, Json(out)).into_response()
642                }
643                Err(e) => {
644                    tracing::error!("Failed to serialize gRPC response: {e}");
645                    error::status_to_response_with_details(
646                        &tonic::Status::internal("failed to serialize response"),
647                        entry.error_details.as_deref(),
648                    )
649                }
650            }
651        }
652        Err(status) => {
653            error::status_to_response_with_details(&status, entry.error_details.as_deref())
654        }
655    }
656}
657
658/// Extract HTTP route entries from proto descriptors.
659fn extract_routes(pool: &DescriptorPool) -> Vec<RouteEntry> {
660    let http_ext = match pool.get_extension_by_name("google.api.http") {
661        Some(ext) => ext,
662        None => {
663            tracing::warn!("google.api.http extension not found in descriptor pool");
664            return Vec::new();
665        }
666    };
667
668    let mut entries = Vec::new();
669
670    for service in pool.services() {
671        for method in service.methods() {
672            if method.is_client_streaming() || method.is_server_streaming() {
673                continue;
674            }
675
676            let grpc_path = format!("/{}/{}", service.full_name(), method.name());
677            let grpc_path: axum::http::uri::PathAndQuery = match grpc_path.parse() {
678                Ok(p) => p,
679                Err(e) => {
680                    tracing::error!("skipping route with invalid gRPC path '{grpc_path}': {e}");
681                    continue;
682                }
683            };
684
685            let response_has_required = codec::has_required_fields(&method.output());
686            for binding in extract_http_bindings(&method, &http_ext) {
687                entries.push(RouteEntry {
688                    http_path: binding.http_path,
689                    http_method: binding.http_method,
690                    grpc_path: grpc_path.clone(),
691                    method: method.clone(),
692                    body: binding.body,
693                    response_body: binding.response_body,
694                    // Decided per mounted path in `routes_with_options`.
695                    error_details: None,
696                    ndjson_envelope: false,
697                    response_has_required,
698                });
699            }
700        }
701    }
702
703    entries
704}
705
706/// Extract server-streaming HTTP route entries.
707fn extract_streaming_routes(pool: &DescriptorPool) -> Vec<RouteEntry> {
708    let http_ext = match pool.get_extension_by_name("google.api.http") {
709        Some(ext) => ext,
710        None => return Vec::new(),
711    };
712
713    let mut entries = Vec::new();
714
715    for service in pool.services() {
716        for method in service.methods() {
717            if !method.is_server_streaming() || method.is_client_streaming() {
718                continue;
719            }
720
721            let grpc_path = format!("/{}/{}", service.full_name(), method.name());
722            let grpc_path: axum::http::uri::PathAndQuery = match grpc_path.parse() {
723                Ok(p) => p,
724                Err(e) => {
725                    tracing::error!("skipping route with invalid gRPC path '{grpc_path}': {e}");
726                    continue;
727                }
728            };
729
730            let response_has_required = codec::has_required_fields(&method.output());
731            for binding in extract_http_bindings(&method, &http_ext) {
732                tracing::info!(
733                    "Registering streaming route: {} {} → {}",
734                    match binding.http_method {
735                        HttpMethod::Get => "GET",
736                        HttpMethod::Post => "POST",
737                        _ => "OTHER",
738                    },
739                    binding.http_path,
740                    grpc_path
741                );
742                entries.push(RouteEntry {
743                    http_path: binding.http_path,
744                    http_method: binding.http_method,
745                    grpc_path: grpc_path.clone(),
746                    method: method.clone(),
747                    body: binding.body,
748                    response_body: binding.response_body,
749                    // Decided per mounted path in `routes_with_options`.
750                    error_details: None,
751                    ndjson_envelope: false,
752                    response_has_required,
753                });
754            }
755        }
756    }
757
758    entries
759}
760
761/// A single HTTP binding parsed from a `google.api.http` rule.
762struct HttpBinding {
763    http_method: HttpMethod,
764    http_path: String,
765    body: request::BodyMapping,
766    response_body: Option<String>,
767}
768
769/// Extract all HTTP bindings (the primary rule plus any `additional_bindings`)
770/// from a method's `google.api.http` extension.
771fn extract_http_bindings(
772    method: &MethodDescriptor,
773    http_ext: &prost_reflect::ExtensionDescriptor,
774) -> Vec<HttpBinding> {
775    let options = method.options();
776    if !options.has_extension(http_ext) {
777        return Vec::new();
778    }
779
780    let prost_reflect::Value::Message(rule_msg) = options.get_extension(http_ext).into_owned()
781    else {
782        return Vec::new();
783    };
784
785    collect_bindings(&rule_msg)
786}
787
788/// Collect the primary binding plus every `additional_bindings` entry from an
789/// `HttpRule` message.
790fn collect_bindings(rule_msg: &DynamicMessage) -> Vec<HttpBinding> {
791    let mut bindings = Vec::new();
792    if let Some(binding) = parse_http_rule(rule_msg) {
793        bindings.push(binding);
794    }
795
796    // additional_bindings is a repeated HttpRule; each carries its own
797    // method/path/body. The proto forbids nesting them further.
798    if let Some(field) = rule_msg.get_field_by_name("additional_bindings") {
799        if let prost_reflect::Value::List(list) = field.into_owned() {
800            for item in list {
801                if let prost_reflect::Value::Message(sub) = item {
802                    if let Some(binding) = parse_http_rule(&sub) {
803                        bindings.push(binding);
804                    }
805                }
806            }
807        }
808    }
809
810    bindings
811}
812
813/// Parse a single `HttpRule` message into a binding (method+path required).
814fn parse_http_rule(rule_msg: &DynamicMessage) -> Option<HttpBinding> {
815    let (http_method, http_path) = [
816        ("get", HttpMethod::Get),
817        ("post", HttpMethod::Post),
818        ("put", HttpMethod::Put),
819        ("delete", HttpMethod::Delete),
820        ("patch", HttpMethod::Patch),
821    ]
822    .into_iter()
823    .find_map(
824        |(name, http_method)| match rule_msg.get_field_by_name(name)?.into_owned() {
825            prost_reflect::Value::String(path) if !path.is_empty() => Some((http_method, path)),
826            _ => None,
827        },
828    )?;
829
830    let body = rule_msg
831        .get_field_by_name("body")
832        .and_then(|v| match v.into_owned() {
833            prost_reflect::Value::String(s) => Some(request::BodyMapping::parse(&s)),
834            _ => None,
835        })
836        .unwrap_or(request::BodyMapping::None);
837
838    let response_body =
839        rule_msg
840            .get_field_by_name("response_body")
841            .and_then(|v| match v.into_owned() {
842                prost_reflect::Value::String(s) if !s.is_empty() => Some(s),
843                _ => None,
844            });
845
846    Some(HttpBinding {
847        http_method,
848        http_path,
849        body,
850        response_body,
851    })
852}
853
854/// Convert a `google.api.http` path template to axum 0.8 path syntax.
855///
856/// The proto `{param}` form IS axum 0.8's native capture syntax, so plain
857/// single-segment params pass through verbatim. Only field-path templates and
858/// bare wildcards need rewriting (axum 0.7 used `:param`; 0.8 uses `{param}`
859/// and rejects any segment starting with `:`):
860/// - `{name=*}`  (single segment)      -> `{name}`
861/// - `{name=**}` (multi-segment) -> `{*name}` (axum catch-all)
862/// - bare `*` segment            -> `{wildcardN}`
863/// - bare `**` segment           -> `{*wildcardN}` (axum catch-all)
864pub fn proto_path_to_axum(path: &str) -> String {
865    let mut out = String::with_capacity(path.len());
866
867    let segments = split_top_level(path);
868    let last = segments.len().saturating_sub(1);
869    for (idx, segment) in segments.iter().enumerate() {
870        if idx > 0 {
871            out.push('/');
872        }
873        out.push_str(&convert_segment(segment, idx, idx == last));
874    }
875
876    out
877}
878
879/// Split a path on `/` boundaries that are NOT inside a `{...}` brace span.
880///
881/// google.api.http field templates can embed slashes inside a single capture
882/// (e.g. the AIP-127 resource name `{name=shelves/*/books/*}`), so a naive
883/// `str::split('/')` would fracture the brace span into invalid fragments.
884/// Tracking brace depth keeps each capture intact.
885fn split_top_level(path: &str) -> Vec<&str> {
886    let mut segments = Vec::new();
887    let mut depth = 0usize;
888    let mut start = 0usize;
889
890    for (i, ch) in path.char_indices() {
891        match ch {
892            '{' => depth += 1,
893            // Decrement only on a matched brace; a stray `}` (malformed input)
894            // is treated as a literal rather than driving depth negative.
895            '}' if depth > 0 => depth -= 1,
896            '/' if depth == 0 => {
897                segments.push(&path[start..i]);
898                start = i + 1;
899            }
900            _ => {}
901        }
902    }
903    segments.push(&path[start..]);
904    segments
905}
906
907/// Convert a single top-level path segment from proto template to axum 0.8 form.
908///
909/// `is_last` indicates the terminal segment: axum permits a catch-all capture
910/// (`{*name}`) only there, so catch-alls in any other position must degrade.
911fn convert_segment(segment: &str, idx: usize, is_last: bool) -> String {
912    if let Some(inner) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
913        // Brace capture, possibly with a `name=template` field path.
914        if let Some((name, template)) = inner.split_once('=') {
915            return match template {
916                // Single-segment field path collapses to a plain capture.
917                "*" => format!("{{{name}}}"),
918                // Multi-segment catch-all maps to axum's `{*name}` (terminal only).
919                "**" => catch_all(name, is_last),
920                // Templates with interspersed literals (`{name=shelves/*/books/*}`)
921                // have no faithful axum form: axum cannot bind literal segments
922                // into one capture. Collapse to a catch-all so routing stays
923                // deterministic and the field still binds to the matched tail,
924                // and warn so the limitation surfaces instead of mis-routing.
925                _ => {
926                    tracing::warn!(
927                        template = %inner,
928                        "google.api.http multi-segment field template is not fully \
929                         supported; routing it as a catch-all capture"
930                    );
931                    catch_all(name, is_last)
932                }
933            };
934        }
935        // Plain `{name}` is already valid axum 0.8 syntax.
936        return format!("{{{inner}}}");
937    }
938
939    // Bare wildcards: name them by position so multiple wildcards never collide.
940    match segment {
941        "**" => catch_all(&format!("wildcard{idx}"), is_last),
942        "*" => format!("{{wildcard{idx}}}"),
943        literal => literal.to_string(),
944    }
945}
946
947/// Emit an axum catch-all `{*name}` when `is_last`, else degrade to a
948/// single-segment `{name}` capture.
949///
950/// axum accepts a catch-all only in the final path segment; a mid-path
951/// `{*name}` is rejected at `Router::route()`. A non-terminal catch-all comes
952/// from a malformed or unsupported google.api.http template, so we degrade
953/// (capturing one segment) and warn rather than panic the whole router.
954fn catch_all(name: &str, is_last: bool) -> String {
955    if is_last {
956        format!("{{*{name}}}")
957    } else {
958        tracing::warn!(
959            capture = %name,
960            "catch-all in a non-terminal path segment is unrepresentable in axum; \
961             degrading to a single-segment capture"
962        );
963        format!("{{{name}}}")
964    }
965}
966
967#[cfg(test)]
968mod tests;