Skip to main content

featherbit/plugins/native/
mocking.rs

1//! Mocking plugin (`mocking`) — a faithful subset of Apache APISIX's
2//! `mocking` plugin: responds with a configured mock instead of proxying
3//! upstream. Useful for stubbing APIs during development and testing.
4//!
5//! **Terminal wiring**: a mocking node always answers the request itself, so
6//! it returns `Ok` with the mock written onto `Context.response` — wire its
7//! **success** port straight to `client.in` (never to an `upstream`). The
8//! `error` port is never taken.
9//!
10//! Deviation from APISIX: `response_schema` (random body generation from a
11//! JSON schema) is not implemented — configs that set it are rejected at
12//! load, so `response_example` is required.
13
14use 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
23/// Content types the plugin accepts (matched on the part before `;`),
24/// mirroring APISIX's `support_content_type`.
25const SUPPORTED_CONTENT_TYPES: [&str; 5] = [
26    "application/xml",
27    "application/json",
28    "text/plain",
29    "text/html",
30    "text/xml",
31];
32
33/// Builds a mock response: optional delay, then status + headers + body.
34/// The body and string header values support `$var` interpolation.
35pub struct MockingPlugin {
36    delay: Duration,
37    response_status: u16,
38    content_type: String,
39    /// Body template (`$var` interpolated per request).
40    response_example: String,
41    /// Lowercased header name → value template.
42    response_headers: Vec<(String, String)>,
43    with_mock_header: bool,
44}
45
46impl MockingPlugin {
47    /// Builds the plugin from node config.
48    ///
49    /// Accepted keys:
50    /// - `response_example` (string, **required**): response body; supports
51    ///   `$var` interpolation (e.g. `$uri`, `$arg_name`).
52    /// - `response_status` (integer >= 100, default `200`).
53    /// - `content_type` (string, default `application/json;charset=utf8`):
54    ///   base type must be one of `application/json`, `application/xml`,
55    ///   `text/plain`, `text/html`, `text/xml`.
56    /// - `response_headers` (map `{name: value}` or array `[{name, value}]`):
57    ///   extra response headers; string values support `$var` interpolation.
58    /// - `with_mock_header` (bool, default `true`): adds
59    ///   `x-mock-by: featherbit-mocking`.
60    /// - `delay` (number, seconds, may be fractional, default `0`): sleep
61    ///   before responding.
62    /// - `response_schema`: **not implemented** — rejected at config load
63    ///   (deviation from APISIX).
64    ///
65    /// ```yaml
66    /// type: mocking
67    /// config:
68    ///   response_status: 200
69    ///   content_type: application/json;charset=utf8
70    ///   response_example: '{"user": "$arg_name", "path": "$uri"}'
71    ///   response_headers:
72    ///     x-mock-env: staging
73    ///   delay: 0.2
74    /// ```
75    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
150/// Accepts `response_headers` as a map (`{name: value}`) or as the UI
151/// editor's array form (`[{name, value}]`); scalar values are stringified,
152/// other shapes are rejected. Header names are lowercased.
153fn 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; // blank row left in the UI editor
180                }
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        // response_example is required.
342        assert!(plugin(serde_json::json!({})).is_err());
343        // response_schema is an explicit deviation: rejected at load.
344        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        // Unsupported content type.
354        assert!(plugin(serde_json::json!({
355            "response_example": "{}",
356            "content_type": "application/octet-stream"
357        }))
358        .is_err());
359        // Bad status.
360        assert!(plugin(serde_json::json!({
361            "response_example": "{}",
362            "response_status": 42
363        }))
364        .is_err());
365        // Negative delay.
366        assert!(plugin(serde_json::json!({
367            "response_example": "{}",
368            "delay": -1
369        }))
370        .is_err());
371    }
372}