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