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::interpolate;
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 {
36 delay: Duration,
37 response_status: u16,
38 content_type: String,
39 response_example: String,
41 response_headers: Vec<(String, String)>,
43 with_mock_header: bool,
44}
45
46impl MockingPlugin {
47 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
76 if config.contains_key("response_schema") {
77 return Err(
78 "mocking: 'response_schema' (random body generation) is not implemented — \
79 use 'response_example' instead"
80 .to_string(),
81 );
82 }
83
84 let response_example = config
85 .get("response_example")
86 .and_then(|v| v.as_str())
87 .ok_or("mocking requires 'response_example' (string body)")?
88 .to_string();
89
90 let response_status = config
91 .get("response_status")
92 .map(|v| {
93 v.as_u64()
94 .filter(|s| (100..=599).contains(s))
95 .ok_or("response_status must be an integer 100-599")
96 })
97 .transpose()?
98 .unwrap_or(200) as u16;
99
100 let content_type = config
101 .get("content_type")
102 .map(|v| {
103 v.as_str()
104 .map(String::from)
105 .ok_or("content_type must be a string")
106 })
107 .transpose()?
108 .unwrap_or_else(|| "application/json;charset=utf8".to_string());
109 let base_type = content_type.split(';').next().unwrap_or("").trim();
110 if !SUPPORTED_CONTENT_TYPES.contains(&base_type) {
111 return Err(format!(
112 "unsupported content type '{}' — supported: {}",
113 content_type,
114 SUPPORTED_CONTENT_TYPES.join(", ")
115 ));
116 }
117
118 let response_headers = match config.get("response_headers") {
119 None => Vec::new(),
120 Some(v) => parse_headers(v)?,
121 };
122
123 let with_mock_header = config
124 .get("with_mock_header")
125 .map(|v| v.as_bool().ok_or("with_mock_header must be a boolean"))
126 .transpose()?
127 .unwrap_or(true);
128
129 let delay = config
130 .get("delay")
131 .map(|v| {
132 v.as_f64()
133 .filter(|d| *d >= 0.0 && d.is_finite())
134 .ok_or("delay must be a non-negative number of seconds")
135 })
136 .transpose()?
137 .unwrap_or(0.0);
138
139 Ok(Self {
140 delay: Duration::from_secs_f64(delay),
141 response_status,
142 content_type,
143 response_example,
144 response_headers,
145 with_mock_header,
146 })
147 }
148}
149
150fn parse_headers(v: &serde_json::Value) -> Result<Vec<(String, String)>, String> {
154 let scalar = |v: &serde_json::Value| -> Option<String> {
155 match v {
156 serde_json::Value::String(s) => Some(s.clone()),
157 serde_json::Value::Number(n) => Some(n.to_string()),
158 serde_json::Value::Bool(b) => Some(b.to_string()),
159 _ => None,
160 }
161 };
162 match v {
163 serde_json::Value::Object(m) => m
164 .iter()
165 .map(|(k, v)| {
166 scalar(v)
167 .map(|s| (k.to_lowercase(), s))
168 .ok_or_else(|| format!("response_headers['{k}'] must be a scalar value"))
169 })
170 .collect(),
171 serde_json::Value::Array(items) => {
172 let mut out = Vec::new();
173 for item in items {
174 let obj = item
175 .as_object()
176 .ok_or("response_headers entries must be objects with 'name' and 'value'")?;
177 let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("");
178 if name.trim().is_empty() {
179 continue; }
181 let value = match obj.get("value") {
182 None => String::new(),
183 Some(v) => scalar(v).ok_or_else(|| {
184 format!("response_headers['{name}'] must be a scalar value")
185 })?,
186 };
187 out.push((name.to_lowercase(), value));
188 }
189 Ok(out)
190 }
191 _ => Err(
192 "response_headers must be a map of name: value or a list of {name, value} objects"
193 .to_string(),
194 ),
195 }
196}
197
198#[async_trait]
199impl Plugin for MockingPlugin {
200 fn plugin_type(&self) -> &str {
201 "mocking"
202 }
203
204 async fn execute(
205 &self,
206 mut ctx: Context,
207 _named_inputs: &HashMap<String, serde_json::Value>,
208 ) -> PluginResult {
209 if !self.delay.is_zero() {
210 tokio::time::sleep(self.delay).await;
211 }
212
213 let body = interpolate(&ctx, &self.response_example);
214 let headers: Vec<(String, String)> = self
215 .response_headers
216 .iter()
217 .map(|(name, tmpl)| (name.clone(), interpolate(&ctx, tmpl)))
218 .collect();
219
220 ctx.response.status_code = self.response_status;
221 ctx.response.body = Bytes::from(body);
222 ctx.response
223 .headers
224 .insert("content-type".to_string(), vec![self.content_type.clone()]);
225 if self.with_mock_header {
226 ctx.response.headers.insert(
227 "x-mock-by".to_string(),
228 vec!["featherbit-mocking".to_string()],
229 );
230 }
231 for (name, value) in headers {
232 ctx.response.headers.insert(name, vec![value]);
233 }
234
235 Ok(PluginOutput {
236 context: ctx,
237 named_outputs: HashMap::new(),
238 })
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
246 use std::time::Instant;
247
248 fn test_ctx() -> Context {
249 let mut query = HashMap::new();
250 query.insert("name".to_string(), vec!["jack".to_string()]);
251 Context {
252 request: GatewayRequest {
253 method: "GET".to_string(),
254 path: "/api/users".to_string(),
255 host: "example.com".to_string(),
256 scheme: "http".to_string(),
257 headers: HashMap::new(),
258 query_params: query,
259 body: Bytes::new(),
260 remote_addr: "10.1.2.3:44321".to_string(),
261 protocol: Protocol::Http1,
262 },
263 response: GatewayResponse {
264 status_code: 0,
265 headers: HashMap::new(),
266 body: Bytes::new(),
267 },
268 message: HashMap::new(),
269 errors: Vec::new(),
270 }
271 }
272
273 fn plugin(config: serde_json::Value) -> Result<MockingPlugin, String> {
274 let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
275 MockingPlugin::from_config(&map)
276 }
277
278 #[tokio::test]
279 async fn test_mock_response_shape() {
280 let p = plugin(serde_json::json!({
281 "response_status": 201,
282 "response_example": r#"{"user": "$arg_name", "path": "$uri"}"#,
283 "response_headers": { "X-Mock-Env": "staging" }
284 }))
285 .unwrap();
286
287 let out = p.execute(test_ctx(), &HashMap::new()).await.unwrap();
288 let resp = out.context.response;
289 assert_eq!(resp.status_code, 201);
290 assert_eq!(
291 resp.body,
292 Bytes::from(r#"{"user": "jack", "path": "/api/users"}"#)
293 );
294 assert_eq!(
295 resp.headers.get("content-type"),
296 Some(&vec!["application/json;charset=utf8".to_string()])
297 );
298 assert_eq!(
299 resp.headers.get("x-mock-by"),
300 Some(&vec!["featherbit-mocking".to_string()])
301 );
302 assert_eq!(
303 resp.headers.get("x-mock-env"),
304 Some(&vec!["staging".to_string()])
305 );
306 }
307
308 #[tokio::test]
309 async fn test_defaults_and_mock_header_disabled() {
310 let p = plugin(serde_json::json!({
311 "response_example": "hello",
312 "content_type": "text/plain",
313 "with_mock_header": false
314 }))
315 .unwrap();
316 let out = p.execute(test_ctx(), &HashMap::new()).await.unwrap();
317 let resp = out.context.response;
318 assert_eq!(resp.status_code, 200);
319 assert_eq!(resp.body, Bytes::from("hello"));
320 assert_eq!(
321 resp.headers.get("content-type"),
322 Some(&vec!["text/plain".to_string()])
323 );
324 assert!(!resp.headers.contains_key("x-mock-by"));
325 }
326
327 #[tokio::test]
328 async fn test_delay_sleeps_before_responding() {
329 let p = plugin(serde_json::json!({
330 "response_example": "{}",
331 "delay": 0.05
332 }))
333 .unwrap();
334 let start = Instant::now();
335 p.execute(test_ctx(), &HashMap::new()).await.unwrap();
336 assert!(start.elapsed() >= Duration::from_millis(45));
337 }
338
339 #[test]
340 fn test_config_errors() {
341 assert!(plugin(serde_json::json!({})).is_err());
343 assert!(plugin(serde_json::json!({
345 "response_schema": { "type": "object" }
346 }))
347 .is_err());
348 assert!(plugin(serde_json::json!({
349 "response_example": "{}",
350 "response_schema": { "type": "object" }
351 }))
352 .is_err());
353 assert!(plugin(serde_json::json!({
355 "response_example": "{}",
356 "content_type": "application/octet-stream"
357 }))
358 .is_err());
359 assert!(plugin(serde_json::json!({
361 "response_example": "{}",
362 "response_status": 42
363 }))
364 .is_err());
365 assert!(plugin(serde_json::json!({
367 "response_example": "{}",
368 "delay": -1
369 }))
370 .is_err());
371 }
372}