1use openapiv3::{
37 AdditionalProperties, NumberType, ObjectType, Schema, SchemaKind, StringType, Type,
38};
39use serde_json::{json, Value};
40
41#[derive(Debug, Clone)]
45pub struct BodyMutation {
46 pub label: String,
48 pub body: Value,
50}
51
52pub fn mutate_body(sample: &Value, schema: &Schema) -> Vec<BodyMutation> {
57 let mut mutations = Vec::new();
58
59 if let SchemaKind::Type(t) = &schema.schema_kind {
63 match t {
64 Type::Object(_) => mutations.push(BodyMutation {
65 label: "request-body:type-mismatch:$root".to_string(),
66 body: json!([sample.clone()]),
67 }),
68 Type::Array(_) => mutations.push(BodyMutation {
69 label: "request-body:type-mismatch:$root".to_string(),
70 body: json!({"unexpected": sample.clone()}),
71 }),
72 _ => {}
73 }
74 }
75
76 if let SchemaKind::Type(Type::Object(obj)) = &schema.schema_kind {
79 walk_object(sample, obj, "", &mut mutations);
80 }
81
82 mutations
83}
84
85fn walk_object(sample: &Value, obj: &ObjectType, prefix: &str, out: &mut Vec<BodyMutation>) {
86 let sample_obj = match sample.as_object() {
87 Some(o) => o,
88 None => return,
89 };
90
91 for field in obj.required.iter().take(5) {
95 if sample_obj.contains_key(field) {
96 let mut mutated = sample.clone();
97 if let Some(o) = mutated.as_object_mut() {
98 o.remove(field);
99 }
100 out.push(BodyMutation {
101 label: format!("request-body:required-removed:{}{}", prefix, field),
102 body: mutated,
103 });
104 }
105 }
106
107 for (field_name, field_schema_ref) in obj.properties.iter().take(20) {
112 let field_schema = match field_schema_ref.as_item() {
113 Some(s) => s,
114 None => continue, };
116 let path = format!("{}{}", prefix, field_name);
117 mutate_field(sample, sample_obj, field_name, field_schema, &path, out);
118 }
119
120 if matches!(obj.additional_properties, Some(AdditionalProperties::Any(false))) {
123 let mut mutated = sample.clone();
124 if let Some(o) = mutated.as_object_mut() {
125 o.insert("self_test_extra_field".to_string(), json!("extra"));
126 }
127 out.push(BodyMutation {
128 label: format!("request-body:additional-property:{}$root", prefix),
129 body: mutated,
130 });
131 }
132}
133
134fn mutate_field(
135 sample: &Value,
136 _sample_obj: &serde_json::Map<String, Value>,
137 field_name: &str,
138 field_schema: &Schema,
139 path: &str,
140 out: &mut Vec<BodyMutation>,
141) {
142 let set_field = |new: Value| -> Value {
144 let mut mutated = sample.clone();
145 if let Some(o) = mutated.as_object_mut() {
146 o.insert(field_name.to_string(), new);
147 }
148 mutated
149 };
150
151 match &field_schema.schema_kind {
152 SchemaKind::Type(Type::String(s)) => mutate_string_field(s, &set_field, path, out),
153 SchemaKind::Type(Type::Number(n)) => mutate_number_field(n, &set_field, path, out, false),
154 SchemaKind::Type(Type::Integer(_)) => {
155 out.push(BodyMutation {
159 label: format!("request-body:type-mismatch:{}", path),
160 body: set_field(json!("not-an-integer")),
161 });
162 out.push(BodyMutation {
163 label: format!("request-body:integer-as-float:{}", path),
164 body: set_field(json!(1.5)),
165 });
166 }
167 SchemaKind::Type(Type::Boolean(_)) => {
168 out.push(BodyMutation {
169 label: format!("request-body:type-mismatch:{}", path),
170 body: set_field(json!("not-a-boolean")),
171 });
172 }
173 SchemaKind::Type(Type::Array(_)) => {
174 out.push(BodyMutation {
175 label: format!("request-body:type-mismatch:{}", path),
176 body: set_field(json!({"not-an-array": true})),
177 });
178 }
179 SchemaKind::Type(Type::Object(_)) => {
180 out.push(BodyMutation {
181 label: format!("request-body:type-mismatch:{}", path),
182 body: set_field(json!("not-an-object")),
183 });
184 }
185 _ => {}
187 }
188
189 if !field_schema.schema_data.extensions.is_empty() {
191 }
193 if let SchemaKind::Type(Type::String(s)) = &field_schema.schema_kind {
194 if !s.enumeration.is_empty() {
195 out.push(BodyMutation {
197 label: format!("request-body:enum-out-of-range:{}", path),
198 body: set_field(json!("self-test-not-in-enum")),
199 });
200 }
201 }
202}
203
204fn mutate_string_field(
205 s: &StringType,
206 set_field: &dyn Fn(Value) -> Value,
207 path: &str,
208 out: &mut Vec<BodyMutation>,
209) {
210 out.push(BodyMutation {
212 label: format!("request-body:type-mismatch:{}", path),
213 body: set_field(json!(12345)),
214 });
215
216 if let Some(min) = s.min_length {
218 if min >= 1 {
219 out.push(BodyMutation {
220 label: format!("request-body:min-length:{}", path),
221 body: set_field(json!("")),
222 });
223 }
224 }
225
226 if let Some(max) = s.max_length {
228 let too_long: String = "x".repeat(max.saturating_add(1).min(10_000));
229 out.push(BodyMutation {
230 label: format!("request-body:max-length:{}", path),
231 body: set_field(json!(too_long)),
232 });
233 }
234
235 if let Some(_pattern) = &s.pattern {
237 out.push(BodyMutation {
241 label: format!("request-body:pattern:{}", path),
242 body: set_field(json!("!!!self-test-pattern-violation!!!")),
243 });
244 }
245}
246
247fn mutate_number_field(
248 n: &NumberType,
249 set_field: &dyn Fn(Value) -> Value,
250 path: &str,
251 out: &mut Vec<BodyMutation>,
252 _integer: bool,
253) {
254 out.push(BodyMutation {
256 label: format!("request-body:type-mismatch:{}", path),
257 body: set_field(json!("not-a-number")),
258 });
259
260 if let Some(min) = n.minimum {
262 out.push(BodyMutation {
263 label: format!("request-body:minimum:{}", path),
264 body: set_field(json!(min - 1.0)),
265 });
266 }
267 if let Some(max) = n.maximum {
269 out.push(BodyMutation {
270 label: format!("request-body:maximum:{}", path),
271 body: set_field(json!(max + 1.0)),
272 });
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use openapiv3::{ObjectType, ReferenceOr, Schema, SchemaData, SchemaKind, Type};
280 use std::collections::BTreeSet;
281
282 fn object_schema(props: Vec<(&str, Schema)>, required: Vec<&str>) -> Schema {
283 let mut obj = ObjectType::default();
284 for (name, schema) in props {
285 obj.properties.insert(name.to_string(), ReferenceOr::Item(Box::new(schema)));
286 }
287 obj.required = required.into_iter().map(|s| s.to_string()).collect();
288 Schema {
289 schema_data: SchemaData::default(),
290 schema_kind: SchemaKind::Type(Type::Object(obj)),
291 }
292 }
293
294 fn string_field(min: Option<usize>, max: Option<usize>, pattern: Option<&str>) -> Schema {
295 let s = StringType {
296 min_length: min,
297 max_length: max,
298 pattern: pattern.map(|p| p.to_string()),
299 ..Default::default()
300 };
301 Schema {
302 schema_data: SchemaData::default(),
303 schema_kind: SchemaKind::Type(Type::String(s)),
304 }
305 }
306
307 fn integer_field() -> Schema {
308 Schema {
309 schema_data: SchemaData::default(),
310 schema_kind: SchemaKind::Type(Type::Integer(openapiv3::IntegerType::default())),
311 }
312 }
313
314 #[test]
315 fn empty_for_non_object_root() {
316 let s = string_field(None, None, None);
317 let m = mutate_body(&json!("hello"), &s);
318 assert!(m.is_empty(), "string root produces no body mutations");
319 }
320
321 #[test]
322 fn required_field_removed_for_each_required() {
323 let s = object_schema(
324 vec![
325 ("name", string_field(None, None, None)),
326 ("age", integer_field()),
327 ],
328 vec!["name", "age"],
329 );
330 let m = mutate_body(&json!({"name": "Ada", "age": 30}), &s);
331 let labels: BTreeSet<String> = m.iter().map(|x| x.label.clone()).collect();
332 assert!(labels.contains("request-body:required-removed:name"), "{labels:?}");
333 assert!(labels.contains("request-body:required-removed:age"), "{labels:?}");
334 }
335
336 #[test]
337 fn type_mismatch_for_typed_fields() {
338 let s = object_schema(vec![("name", string_field(None, None, None))], vec![]);
339 let m = mutate_body(&json!({"name": "Ada"}), &s);
340 let labels: BTreeSet<String> = m.iter().map(|x| x.label.clone()).collect();
341 assert!(labels.contains("request-body:type-mismatch:name"), "{labels:?}");
342 }
343
344 #[test]
345 fn min_max_length_for_string() {
346 let s = object_schema(vec![("user", string_field(Some(3), Some(10), None))], vec![]);
347 let m = mutate_body(&json!({"user": "abc"}), &s);
348 let labels: BTreeSet<String> = m.iter().map(|x| x.label.clone()).collect();
349 assert!(labels.contains("request-body:min-length:user"), "{labels:?}");
350 assert!(labels.contains("request-body:max-length:user"), "{labels:?}");
351 }
352
353 #[test]
354 fn pattern_violation_emitted() {
355 let s = object_schema(
356 vec![("ssn", string_field(None, None, Some(r"^\d{3}-\d{2}-\d{4}$")))],
357 vec![],
358 );
359 let m = mutate_body(&json!({"ssn": "123-45-6789"}), &s);
360 let labels: BTreeSet<String> = m.iter().map(|x| x.label.clone()).collect();
361 assert!(labels.contains("request-body:pattern:ssn"), "{labels:?}");
362 }
363
364 #[test]
365 fn integer_specific_mutations() {
366 let s = object_schema(vec![("age", integer_field())], vec![]);
367 let m = mutate_body(&json!({"age": 30}), &s);
368 let labels: BTreeSet<String> = m.iter().map(|x| x.label.clone()).collect();
369 assert!(labels.contains("request-body:type-mismatch:age"), "{labels:?}");
370 assert!(labels.contains("request-body:integer-as-float:age"), "{labels:?}");
371 }
372
373 #[test]
374 fn root_type_mismatch_for_object_root() {
375 let s = object_schema(vec![], vec![]);
376 let m = mutate_body(&json!({}), &s);
377 let labels: BTreeSet<String> = m.iter().map(|x| x.label.clone()).collect();
378 assert!(labels.contains("request-body:type-mismatch:$root"), "{labels:?}");
379 }
380}