Skip to main content

tork_core/
hooks.rs

1//! Request-lifecycle observability hooks and their event contexts.
2//!
3//! Hooks are observe-only callbacks the application registers to watch the
4//! request lifecycle for logging, metrics, or tracing. They receive an event
5//! context carrying request metadata (and, where relevant, the response status,
6//! elapsed time, error, or panic message) and cannot alter the response.
7//!
8//! The contexts own a snapshot of their data so that a hook may move the context
9//! into a `'static` future. None of them expose the request or response body.
10
11use std::sync::Arc;
12use std::time::Duration;
13
14use http::{Method, StatusCode};
15
16use crate::error::ErrorDetail;
17
18/// Request metadata shared by every hook event.
19///
20/// Built once per request and cloned into each event that fires for it.
21#[derive(Clone, Debug)]
22pub(crate) struct RequestInfo(Arc<RequestInfoInner>);
23
24#[derive(Debug)]
25struct RequestInfoInner {
26    method: Method,
27    path: Arc<str>,
28    route: Option<Arc<str>>,
29    request_id: Option<Arc<str>>,
30}
31
32impl RequestInfo {
33    /// Creates request metadata for the current request.
34    pub(crate) fn new(
35        method: Method,
36        path: Arc<str>,
37        route: Option<Arc<str>>,
38        request_id: Option<Arc<str>>,
39    ) -> Self {
40        Self(Arc::new(RequestInfoInner {
41            method,
42            path,
43            route,
44            request_id,
45        }))
46    }
47
48    pub(crate) fn method(&self) -> &Method {
49        &self.0.method
50    }
51
52    pub(crate) fn path(&self) -> &str {
53        self.0.path.as_ref()
54    }
55
56    pub(crate) fn route(&self) -> Option<&str> {
57        self.0.route.as_deref()
58    }
59
60    pub(crate) fn request_id(&self) -> Option<&str> {
61        self.0.request_id.as_deref()
62    }
63}
64
65/// Generates the request-metadata accessors shared by every event context.
66macro_rules! shared_accessors {
67    ($t:ty) => {
68        impl $t {
69            /// The HTTP method of the request.
70            pub fn method(&self) -> &Method {
71                &self.info.0.method
72            }
73
74            /// The request path (the concrete URI path, not the route pattern).
75            pub fn path(&self) -> &str {
76                self.info.0.path.as_ref()
77            }
78
79            /// The matched route pattern (for example `/users/{id}`), if routing
80            /// resolved one.
81            pub fn route(&self) -> Option<&str> {
82                self.info.0.route.as_deref()
83            }
84
85            /// The request identifier (the `x-request-id` value), if present.
86            pub fn request_id(&self) -> Option<&str> {
87                self.info.0.request_id.as_deref()
88            }
89        }
90    };
91}
92
93/// Context for [`on_request`](crate::App::on_request): a request has arrived.
94pub struct RequestEvent {
95    info: RequestInfo,
96}
97
98impl RequestEvent {
99    pub(crate) fn new(info: RequestInfo) -> Self {
100        Self { info }
101    }
102}
103
104shared_accessors!(RequestEvent);
105
106/// Context for [`on_response`](crate::App::on_response): a response is ready.
107pub struct ResponseEvent {
108    info: RequestInfo,
109    status: StatusCode,
110    elapsed: Duration,
111}
112
113impl ResponseEvent {
114    pub(crate) fn new(info: RequestInfo, status: StatusCode, elapsed: Duration) -> Self {
115        Self {
116            info,
117            status,
118            elapsed,
119        }
120    }
121
122    /// The status code of the response being returned.
123    pub fn status(&self) -> StatusCode {
124        self.status
125    }
126
127    /// How long the request took, measured from the start of handling.
128    pub fn elapsed(&self) -> Duration {
129        self.elapsed
130    }
131}
132
133shared_accessors!(ResponseEvent);
134
135/// Context for [`on_error`](crate::App::on_error): a non-validation error was
136/// produced.
137pub struct ErrorEvent {
138    info: RequestInfo,
139    status: StatusCode,
140    code: &'static str,
141    message: String,
142}
143
144impl ErrorEvent {
145    pub(crate) fn new(
146        info: RequestInfo,
147        status: StatusCode,
148        code: &'static str,
149        message: String,
150    ) -> Self {
151        Self {
152            info,
153            status,
154            code,
155            message,
156        }
157    }
158
159    /// The HTTP status the error renders to.
160    pub fn status(&self) -> StatusCode {
161        self.status
162    }
163
164    /// The machine-readable error code (for example `NOT_FOUND`).
165    pub fn code(&self) -> &str {
166        self.code
167    }
168
169    /// The server-side error message (not necessarily what the client receives).
170    pub fn message(&self) -> &str {
171        &self.message
172    }
173}
174
175shared_accessors!(ErrorEvent);
176
177/// Context for [`on_validation_error`](crate::App::on_validation_error): a
178/// request body failed validation (`422`).
179pub struct ValidationErrorEvent {
180    info: RequestInfo,
181    details: Vec<ErrorDetail>,
182}
183
184impl ValidationErrorEvent {
185    pub(crate) fn new(info: RequestInfo, details: Vec<ErrorDetail>) -> Self {
186        Self { info, details }
187    }
188
189    /// The field-level validation failures.
190    pub fn details(&self) -> &[ErrorDetail] {
191        &self.details
192    }
193}
194
195shared_accessors!(ValidationErrorEvent);
196
197/// Context for [`on_panic`](crate::App::on_panic): a handler panicked and the
198/// panic was caught by the panic boundary.
199pub struct PanicEvent {
200    info: RequestInfo,
201    message: String,
202}
203
204impl PanicEvent {
205    pub(crate) fn new(info: RequestInfo, message: String) -> Self {
206        Self { info, message }
207    }
208
209    /// The panic payload rendered as text.
210    pub fn message(&self) -> &str {
211        &self.message
212    }
213}
214
215shared_accessors!(PanicEvent);
216
217/// Context passed to an [`exception_handler`](crate::App::exception_handler)
218/// alongside the recovered typed error.
219pub struct ErrorContext {
220    info: RequestInfo,
221}
222
223impl ErrorContext {
224    pub(crate) fn new(info: RequestInfo) -> Self {
225        Self { info }
226    }
227}
228
229shared_accessors!(ErrorContext);
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    fn info() -> RequestInfo {
236        RequestInfo::new(
237            Method::GET,
238            Arc::from("/users/7"),
239            Some(Arc::from("/users/{id}")),
240            Some(Arc::from("req-1")),
241        )
242    }
243
244    #[test]
245    fn shared_accessors_expose_request_metadata() {
246        let event = RequestEvent::new(info());
247        assert_eq!(event.method(), Method::GET);
248        assert_eq!(event.path(), "/users/7");
249        assert_eq!(event.route(), Some("/users/{id}"));
250        assert_eq!(event.request_id(), Some("req-1"));
251    }
252
253    #[test]
254    fn response_event_carries_status_and_elapsed() {
255        let event = ResponseEvent::new(info(), StatusCode::OK, Duration::from_millis(5));
256        assert_eq!(event.status(), StatusCode::OK);
257        assert_eq!(event.elapsed(), Duration::from_millis(5));
258    }
259
260    #[test]
261    fn error_event_carries_status_code_and_message() {
262        let event = ErrorEvent::new(
263            info(),
264            StatusCode::NOT_FOUND,
265            "NOT_FOUND",
266            "missing".to_owned(),
267        );
268        assert_eq!(event.status(), StatusCode::NOT_FOUND);
269        assert_eq!(event.code(), "NOT_FOUND");
270        assert_eq!(event.message(), "missing");
271    }
272
273    #[test]
274    fn validation_event_carries_details() {
275        let event = ValidationErrorEvent::new(
276            info(),
277            vec![ErrorDetail::new("name", "TOO_SHORT", "too short")],
278        );
279        assert_eq!(event.details().len(), 1);
280        assert_eq!(event.details()[0].field, "name");
281    }
282
283    #[test]
284    fn panic_event_carries_message() {
285        let event = PanicEvent::new(info(), "boom".to_owned());
286        assert_eq!(event.message(), "boom");
287    }
288
289    #[test]
290    fn error_context_exposes_route() {
291        let ctx = ErrorContext::new(info());
292        assert_eq!(ctx.route(), Some("/users/{id}"));
293    }
294}