featherbit/plugins/native/
request_id.rs1use 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
15pub struct RequestIdPlugin {
28 header_name: Template,
31 include_in_response: bool,
33}
34
35impl RequestIdPlugin {
36 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 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
86fn 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 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 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 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 assert!(
173 uuid::Uuid::parse_str(req_id).is_ok(),
174 "not a uuid: {req_id}"
175 );
176 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 #[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 #[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 #[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}