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