Skip to main content

mockd/
config.rs

1//! Domain models and YAML configuration loading.
2//!
3//! This module defines the shape of a mockd configuration file:
4//!
5//! ```yaml
6//! listen: ":8080"
7//! routes:
8//!   - method: GET
9//!     path: /users/{id}
10//!     when:
11//!       query:
12//!         role: admin
13//!     response:
14//!       status: 200
15//!       body:
16//!         id: "{{path.id}}"
17//! ```
18
19use std::collections::HashMap;
20use std::path::Path;
21use std::str::FromStr;
22use std::time::Duration;
23
24use schemars::JsonSchema;
25use serde::{Deserialize, Serialize};
26use serde_json::Value;
27
28// ---------------------------------------------------------------------------
29// Method
30// ---------------------------------------------------------------------------
31
32/// Supported HTTP methods.
33///
34/// Serialized in upper-case form (`GET`, `POST`, ...) to match the way methods
35/// are written in HTTP and in the configuration file.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
37#[serde(rename_all = "UPPERCASE")]
38pub enum Method {
39    Get,
40    Post,
41    Put,
42    Patch,
43    Delete,
44}
45
46impl Method {
47    /// Parse an HTTP method string into a [`Method`].
48    ///
49    /// Returns `None` for methods that mockd does not yet support.
50    pub fn from_http_str(s: &str) -> Option<Self> {
51        match s.to_ascii_uppercase().as_str() {
52            "GET" => Some(Method::Get),
53            "POST" => Some(Method::Post),
54            "PUT" => Some(Method::Put),
55            "PATCH" => Some(Method::Patch),
56            "DELETE" => Some(Method::Delete),
57            _ => None,
58        }
59    }
60
61    /// The canonical upper-case representation of the method.
62    pub fn as_str(&self) -> &'static str {
63        match self {
64            Method::Get => "GET",
65            Method::Post => "POST",
66            Method::Put => "PUT",
67            Method::Patch => "PATCH",
68            Method::Delete => "DELETE",
69        }
70    }
71}
72
73impl FromStr for Method {
74    type Err = UnknownMethodError;
75
76    fn from_str(s: &str) -> Result<Self, Self::Err> {
77        Method::from_http_str(s).ok_or_else(|| UnknownMethodError(s.to_string()))
78    }
79}
80
81/// Error returned when a method string cannot be parsed.
82#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
83#[error("unsupported HTTP method: {0}")]
84pub struct UnknownMethodError(pub String);
85
86// ---------------------------------------------------------------------------
87// Request matching
88// ---------------------------------------------------------------------------
89
90/// Rules used to decide whether a [`Route`] matches an incoming request.
91///
92/// All fields are optional; an empty [`RequestMatch`] matches every request.
93/// Header matching is performed case-insensitively by the router.
94#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
95pub struct RequestMatch {
96    /// Query parameters that must be present with the given value.
97    #[serde(default)]
98    pub query: HashMap<String, String>,
99
100    /// Request headers that must be present with the given value.
101    ///
102    /// Matched case-insensitively.
103    #[serde(default)]
104    pub headers: HashMap<String, String>,
105
106    /// A JSON value that must be a subset of the request body.
107    ///
108    /// Subset matching means: every field of a JSON object in `body` must be
109    /// present (and equal) in the request body. Arrays must match element by
110    /// element and have the same length. Scalar values use equality.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub body: Option<Value>,
113}
114
115// ---------------------------------------------------------------------------
116// Response
117// ---------------------------------------------------------------------------
118
119/// How a matched request should be answered.
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
121pub struct ResponseConfig {
122    /// HTTP status code. Defaults to `200`.
123    #[serde(default = "default_status")]
124    pub status: u16,
125
126    /// Response headers.
127    #[serde(default)]
128    pub headers: HashMap<String, String>,
129
130    /// Response body. Rendered as JSON.
131    ///
132    /// May contain template expressions such as `{{path.id}}` (see the
133    /// [`template`](crate::template) module).
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub body: Option<Value>,
136
137    /// Optional artificial delay before the response is sent.
138    ///
139    /// Parsed from human-friendly durations, e.g. `2s`, `250ms`, `1m 30s`.
140    #[serde(
141        default,
142        with = "duration_option",
143        skip_serializing_if = "Option::is_none"
144    )]
145    #[schemars(with = "Option<String>")]
146    pub delay: Option<Duration>,
147
148    /// When `true`, the server signals that the connection should be closed
149    /// after the response (by sending the `Connection: close` header).
150    #[serde(default)]
151    pub close_connection: bool,
152}
153
154impl Default for ResponseConfig {
155    fn default() -> Self {
156        ResponseConfig {
157            status: default_status(),
158            headers: HashMap::new(),
159            body: None,
160            delay: None,
161            close_connection: false,
162        }
163    }
164}
165
166fn default_status() -> u16 {
167    200
168}
169
170// ---------------------------------------------------------------------------
171// Response spec: a single response or a sequence of responses
172// ---------------------------------------------------------------------------
173
174/// Either a single response or an ordered sequence of responses.
175///
176/// A route's `response` field accepts either shape via YAML:
177///
178/// ```yaml
179/// # Single response (the existing form).
180/// response:
181///   status: 200
182///   body: { ok: true }
183///
184/// # Sequence: each call advances to the next response.
185/// # After the last one is reached, the last response is repeated forever.
186/// response:
187///   sequence:
188///     - status: 500
189///     - status: 500
190///     - status: 200
191///       body: { ok: true }
192/// ```
193///
194/// Sequence responses are useful for testing retry, polling and pagination
195/// logic in clients.
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
197#[serde(untagged)]
198pub enum ResponseSpec {
199    /// A response sequence. Each match advances to the next item; the last
200    /// item is sticky (repeated on every subsequent call).
201    Sequence {
202        /// The ordered responses.
203        sequence: Vec<ResponseConfig>,
204    },
205    /// A single static response.
206    Single(ResponseConfig),
207}
208
209impl ResponseSpec {
210    /// Flatten this spec into the underlying list of responses.
211    ///
212    /// `Single(r)` becomes `vec![r]`; `Sequence { sequence }` is returned as-is.
213    pub fn into_responses(self) -> Vec<ResponseConfig> {
214        match self {
215            ResponseSpec::Single(r) => vec![r],
216            ResponseSpec::Sequence { sequence } => sequence,
217        }
218    }
219}
220
221impl Default for ResponseSpec {
222    fn default() -> Self {
223        ResponseSpec::Single(ResponseConfig::default())
224    }
225}
226
227// ---------------------------------------------------------------------------
228// Route
229// ---------------------------------------------------------------------------
230
231/// A single mock route.
232///
233/// A route is selected when its HTTP `method` and `path` match the request,
234/// and (optionally) the [`RequestMatch`] rules in `when` are satisfied.
235#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
236pub struct Route {
237    /// HTTP method for this route.
238    pub method: Method,
239
240    /// Path pattern, e.g. `/users/{id}`.
241    ///
242    /// Segments wrapped in curly braces are captured as path parameters.
243    pub path: String,
244
245    /// Optional additional request matching rules.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub when: Option<RequestMatch>,
248
249    /// Response (single or sequence) produced when the route matches.
250    #[serde(default)]
251    pub response: ResponseSpec,
252}
253
254// ---------------------------------------------------------------------------
255// Top-level config file
256// ---------------------------------------------------------------------------
257
258/// Top-level configuration file.
259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
260pub struct Config {
261    /// Socket address to listen on, e.g. `":8080"` or `"127.0.0.1:9000"`.
262    #[serde(default = "default_listen")]
263    pub listen: String,
264
265    /// Mock routes, evaluated in declaration order (first match wins).
266    #[serde(default)]
267    pub routes: Vec<Route>,
268}
269
270impl Default for Config {
271    fn default() -> Self {
272        Config {
273            listen: default_listen(),
274            routes: Vec::new(),
275        }
276    }
277}
278
279fn default_listen() -> String {
280    ":8080".to_string()
281}
282
283impl Config {
284    /// Parse a configuration from a YAML string.
285    pub fn parse(input: &str) -> Result<Self, ConfigError> {
286        serde_yaml::from_str(input).map_err(ConfigError::from)
287    }
288
289    /// Load and parse a configuration from a file.
290    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
291        let contents = std::fs::read_to_string(path.as_ref()).map_err(ConfigError::Read)?;
292        Self::parse(&contents)
293    }
294    /// Save JSON schema
295    pub fn write_config_schema(path: &Path) -> anyhow::Result<()> {
296        let schema = schemars::schema_for!(Config);
297        let mut json = serde_json::to_string_pretty(&schema)?;
298        json.push('\n');
299
300        std::fs::create_dir_all(path)?;
301        let schema_path = path.join("schema.json");
302        std::fs::write(schema_path, json)?;
303
304        Ok(())
305    }
306}
307
308/// Errors that can occur while loading a configuration.
309#[derive(Debug, thiserror::Error)]
310pub enum ConfigError {
311    /// The file could not be read.
312    #[error("could not read config file: {0}")]
313    Read(#[source] std::io::Error),
314
315    /// The YAML could not be parsed into a [`Config`].
316    #[error("could not parse config: {0}")]
317    Parse(#[from] serde_yaml::Error),
318}
319
320// ---------------------------------------------------------------------------
321// Duration serde helper (kept private to this module)
322// ---------------------------------------------------------------------------
323
324/// Serde adapter for `Option<Duration>` using human-friendly encoding
325/// (`2s`, `250ms`, `1m 30s`, ...).
326mod duration_option {
327    use super::*;
328
329    pub fn serialize<S>(value: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
330    where
331        S: serde::Serializer,
332    {
333        match value {
334            None => serializer.serialize_none(),
335            Some(d) => serializer.serialize_str(&humantime::format_duration(*d).to_string()),
336        }
337    }
338
339    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
340    where
341        D: serde::Deserializer<'de>,
342    {
343        let opt: Option<String> = Option::deserialize(deserializer)?;
344        match opt {
345            None => Ok(None),
346            Some(raw) => humantime::parse_duration(&raw)
347                .map(Some)
348                .map_err(serde::de::Error::custom),
349        }
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn method_round_trip_uppercase() {
359        let yaml = "GET";
360        let method: Method = serde_yaml::from_str(yaml).unwrap();
361        assert_eq!(method, Method::Get);
362
363        let back: String = serde_yaml::to_string(&method).unwrap();
364        assert!(back.contains("GET"));
365    }
366
367    #[test]
368    fn method_from_http_str_is_case_insensitive() {
369        assert_eq!(Method::from_http_str("get"), Some(Method::Get));
370        assert_eq!(Method::from_http_str("Delete"), Some(Method::Delete));
371        assert_eq!(Method::from_http_str("FOO"), None);
372    }
373
374    #[test]
375    fn empty_uses_defaults() {
376        let cfg = Config::parse("").unwrap();
377        assert_eq!(cfg.listen, ":8080");
378        assert!(cfg.routes.is_empty());
379    }
380
381    #[test]
382    fn parses_full_example() {
383        let yaml = r#"
384listen: ":9090"
385routes:
386  - method: GET
387    path: /users/{id}
388    when:
389      query:
390        role: admin
391    response:
392      status: 200
393      delay: 2s
394      body:
395        id: "{{path.id}}"
396"#;
397        let cfg = Config::parse(yaml).unwrap();
398        assert_eq!(cfg.listen, ":9090");
399        assert_eq!(cfg.routes.len(), 1);
400        let route = &cfg.routes[0];
401        assert_eq!(route.method, Method::Get);
402        assert_eq!(route.path, "/users/{id}");
403        assert_eq!(
404            route.when.as_ref().unwrap().query.get("role").unwrap(),
405            "admin"
406        );
407        let resp = match &route.response {
408            ResponseSpec::Single(r) => r,
409            ResponseSpec::Sequence { .. } => panic!("expected Single"),
410        };
411        assert_eq!(resp.status, 200);
412        assert_eq!(resp.delay, Some(Duration::from_secs(2)));
413    }
414
415    #[test]
416    fn invalid_yaml_is_rejected() {
417        let yaml = "listen: :8080\n  routes: [broken\n";
418        assert!(Config::parse(yaml).is_err());
419    }
420
421    #[test]
422    fn unknown_method_is_rejected() {
423        let yaml = "routes:\n  - method: FOO\n    path: /x\n";
424        assert!(Config::parse(yaml).is_err());
425    }
426
427    #[test]
428    fn round_trip_keeps_listen_and_routes() {
429        let yaml = r#"
430listen: ":8080"
431routes:
432  - method: POST
433    path: /items
434    response:
435      status: 201
436"#;
437        let cfg = Config::parse(yaml).unwrap();
438        let reserialized = serde_yaml::to_string(&cfg).unwrap();
439        let cfg2 = Config::parse(&reserialized).unwrap();
440        assert_eq!(cfg, cfg2);
441    }
442
443    #[test]
444    fn missing_file_errors() {
445        let err = Config::from_file("/nonexistent/path/to/config.yaml").unwrap_err();
446        assert!(matches!(err, ConfigError::Read(_)));
447    }
448
449    #[test]
450    fn parses_sequence_response() {
451        let yaml = r#"
452routes:
453  - method: GET
454    path: /flaky
455    response:
456      sequence:
457        - status: 500
458        - status: 200
459          body:
460            ok: true
461"#;
462        let cfg = Config::parse(yaml).unwrap();
463        let route = &cfg.routes[0];
464        match &route.response {
465            ResponseSpec::Sequence { sequence } => {
466                assert_eq!(sequence.len(), 2);
467                assert_eq!(sequence[0].status, 500);
468                assert_eq!(sequence[1].status, 200);
469                assert_eq!(
470                    sequence[1].body,
471                    Some(Value::Object(serde_json::Map::from_iter([(
472                        "ok".to_string(),
473                        Value::Bool(true)
474                    )])))
475                );
476            }
477            other => panic!("expected Sequence, got {other:?}"),
478        }
479    }
480
481    #[test]
482    fn parses_single_response_by_default() {
483        // Same shape as before the sequence feature; must still parse as Single.
484        let yaml = r#"
485routes:
486  - method: GET
487    path: /health
488    response:
489      status: 200
490      body:
491        ok: true
492"#;
493        let cfg = Config::parse(yaml).unwrap();
494        assert!(matches!(cfg.routes[0].response, ResponseSpec::Single(_)));
495    }
496
497    #[test]
498    fn sequence_round_trip() {
499        let yaml = r#"
500routes:
501  - method: GET
502    path: /retry
503    response:
504      sequence:
505        - status: 500
506        - status: 200
507"#;
508        let cfg = Config::parse(yaml).unwrap();
509        let reserialized = serde_yaml::to_string(&cfg).unwrap();
510        let cfg2 = Config::parse(&reserialized).unwrap();
511        assert_eq!(cfg, cfg2);
512    }
513
514    /// Guard against the committed schema drifting from the Rust types.
515    #[test]
516    fn schema_does_not_drift() {
517        let schema = schemars::schema_for!(Config);
518        let mut actual = serde_json::to_string_pretty(&schema).unwrap();
519        actual.push('\n');
520        let expected = std::fs::read_to_string("docs/schema.json")
521            .expect("docs/schema.json is missing; run `cargo run -- generate`");
522        assert_eq!(actual, expected);
523    }
524}