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::template::Template;
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 `{{namespace.path}}`
35/// references plus legacy `$var` interpolation.
36pub struct MockingPlugin {
37    delay: Duration,
38    response_status: u16,
39    /// Supports `{{namespace.path}}` references (no legacy `$var`
40    /// interpolation — mocking's `content_type` never supported it, so this
41    /// sweep must not start). The `supported_content_type` allowlist below is
42    /// only enforced when the configured value is a literal (no `{{...}}`
43    /// references) — a templated value is resolved per request and cannot be
44    /// checked at load time.
45    content_type: Template,
46    /// Body template: supports `{{namespace.path}}` references and legacy
47    /// `$var` interpolation (see [`Template::render_with_legacy`]).
48    response_example: Template,
49    /// Lowercased header name → value template.
50    response_headers: Vec<(String, Template)>,
51    with_mock_header: bool,
52}
53
54impl MockingPlugin {
55    /// Builds the plugin from node config.
56    ///
57    /// Accepted keys:
58    /// - `response_example` (string, **required**): response body; supports
59    ///   `{{namespace.path}}` references plus legacy `$var` interpolation
60    ///   (e.g. `$uri`, `$arg_name`).
61    /// - `response_status` (integer >= 100, default `200`).
62    /// - `content_type` (string, default `application/json;charset=utf8`):
63    ///   supports `{{namespace.path}}` references; a **literal** value's base
64    ///   type must be one of `application/json`, `application/xml`,
65    ///   `text/plain`, `text/html`, `text/xml` (a templated value is resolved
66    ///   per request and is not checked at load time).
67    /// - `response_headers` (map `{name: value}` or array `[{name, value}]`):
68    ///   extra response headers; string values support `{{namespace.path}}`
69    ///   references plus legacy `$var` interpolation.
70    /// - `with_mock_header` (bool, default `true`): adds
71    ///   `x-mock-by: featherbit-mocking`.
72    /// - `delay` (number, seconds, may be fractional, default `0`): sleep
73    ///   before responding.
74    /// - `response_schema`: **not implemented** — rejected at config load
75    ///   (deviation from APISIX).
76    ///
77    /// ```yaml
78    /// type: mocking
79    /// config:
80    ///   response_status: 200
81    ///   content_type: application/json;charset=utf8
82    ///   response_example: '{"user": "$arg_name", "path": "$uri"}'
83    ///   response_headers:
84    ///     x-mock-env: staging
85    ///   delay: 0.2
86    /// ```
87    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        // Discard warnings here — the compile-time walk (a later task)
101        // reports well-formed-but-unknown references; execution must not.
102        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        // Discard warnings here — the compile-time walk (a later task)
124        // reports well-formed-but-unknown references; execution must not.
125        let content_type_tpl = Template::parse(&content_type).0;
126        // The allowlist can only be checked against a literal value — a
127        // templated content type is resolved per request, and a literal
128        // template's raw config string is exactly what it renders (no refs
129        // means every `{{...}}` occurrence, if any, already passed through
130        // unchanged).
131        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
178/// Accepts `response_headers` as a map (`{name: value}`) or as the UI
179/// editor's array form (`[{name, value}]`); scalar values are stringified,
180/// other shapes are rejected. Header names are lowercased.
181fn 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; // blank row left in the UI editor
208                }
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        // `response_example` and `response_headers` values must render both
334        // the new `{{...}}` template syntax and the legacy `$var` syntax in
335        // the same value (superset behavior).
336        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        // response_example is required.
403        assert!(plugin(serde_json::json!({})).is_err());
404        // response_schema is an explicit deviation: rejected at load.
405        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        // Unsupported content type.
415        assert!(plugin(serde_json::json!({
416            "response_example": "{}",
417            "content_type": "application/octet-stream"
418        }))
419        .is_err());
420        // Bad status.
421        assert!(plugin(serde_json::json!({
422            "response_example": "{}",
423            "response_status": 42
424        }))
425        .is_err());
426        // Negative delay.
427        assert!(plugin(serde_json::json!({
428            "response_example": "{}",
429            "delay": -1
430        }))
431        .is_err());
432    }
433}