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};
13
14pub struct RequestIdPlugin {
27 header_name: String,
29 include_in_response: bool,
31}
32
33impl RequestIdPlugin {
34 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
81fn 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 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 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 assert!(
171 uuid::Uuid::parse_str(req_id).is_ok(),
172 "not a uuid: {req_id}"
173 );
174 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}