1use std::collections::{BTreeSet, HashMap};
20use std::path::{Path, PathBuf};
21
22use jsonschema::error::ValidationErrorKind;
23use jsonschema::Validator;
24use serde_json::Value;
25use waf_core::{Config, Decision, Phase, RequestContext, ScoreItem, Severity, WafModule};
26
27#[derive(Debug, Clone)]
31pub struct ApiSchemaEnforcementConfig {
32 pub enabled: bool,
34 pub paths: Vec<String>,
38 pub spec_path: PathBuf,
41 pub unknown_route_severity: Severity,
44 pub violation_severity: Severity,
48 pub strict_params: bool,
50 pub strict_body: bool,
53}
54
55impl Default for ApiSchemaEnforcementConfig {
56 fn default() -> Self {
57 Self {
58 enabled: false,
59 paths: vec!["/api".to_string()],
60 spec_path: PathBuf::new(),
61 unknown_route_severity: Severity::Critical,
62 violation_severity: Severity::Warning,
63 strict_params: true,
64 strict_body: true,
65 }
66 }
67}
68
69#[derive(Debug, Clone)]
73pub struct LoadedSpec {
74 pub doc: Value,
77 pub is_3_0: bool,
80 pub router: Router,
83}
84
85pub fn parse_spec(json: &str) -> Result<LoadedSpec, String> {
89 oas3::from_json(json).map_err(|e| format!("invalid OpenAPI spec: {e}"))?;
91 let doc: Value = serde_json::from_str(json).map_err(|e| format!("spec is not valid JSON: {e}"))?;
92 let is_3_0 = doc
93 .get("openapi")
94 .and_then(Value::as_str)
95 .is_some_and(|v| v.starts_with("3.0"));
96 let router = Router::build(&doc);
97 Ok(LoadedSpec { doc, is_3_0, router })
98}
99
100pub fn load_spec(path: &Path) -> Result<LoadedSpec, String> {
103 let json = std::fs::read_to_string(path)
104 .map_err(|e| format!("cannot read OpenAPI spec {}: {e}", path.display()))?;
105 parse_spec(&json)
106}
107
108pub fn normalize_schema_3_0(v: &mut Value) {
114 match v {
115 Value::Object(map) => {
116 if map.get("nullable") == Some(&Value::Bool(true)) {
118 if let Some(t) = map.get_mut("type") {
119 add_null_to_type(t);
120 }
121 }
122 map.remove("nullable");
123 fix_exclusive_bound(map, "exclusiveMinimum", "minimum");
126 fix_exclusive_bound(map, "exclusiveMaximum", "maximum");
127 for child in map.values_mut() {
128 normalize_schema_3_0(child);
129 }
130 }
131 Value::Array(items) => {
132 for child in items {
133 normalize_schema_3_0(child);
134 }
135 }
136 _ => {}
137 }
138}
139
140fn add_null_to_type(t: &mut Value) {
142 let null = Value::String("null".to_string());
143 match t {
144 Value::String(s) => {
145 *t = Value::Array(vec![Value::String(std::mem::take(s)), null]);
146 }
147 Value::Array(arr) if !arr.contains(&null) => arr.push(null),
148 _ => {}
149 }
150}
151
152fn fix_exclusive_bound(map: &mut serde_json::Map<String, Value>, excl: &str, bound: &str) {
156 if let Some(Value::Bool(is_exclusive)) = map.get(excl).cloned() {
157 map.remove(excl);
158 if is_exclusive {
159 if let Some(bound_val) = map.remove(bound) {
160 map.insert(excl.to_string(), bound_val);
161 }
162 }
163 }
164}
165
166const HTTP_METHODS: &[&str] = &["get", "put", "post", "delete", "options", "head", "patch", "trace"];
174
175#[derive(Debug, Clone, PartialEq, Eq)]
177enum Seg {
178 Static(String),
180 Param(String),
182}
183
184#[derive(Debug, Clone)]
186struct Route {
187 template: String,
188 segments: Vec<Seg>,
189 methods: std::collections::BTreeSet<String>,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
194pub enum Resolution {
195 UnknownRoute,
197 MethodNotAllowed,
199 Matched {
201 template: String,
203 path_params: Vec<(String, String)>,
205 },
206}
207
208#[derive(Debug, Clone, Default)]
210pub struct Router {
211 routes: Vec<Route>,
213}
214
215impl Router {
216 pub fn build(doc: &Value) -> Self {
219 let mut routes = Vec::new();
220 if let Some(paths) = doc.get("paths").and_then(Value::as_object) {
221 for (template, item) in paths {
222 let Some(item) = item.as_object() else { continue };
223 let methods: std::collections::BTreeSet<String> = HTTP_METHODS
224 .iter()
225 .filter(|m| item.contains_key(**m))
226 .map(|m| (*m).to_string())
227 .collect();
228 if methods.is_empty() {
229 continue; }
231 routes.push(Route { segments: parse_template(template), template: template.clone(), methods });
232 }
233 }
234 routes.sort_by(|a, b| {
237 specificity(&a.segments).cmp(&specificity(&b.segments)).then(a.template.cmp(&b.template))
238 });
239 Self { routes }
240 }
241
242 pub fn resolve(&self, path: &str, method: &str) -> Resolution {
244 let req = split_segments(path);
245 let m = method.to_ascii_lowercase();
246 for route in &self.routes {
247 if let Some(path_params) = route.match_path(&req) {
248 return if route.methods.contains(&m) {
250 Resolution::Matched { template: route.template.clone(), path_params }
251 } else {
252 Resolution::MethodNotAllowed
253 };
254 }
255 }
256 Resolution::UnknownRoute
257 }
258}
259
260impl Route {
261 fn match_path(&self, req: &[&str]) -> Option<Vec<(String, String)>> {
263 if self.segments.len() != req.len() {
264 return None;
265 }
266 let mut params = Vec::new();
267 for (seg, val) in self.segments.iter().zip(req) {
268 match seg {
269 Seg::Static(s) if s == val => {}
270 Seg::Static(_) => return None,
271 Seg::Param(_) if val.is_empty() => return None,
272 Seg::Param(name) => params.push((name.clone(), (*val).to_string())),
273 }
274 }
275 Some(params)
276 }
277}
278
279fn split_segments(path: &str) -> Vec<&str> {
282 let path = path.split(['?', '#']).next().unwrap_or(path);
283 path.split('/').filter(|s| !s.is_empty()).collect()
284}
285
286fn parse_template(template: &str) -> Vec<Seg> {
289 split_segments(template)
290 .into_iter()
291 .map(|s| match s.strip_prefix('{').and_then(|inner| inner.strip_suffix('}')) {
292 Some(name) if !name.is_empty() => Seg::Param(name.to_string()),
293 _ => Seg::Static(s.to_string()),
294 })
295 .collect()
296}
297
298fn specificity(segs: &[Seg]) -> Vec<u8> {
300 segs.iter().map(|s| u8::from(matches!(s, Seg::Param(_)))).collect()
301}
302
303#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
313pub enum Violation {
314 UnknownParam,
316 MissingRequired,
318 TypeViolation,
320 UnknownProperty,
322 ContentType,
324 BodyParseError,
326}
327
328impl Violation {
329 pub fn rule(self) -> crate::rules::Rule {
331 use crate::rules;
332 match self {
333 Violation::UnknownParam => rules::API_UNKNOWN_PARAM,
334 Violation::MissingRequired => rules::API_MISSING_REQUIRED,
335 Violation::TypeViolation => rules::API_TYPE_VIOLATION,
336 Violation::UnknownProperty => rules::API_UNKNOWN_PROPERTY,
337 Violation::ContentType => rules::API_CONTENT_TYPE,
338 Violation::BodyParseError => rules::API_BODY_PARSE_ERROR,
339 }
340 }
341}
342
343pub struct RequestParts<'a> {
345 pub path_params: &'a [(String, String)],
347 pub query_params: &'a [(String, String)],
349 pub headers: &'a [(String, String)],
351 pub content_type: Option<&'a str>,
353 pub body: Option<&'a [u8]>,
355}
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq)]
359enum ParamIn {
360 Path,
361 Query,
362 Header,
363}
364
365#[derive(Debug, Clone, Copy)]
368enum ScalarType {
369 Integer,
370 Number,
371 Boolean,
372 String,
373}
374
375struct CompiledParam {
377 name: String,
378 location: ParamIn,
379 required: bool,
380 scalar: Option<ScalarType>,
381 validator: Option<Validator>,
382}
383
384struct CompiledBody {
386 required: bool,
387 media_types: Vec<String>,
389 json: Option<Validator>,
391}
392
393struct CompiledOp {
395 params: Vec<CompiledParam>,
396 body: Option<CompiledBody>,
397}
398
399pub struct CompiledSpec {
402 router: Router,
403 strict_params: bool,
404 ops: HashMap<(String, String), CompiledOp>,
405}
406
407impl CompiledSpec {
408 pub fn compile(spec: &LoadedSpec, cfg: &ApiSchemaEnforcementConfig) -> Result<Self, String> {
411 let doc = &spec.doc;
412 let mut ops = HashMap::new();
413 if let Some(paths) = doc.get("paths").and_then(Value::as_object) {
414 for (template, item) in paths {
415 let Some(item_obj) = item.as_object() else { continue };
416 let shared = item_obj.get("parameters");
417 for m in HTTP_METHODS {
418 let Some(op) = item_obj.get(*m) else { continue };
419 let compiled = compile_op(doc, op, shared, spec.is_3_0, cfg.strict_body)
420 .map_err(|e| format!("{} {template}: {e}", m.to_uppercase()))?;
421 ops.insert((template.clone(), (*m).to_string()), compiled);
422 }
423 }
424 }
425 Ok(Self { router: spec.router.clone(), strict_params: cfg.strict_params, ops })
426 }
427
428 pub fn resolve(&self, path: &str, method: &str) -> Resolution {
430 self.router.resolve(path, method)
431 }
432
433 pub fn validate(&self, template: &str, method: &str, req: &RequestParts) -> BTreeSet<Violation> {
436 let mut out = BTreeSet::new();
437 let Some(op) = self.ops.get(&(template.to_string(), method.to_ascii_lowercase())) else {
438 return out;
439 };
440
441 for p in &op.params {
443 let observed = match p.location {
444 ParamIn::Path => find(req.path_params, &p.name, false),
445 ParamIn::Query => find(req.query_params, &p.name, false),
446 ParamIn::Header => find(req.headers, &p.name, true),
447 };
448 match observed {
449 None if p.required => {
450 out.insert(Violation::MissingRequired);
451 }
452 None => {}
453 Some(val) => {
454 if let (Some(scalar), Some(v)) = (p.scalar, &p.validator) {
455 if !v.is_valid(&coerce(val, scalar)) {
456 out.insert(Violation::TypeViolation);
457 }
458 }
459 }
460 }
461 }
462
463 if self.strict_params {
465 let declared: Vec<&str> = op
466 .params
467 .iter()
468 .filter(|p| p.location == ParamIn::Query)
469 .map(|p| p.name.as_str())
470 .collect();
471 if req.query_params.iter().any(|(k, _)| !declared.contains(&k.as_str())) {
472 out.insert(Violation::UnknownParam);
473 }
474 }
475
476 if let Some(body) = &op.body {
478 let present = req.body.is_some_and(|b| !b.is_empty());
479 if !present {
480 if body.required {
481 out.insert(Violation::MissingRequired);
482 }
483 } else {
484 let ct = req.content_type.map(media_type_key).unwrap_or_default();
485 if !body.media_types.is_empty()
486 && !body.media_types.iter().any(|m| m.as_str() == ct || m == "*/*")
487 {
488 out.insert(Violation::ContentType);
489 }
490 if is_json_media_type(&ct) {
491 if let Some(v) = &body.json {
492 match serde_json::from_slice::<Value>(req.body.unwrap()) {
493 Err(_) => {
494 out.insert(Violation::BodyParseError);
495 }
496 Ok(instance) => {
497 for err in v.iter_errors(&instance) {
498 out.insert(classify(err.kind()));
499 }
500 }
501 }
502 }
503 }
504 }
505 }
506 out
507 }
508}
509
510fn find<'a>(pairs: &'a [(String, String)], name: &str, ci: bool) -> Option<&'a str> {
512 pairs
513 .iter()
514 .find(|(k, _)| if ci { k.eq_ignore_ascii_case(name) } else { k == name })
515 .map(|(_, v)| v.as_str())
516}
517
518fn classify(kind: &ValidationErrorKind) -> Violation {
520 match kind {
521 ValidationErrorKind::AdditionalProperties { .. }
522 | ValidationErrorKind::UnevaluatedProperties { .. } => Violation::UnknownProperty,
523 ValidationErrorKind::Required { .. } => Violation::MissingRequired,
524 _ => Violation::TypeViolation,
525 }
526}
527
528fn coerce(val: &str, scalar: ScalarType) -> Value {
531 match scalar {
532 ScalarType::String => Value::String(val.to_string()),
533 ScalarType::Boolean => match val {
534 "true" => Value::Bool(true),
535 "false" => Value::Bool(false),
536 _ => Value::String(val.to_string()),
537 },
538 ScalarType::Integer => {
539 val.parse::<i64>().map(Value::from).unwrap_or_else(|_| Value::String(val.to_string()))
540 }
541 ScalarType::Number => val
542 .parse::<f64>()
543 .ok()
544 .and_then(serde_json::Number::from_f64)
545 .map(Value::Number)
546 .unwrap_or_else(|| Value::String(val.to_string())),
547 }
548}
549
550fn media_type_key(ct: &str) -> String {
552 ct.split(';').next().unwrap_or("").trim().to_ascii_lowercase()
553}
554
555fn is_json_media_type(ct: &str) -> bool {
556 ct == "application/json" || ct.ends_with("+json")
557}
558
559fn compile_op(
562 doc: &Value,
563 op: &Value,
564 shared: Option<&Value>,
565 is_3_0: bool,
566 strict_body: bool,
567) -> Result<CompiledOp, String> {
568 let mut params = Vec::new();
569 let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
570 for src in [op.get("parameters"), shared] {
572 let Some(list) = src.and_then(Value::as_array) else { continue };
573 for raw in list {
574 let pobj = resolve_ref(doc, raw);
575 let (Some(name), Some(loc)) =
576 (pobj.get("name").and_then(Value::as_str), pobj.get("in").and_then(Value::as_str))
577 else {
578 continue;
579 };
580 let location = match loc {
581 "path" => ParamIn::Path,
582 "query" => ParamIn::Query,
583 "header" => ParamIn::Header,
584 _ => continue, };
586 if !seen.insert((name.to_string(), loc.to_string())) {
587 continue; }
589 let required =
590 loc == "path" || pobj.get("required").and_then(Value::as_bool).unwrap_or(false);
591 let (scalar, validator) = match pobj.get("schema") {
592 Some(schema) => {
593 let resolved = resolve_ref(doc, schema);
594 match scalar_type(resolved) {
595 Some(s) => (Some(s), Some(compile_validator(doc, resolved, is_3_0, false)?)),
596 None => (None, None), }
598 }
599 None => (None, None),
600 };
601 params.push(CompiledParam { name: name.to_string(), location, required, scalar, validator });
602 }
603 }
604
605 let body = match op.get("requestBody") {
606 Some(rb) => Some(compile_body(doc, resolve_ref(doc, rb), is_3_0, strict_body)?),
607 None => None,
608 };
609
610 Ok(CompiledOp { params, body })
611}
612
613fn compile_body(
614 doc: &Value,
615 rb: &Value,
616 is_3_0: bool,
617 strict_body: bool,
618) -> Result<CompiledBody, String> {
619 let required = rb.get("required").and_then(Value::as_bool).unwrap_or(false);
620 let mut media_types = Vec::new();
621 let mut json = None;
622 if let Some(content) = rb.get("content").and_then(Value::as_object) {
623 for (mt, media) in content {
624 let key = media_type_key(mt);
625 if json.is_none() && is_json_media_type(&key) {
626 if let Some(schema) = media.get("schema") {
627 json = Some(compile_validator(doc, resolve_ref(doc, schema), is_3_0, strict_body)?);
628 }
629 }
630 media_types.push(key);
631 }
632 }
633 Ok(CompiledBody { required, media_types, json })
634}
635
636fn scalar_type(schema: &Value) -> Option<ScalarType> {
638 match schema.get("type").and_then(Value::as_str) {
639 Some("integer") => Some(ScalarType::Integer),
640 Some("number") => Some(ScalarType::Number),
641 Some("boolean") => Some(ScalarType::Boolean),
642 Some("string") => Some(ScalarType::String),
643 _ => None,
644 }
645}
646
647fn compile_validator(
651 doc: &Value,
652 schema: &Value,
653 is_3_0: bool,
654 strict_object: bool,
655) -> Result<Validator, String> {
656 let mut s = schema.clone();
657 if strict_object {
658 enforce_additional_properties(&mut s);
659 }
660 if let (Value::Object(map), Some(comps)) = (&mut s, doc.get("components")) {
661 map.entry("components".to_string()).or_insert_with(|| comps.clone());
662 }
663 if is_3_0 {
664 normalize_schema_3_0(&mut s);
665 }
666 jsonschema::validator_for(&s).map_err(|e| format!("schema does not compile: {e}"))
667}
668
669fn enforce_additional_properties(s: &mut Value) {
673 let Value::Object(map) = s else { return };
674 let composed = ["allOf", "anyOf", "oneOf", "$ref", "not"].iter().any(|k| map.contains_key(*k));
675 let is_object =
676 map.get("type").and_then(Value::as_str) == Some("object") || map.contains_key("properties");
677 if is_object && !composed && !map.contains_key("additionalProperties") {
678 map.insert("additionalProperties".to_string(), Value::Bool(false));
679 }
680}
681
682fn resolve_ref<'a>(doc: &'a Value, mut node: &'a Value) -> &'a Value {
685 for _ in 0..32 {
686 match node.get("$ref").and_then(Value::as_str) {
687 Some(r) => match r.strip_prefix('#').and_then(|p| doc.pointer(p)) {
688 Some(target) => node = target,
689 None => break,
690 },
691 None => break,
692 }
693 }
694 node
695}
696
697pub struct ApiSchemaModule {
707 compiled: CompiledSpec,
708 enabled: bool,
709 paths: Vec<String>,
711 unknown_route_severity: Severity,
712 violation_severity: Severity,
713}
714
715impl ApiSchemaModule {
716 pub fn from_config(cfg: &ApiSchemaEnforcementConfig) -> Result<Self, String> {
720 Self::build(load_spec(&cfg.spec_path)?, cfg)
721 }
722
723 pub fn from_spec_json(json: &str, cfg: &ApiSchemaEnforcementConfig) -> Result<Self, String> {
725 Self::build(parse_spec(json)?, cfg)
726 }
727
728 fn build(spec: LoadedSpec, cfg: &ApiSchemaEnforcementConfig) -> Result<Self, String> {
729 Ok(Self {
730 compiled: CompiledSpec::compile(&spec, cfg)?,
731 enabled: cfg.enabled,
732 paths: cfg.paths.clone(),
733 unknown_route_severity: cfg.unknown_route_severity,
734 violation_severity: cfg.violation_severity,
735 })
736 }
737
738 fn guards(&self, path: &str) -> bool {
740 self.paths.iter().any(|p| path.starts_with(p.as_str()))
741 }
742}
743
744fn one(rule: crate::rules::Rule, severity: Severity) -> Decision {
746 Decision::Scores(vec![ScoreItem { rule_id: rule.id(), severity }])
747}
748
749impl WafModule for ApiSchemaModule {
750 fn id(&self) -> &str {
751 "schema-api"
752 }
753
754 fn phase(&self) -> Phase {
755 Phase::Body
756 }
757
758 fn structural(&self) -> bool {
760 true
761 }
762
763 fn init(&mut self, _cfg: &Config) {}
764
765 fn inspect(&self, ctx: &RequestContext) -> Decision {
766 if !self.enabled {
767 return Decision::Allow;
768 }
769 let path = ctx.normalized.path.as_str();
770 if !self.guards(path) {
771 return Decision::Allow;
772 }
773 match self.compiled.resolve(path, &ctx.method) {
774 Resolution::UnknownRoute => {
775 one(crate::rules::API_UNKNOWN_ROUTE, self.unknown_route_severity)
776 }
777 Resolution::MethodNotAllowed => {
778 one(crate::rules::API_METHOD_NOT_ALLOWED, self.unknown_route_severity)
779 }
780 Resolution::Matched { template, path_params } => {
781 let content_type = ctx
782 .normalized
783 .headers
784 .iter()
785 .find(|(k, _)| k == "content-type")
786 .map(|(_, v)| v.as_str());
787 let body = (!ctx.body.is_empty()).then(|| ctx.body.as_ref());
788 let req = RequestParts {
789 path_params: &path_params,
790 query_params: &ctx.normalized.query_params,
791 headers: &ctx.normalized.headers,
792 content_type,
793 body,
794 };
795 let violations = self.compiled.validate(&template, &ctx.method, &req);
796 if violations.is_empty() {
797 Decision::Allow
798 } else {
799 Decision::Scores(
800 violations
801 .into_iter()
802 .map(|v| ScoreItem { rule_id: v.rule().id(), severity: self.violation_severity })
803 .collect(),
804 )
805 }
806 }
807 }
808 }
809}
810
811#[cfg(test)]
812mod tests {
813 use super::*;
814 use serde_json::json;
815
816 const SPEC_30: &str = r#"{
817 "openapi": "3.0.3",
818 "info": { "title": "t", "version": "1" },
819 "paths": { "/users": { "post": { "responses": { "200": { "description": "ok" } } } } }
820 }"#;
821
822 const SPEC_31: &str = r#"{
823 "openapi": "3.1.0",
824 "info": { "title": "t", "version": "1" },
825 "paths": {}
826 }"#;
827
828 fn temp_path(tag: &str) -> PathBuf {
829 let uniq = format!(
830 "waf-oas-{tag}-{}-{:?}.json",
831 std::process::id(),
832 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
833 );
834 std::env::temp_dir().join(uniq)
835 }
836
837 #[test]
838 fn parses_a_valid_3_0_spec_and_flags_the_dialect() {
839 let loaded = parse_spec(SPEC_30).expect("valid 3.0 spec");
840 assert!(loaded.is_3_0, "3.0.x must be flagged for the normalization pass");
841 }
842
843 #[test]
844 fn parses_a_valid_3_1_spec_without_the_3_0_flag() {
845 let loaded = parse_spec(SPEC_31).expect("valid 3.1 spec");
846 assert!(!loaded.is_3_0, "3.1 is draft-2020-12 — no dialect normalization needed");
847 }
848
849 #[test]
850 fn rejects_non_json() {
851 assert!(parse_spec("openapi: 3.0.3").is_err(), "YAML/non-JSON must be rejected (v1 JSON-only)");
852 }
853
854 #[test]
855 fn rejects_a_structurally_invalid_spec() {
856 assert!(parse_spec(r#"{"not":"a spec"}"#).is_err());
858 }
859
860 #[test]
861 fn default_config_is_off() {
862 assert!(!ApiSchemaEnforcementConfig::default().enabled);
863 }
864
865 #[test]
866 fn load_spec_reads_and_validates_a_file() {
867 let path = temp_path("ok");
868 std::fs::write(&path, SPEC_30).unwrap();
869 let loaded = load_spec(&path);
870 let _ = std::fs::remove_file(&path);
871 assert!(loaded.is_ok());
872 }
873
874 #[test]
875 fn load_spec_missing_file_is_err() {
876 assert!(load_spec(&temp_path("missing")).is_err());
877 }
878
879 #[test]
882 fn nullable_string_becomes_a_type_union() {
883 let mut s = json!({ "type": "string", "nullable": true });
884 normalize_schema_3_0(&mut s);
885 assert_eq!(s, json!({ "type": ["string", "null"] }));
886 }
887
888 #[test]
889 fn nullable_is_applied_deep_in_nested_schemas() {
890 let mut s = json!({
891 "type": "object",
892 "properties": {
893 "user": {
894 "type": "object",
895 "properties": { "nickname": { "type": "string", "nullable": true } }
896 }
897 }
898 });
899 normalize_schema_3_0(&mut s);
900 assert_eq!(
901 s["properties"]["user"]["properties"]["nickname"],
902 json!({ "type": ["string", "null"] })
903 );
904 }
905
906 #[test]
907 fn nullable_on_an_array_type_appends_null_once() {
908 let mut s = json!({ "type": ["string", "integer"], "nullable": true });
909 normalize_schema_3_0(&mut s);
910 assert_eq!(s, json!({ "type": ["string", "integer", "null"] }));
911 normalize_schema_3_0(&mut s);
913 assert_eq!(s, json!({ "type": ["string", "integer", "null"] }));
914 }
915
916 #[test]
917 fn boolean_exclusive_minimum_becomes_numeric() {
918 let mut s = json!({ "type": "integer", "minimum": 1, "exclusiveMinimum": true });
919 normalize_schema_3_0(&mut s);
920 assert_eq!(s, json!({ "type": "integer", "exclusiveMinimum": 1 }));
921
922 let mut inclusive = json!({ "type": "integer", "minimum": 1, "exclusiveMinimum": false });
924 normalize_schema_3_0(&mut inclusive);
925 assert_eq!(inclusive, json!({ "type": "integer", "minimum": 1 }));
926 }
927
928 #[test]
929 fn a_3_1_schema_is_left_unchanged() {
930 let original = json!({ "type": ["string", "null"], "properties": { "n": { "type": "string" } } });
931 let mut s = original.clone();
932 normalize_schema_3_0(&mut s);
933 assert_eq!(s, original, "a standard 2020-12 schema must be a no-op");
934 }
935
936 const ROUTER_SPEC: &str = r#"{
939 "openapi": "3.0.3",
940 "info": { "title": "t", "version": "1" },
941 "paths": {
942 "/users": {
943 "get": { "responses": { "200": { "description": "ok" } } },
944 "post": { "responses": { "200": { "description": "ok" } } }
945 },
946 "/users/{id}": {
947 "get": { "responses": { "200": { "description": "ok" } } },
948 "delete": { "responses": { "200": { "description": "ok" } } }
949 },
950 "/users/me": {
951 "get": { "responses": { "200": { "description": "ok" } } }
952 }
953 }
954 }"#;
955
956 fn router() -> Router {
957 parse_spec(ROUTER_SPEC).expect("valid spec").router
958 }
959
960 fn matched(template: &str, params: &[(&str, &str)]) -> Resolution {
961 Resolution::Matched {
962 template: template.to_string(),
963 path_params: params.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
964 }
965 }
966
967 #[test]
968 fn resolves_static_route_and_method() {
969 assert_eq!(router().resolve("/users", "GET"), matched("/users", &[]));
970 assert_eq!(router().resolve("/users", "POST"), matched("/users", &[]));
971 }
972
973 #[test]
974 fn method_not_declared_is_method_not_allowed() {
975 assert_eq!(router().resolve("/users", "DELETE"), Resolution::MethodNotAllowed);
976 }
977
978 #[test]
979 fn resolves_templated_route_and_extracts_path_param() {
980 assert_eq!(router().resolve("/users/123", "GET"), matched("/users/{id}", &[("id", "123")]));
981 assert_eq!(router().resolve("/users/123", "DELETE"), matched("/users/{id}", &[("id", "123")]));
982 }
983
984 #[test]
985 fn static_route_beats_templated_at_same_position() {
986 assert_eq!(router().resolve("/users/me", "GET"), matched("/users/me", &[]));
988 assert_eq!(router().resolve("/users/me", "DELETE"), Resolution::MethodNotAllowed);
991 }
992
993 #[test]
994 fn unknown_path_and_wrong_arity_are_unknown_route() {
995 assert_eq!(router().resolve("/nope", "GET"), Resolution::UnknownRoute);
996 assert_eq!(router().resolve("/users/123/posts", "GET"), Resolution::UnknownRoute);
997 }
998
999 #[test]
1000 fn trailing_slash_is_ignored_but_case_is_significant() {
1001 assert_eq!(router().resolve("/users/", "GET"), matched("/users", &[]));
1002 assert_eq!(router().resolve("/Users", "GET"), Resolution::UnknownRoute);
1004 }
1005
1006 #[test]
1007 fn method_matching_is_case_insensitive() {
1008 assert_eq!(router().resolve("/users", "get"), matched("/users", &[]));
1009 }
1010
1011 const VALIDATION_SPEC: &str = r##"{
1016 "openapi": "3.0.3",
1017 "info": { "title": "t", "version": "1" },
1018 "components": {
1019 "schemas": {
1020 "User": {
1021 "type": "object",
1022 "additionalProperties": false,
1023 "required": ["name"],
1024 "properties": {
1025 "name": { "type": "string" },
1026 "age": { "type": "integer", "minimum": 0 },
1027 "nickname": { "type": "string", "nullable": true }
1028 }
1029 }
1030 }
1031 },
1032 "paths": {
1033 "/users/{id}": {
1034 "parameters": [
1035 { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
1036 ],
1037 "get": {
1038 "parameters": [
1039 { "name": "verbose", "in": "query", "required": false, "schema": { "type": "boolean" } }
1040 ],
1041 "responses": { "200": { "description": "ok" } }
1042 },
1043 "post": {
1044 "requestBody": {
1045 "required": true,
1046 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } }
1047 },
1048 "responses": { "200": { "description": "ok" } }
1049 }
1050 }
1051 }
1052 }"##;
1053
1054 fn compiled() -> CompiledSpec {
1055 let spec = parse_spec(VALIDATION_SPEC).expect("valid spec");
1056 CompiledSpec::compile(&spec, &ApiSchemaEnforcementConfig::default()).expect("compiles")
1057 }
1058
1059 fn v(items: &[Violation]) -> BTreeSet<Violation> {
1060 items.iter().copied().collect()
1061 }
1062
1063 fn post_body(spec: &CompiledSpec, ct: &str, body: &str) -> BTreeSet<Violation> {
1064 let pp = vec![("id".to_string(), "1".to_string())];
1065 let req = RequestParts {
1066 path_params: &pp,
1067 query_params: &[],
1068 headers: &[],
1069 content_type: Some(ct),
1070 body: Some(body.as_bytes()),
1071 };
1072 spec.validate("/users/{id}", "post", &req)
1073 }
1074
1075 fn get_users(spec: &CompiledSpec, id: &str, query: &[(&str, &str)]) -> BTreeSet<Violation> {
1076 match spec.resolve(&format!("/users/{id}"), "GET") {
1077 Resolution::Matched { template, path_params } => {
1078 let q: Vec<(String, String)> =
1079 query.iter().map(|(k, val)| (k.to_string(), val.to_string())).collect();
1080 let req = RequestParts {
1081 path_params: &path_params,
1082 query_params: &q,
1083 headers: &[],
1084 content_type: None,
1085 body: None,
1086 };
1087 spec.validate(&template, "get", &req)
1088 }
1089 other => panic!("expected Matched, got {other:?}"),
1090 }
1091 }
1092
1093 #[test]
1094 fn compile_is_boot_fatal_on_a_broken_schema() {
1095 let broken = r#"{
1098 "openapi": "3.0.3", "info": { "title": "t", "version": "1" },
1099 "paths": { "/x": { "get": {
1100 "parameters": [ { "name": "q", "in": "query", "schema": { "type": "string", "pattern": "[" } } ],
1101 "responses": { "200": { "description": "ok" } }
1102 } } }
1103 }"#;
1104 let spec = parse_spec(broken).expect("spec parses structurally");
1105 assert!(
1106 CompiledSpec::compile(&spec, &ApiSchemaEnforcementConfig::default()).is_err(),
1107 "a parameter schema jsonschema cannot compile must be boot-fatal"
1108 );
1109 }
1110
1111 #[test]
1112 fn valid_request_has_no_violations() {
1113 assert!(post_body(&compiled(), "application/json", r#"{"name":"ada","age":30}"#).is_empty());
1114 assert!(get_users(&compiled(), "5", &[("verbose", "true")]).is_empty());
1115 }
1116
1117 #[test]
1118 fn nullable_body_field_set_to_null_is_clean() {
1119 assert!(post_body(&compiled(), "application/json", r#"{"name":"ada","nickname":null}"#).is_empty());
1122 }
1123
1124 #[test]
1125 fn missing_required_body_field_is_flagged() {
1126 assert_eq!(
1127 post_body(&compiled(), "application/json", r#"{"age":30}"#),
1128 v(&[Violation::MissingRequired])
1129 );
1130 }
1131
1132 #[test]
1133 fn wrong_body_type_is_a_type_violation() {
1134 assert_eq!(
1135 post_body(&compiled(), "application/json", r#"{"name":123}"#),
1136 v(&[Violation::TypeViolation])
1137 );
1138 }
1139
1140 #[test]
1141 fn unknown_body_property_is_flagged_via_additional_properties() {
1142 assert_eq!(
1143 post_body(&compiled(), "application/json", r#"{"name":"ada","isAdmin":true}"#),
1144 v(&[Violation::UnknownProperty])
1145 );
1146 }
1147
1148 #[test]
1149 fn undeclared_content_type_is_flagged_and_body_not_json_validated() {
1150 assert_eq!(post_body(&compiled(), "text/plain", "hello"), v(&[Violation::ContentType]));
1152 }
1153
1154 #[test]
1155 fn unparseable_json_body_is_a_parse_error() {
1156 assert_eq!(
1157 post_body(&compiled(), "application/json", "{not json"),
1158 v(&[Violation::BodyParseError])
1159 );
1160 }
1161
1162 #[test]
1163 fn missing_required_body_altogether_is_flagged() {
1164 let pp = vec![("id".to_string(), "1".to_string())];
1165 let req = RequestParts {
1166 path_params: &pp,
1167 query_params: &[],
1168 headers: &[],
1169 content_type: None,
1170 body: None,
1171 };
1172 assert_eq!(compiled().validate("/users/{id}", "post", &req), v(&[Violation::MissingRequired]));
1173 }
1174
1175 #[test]
1176 fn path_param_type_is_coerced_and_validated() {
1177 assert!(get_users(&compiled(), "5", &[]).is_empty());
1178 assert_eq!(get_users(&compiled(), "abc", &[]), v(&[Violation::TypeViolation]));
1179 }
1180
1181 #[test]
1182 fn undeclared_query_param_is_flagged_when_strict() {
1183 assert_eq!(get_users(&compiled(), "5", &[("foo", "bar")]), v(&[Violation::UnknownParam]));
1184 }
1185
1186 #[test]
1187 fn wrong_query_param_type_is_a_type_violation() {
1188 assert_eq!(
1189 get_users(&compiled(), "5", &[("verbose", "maybe")]),
1190 v(&[Violation::TypeViolation])
1191 );
1192 }
1193
1194 #[test]
1195 fn strict_params_off_allows_undeclared_query_params() {
1196 let spec = parse_spec(VALIDATION_SPEC).unwrap();
1197 let cfg = ApiSchemaEnforcementConfig { strict_params: false, ..Default::default() };
1198 let compiled = CompiledSpec::compile(&spec, &cfg).unwrap();
1199 match compiled.resolve("/users/5", "GET") {
1200 Resolution::Matched { template, path_params } => {
1201 let q = vec![("foo".to_string(), "bar".to_string())];
1202 let req = RequestParts {
1203 path_params: &path_params,
1204 query_params: &q,
1205 headers: &[],
1206 content_type: None,
1207 body: None,
1208 };
1209 assert!(compiled.validate(&template, "get", &req).is_empty());
1210 }
1211 other => panic!("expected Matched, got {other:?}"),
1212 }
1213 }
1214}