Skip to main content

featherbit/plugins/native/
request_id.rs

1//! The `request-id` node — ensures every request carries a unique id header
2//! and optionally echoes it on the response.
3//!
4//! Port of APISIX's `request-id` plugin. featherbit implements the `uuid`
5//! algorithm (UUID v4); APISIX's `nanoid`, `range_id`, `ksuid` and `uuidv7`
6//! algorithms are not supported and are rejected at config load.
7
8use async_trait::async_trait;
9use std::collections::HashMap;
10
11use crate::context::Context;
12use crate::plugins::{Plugin, PluginOutput, PluginResult};
13use crate::vars::template::Template;
14
15/// Guarantees a request-id header on the request: when the header is absent
16/// (or empty) a fresh UUID v4 is generated and set; an id supplied by the
17/// client is kept as-is. With `include_in_response` the same id is also set
18/// on the response, unless the response already carries one.
19///
20/// This plugin never fails at execution time.
21///
22/// Note on placement: the `upstream` node replaces `context.response.headers`
23/// wholesale with the upstream's response headers. To guarantee the id on the
24/// response, place a second `request-id` node after `upstream` — it finds the
25/// request header already set, reuses the same id, and stamps it on the
26/// response.
27pub struct RequestIdPlugin {
28    /// Name of the id header (rendered per request, then lowercased).
29    /// Supports `{{namespace.path}}` template references.
30    header_name: Template,
31    /// When true, the id is echoed on the response (if not already present).
32    include_in_response: bool,
33}
34
35impl RequestIdPlugin {
36    /// Builds the plugin from node config.
37    ///
38    /// Accepted keys (all optional):
39    /// - `header_name` (string, default `X-Request-Id`): header carrying the
40    ///   id (lowercased internally, per featherbit's header convention).
41    ///   Supports `{{namespace.path}}` references.
42    /// - `include_in_response` (bool, default `true`): also set the id on the
43    ///   response when the response does not already carry the header.
44    /// - `algorithm` (string, default `uuid`): id generation algorithm. Only
45    ///   `uuid` (UUID v4) is supported; any other value (APISIX also offers
46    ///   `nanoid`, `range_id`, `ksuid`, `uuidv7`) is a config error.
47    ///
48    /// ```yaml
49    /// type: request-id
50    /// config:
51    ///   header_name: X-Request-Id
52    ///   include_in_response: true
53    ///   algorithm: uuid
54    /// ```
55    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
56        let header_name = config
57            .get("header_name")
58            .and_then(|v| v.as_str())
59            .unwrap_or("X-Request-Id");
60        // Discard warnings here — the compile-time walk (a later task)
61        // reports well-formed-but-unknown references; execution must not.
62        let header_name = Template::parse(header_name).0;
63
64        let include_in_response = config
65            .get("include_in_response")
66            .and_then(|v| v.as_bool())
67            .unwrap_or(true);
68
69        match config.get("algorithm").and_then(|v| v.as_str()) {
70            None | Some("uuid") => {}
71            Some(other) => {
72                return Err(format!(
73                    "request-id algorithm '{}' is not supported — supported: uuid",
74                    other
75                ));
76            }
77        }
78
79        Ok(Self {
80            header_name,
81            include_in_response,
82        })
83    }
84}
85
86/// Returns the first non-empty value of `header` in `headers`.
87fn first_non_empty(headers: &HashMap<String, Vec<String>>, header: &str) -> Option<String> {
88    headers
89        .get(header)
90        .and_then(|v| v.first())
91        .filter(|v| !v.is_empty())
92        .cloned()
93}
94
95#[async_trait]
96impl Plugin for RequestIdPlugin {
97    fn plugin_type(&self) -> &str {
98        "request-id"
99    }
100
101    fn reads_response_body(&self) -> bool {
102        // `header_name` is a template, so it can reference the response body
103        // even though this node otherwise only reads and writes headers.
104        self.header_name.references_response_body()
105    }
106
107    async fn execute(&self, mut ctx: Context) -> PluginResult {
108        let header_name = self.header_name.render(&ctx).to_lowercase();
109
110        // Keep a client-supplied id; generate one otherwise (APISIX rewrite phase).
111        let id = match first_non_empty(&ctx.request.headers, &header_name) {
112            Some(existing) => existing,
113            None => {
114                let id = uuid::Uuid::new_v4().to_string();
115                ctx.request
116                    .headers
117                    .insert(header_name.clone(), vec![id.clone()]);
118                id
119            }
120        };
121
122        // Echo on the response unless it already carries the header
123        // (APISIX header_filter phase).
124        if self.include_in_response
125            && first_non_empty(&ctx.response.headers, &header_name).is_none()
126        {
127            ctx.response.headers.insert(header_name, vec![id]);
128        }
129
130        Ok(PluginOutput::success(ctx))
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
138    use bytes::Bytes;
139
140    fn test_context() -> Context {
141        Context {
142            request: GatewayRequest {
143                method: "GET".to_string(),
144                path: "/".to_string(),
145                host: "localhost".to_string(),
146                scheme: "http".to_string(),
147                headers: HashMap::new(),
148                query_params: HashMap::new(),
149                body: Bytes::new(),
150                remote_addr: "127.0.0.1:12345".to_string(),
151                protocol: Protocol::Http1,
152            },
153            response: GatewayResponse {
154                status_code: 0,
155                headers: HashMap::new(),
156                body: Bytes::new(),
157                stream: None,
158            },
159            message: HashMap::new(),
160            errors: Vec::new(),
161        }
162    }
163
164    #[tokio::test]
165    async fn test_request_id_generates_uuid_when_absent() {
166        let plugin = RequestIdPlugin::from_config(&HashMap::new()).unwrap();
167        let result = plugin.execute(test_context()).await.unwrap();
168        let ctx = result.context;
169
170        let req_id = &ctx.request.headers.get("x-request-id").unwrap()[0];
171        // Must be a valid UUID
172        assert!(
173            uuid::Uuid::parse_str(req_id).is_ok(),
174            "not a uuid: {req_id}"
175        );
176        // Echoed on the response by default
177        assert_eq!(
178            ctx.response.headers.get("x-request-id"),
179            Some(&vec![req_id.clone()])
180        );
181    }
182
183    #[tokio::test]
184    async fn test_request_id_keeps_client_supplied_id() {
185        let plugin = RequestIdPlugin::from_config(&HashMap::new()).unwrap();
186        let mut ctx = test_context();
187        ctx.request
188            .headers
189            .insert("x-request-id".to_string(), vec!["client-id-1".to_string()]);
190
191        let result = plugin.execute(ctx).await.unwrap();
192        assert_eq!(
193            result.context.request.headers.get("x-request-id"),
194            Some(&vec!["client-id-1".to_string()])
195        );
196        assert_eq!(
197            result.context.response.headers.get("x-request-id"),
198            Some(&vec!["client-id-1".to_string()])
199        );
200    }
201
202    #[tokio::test]
203    async fn test_request_id_empty_header_regenerated() {
204        let plugin = RequestIdPlugin::from_config(&HashMap::new()).unwrap();
205        let mut ctx = test_context();
206        ctx.request
207            .headers
208            .insert("x-request-id".to_string(), vec!["".to_string()]);
209
210        let result = plugin.execute(ctx).await.unwrap();
211        let req_id = &result.context.request.headers.get("x-request-id").unwrap()[0];
212        assert!(uuid::Uuid::parse_str(req_id).is_ok());
213    }
214
215    #[tokio::test]
216    async fn test_request_id_custom_header_name() {
217        let mut config = HashMap::new();
218        config.insert(
219            "header_name".to_string(),
220            serde_json::json!("X-Correlation-Id"),
221        );
222        let plugin = RequestIdPlugin::from_config(&config).unwrap();
223        let result = plugin.execute(test_context()).await.unwrap();
224        assert!(result
225            .context
226            .request
227            .headers
228            .contains_key("x-correlation-id"));
229    }
230
231    #[tokio::test]
232    async fn test_request_id_include_in_response_false() {
233        let mut config = HashMap::new();
234        config.insert("include_in_response".to_string(), serde_json::json!(false));
235        let plugin = RequestIdPlugin::from_config(&config).unwrap();
236        let result = plugin.execute(test_context()).await.unwrap();
237        assert!(!result.context.response.headers.contains_key("x-request-id"));
238    }
239
240    #[tokio::test]
241    async fn test_request_id_does_not_override_existing_response_header() {
242        let plugin = RequestIdPlugin::from_config(&HashMap::new()).unwrap();
243        let mut ctx = test_context();
244        ctx.response
245            .headers
246            .insert("x-request-id".to_string(), vec!["upstream-id".to_string()]);
247
248        let result = plugin.execute(ctx).await.unwrap();
249        assert_eq!(
250            result.context.response.headers.get("x-request-id"),
251            Some(&vec!["upstream-id".to_string()])
252        );
253    }
254
255    /// TDD (Task 3): `header_name` renders `{{...}}` references, letting the
256    /// id header's own name vary per request (e.g. by tenant).
257    #[tokio::test]
258    async fn test_request_id_custom_header_name_renders_template() {
259        let mut config = HashMap::new();
260        config.insert(
261            "header_name".to_string(),
262            serde_json::json!("X-{{request.headers.x-tenant}}-Id"),
263        );
264        let plugin = RequestIdPlugin::from_config(&config).unwrap();
265        let mut ctx = test_context();
266        ctx.request
267            .headers
268            .insert("x-tenant".to_string(), vec!["acme".to_string()]);
269
270        let result = plugin.execute(ctx).await.unwrap();
271        let req_id = &result.context.request.headers.get("x-acme-id").unwrap()[0];
272        assert!(
273            uuid::Uuid::parse_str(req_id).is_ok(),
274            "not a uuid: {req_id}"
275        );
276        assert_eq!(
277            result.context.response.headers.get("x-acme-id"),
278            Some(&vec![req_id.clone()])
279        );
280    }
281
282    #[test]
283    fn test_request_id_algorithm_validation() {
284        let mut config = HashMap::new();
285        config.insert("algorithm".to_string(), serde_json::json!("uuid"));
286        assert!(RequestIdPlugin::from_config(&config).is_ok());
287
288        for unsupported in ["nanoid", "range_id", "ksuid", "uuidv7"] {
289            let mut config = HashMap::new();
290            config.insert("algorithm".to_string(), serde_json::json!(unsupported));
291            let err = RequestIdPlugin::from_config(&config).err().unwrap();
292            assert!(
293                err.contains(unsupported),
294                "error should name '{unsupported}'"
295            );
296            assert!(
297                err.contains("uuid"),
298                "error should list supported algorithms"
299            );
300        }
301    }
302
303    /// `header_name` is a template, so it can reference the response body even
304    /// though the node otherwise only touches headers.
305    #[test]
306    fn test_request_id_header_name_reading_the_body_forces_buffering() {
307        let mut config = HashMap::new();
308        config.insert(
309            "header_name".to_string(),
310            serde_json::Value::String("x-id-{{response.body}}".to_string()),
311        );
312        let p = RequestIdPlugin::from_config(&config).unwrap();
313        assert!(p.reads_response_body());
314    }
315
316    /// The default configuration must stay stream-safe.
317    #[test]
318    fn test_request_id_default_stays_stream_safe() {
319        let p = RequestIdPlugin::from_config(&HashMap::new()).unwrap();
320        assert!(!p.reads_response_body());
321    }
322}