use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use jsonschema::error::ValidationErrorKind;
use jsonschema::Validator;
use serde_json::Value;
use waf_core::{Config, Decision, Phase, RequestContext, ScoreItem, Severity, WafModule};
#[derive(Debug, Clone)]
pub struct ApiSchemaEnforcementConfig {
pub enabled: bool,
pub paths: Vec<String>,
pub spec_path: PathBuf,
pub unknown_route_severity: Severity,
pub violation_severity: Severity,
pub strict_params: bool,
pub strict_body: bool,
}
impl Default for ApiSchemaEnforcementConfig {
fn default() -> Self {
Self {
enabled: false,
paths: vec!["/api".to_string()],
spec_path: PathBuf::new(),
unknown_route_severity: Severity::Critical,
violation_severity: Severity::Warning,
strict_params: true,
strict_body: true,
}
}
}
#[derive(Debug, Clone)]
pub struct LoadedSpec {
pub doc: Value,
pub is_3_0: bool,
pub router: Router,
}
pub fn parse_spec(json: &str) -> Result<LoadedSpec, String> {
oas3::from_json(json).map_err(|e| format!("invalid OpenAPI spec: {e}"))?;
let doc: Value = serde_json::from_str(json).map_err(|e| format!("spec is not valid JSON: {e}"))?;
let is_3_0 = doc
.get("openapi")
.and_then(Value::as_str)
.is_some_and(|v| v.starts_with("3.0"));
let router = Router::build(&doc);
Ok(LoadedSpec { doc, is_3_0, router })
}
pub fn load_spec(path: &Path) -> Result<LoadedSpec, String> {
let json = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read OpenAPI spec {}: {e}", path.display()))?;
parse_spec(&json)
}
pub fn normalize_schema_3_0(v: &mut Value) {
match v {
Value::Object(map) => {
if map.get("nullable") == Some(&Value::Bool(true)) {
if let Some(t) = map.get_mut("type") {
add_null_to_type(t);
}
}
map.remove("nullable");
fix_exclusive_bound(map, "exclusiveMinimum", "minimum");
fix_exclusive_bound(map, "exclusiveMaximum", "maximum");
for child in map.values_mut() {
normalize_schema_3_0(child);
}
}
Value::Array(items) => {
for child in items {
normalize_schema_3_0(child);
}
}
_ => {}
}
}
fn add_null_to_type(t: &mut Value) {
let null = Value::String("null".to_string());
match t {
Value::String(s) => {
*t = Value::Array(vec![Value::String(std::mem::take(s)), null]);
}
Value::Array(arr) if !arr.contains(&null) => arr.push(null),
_ => {}
}
}
fn fix_exclusive_bound(map: &mut serde_json::Map<String, Value>, excl: &str, bound: &str) {
if let Some(Value::Bool(is_exclusive)) = map.get(excl).cloned() {
map.remove(excl);
if is_exclusive {
if let Some(bound_val) = map.remove(bound) {
map.insert(excl.to_string(), bound_val);
}
}
}
}
const HTTP_METHODS: &[&str] = &["get", "put", "post", "delete", "options", "head", "patch", "trace"];
#[derive(Debug, Clone, PartialEq, Eq)]
enum Seg {
Static(String),
Param(String),
}
#[derive(Debug, Clone)]
struct Route {
template: String,
segments: Vec<Seg>,
methods: std::collections::BTreeSet<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resolution {
UnknownRoute,
MethodNotAllowed,
Matched {
template: String,
path_params: Vec<(String, String)>,
},
}
#[derive(Debug, Clone, Default)]
pub struct Router {
routes: Vec<Route>,
}
impl Router {
pub fn build(doc: &Value) -> Self {
let mut routes = Vec::new();
if let Some(paths) = doc.get("paths").and_then(Value::as_object) {
for (template, item) in paths {
let Some(item) = item.as_object() else { continue };
let methods: std::collections::BTreeSet<String> = HTTP_METHODS
.iter()
.filter(|m| item.contains_key(**m))
.map(|m| (*m).to_string())
.collect();
if methods.is_empty() {
continue; }
routes.push(Route { segments: parse_template(template), template: template.clone(), methods });
}
}
routes.sort_by(|a, b| {
specificity(&a.segments).cmp(&specificity(&b.segments)).then(a.template.cmp(&b.template))
});
Self { routes }
}
pub fn resolve(&self, path: &str, method: &str) -> Resolution {
let req = split_segments(path);
let m = method.to_ascii_lowercase();
for route in &self.routes {
if let Some(path_params) = route.match_path(&req) {
return if route.methods.contains(&m) {
Resolution::Matched { template: route.template.clone(), path_params }
} else {
Resolution::MethodNotAllowed
};
}
}
Resolution::UnknownRoute
}
}
impl Route {
fn match_path(&self, req: &[&str]) -> Option<Vec<(String, String)>> {
if self.segments.len() != req.len() {
return None;
}
let mut params = Vec::new();
for (seg, val) in self.segments.iter().zip(req) {
match seg {
Seg::Static(s) if s == val => {}
Seg::Static(_) => return None,
Seg::Param(_) if val.is_empty() => return None,
Seg::Param(name) => params.push((name.clone(), (*val).to_string())),
}
}
Some(params)
}
}
fn split_segments(path: &str) -> Vec<&str> {
let path = path.split(['?', '#']).next().unwrap_or(path);
path.split('/').filter(|s| !s.is_empty()).collect()
}
fn parse_template(template: &str) -> Vec<Seg> {
split_segments(template)
.into_iter()
.map(|s| match s.strip_prefix('{').and_then(|inner| inner.strip_suffix('}')) {
Some(name) if !name.is_empty() => Seg::Param(name.to_string()),
_ => Seg::Static(s.to_string()),
})
.collect()
}
fn specificity(segs: &[Seg]) -> Vec<u8> {
segs.iter().map(|s| u8::from(matches!(s, Seg::Param(_)))).collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Violation {
UnknownParam,
MissingRequired,
TypeViolation,
UnknownProperty,
ContentType,
BodyParseError,
}
impl Violation {
pub fn rule(self) -> crate::rules::Rule {
use crate::rules;
match self {
Violation::UnknownParam => rules::API_UNKNOWN_PARAM,
Violation::MissingRequired => rules::API_MISSING_REQUIRED,
Violation::TypeViolation => rules::API_TYPE_VIOLATION,
Violation::UnknownProperty => rules::API_UNKNOWN_PROPERTY,
Violation::ContentType => rules::API_CONTENT_TYPE,
Violation::BodyParseError => rules::API_BODY_PARSE_ERROR,
}
}
}
pub struct RequestParts<'a> {
pub path_params: &'a [(String, String)],
pub query_params: &'a [(String, String)],
pub headers: &'a [(String, String)],
pub content_type: Option<&'a str>,
pub body: Option<&'a [u8]>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParamIn {
Path,
Query,
Header,
}
#[derive(Debug, Clone, Copy)]
enum ScalarType {
Integer,
Number,
Boolean,
String,
}
struct CompiledParam {
name: String,
location: ParamIn,
required: bool,
scalar: Option<ScalarType>,
validator: Option<Validator>,
}
struct CompiledBody {
required: bool,
media_types: Vec<String>,
json: Option<Validator>,
}
struct CompiledOp {
params: Vec<CompiledParam>,
body: Option<CompiledBody>,
}
pub struct CompiledSpec {
router: Router,
strict_params: bool,
ops: HashMap<(String, String), CompiledOp>,
}
impl CompiledSpec {
pub fn compile(spec: &LoadedSpec, cfg: &ApiSchemaEnforcementConfig) -> Result<Self, String> {
let doc = &spec.doc;
let mut ops = HashMap::new();
if let Some(paths) = doc.get("paths").and_then(Value::as_object) {
for (template, item) in paths {
let Some(item_obj) = item.as_object() else { continue };
let shared = item_obj.get("parameters");
for m in HTTP_METHODS {
let Some(op) = item_obj.get(*m) else { continue };
let compiled = compile_op(doc, op, shared, spec.is_3_0, cfg.strict_body)
.map_err(|e| format!("{} {template}: {e}", m.to_uppercase()))?;
ops.insert((template.clone(), (*m).to_string()), compiled);
}
}
}
Ok(Self { router: spec.router.clone(), strict_params: cfg.strict_params, ops })
}
pub fn resolve(&self, path: &str, method: &str) -> Resolution {
self.router.resolve(path, method)
}
pub fn validate(&self, template: &str, method: &str, req: &RequestParts) -> BTreeSet<Violation> {
let mut out = BTreeSet::new();
let Some(op) = self.ops.get(&(template.to_string(), method.to_ascii_lowercase())) else {
return out;
};
for p in &op.params {
let observed = match p.location {
ParamIn::Path => find(req.path_params, &p.name, false),
ParamIn::Query => find(req.query_params, &p.name, false),
ParamIn::Header => find(req.headers, &p.name, true),
};
match observed {
None if p.required => {
out.insert(Violation::MissingRequired);
}
None => {}
Some(val) => {
if let (Some(scalar), Some(v)) = (p.scalar, &p.validator) {
if !v.is_valid(&coerce(val, scalar)) {
out.insert(Violation::TypeViolation);
}
}
}
}
}
if self.strict_params {
let declared: Vec<&str> = op
.params
.iter()
.filter(|p| p.location == ParamIn::Query)
.map(|p| p.name.as_str())
.collect();
if req.query_params.iter().any(|(k, _)| !declared.contains(&k.as_str())) {
out.insert(Violation::UnknownParam);
}
}
if let Some(body) = &op.body {
let present = req.body.is_some_and(|b| !b.is_empty());
if !present {
if body.required {
out.insert(Violation::MissingRequired);
}
} else {
let ct = req.content_type.map(media_type_key).unwrap_or_default();
if !body.media_types.is_empty()
&& !body.media_types.iter().any(|m| m.as_str() == ct || m == "*/*")
{
out.insert(Violation::ContentType);
}
if is_json_media_type(&ct) {
if let Some(v) = &body.json {
match serde_json::from_slice::<Value>(req.body.unwrap()) {
Err(_) => {
out.insert(Violation::BodyParseError);
}
Ok(instance) => {
for err in v.iter_errors(&instance) {
out.insert(classify(err.kind()));
}
}
}
}
}
}
}
out
}
}
fn find<'a>(pairs: &'a [(String, String)], name: &str, ci: bool) -> Option<&'a str> {
pairs
.iter()
.find(|(k, _)| if ci { k.eq_ignore_ascii_case(name) } else { k == name })
.map(|(_, v)| v.as_str())
}
fn classify(kind: &ValidationErrorKind) -> Violation {
match kind {
ValidationErrorKind::AdditionalProperties { .. }
| ValidationErrorKind::UnevaluatedProperties { .. } => Violation::UnknownProperty,
ValidationErrorKind::Required { .. } => Violation::MissingRequired,
_ => Violation::TypeViolation,
}
}
fn coerce(val: &str, scalar: ScalarType) -> Value {
match scalar {
ScalarType::String => Value::String(val.to_string()),
ScalarType::Boolean => match val {
"true" => Value::Bool(true),
"false" => Value::Bool(false),
_ => Value::String(val.to_string()),
},
ScalarType::Integer => {
val.parse::<i64>().map(Value::from).unwrap_or_else(|_| Value::String(val.to_string()))
}
ScalarType::Number => val
.parse::<f64>()
.ok()
.and_then(serde_json::Number::from_f64)
.map(Value::Number)
.unwrap_or_else(|| Value::String(val.to_string())),
}
}
fn media_type_key(ct: &str) -> String {
ct.split(';').next().unwrap_or("").trim().to_ascii_lowercase()
}
fn is_json_media_type(ct: &str) -> bool {
ct == "application/json" || ct.ends_with("+json")
}
fn compile_op(
doc: &Value,
op: &Value,
shared: Option<&Value>,
is_3_0: bool,
strict_body: bool,
) -> Result<CompiledOp, String> {
let mut params = Vec::new();
let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
for src in [op.get("parameters"), shared] {
let Some(list) = src.and_then(Value::as_array) else { continue };
for raw in list {
let pobj = resolve_ref(doc, raw);
let (Some(name), Some(loc)) =
(pobj.get("name").and_then(Value::as_str), pobj.get("in").and_then(Value::as_str))
else {
continue;
};
let location = match loc {
"path" => ParamIn::Path,
"query" => ParamIn::Query,
"header" => ParamIn::Header,
_ => continue, };
if !seen.insert((name.to_string(), loc.to_string())) {
continue; }
let required =
loc == "path" || pobj.get("required").and_then(Value::as_bool).unwrap_or(false);
let (scalar, validator) = match pobj.get("schema") {
Some(schema) => {
let resolved = resolve_ref(doc, schema);
match scalar_type(resolved) {
Some(s) => (Some(s), Some(compile_validator(doc, resolved, is_3_0, false)?)),
None => (None, None), }
}
None => (None, None),
};
params.push(CompiledParam { name: name.to_string(), location, required, scalar, validator });
}
}
let body = match op.get("requestBody") {
Some(rb) => Some(compile_body(doc, resolve_ref(doc, rb), is_3_0, strict_body)?),
None => None,
};
Ok(CompiledOp { params, body })
}
fn compile_body(
doc: &Value,
rb: &Value,
is_3_0: bool,
strict_body: bool,
) -> Result<CompiledBody, String> {
let required = rb.get("required").and_then(Value::as_bool).unwrap_or(false);
let mut media_types = Vec::new();
let mut json = None;
if let Some(content) = rb.get("content").and_then(Value::as_object) {
for (mt, media) in content {
let key = media_type_key(mt);
if json.is_none() && is_json_media_type(&key) {
if let Some(schema) = media.get("schema") {
json = Some(compile_validator(doc, resolve_ref(doc, schema), is_3_0, strict_body)?);
}
}
media_types.push(key);
}
}
Ok(CompiledBody { required, media_types, json })
}
fn scalar_type(schema: &Value) -> Option<ScalarType> {
match schema.get("type").and_then(Value::as_str) {
Some("integer") => Some(ScalarType::Integer),
Some("number") => Some(ScalarType::Number),
Some("boolean") => Some(ScalarType::Boolean),
Some("string") => Some(ScalarType::String),
_ => None,
}
}
fn compile_validator(
doc: &Value,
schema: &Value,
is_3_0: bool,
strict_object: bool,
) -> Result<Validator, String> {
let mut s = schema.clone();
if strict_object {
enforce_additional_properties(&mut s);
}
if let (Value::Object(map), Some(comps)) = (&mut s, doc.get("components")) {
map.entry("components".to_string()).or_insert_with(|| comps.clone());
}
if is_3_0 {
normalize_schema_3_0(&mut s);
}
jsonschema::validator_for(&s).map_err(|e| format!("schema does not compile: {e}"))
}
fn enforce_additional_properties(s: &mut Value) {
let Value::Object(map) = s else { return };
let composed = ["allOf", "anyOf", "oneOf", "$ref", "not"].iter().any(|k| map.contains_key(*k));
let is_object =
map.get("type").and_then(Value::as_str) == Some("object") || map.contains_key("properties");
if is_object && !composed && !map.contains_key("additionalProperties") {
map.insert("additionalProperties".to_string(), Value::Bool(false));
}
}
fn resolve_ref<'a>(doc: &'a Value, mut node: &'a Value) -> &'a Value {
for _ in 0..32 {
match node.get("$ref").and_then(Value::as_str) {
Some(r) => match r.strip_prefix('#').and_then(|p| doc.pointer(p)) {
Some(target) => node = target,
None => break,
},
None => break,
}
}
node
}
pub struct ApiSchemaModule {
compiled: CompiledSpec,
enabled: bool,
paths: Vec<String>,
unknown_route_severity: Severity,
violation_severity: Severity,
}
impl ApiSchemaModule {
pub fn from_config(cfg: &ApiSchemaEnforcementConfig) -> Result<Self, String> {
Self::build(load_spec(&cfg.spec_path)?, cfg)
}
pub fn from_spec_json(json: &str, cfg: &ApiSchemaEnforcementConfig) -> Result<Self, String> {
Self::build(parse_spec(json)?, cfg)
}
fn build(spec: LoadedSpec, cfg: &ApiSchemaEnforcementConfig) -> Result<Self, String> {
Ok(Self {
compiled: CompiledSpec::compile(&spec, cfg)?,
enabled: cfg.enabled,
paths: cfg.paths.clone(),
unknown_route_severity: cfg.unknown_route_severity,
violation_severity: cfg.violation_severity,
})
}
fn guards(&self, path: &str) -> bool {
self.paths.iter().any(|p| path.starts_with(p.as_str()))
}
}
fn one(rule: crate::rules::Rule, severity: Severity) -> Decision {
Decision::Scores(vec![ScoreItem { rule_id: rule.id(), severity }])
}
impl WafModule for ApiSchemaModule {
fn id(&self) -> &str {
"schema-api"
}
fn phase(&self) -> Phase {
Phase::Body
}
fn structural(&self) -> bool {
true
}
fn init(&mut self, _cfg: &Config) {}
fn inspect(&self, ctx: &RequestContext) -> Decision {
if !self.enabled {
return Decision::Allow;
}
let path = ctx.normalized.path.as_str();
if !self.guards(path) {
return Decision::Allow;
}
match self.compiled.resolve(path, &ctx.method) {
Resolution::UnknownRoute => {
one(crate::rules::API_UNKNOWN_ROUTE, self.unknown_route_severity)
}
Resolution::MethodNotAllowed => {
one(crate::rules::API_METHOD_NOT_ALLOWED, self.unknown_route_severity)
}
Resolution::Matched { template, path_params } => {
let content_type = ctx
.normalized
.headers
.iter()
.find(|(k, _)| k == "content-type")
.map(|(_, v)| v.as_str());
let body = (!ctx.body.is_empty()).then(|| ctx.body.as_ref());
let req = RequestParts {
path_params: &path_params,
query_params: &ctx.normalized.query_params,
headers: &ctx.normalized.headers,
content_type,
body,
};
let violations = self.compiled.validate(&template, &ctx.method, &req);
if violations.is_empty() {
Decision::Allow
} else {
Decision::Scores(
violations
.into_iter()
.map(|v| ScoreItem { rule_id: v.rule().id(), severity: self.violation_severity })
.collect(),
)
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const SPEC_30: &str = r#"{
"openapi": "3.0.3",
"info": { "title": "t", "version": "1" },
"paths": { "/users": { "post": { "responses": { "200": { "description": "ok" } } } } }
}"#;
const SPEC_31: &str = r#"{
"openapi": "3.1.0",
"info": { "title": "t", "version": "1" },
"paths": {}
}"#;
fn temp_path(tag: &str) -> PathBuf {
let uniq = format!(
"waf-oas-{tag}-{}-{:?}.json",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
);
std::env::temp_dir().join(uniq)
}
#[test]
fn parses_a_valid_3_0_spec_and_flags_the_dialect() {
let loaded = parse_spec(SPEC_30).expect("valid 3.0 spec");
assert!(loaded.is_3_0, "3.0.x must be flagged for the normalization pass");
}
#[test]
fn parses_a_valid_3_1_spec_without_the_3_0_flag() {
let loaded = parse_spec(SPEC_31).expect("valid 3.1 spec");
assert!(!loaded.is_3_0, "3.1 is draft-2020-12 — no dialect normalization needed");
}
#[test]
fn rejects_non_json() {
assert!(parse_spec("openapi: 3.0.3").is_err(), "YAML/non-JSON must be rejected (v1 JSON-only)");
}
#[test]
fn rejects_a_structurally_invalid_spec() {
assert!(parse_spec(r#"{"not":"a spec"}"#).is_err());
}
#[test]
fn default_config_is_off() {
assert!(!ApiSchemaEnforcementConfig::default().enabled);
}
#[test]
fn load_spec_reads_and_validates_a_file() {
let path = temp_path("ok");
std::fs::write(&path, SPEC_30).unwrap();
let loaded = load_spec(&path);
let _ = std::fs::remove_file(&path);
assert!(loaded.is_ok());
}
#[test]
fn load_spec_missing_file_is_err() {
assert!(load_spec(&temp_path("missing")).is_err());
}
#[test]
fn nullable_string_becomes_a_type_union() {
let mut s = json!({ "type": "string", "nullable": true });
normalize_schema_3_0(&mut s);
assert_eq!(s, json!({ "type": ["string", "null"] }));
}
#[test]
fn nullable_is_applied_deep_in_nested_schemas() {
let mut s = json!({
"type": "object",
"properties": {
"user": {
"type": "object",
"properties": { "nickname": { "type": "string", "nullable": true } }
}
}
});
normalize_schema_3_0(&mut s);
assert_eq!(
s["properties"]["user"]["properties"]["nickname"],
json!({ "type": ["string", "null"] })
);
}
#[test]
fn nullable_on_an_array_type_appends_null_once() {
let mut s = json!({ "type": ["string", "integer"], "nullable": true });
normalize_schema_3_0(&mut s);
assert_eq!(s, json!({ "type": ["string", "integer", "null"] }));
normalize_schema_3_0(&mut s);
assert_eq!(s, json!({ "type": ["string", "integer", "null"] }));
}
#[test]
fn boolean_exclusive_minimum_becomes_numeric() {
let mut s = json!({ "type": "integer", "minimum": 1, "exclusiveMinimum": true });
normalize_schema_3_0(&mut s);
assert_eq!(s, json!({ "type": "integer", "exclusiveMinimum": 1 }));
let mut inclusive = json!({ "type": "integer", "minimum": 1, "exclusiveMinimum": false });
normalize_schema_3_0(&mut inclusive);
assert_eq!(inclusive, json!({ "type": "integer", "minimum": 1 }));
}
#[test]
fn a_3_1_schema_is_left_unchanged() {
let original = json!({ "type": ["string", "null"], "properties": { "n": { "type": "string" } } });
let mut s = original.clone();
normalize_schema_3_0(&mut s);
assert_eq!(s, original, "a standard 2020-12 schema must be a no-op");
}
const ROUTER_SPEC: &str = r#"{
"openapi": "3.0.3",
"info": { "title": "t", "version": "1" },
"paths": {
"/users": {
"get": { "responses": { "200": { "description": "ok" } } },
"post": { "responses": { "200": { "description": "ok" } } }
},
"/users/{id}": {
"get": { "responses": { "200": { "description": "ok" } } },
"delete": { "responses": { "200": { "description": "ok" } } }
},
"/users/me": {
"get": { "responses": { "200": { "description": "ok" } } }
}
}
}"#;
fn router() -> Router {
parse_spec(ROUTER_SPEC).expect("valid spec").router
}
fn matched(template: &str, params: &[(&str, &str)]) -> Resolution {
Resolution::Matched {
template: template.to_string(),
path_params: params.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
}
}
#[test]
fn resolves_static_route_and_method() {
assert_eq!(router().resolve("/users", "GET"), matched("/users", &[]));
assert_eq!(router().resolve("/users", "POST"), matched("/users", &[]));
}
#[test]
fn method_not_declared_is_method_not_allowed() {
assert_eq!(router().resolve("/users", "DELETE"), Resolution::MethodNotAllowed);
}
#[test]
fn resolves_templated_route_and_extracts_path_param() {
assert_eq!(router().resolve("/users/123", "GET"), matched("/users/{id}", &[("id", "123")]));
assert_eq!(router().resolve("/users/123", "DELETE"), matched("/users/{id}", &[("id", "123")]));
}
#[test]
fn static_route_beats_templated_at_same_position() {
assert_eq!(router().resolve("/users/me", "GET"), matched("/users/me", &[]));
assert_eq!(router().resolve("/users/me", "DELETE"), Resolution::MethodNotAllowed);
}
#[test]
fn unknown_path_and_wrong_arity_are_unknown_route() {
assert_eq!(router().resolve("/nope", "GET"), Resolution::UnknownRoute);
assert_eq!(router().resolve("/users/123/posts", "GET"), Resolution::UnknownRoute);
}
#[test]
fn trailing_slash_is_ignored_but_case_is_significant() {
assert_eq!(router().resolve("/users/", "GET"), matched("/users", &[]));
assert_eq!(router().resolve("/Users", "GET"), Resolution::UnknownRoute);
}
#[test]
fn method_matching_is_case_insensitive() {
assert_eq!(router().resolve("/users", "get"), matched("/users", &[]));
}
const VALIDATION_SPEC: &str = r##"{
"openapi": "3.0.3",
"info": { "title": "t", "version": "1" },
"components": {
"schemas": {
"User": {
"type": "object",
"additionalProperties": false,
"required": ["name"],
"properties": {
"name": { "type": "string" },
"age": { "type": "integer", "minimum": 0 },
"nickname": { "type": "string", "nullable": true }
}
}
}
},
"paths": {
"/users/{id}": {
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
],
"get": {
"parameters": [
{ "name": "verbose", "in": "query", "required": false, "schema": { "type": "boolean" } }
],
"responses": { "200": { "description": "ok" } }
},
"post": {
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } }
},
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
fn compiled() -> CompiledSpec {
let spec = parse_spec(VALIDATION_SPEC).expect("valid spec");
CompiledSpec::compile(&spec, &ApiSchemaEnforcementConfig::default()).expect("compiles")
}
fn v(items: &[Violation]) -> BTreeSet<Violation> {
items.iter().copied().collect()
}
fn post_body(spec: &CompiledSpec, ct: &str, body: &str) -> BTreeSet<Violation> {
let pp = vec![("id".to_string(), "1".to_string())];
let req = RequestParts {
path_params: &pp,
query_params: &[],
headers: &[],
content_type: Some(ct),
body: Some(body.as_bytes()),
};
spec.validate("/users/{id}", "post", &req)
}
fn get_users(spec: &CompiledSpec, id: &str, query: &[(&str, &str)]) -> BTreeSet<Violation> {
match spec.resolve(&format!("/users/{id}"), "GET") {
Resolution::Matched { template, path_params } => {
let q: Vec<(String, String)> =
query.iter().map(|(k, val)| (k.to_string(), val.to_string())).collect();
let req = RequestParts {
path_params: &path_params,
query_params: &q,
headers: &[],
content_type: None,
body: None,
};
spec.validate(&template, "get", &req)
}
other => panic!("expected Matched, got {other:?}"),
}
}
#[test]
fn compile_is_boot_fatal_on_a_broken_schema() {
let broken = r#"{
"openapi": "3.0.3", "info": { "title": "t", "version": "1" },
"paths": { "/x": { "get": {
"parameters": [ { "name": "q", "in": "query", "schema": { "type": "string", "pattern": "[" } } ],
"responses": { "200": { "description": "ok" } }
} } }
}"#;
let spec = parse_spec(broken).expect("spec parses structurally");
assert!(
CompiledSpec::compile(&spec, &ApiSchemaEnforcementConfig::default()).is_err(),
"a parameter schema jsonschema cannot compile must be boot-fatal"
);
}
#[test]
fn valid_request_has_no_violations() {
assert!(post_body(&compiled(), "application/json", r#"{"name":"ada","age":30}"#).is_empty());
assert!(get_users(&compiled(), "5", &[("verbose", "true")]).is_empty());
}
#[test]
fn nullable_body_field_set_to_null_is_clean() {
assert!(post_body(&compiled(), "application/json", r#"{"name":"ada","nickname":null}"#).is_empty());
}
#[test]
fn missing_required_body_field_is_flagged() {
assert_eq!(
post_body(&compiled(), "application/json", r#"{"age":30}"#),
v(&[Violation::MissingRequired])
);
}
#[test]
fn wrong_body_type_is_a_type_violation() {
assert_eq!(
post_body(&compiled(), "application/json", r#"{"name":123}"#),
v(&[Violation::TypeViolation])
);
}
#[test]
fn unknown_body_property_is_flagged_via_additional_properties() {
assert_eq!(
post_body(&compiled(), "application/json", r#"{"name":"ada","isAdmin":true}"#),
v(&[Violation::UnknownProperty])
);
}
#[test]
fn undeclared_content_type_is_flagged_and_body_not_json_validated() {
assert_eq!(post_body(&compiled(), "text/plain", "hello"), v(&[Violation::ContentType]));
}
#[test]
fn unparseable_json_body_is_a_parse_error() {
assert_eq!(
post_body(&compiled(), "application/json", "{not json"),
v(&[Violation::BodyParseError])
);
}
#[test]
fn missing_required_body_altogether_is_flagged() {
let pp = vec![("id".to_string(), "1".to_string())];
let req = RequestParts {
path_params: &pp,
query_params: &[],
headers: &[],
content_type: None,
body: None,
};
assert_eq!(compiled().validate("/users/{id}", "post", &req), v(&[Violation::MissingRequired]));
}
#[test]
fn path_param_type_is_coerced_and_validated() {
assert!(get_users(&compiled(), "5", &[]).is_empty());
assert_eq!(get_users(&compiled(), "abc", &[]), v(&[Violation::TypeViolation]));
}
#[test]
fn undeclared_query_param_is_flagged_when_strict() {
assert_eq!(get_users(&compiled(), "5", &[("foo", "bar")]), v(&[Violation::UnknownParam]));
}
#[test]
fn wrong_query_param_type_is_a_type_violation() {
assert_eq!(
get_users(&compiled(), "5", &[("verbose", "maybe")]),
v(&[Violation::TypeViolation])
);
}
#[test]
fn strict_params_off_allows_undeclared_query_params() {
let spec = parse_spec(VALIDATION_SPEC).unwrap();
let cfg = ApiSchemaEnforcementConfig { strict_params: false, ..Default::default() };
let compiled = CompiledSpec::compile(&spec, &cfg).unwrap();
match compiled.resolve("/users/5", "GET") {
Resolution::Matched { template, path_params } => {
let q = vec![("foo".to_string(), "bar".to_string())];
let req = RequestParts {
path_params: &path_params,
query_params: &q,
headers: &[],
content_type: None,
body: None,
};
assert!(compiled.validate(&template, "get", &req).is_empty());
}
other => panic!("expected Matched, got {other:?}"),
}
}
}