1use 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#[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 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 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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
83#[error("unsupported HTTP method: {0}")]
84pub struct UnknownMethodError(pub String);
85
86#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
95pub struct RequestMatch {
96 #[serde(default)]
98 pub query: HashMap<String, String>,
99
100 #[serde(default)]
104 pub headers: HashMap<String, String>,
105
106 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub body: Option<Value>,
113}
114
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
121pub struct ResponseConfig {
122 #[serde(default = "default_status")]
124 pub status: u16,
125
126 #[serde(default)]
128 pub headers: HashMap<String, String>,
129
130 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub body: Option<Value>,
136
137 #[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 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
197#[serde(untagged)]
198pub enum ResponseSpec {
199 Sequence {
202 sequence: Vec<ResponseConfig>,
204 },
205 Single(ResponseConfig),
207}
208
209impl ResponseSpec {
210 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
236pub struct Route {
237 pub method: Method,
239
240 pub path: String,
244
245 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub when: Option<RequestMatch>,
248
249 #[serde(default)]
251 pub response: ResponseSpec,
252}
253
254#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
260pub struct Config {
261 #[serde(default = "default_listen")]
263 pub listen: String,
264
265 #[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 pub fn parse(input: &str) -> Result<Self, ConfigError> {
286 serde_yaml::from_str(input).map_err(ConfigError::from)
287 }
288
289 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 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#[derive(Debug, thiserror::Error)]
310pub enum ConfigError {
311 #[error("could not read config file: {0}")]
313 Read(#[source] std::io::Error),
314
315 #[error("could not parse config: {0}")]
317 Parse(#[from] serde_yaml::Error),
318}
319
320mod 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 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 #[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}