Skip to main content

cratestack_axum/rpc/
grpc_bridge.rs

1//! Bridges a macro-generated `_dispatch` fn's `axum::response::Response`
2//! into a value a `transport grpc` service method can hand back to tonic —
3//! ticket #171's "no second dispatch path" requirement: gRPC method bodies
4//! call the exact same `handle_*_dispatch` fns REST/RPC already call, then
5//! use this module to turn the resulting `Response` into either the
6//! decoded domain value or the REST-style `(code, message)` pair
7//! `cratestack_grpc::cool_error_code_to_tonic_code` maps to a
8//! `tonic::Status`.
9//!
10//! Lives here (not in `cratestack-grpc`) because it needs `axum::Response`,
11//! `HttpTransport`, and [`decode_rpc_body`] — this crate already owns all
12//! three; `cratestack-grpc` deliberately stays axum-agnostic (its own
13//! module docs: "runtime primitives... prost/tonic re-exports", no axum
14//! dependency). `cratestack-grpc`'s error module supplies the code->status
15//! mapping; this function only needs to get the caller to a plain
16//! `(code, message)` pair, not `tonic::Status` itself, so this crate
17//! doesn't need a `tonic` dependency either.
18
19use axum::http::HeaderMap;
20use axum::response::Response;
21use cratestack_core::CoolErrorResponse;
22use serde::de::DeserializeOwned;
23
24use super::codec_helpers::decode_rpc_body;
25use crate::transport::HttpTransport;
26
27/// `headers` must be the *same* `HeaderMap` passed to the dispatch call
28/// that produced `response` — no `Content-Type`/`Accept` set, so content
29/// negotiation resolves to the same default codec on both the dispatch
30/// side and this decode side (mirrors [`decode_rpc_body`]'s own
31/// `DEFAULT_CONTENT_TYPE` fallback, reused unchanged here).
32///
33/// `Ok(value)` on a 2xx dispatch response, decoded straight to `T`.
34/// `Err((code, message))` otherwise — either the dispatch response's own
35/// `CoolErrorResponse` (its `code` is `cratestack_core::CoolError::code()`'s
36/// screaming-snake vocabulary, e.g. `"NOT_FOUND"`), or, if buffering /
37/// decoding the response body itself fails, a synthesized `"INTERNAL_ERROR"`.
38/// Callers map `code` to a `tonic::Status` via
39/// `cratestack_grpc::cool_error_code_to_tonic_code`.
40pub async fn bridge_grpc_response<C, T>(
41    response: Response,
42    codec: &C,
43    headers: &HeaderMap,
44) -> Result<T, (String, String)>
45where
46    C: HttpTransport,
47    T: DeserializeOwned,
48{
49    let status = response.status();
50    let body_bytes = match axum::body::to_bytes(response.into_body(), usize::MAX).await {
51        Ok(bytes) => bytes,
52        Err(error) => {
53            return Err((
54                "INTERNAL_ERROR".to_owned(),
55                format!("failed to buffer dispatch response: {error}"),
56            ));
57        }
58    };
59
60    if status.is_success() {
61        decode_rpc_body::<_, T>(codec, headers, &body_bytes)
62            .map_err(|error| (error.code().to_owned(), error.public_message().into_owned()))
63    } else {
64        match decode_rpc_body::<_, CoolErrorResponse>(codec, headers, &body_bytes) {
65            Ok(parsed) => Err((parsed.code, parsed.message)),
66            Err(error) => Err((error.code().to_owned(), error.public_message().into_owned())),
67        }
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use axum::http::StatusCode;
74    use cratestack_core::CoolError;
75    use serde::{Deserialize, Serialize};
76
77    use super::*;
78    use crate::transport::encode_transport_result_with_status_for;
79
80    #[derive(Debug, Serialize, Deserialize, PartialEq)]
81    struct Widget {
82        name: String,
83    }
84
85    fn capabilities() -> cratestack_core::RouteTransportCapabilities {
86        cratestack_core::RouteTransportCapabilities {
87            request_types: &["application/cbor"],
88            response_types: &["application/cbor"],
89            default_response_type: "application/cbor",
90            supports_sequence_response: false,
91        }
92    }
93
94    #[tokio::test]
95    async fn success_response_decodes_to_domain_value() {
96        let codec = cratestack_codec_cbor::CborCodec;
97        let headers = HeaderMap::new();
98        let response = encode_transport_result_with_status_for(
99            &codec,
100            &headers,
101            &capabilities(),
102            StatusCode::OK,
103            Ok::<_, CoolError>(Widget {
104                name: "gizmo".to_owned(),
105            }),
106        );
107
108        let result: Result<Widget, _> = bridge_grpc_response(response, &codec, &headers).await;
109        assert_eq!(
110            result.unwrap(),
111            Widget {
112                name: "gizmo".to_owned()
113            }
114        );
115    }
116
117    #[tokio::test]
118    async fn error_response_decodes_to_cool_error_code_and_message() {
119        let codec = cratestack_codec_cbor::CborCodec;
120        let headers = HeaderMap::new();
121        let response = encode_transport_result_with_status_for::<_, Widget>(
122            &codec,
123            &headers,
124            &capabilities(),
125            StatusCode::OK,
126            Err(CoolError::NotFound("widget not found".to_owned())),
127        );
128
129        let result: Result<Widget, _> = bridge_grpc_response(response, &codec, &headers).await;
130        let (code, message) = result.unwrap_err();
131        assert_eq!(code, "NOT_FOUND");
132        assert_eq!(message, "widget not found");
133    }
134}