Skip to main content

rskit_server/
error_layer.rs

1//! Tower layer that intercepts gRPC responses
2//! and enriches error statuses with structured [`rskit_errors::AppError`] details.
3//!
4//! Applied automatically by [`GrpcServerBuilder`]
5//! so service implementations can use `rskit_grpc::app_error_to_status`
6//! and the canonical RFC 9457 JSON error body is always present in the status details.
7
8use std::future::Future;
9use std::pin::Pin;
10use std::task::{Context, Poll};
11
12use http::{Request, Response};
13use tonic::body::Body;
14use tower::{Layer, Service};
15
16use rskit_errors::{AppError, ProblemDetail};
17use rskit_grpc::grpc_code_to_error_code;
18
19// ── Layer ────────────────────────────────────────────────────────────────────
20
21/// A [`Layer`] that wraps each gRPC service to ensure every error `tonic::Status` carries structured RFC 9457 JSON details.
22#[derive(Debug, Clone, Default)]
23pub struct ErrorLayer;
24
25impl ErrorLayer {
26    /// Create a new `ErrorLayer`.
27    pub fn new() -> Self {
28        Self
29    }
30}
31
32impl<S> Layer<S> for ErrorLayer {
33    type Service = ErrorService<S>;
34
35    fn layer(&self, inner: S) -> Self::Service {
36        ErrorService { inner }
37    }
38}
39
40// ── Service ──────────────────────────────────────────────────────────────────
41
42/// The [`Service`] produced by [`ErrorLayer`].
43///
44/// It forwards requests to the inner service and,
45/// if the response indicates a gRPC error (via the `grpc-status` header) without structured details,
46/// it enriches the trailers with canonical RFC 9457 error JSON.
47#[derive(Debug, Clone)]
48pub struct ErrorService<S> {
49    inner: S,
50}
51
52impl<S: tonic::server::NamedService> tonic::server::NamedService for ErrorService<S> {
53    const NAME: &'static str = S::NAME;
54}
55
56impl<S> Service<Request<Body>> for ErrorService<S>
57where
58    S: Service<Request<Body>, Response = Response<Body>> + Clone + Send + 'static,
59    S::Future: Send + 'static,
60    S::Error: Send + 'static,
61{
62    type Response = S::Response;
63    type Error = S::Error;
64    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
65
66    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
67        self.inner.poll_ready(cx)
68    }
69
70    fn call(&mut self, req: Request<Body>) -> Self::Future {
71        let mut inner = self.inner.clone();
72        Box::pin(async move {
73            let response = inner.call(req).await?;
74            Ok(enrich_error_response(response))
75        })
76    }
77}
78
79/// If the response carries a `grpc-status` header indicating an error
80/// but no structured details in `grpc-status-details-bin`, synthesise them from the status code
81/// and message.
82fn enrich_error_response(response: Response<Body>) -> Response<Body> {
83    // Only process error responses (grpc-status != 0)
84    let status_code = response
85        .headers()
86        .get("grpc-status")
87        .and_then(|v| v.to_str().ok())
88        .and_then(|v| v.parse::<i32>().ok())
89        .unwrap_or(0);
90
91    if status_code == 0 {
92        return response;
93    }
94
95    // If details are already present, leave the response as-is —
96    // the service already attached them through rskit-grpc status mapping.
97    if response.headers().contains_key("grpc-status-details-bin") {
98        return response;
99    }
100
101    // Synthesise RFC 9457 ProblemDetail from the raw code + message.
102    let grpc_message = response
103        .headers()
104        .get("grpc-message")
105        .and_then(|v| v.to_str().ok())
106        .unwrap_or("unknown error")
107        .to_string();
108
109    let code = tonic::Code::from_i32(status_code);
110    let error_code = grpc_code_to_error_code(code);
111    let app_err = AppError::new(error_code, grpc_message);
112    let problem = ProblemDetail::from(&app_err);
113
114    if let Ok(json_bytes) = serde_json::to_vec(&problem) {
115        let encoded = base64_encode(&json_bytes);
116        let (mut parts, body) = response.into_parts();
117        if let Ok(val) = http::HeaderValue::from_str(&encoded) {
118            parts.headers.insert("grpc-status-details-bin", val);
119        }
120        Response::from_parts(parts, body)
121    } else {
122        response
123    }
124}
125
126/// Base64-encode bytes (standard base64, no padding trimming).
127fn base64_encode(data: &[u8]) -> String {
128    use base64::Engine;
129    base64::engine::general_purpose::STANDARD.encode(data)
130}
131
132// ── Tests ────────────────────────────────────────────────────────────────────
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use rskit_errors::ErrorCode;
138
139    #[test]
140    fn enrich_skips_ok_response() {
141        let resp = Response::builder()
142            .header("grpc-status", "0")
143            .body(Body::default())
144            .unwrap();
145        let result = enrich_error_response(resp);
146        assert!(!result.headers().contains_key("grpc-status-details-bin"));
147    }
148
149    #[test]
150    fn enrich_skips_when_details_already_present() {
151        let resp = Response::builder()
152            .header("grpc-status", "5")
153            .header("grpc-status-details-bin", "existing")
154            .body(Body::default())
155            .unwrap();
156        let result = enrich_error_response(resp);
157        assert_eq!(
158            result.headers().get("grpc-status-details-bin").unwrap(),
159            "existing"
160        );
161    }
162
163    #[test]
164    fn enrich_adds_problem_detail_for_error_without_details() {
165        let resp = Response::builder()
166            .header("grpc-status", "5")
167            .header("grpc-message", "user not found")
168            .body(Body::default())
169            .unwrap();
170        let result = enrich_error_response(resp);
171        assert!(result.headers().contains_key("grpc-status-details-bin"));
172
173        let encoded = result
174            .headers()
175            .get("grpc-status-details-bin")
176            .unwrap()
177            .to_str()
178            .unwrap();
179        use base64::Engine;
180        let decoded = base64::engine::general_purpose::STANDARD
181            .decode(encoded)
182            .unwrap();
183        let pd: ProblemDetail = serde_json::from_slice(&decoded).unwrap();
184        assert_eq!(pd.code, ErrorCode::NotFound);
185        assert_eq!(pd.detail, "user not found");
186        assert_eq!(pd.status, 404);
187        assert!(pd.error_type.ends_with("not-found"));
188    }
189
190    #[test]
191    fn enrich_handles_missing_grpc_status_header() {
192        let resp = Response::builder().body(Body::default()).unwrap();
193        let result = enrich_error_response(resp);
194        assert!(!result.headers().contains_key("grpc-status-details-bin"));
195    }
196
197    #[test]
198    fn error_layer_default_and_new_are_equivalent() {
199        let _l1 = ErrorLayer;
200        let _l2 = ErrorLayer::new();
201    }
202
203    #[tokio::test]
204    async fn layer_wraps_service_and_enriches_error_response() {
205        use tower::ServiceExt;
206
207        let inner = tower::service_fn(|_req: Request<Body>| async {
208            Ok::<_, std::convert::Infallible>(
209                Response::builder()
210                    .header("grpc-status", "14")
211                    .header("grpc-message", "backend unavailable")
212                    .body(Body::default())
213                    .unwrap(),
214            )
215        });
216        let service = ErrorLayer::new().layer(inner);
217
218        let result = service
219            .oneshot(Request::new(Body::default()))
220            .await
221            .expect("service response");
222
223        assert!(result.headers().contains_key("grpc-status-details-bin"));
224    }
225}