featherbit/plugins/native/
mocking.rs1use async_trait::async_trait;
15use bytes::Bytes;
16use std::collections::HashMap;
17use std::time::Duration;
18
19use crate::context::Context;
20use crate::plugins::{Plugin, PluginOutput, PluginResult};
21use crate::vars::template::Template;
22
23const SUPPORTED_CONTENT_TYPES: [&str; 5] = [
26 "application/xml",
27 "application/json",
28 "text/plain",
29 "text/html",
30 "text/xml",
31];
32
33pub struct MockingPlugin {
37 delay: Duration,
38 response_status: u16,
39 content_type: Template,
46 response_example: Template,
49 response_headers: Vec<(String, Template)>,
51 with_mock_header: bool,
52}
53
54impl MockingPlugin {
55 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
88 if config.contains_key("response_schema") {
89 return Err(
90 "mocking: 'response_schema' (random body generation) is not implemented — \
91 use 'response_example' instead"
92 .to_string(),
93 );
94 }
95
96 let response_example = config
97 .get("response_example")
98 .and_then(|v| v.as_str())
99 .ok_or("mocking requires 'response_example' (string body)")?;
100 let response_example = Template::parse(response_example).0;
103
104 let response_status = config
105 .get("response_status")
106 .map(|v| {
107 v.as_u64()
108 .filter(|s| (100..=599).contains(s))
109 .ok_or("response_status must be an integer 100-599")
110 })
111 .transpose()?
112 .unwrap_or(200) as u16;
113
114 let content_type = config
115 .get("content_type")
116 .map(|v| {
117 v.as_str()
118 .map(String::from)
119 .ok_or("content_type must be a string")
120 })
121 .transpose()?
122 .unwrap_or_else(|| "application/json;charset=utf8".to_string());
123 let content_type_tpl = Template::parse(&content_type).0;
126 if content_type_tpl.is_literal() {
132 let base_type = content_type.split(';').next().unwrap_or("").trim();
133 if !SUPPORTED_CONTENT_TYPES.contains(&base_type) {
134 return Err(format!(
135 "unsupported content type '{}' — supported: {}",
136 content_type,
137 SUPPORTED_CONTENT_TYPES.join(", ")
138 ));
139 }
140 }
141 let content_type = content_type_tpl;
142
143 let response_headers = match config.get("response_headers") {
144 None => Vec::new(),
145 Some(v) => parse_headers(v)?
146 .into_iter()
147 .map(|(name, value)| (name, Template::parse(&value).0))
148 .collect(),
149 };
150
151 let with_mock_header = config
152 .get("with_mock_header")
153 .map(|v| v.as_bool().ok_or("with_mock_header must be a boolean"))
154 .transpose()?
155 .unwrap_or(true);
156
157 let delay = config
158 .get("delay")
159 .map(|v| {
160 v.as_f64()
161 .filter(|d| *d >= 0.0 && d.is_finite())
162 .ok_or("delay must be a non-negative number of seconds")
163 })
164 .transpose()?
165 .unwrap_or(0.0);
166
167 Ok(Self {
168 delay: Duration::from_secs_f64(delay),
169 response_status,
170 content_type,
171 response_example,
172 response_headers,
173 with_mock_header,
174 })
175 }
176}
177
178fn parse_headers(v: &serde_json::Value) -> Result<Vec<(String, String)>, String> {
182 let scalar = |v: &serde_json::Value| -> Option<String> {
183 match v {
184 serde_json::Value::String(s) => Some(s.clone()),
185 serde_json::Value::Number(n) => Some(n.to_string()),
186 serde_json::Value::Bool(b) => Some(b.to_string()),
187 _ => None,
188 }
189 };
190 match v {
191 serde_json::Value::Object(m) => m
192 .iter()
193 .map(|(k, v)| {
194 scalar(v)
195 .map(|s| (k.to_lowercase(), s))
196 .ok_or_else(|| format!("response_headers['{k}'] must be a scalar value"))
197 })
198 .collect(),
199 serde_json::Value::Array(items) => {
200 let mut out = Vec::new();
201 for item in items {
202 let obj = item
203 .as_object()
204 .ok_or("response_headers entries must be objects with 'name' and 'value'")?;
205 let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("");
206 if name.trim().is_empty() {
207 continue; }
209 let value = match obj.get("value") {
210 None => String::new(),
211 Some(v) => scalar(v).ok_or_else(|| {
212 format!("response_headers['{name}'] must be a scalar value")
213 })?,
214 };
215 out.push((name.to_lowercase(), value));
216 }
217 Ok(out)
218 }
219 _ => Err(
220 "response_headers must be a map of name: value or a list of {name, value} objects"
221 .to_string(),
222 ),
223 }
224}
225
226#[async_trait]
227impl Plugin for MockingPlugin {
228 fn plugin_type(&self) -> &str {
229 "mocking"
230 }
231
232 async fn execute(&self, mut ctx: Context) -> PluginResult {
233 if !self.delay.is_zero() {
234 tokio::time::sleep(self.delay).await;
235 }
236
237 let body = self.response_example.render_with_legacy(&ctx);
238 let headers: Vec<(String, String)> = self
239 .response_headers
240 .iter()
241 .map(|(name, tmpl)| (name.clone(), tmpl.render_with_legacy(&ctx)))
242 .collect();
243
244 ctx.response.status_code = self.response_status;
245 ctx.response.body = Bytes::from(body);
246 ctx.response.headers.insert(
247 "content-type".to_string(),
248 vec![self.content_type.render(&ctx).into_owned()],
249 );
250 if self.with_mock_header {
251 ctx.response.headers.insert(
252 "x-mock-by".to_string(),
253 vec!["featherbit-mocking".to_string()],
254 );
255 }
256 for (name, value) in headers {
257 ctx.response.headers.insert(name, vec![value]);
258 }
259
260 Ok(PluginOutput::success(ctx))
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
268 use std::time::Instant;
269
270 fn test_ctx() -> Context {
271 let mut query = HashMap::new();
272 query.insert("name".to_string(), vec!["jack".to_string()]);
273 Context {
274 request: GatewayRequest {
275 method: "GET".to_string(),
276 path: "/api/users".to_string(),
277 host: "example.com".to_string(),
278 scheme: "http".to_string(),
279 headers: HashMap::new(),
280 query_params: query,
281 body: Bytes::new(),
282 remote_addr: "10.1.2.3:44321".to_string(),
283 protocol: Protocol::Http1,
284 },
285 response: GatewayResponse {
286 status_code: 0,
287 headers: HashMap::new(),
288 body: Bytes::new(),
289 stream: None,
290 },
291 message: HashMap::new(),
292 errors: Vec::new(),
293 }
294 }
295
296 fn plugin(config: serde_json::Value) -> Result<MockingPlugin, String> {
297 let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
298 MockingPlugin::from_config(&map)
299 }
300
301 #[tokio::test]
302 async fn test_mock_response_shape() {
303 let p = plugin(serde_json::json!({
304 "response_status": 201,
305 "response_example": r#"{"user": "$arg_name", "path": "$uri"}"#,
306 "response_headers": { "X-Mock-Env": "staging" }
307 }))
308 .unwrap();
309
310 let out = p.execute(test_ctx()).await.unwrap();
311 let resp = out.context.response;
312 assert_eq!(resp.status_code, 201);
313 assert_eq!(
314 resp.body,
315 Bytes::from(r#"{"user": "jack", "path": "/api/users"}"#)
316 );
317 assert_eq!(
318 resp.headers.get("content-type"),
319 Some(&vec!["application/json;charset=utf8".to_string()])
320 );
321 assert_eq!(
322 resp.headers.get("x-mock-by"),
323 Some(&vec!["featherbit-mocking".to_string()])
324 );
325 assert_eq!(
326 resp.headers.get("x-mock-env"),
327 Some(&vec!["staging".to_string()])
328 );
329 }
330
331 #[tokio::test]
332 async fn test_mock_superset_template_and_legacy_dollar() {
333 let p = plugin(serde_json::json!({
337 "response_example": "{{request.method}} $uri",
338 "response_headers": { "X-Combo": "{{request.host}}-$uri" }
339 }))
340 .unwrap();
341
342 let out = p.execute(test_ctx()).await.unwrap();
343 let resp = out.context.response;
344 assert_eq!(resp.body, Bytes::from("GET /api/users"));
345 assert_eq!(
346 resp.headers.get("x-combo"),
347 Some(&vec!["example.com-/api/users".to_string()])
348 );
349 }
350
351 #[tokio::test]
352 async fn test_content_type_renders_template() {
353 let p = plugin(serde_json::json!({
354 "response_example": "{}",
355 "content_type": "text/plain;charset={{request.headers.x-charset}}"
356 }))
357 .unwrap();
358 let mut ctx = test_ctx();
359 ctx.request
360 .headers
361 .insert("x-charset".to_string(), vec!["utf-16".to_string()]);
362 let out = p.execute(ctx).await.unwrap();
363 assert_eq!(
364 out.context.response.headers.get("content-type"),
365 Some(&vec!["text/plain;charset=utf-16".to_string()])
366 );
367 }
368
369 #[tokio::test]
370 async fn test_defaults_and_mock_header_disabled() {
371 let p = plugin(serde_json::json!({
372 "response_example": "hello",
373 "content_type": "text/plain",
374 "with_mock_header": false
375 }))
376 .unwrap();
377 let out = p.execute(test_ctx()).await.unwrap();
378 let resp = out.context.response;
379 assert_eq!(resp.status_code, 200);
380 assert_eq!(resp.body, Bytes::from("hello"));
381 assert_eq!(
382 resp.headers.get("content-type"),
383 Some(&vec!["text/plain".to_string()])
384 );
385 assert!(!resp.headers.contains_key("x-mock-by"));
386 }
387
388 #[tokio::test]
389 async fn test_delay_sleeps_before_responding() {
390 let p = plugin(serde_json::json!({
391 "response_example": "{}",
392 "delay": 0.05
393 }))
394 .unwrap();
395 let start = Instant::now();
396 p.execute(test_ctx()).await.unwrap();
397 assert!(start.elapsed() >= Duration::from_millis(45));
398 }
399
400 #[test]
401 fn test_config_errors() {
402 assert!(plugin(serde_json::json!({})).is_err());
404 assert!(plugin(serde_json::json!({
406 "response_schema": { "type": "object" }
407 }))
408 .is_err());
409 assert!(plugin(serde_json::json!({
410 "response_example": "{}",
411 "response_schema": { "type": "object" }
412 }))
413 .is_err());
414 assert!(plugin(serde_json::json!({
416 "response_example": "{}",
417 "content_type": "application/octet-stream"
418 }))
419 .is_err());
420 assert!(plugin(serde_json::json!({
422 "response_example": "{}",
423 "response_status": 42
424 }))
425 .is_err());
426 assert!(plugin(serde_json::json!({
428 "response_example": "{}",
429 "delay": -1
430 }))
431 .is_err());
432 }
433}