Skip to main content

mockforge_bench/conformance/
schema_validator.rs

1//! Response schema validation JS generator
2//!
3//! Generates JavaScript validation expressions from OpenAPI schemas for use in k6 `check()` calls.
4
5use openapiv3::{ReferenceOr, Schema, SchemaKind, StringFormat, Type, VariantOrUnknownOrEmpty};
6
7/// Generates JavaScript validation expressions from OpenAPI schemas
8pub struct SchemaValidatorGenerator;
9
10impl SchemaValidatorGenerator {
11    /// Generate a JavaScript validation expression from an OpenAPI schema.
12    ///
13    /// The generated expression evaluates to `true` if `body` matches the schema,
14    /// `false` otherwise. It's designed to be used inside k6's `check()` callback.
15    pub fn generate_validation(schema: &Schema) -> String {
16        Self::generate_for_schema(schema, "body")
17    }
18
19    fn generate_for_schema(schema: &Schema, var: &str) -> String {
20        match &schema.schema_kind {
21            SchemaKind::Type(Type::Object(obj)) => {
22                let mut checks = vec![format!("typeof {} === 'object'", var)];
23                checks.push(format!("{} !== null", var));
24
25                // Check required fields exist
26                for field in &obj.required {
27                    checks.push(format!("'{}' in {}", field, var));
28                }
29
30                // Check property types
31                for (name, prop_ref) in &obj.properties {
32                    if let ReferenceOr::Item(prop_schema) = prop_ref {
33                        let prop_var = format!("{}['{}']", var, name);
34                        let type_check = Self::generate_type_check(prop_schema, &prop_var);
35                        if !type_check.is_empty() {
36                            // Only validate if the property exists (it might be optional)
37                            if obj.required.contains(name) {
38                                checks.push(type_check);
39                            } else {
40                                checks.push(format!(
41                                    "({} === undefined || {})",
42                                    prop_var, type_check
43                                ));
44                            }
45                        }
46                    }
47                }
48
49                checks.join(" && ")
50            }
51            SchemaKind::Type(Type::Array(arr)) => {
52                let mut checks = vec![format!("Array.isArray({})", var)];
53
54                if let Some(ReferenceOr::Item(item_schema)) = &arr.items {
55                    let item_check = Self::generate_type_check(item_schema, &format!("{}[0]", var));
56                    if !item_check.is_empty() {
57                        // Only validate items if array is non-empty
58                        checks.push(format!("({}.length === 0 || {})", var, item_check));
59                    }
60                }
61
62                checks.join(" && ")
63            }
64            SchemaKind::Type(Type::String(s)) => {
65                let mut checks = vec![format!("typeof {} === 'string'", var)];
66
67                // Format validation
68                let format_str = match &s.format {
69                    VariantOrUnknownOrEmpty::Item(StringFormat::Date) => Some("date"),
70                    VariantOrUnknownOrEmpty::Item(StringFormat::DateTime) => Some("date-time"),
71                    VariantOrUnknownOrEmpty::Unknown(f) => Some(f.as_str()),
72                    _ => None,
73                };
74                if let Some(fmt) = format_str {
75                    if let Some(regex) = Self::format_regex(fmt) {
76                        checks.push(format!("{}.match({})", var, regex));
77                    }
78                }
79
80                // Enum check
81                if !s.enumeration.is_empty() {
82                    let values: Vec<String> = s
83                        .enumeration
84                        .iter()
85                        .filter_map(|v| v.as_ref().map(|s| format!("'{}'", s)))
86                        .collect();
87                    if !values.is_empty() {
88                        checks.push(format!("[{}].includes({})", values.join(","), var));
89                    }
90                }
91
92                // Length constraints
93                if let Some(min) = s.min_length {
94                    checks.push(format!("{}.length >= {}", var, min));
95                }
96                if let Some(max) = s.max_length {
97                    checks.push(format!("{}.length <= {}", var, max));
98                }
99
100                checks.join(" && ")
101            }
102            SchemaKind::Type(Type::Integer(i)) => {
103                let mut checks = vec![format!("typeof {} === 'number'", var)];
104                checks.push(format!("Number.isInteger({})", var));
105
106                if let Some(min) = i.minimum {
107                    checks.push(format!("{} >= {}", var, min));
108                }
109                if let Some(max) = i.maximum {
110                    checks.push(format!("{} <= {}", var, max));
111                }
112                if !i.enumeration.is_empty() {
113                    let values: Vec<String> =
114                        i.enumeration.iter().filter_map(|v| v.map(|n| n.to_string())).collect();
115                    if !values.is_empty() {
116                        checks.push(format!("[{}].includes({})", values.join(","), var));
117                    }
118                }
119
120                checks.join(" && ")
121            }
122            SchemaKind::Type(Type::Number(n)) => {
123                let mut checks = vec![format!("typeof {} === 'number'", var)];
124
125                if let Some(min) = n.minimum {
126                    checks.push(format!("{} >= {}", var, min));
127                }
128                if let Some(max) = n.maximum {
129                    checks.push(format!("{} <= {}", var, max));
130                }
131
132                checks.join(" && ")
133            }
134            SchemaKind::Type(Type::Boolean(_)) => {
135                format!("typeof {} === 'boolean'", var)
136            }
137            _ => "true".to_string(), // For unknown/complex schemas, pass by default
138        }
139    }
140
141    /// Generate a simple type check expression (used for property validation)
142    fn generate_type_check(schema: &Schema, var: &str) -> String {
143        match &schema.schema_kind {
144            SchemaKind::Type(Type::String(_)) => format!("typeof {} === 'string'", var),
145            SchemaKind::Type(Type::Integer(_)) => format!("typeof {} === 'number'", var),
146            SchemaKind::Type(Type::Number(_)) => format!("typeof {} === 'number'", var),
147            SchemaKind::Type(Type::Boolean(_)) => format!("typeof {} === 'boolean'", var),
148            SchemaKind::Type(Type::Array(_)) => format!("Array.isArray({})", var),
149            SchemaKind::Type(Type::Object(_)) => {
150                format!("typeof {} === 'object' && {} !== null", var, var)
151            }
152            _ => String::new(),
153        }
154    }
155
156    /// Get a JS regex for a string format
157    fn format_regex(format: &str) -> Option<&'static str> {
158        match format {
159            "email" => Some(r#"/^[^\s@]+@[^\s@]+\.[^\s@]+$/"#),
160            "uuid" => Some(r#"/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i"#),
161            "date" => Some(r#"/^\d{4}-\d{2}-\d{2}$/"#),
162            "date-time" => Some(r#"/^\d{4}-\d{2}-\d{2}T/"#),
163            "uri" | "url" => Some(r#"/^https?:\/\//"#),
164            "ipv4" => Some(r#"/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/"#),
165            "ipv6" => Some(r#"/^[0-9a-fA-F:]+$/"#),
166            _ => None,
167        }
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use openapiv3::*;
175
176    fn string_schema() -> Schema {
177        Schema {
178            schema_data: SchemaData::default(),
179            schema_kind: SchemaKind::Type(Type::String(StringType::default())),
180        }
181    }
182
183    fn integer_schema() -> Schema {
184        Schema {
185            schema_data: SchemaData::default(),
186            schema_kind: SchemaKind::Type(Type::Integer(IntegerType::default())),
187        }
188    }
189
190    #[test]
191    fn test_string_validation() {
192        let js = SchemaValidatorGenerator::generate_validation(&string_schema());
193        assert!(js.contains("typeof body === 'string'"));
194    }
195
196    #[test]
197    fn test_integer_validation() {
198        let js = SchemaValidatorGenerator::generate_validation(&integer_schema());
199        assert!(js.contains("typeof body === 'number'"));
200        assert!(js.contains("Number.isInteger(body)"));
201    }
202
203    #[test]
204    fn test_boolean_validation() {
205        let schema = Schema {
206            schema_data: SchemaData::default(),
207            schema_kind: SchemaKind::Type(Type::Boolean(BooleanType::default())),
208        };
209        let js = SchemaValidatorGenerator::generate_validation(&schema);
210        assert_eq!(js, "typeof body === 'boolean'");
211    }
212
213    #[test]
214    fn test_object_validation() {
215        let mut obj = ObjectType::default();
216        obj.required.push("name".to_string());
217        obj.properties
218            .insert("name".to_string(), ReferenceOr::Item(Box::new(string_schema())));
219        obj.properties
220            .insert("age".to_string(), ReferenceOr::Item(Box::new(integer_schema())));
221
222        let schema = Schema {
223            schema_data: SchemaData::default(),
224            schema_kind: SchemaKind::Type(Type::Object(obj)),
225        };
226
227        let js = SchemaValidatorGenerator::generate_validation(&schema);
228        assert!(js.contains("typeof body === 'object'"));
229        assert!(js.contains("'name' in body"));
230        assert!(js.contains("typeof body['name'] === 'string'"));
231        // age is optional
232        assert!(js.contains("body['age'] === undefined || typeof body['age'] === 'number'"));
233    }
234
235    #[test]
236    fn test_array_validation() {
237        let arr = ArrayType {
238            items: Some(ReferenceOr::Item(Box::new(string_schema()))),
239            min_items: None,
240            max_items: None,
241            unique_items: false,
242        };
243
244        let schema = Schema {
245            schema_data: SchemaData::default(),
246            schema_kind: SchemaKind::Type(Type::Array(arr)),
247        };
248
249        let js = SchemaValidatorGenerator::generate_validation(&schema);
250        assert!(js.contains("Array.isArray(body)"));
251        assert!(js.contains("typeof body[0] === 'string'"));
252    }
253
254    #[test]
255    fn test_format_regex() {
256        assert!(SchemaValidatorGenerator::format_regex("email").is_some());
257        assert!(SchemaValidatorGenerator::format_regex("uuid").is_some());
258        assert!(SchemaValidatorGenerator::format_regex("date").is_some());
259        assert!(SchemaValidatorGenerator::format_regex("date-time").is_some());
260        assert!(SchemaValidatorGenerator::format_regex("uri").is_some());
261        assert!(SchemaValidatorGenerator::format_regex("ipv4").is_some());
262        assert!(SchemaValidatorGenerator::format_regex("ipv6").is_some());
263        assert!(SchemaValidatorGenerator::format_regex("unknown").is_none());
264    }
265
266    #[test]
267    fn test_string_with_date_format() {
268        let schema = Schema {
269            schema_data: SchemaData::default(),
270            schema_kind: SchemaKind::Type(Type::String(StringType {
271                format: VariantOrUnknownOrEmpty::Item(StringFormat::Date),
272                ..Default::default()
273            })),
274        };
275
276        let js = SchemaValidatorGenerator::generate_validation(&schema);
277        assert!(js.contains("typeof body === 'string'"));
278        assert!(js.contains(".match("));
279    }
280
281    #[test]
282    fn test_integer_with_range() {
283        let int = IntegerType {
284            minimum: Some(0),
285            maximum: Some(100),
286            ..Default::default()
287        };
288
289        let schema = Schema {
290            schema_data: SchemaData::default(),
291            schema_kind: SchemaKind::Type(Type::Integer(int)),
292        };
293
294        let js = SchemaValidatorGenerator::generate_validation(&schema);
295        assert!(js.contains("body >= 0"));
296        assert!(js.contains("body <= 100"));
297    }
298
299    #[test]
300    fn test_number_validation() {
301        let num = NumberType {
302            minimum: Some(0.0),
303            maximum: Some(99.9),
304            ..Default::default()
305        };
306
307        let schema = Schema {
308            schema_data: SchemaData::default(),
309            schema_kind: SchemaKind::Type(Type::Number(num)),
310        };
311
312        let js = SchemaValidatorGenerator::generate_validation(&schema);
313        assert!(js.contains("typeof body === 'number'"));
314        assert!(js.contains("body >= 0"));
315        assert!(js.contains("body <= 99.9"));
316    }
317}