1use 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#[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 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 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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
82#[error("unsupported HTTP method: {0}")]
83pub struct UnknownMethodError(pub String);
84
85#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
94pub struct RequestMatch {
95 #[serde(default)]
97 pub query: HashMap<String, String>,
98
99 #[serde(default)]
103 pub headers: HashMap<String, String>,
104
105 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub body: Option<Value>,
112}
113
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120pub struct ResponseConfig {
121 #[serde(default = "default_status")]
123 pub status: u16,
124
125 #[serde(default)]
127 pub headers: HashMap<String, String>,
128
129 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub body: Option<Value>,
135
136 #[serde(
140 default,
141 with = "duration_option",
142 skip_serializing_if = "Option::is_none"
143 )]
144 pub delay: Option<Duration>,
145
146 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195#[serde(untagged)]
196pub enum ResponseSpec {
197 Sequence {
200 sequence: Vec<ResponseConfig>,
202 },
203 Single(ResponseConfig),
205}
206
207impl ResponseSpec {
208 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234pub struct Route {
235 pub method: Method,
237
238 pub path: String,
242
243 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub when: Option<RequestMatch>,
246
247 #[serde(default)]
249 pub response: ResponseSpec,
250}
251
252#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
258pub struct Config {
259 #[serde(default = "default_listen")]
261 pub listen: String,
262
263 #[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 pub fn parse(input: &str) -> Result<Self, ConfigError> {
284 serde_yaml::from_str(input).map_err(ConfigError::from)
285 }
286
287 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#[derive(Debug, thiserror::Error)]
296pub enum ConfigError {
297 #[error("could not read config file: {0}")]
299 Read(#[source] std::io::Error),
300
301 #[error("could not parse config: {0}")]
303 Parse(#[from] serde_yaml::Error),
304}
305
306mod 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 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}