1use crate::{AdditionalProperties, Api, SchemaKind, SchemaValue};
4use serde::Serialize;
5use serde_json::{Map, Value, json};
6use std::collections::BTreeSet;
7
8#[derive(Clone, Copy, Debug)]
9pub struct SampleOptions {
10 pub max_depth: usize,
11 pub max_samples: usize,
12 pub max_array_items: usize,
13}
14impl Default for SampleOptions {
15 fn default() -> Self {
16 Self {
17 max_depth: 12,
18 max_samples: 16,
19 max_array_items: 3,
20 }
21 }
22}
23#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
24pub struct WireSample {
25 pub name: String,
26 pub value: Value,
27}
28#[derive(Clone, Debug, Default, Serialize)]
29pub struct SampleReport {
30 pub samples: Vec<WireSample>,
31 pub diagnostics: Vec<String>,
32}
33
34pub fn schema_samples(api: &Api, schema: &SchemaValue, options: SampleOptions) -> SampleReport {
38 let mut report = SampleReport::default();
39 if options.max_samples == 0 {
40 return report;
41 }
42 for (name, full, nulls, alternative) in [
43 ("minimal".to_owned(), false, false, 0),
44 ("full".to_owned(), true, false, 0),
45 ("nullable".to_owned(), true, true, 0),
46 ]
47 .into_iter()
48 .chain(
49 (1..options.max_samples).map(|choice| (format!("variant-{choice}"), true, false, choice)),
50 ) {
51 let mut seen = BTreeSet::new();
52 match build(api, schema, options, 0, full, nulls, alternative, &mut seen) {
53 Ok(value) => {
54 if !report.samples.iter().any(|sample| sample.value == value) {
55 report.samples.push(WireSample { name, value });
56 if report.samples.len() >= options.max_samples {
57 break;
58 }
59 }
60 }
61 Err(error) => {
62 if !report.diagnostics.contains(&error) {
63 report.diagnostics.push(error);
64 }
65 }
66 }
67 }
68 report
69}
70
71#[allow(clippy::too_many_arguments)]
72fn build(
73 api: &Api,
74 schema: &SchemaValue,
75 options: SampleOptions,
76 depth: usize,
77 full: bool,
78 nulls: bool,
79 choice: usize,
80 seen: &mut BTreeSet<String>,
81) -> Result<Value, String> {
82 if let Some(value) = &schema.const_value {
83 return Ok(value.clone());
84 }
85 if !schema.enum_values.is_empty() {
86 return Ok(schema.enum_values[choice % schema.enum_values.len()].clone());
87 }
88 if (nulls || depth >= options.max_depth) && (schema.nullable || schema.nullish) {
89 return Ok(Value::Null);
90 }
91 if depth > options.max_depth {
92 return Err("sample nesting bound reached".into());
93 }
94 for keyword in schema.constraints.keys() {
96 if !matches!(
97 keyword.as_str(),
98 "minimum"
99 | "maximum"
100 | "exclusiveMinimum"
101 | "exclusiveMaximum"
102 | "multipleOf"
103 | "minLength"
104 | "maxLength"
105 | "minItems"
106 | "maxItems"
107 | "uniqueItems"
108 | "minProperties"
109 | "maxProperties"
110 ) {
111 return Err(format!(
112 "sample generation does not support constraint {keyword}"
113 ));
114 }
115 }
116 let recurse = |value: &SchemaValue, seen: &mut BTreeSet<String>| {
117 build(api, value, options, depth + 1, full, nulls, choice, seen)
118 };
119 let value = match &schema.kind {
120 SchemaKind::Any => json!({"example": "value"}),
121 SchemaKind::Null => Value::Null,
122 SchemaKind::Boolean => Value::Bool(choice % 2 == 0),
123 SchemaKind::Integer | SchemaKind::Number => {
124 let integer = matches!(schema.kind, SchemaKind::Integer);
125 let minimum = schema
126 .constraints
127 .get("minimum")
128 .and_then(Value::as_f64)
129 .unwrap_or_else(|| {
130 schema
131 .constraints
132 .get("maximum")
133 .and_then(Value::as_f64)
134 .or_else(|| {
135 schema
136 .constraints
137 .get("exclusiveMaximum")
138 .and_then(Value::as_f64)
139 .map(|maximum| maximum - 1.0)
140 })
141 .unwrap_or(0.0)
142 .min(0.0)
143 });
144 let exclusive_min = schema
145 .constraints
146 .get("exclusiveMinimum")
147 .and_then(Value::as_f64);
148 let mut number = exclusive_min.map_or(minimum, |minimum| minimum + 1.0);
149 if integer {
150 number = number.ceil();
151 }
152 if let Some(multiple) = schema.constraints.get("multipleOf").and_then(Value::as_f64) {
153 if !multiple.is_finite() || multiple <= 0.0 {
154 return Err("invalid multipleOf constraint".into());
155 }
156 number = (number / multiple).ceil() * multiple;
157 if integer && number.fract() != 0.0 {
158 return Err("cannot construct integer multipleOf fixture".into());
159 }
160 }
161 if schema
162 .constraints
163 .get("maximum")
164 .and_then(Value::as_f64)
165 .is_some_and(|max| number > max)
166 || schema
167 .constraints
168 .get("exclusiveMaximum")
169 .and_then(Value::as_f64)
170 .is_some_and(|max| number >= max)
171 {
172 return Err("incompatible numeric sample bounds".into());
173 }
174 if integer {
175 if number < i64::MIN as f64 || number >= i64::MAX as f64 {
176 return Err("integer fixture exceeds signed 64-bit range".into());
177 }
178 if schema.format.as_deref() == Some("int64")
179 && schema.constraints.is_empty()
180 && choice > 0
181 {
182 Value::from(if choice % 2 == 0 {
183 -9_007_199_254_740_993_i64
184 } else {
185 9_007_199_254_740_993_i64
186 })
187 } else {
188 Value::from(number as i64)
189 }
190 } else {
191 serde_json::Number::from_f64(number)
192 .map(Value::Number)
193 .ok_or("non-finite numeric fixture")?
194 }
195 }
196 SchemaKind::String => {
197 let example = match schema.format.as_deref() {
198 Some("date") => "2024-01-02",
199 Some("date-time") => "2024-01-02T03:04:05Z",
200 Some("uuid") => "00000000-0000-4000-8000-000000000001",
201 Some("email") => "sample@example.com",
202 Some("uri" | "url") => "https://example.com/",
203 Some("byte") => "aGVsbG8=",
204 _ => "sample",
205 };
206 let min = usize_constraint(schema, "minLength", 0)?;
207 let max = usize_constraint(schema, "maxLength", usize::MAX)?;
208 if min > max || min > 4096 {
209 return Err("string sample bounds exceed supported budget".into());
210 }
211 let mut value = example.to_owned();
212 if schema.format.is_some() && (value.len() < min || value.len() > max) {
213 return Err("format and length bounds need a custom fixture".into());
214 }
215 if value.len() < min {
216 value.extend(std::iter::repeat_n('x', min - value.len()));
217 }
218 if value.len() > max {
219 value.truncate(max);
220 }
221 Value::String(value)
222 }
223 SchemaKind::Array { items } => {
224 let min = usize_constraint(schema, "minItems", 0)?;
225 let max = usize_constraint(schema, "maxItems", options.max_array_items)?;
226 if min > max || min > options.max_array_items {
227 return Err("array sample bounds exceed configured budget".into());
228 }
229 let count = if full {
230 min.max(1).min(max).min(options.max_array_items)
231 } else {
232 min
233 };
234 let mut values = Vec::new();
235 for index in 0..count {
236 let item = build(
237 api,
238 items,
239 options,
240 depth + 1,
241 full,
242 nulls,
243 choice + index,
244 seen,
245 )?;
246 if schema.constraints.get("uniqueItems") == Some(&Value::Bool(true))
247 && values.contains(&item)
248 {
249 return Err("cannot construct sufficient unique array items".into());
250 }
251 values.push(item);
252 }
253 Value::Array(values)
254 }
255 SchemaKind::Object {
256 fields,
257 additional_properties,
258 } => {
259 let mut values = Map::new();
260 for field in fields {
261 if field.required || full {
262 match recurse(&field.value, seen) {
263 Ok(value) => {
264 values.insert(field.name.clone(), value);
265 }
266 Err(_) if !field.required => {}
267 Err(error) => {
268 return Err(format!("required field {}: {error}", field.name));
269 }
270 }
271 }
272 }
273 if full {
274 let mut name = "additional_example".to_owned();
275 while values.contains_key(&name) {
276 name.push('_');
277 }
278 match additional_properties {
279 AdditionalProperties::Any | AdditionalProperties::Unspecified => {
280 values.insert(name, json!({"retained": true}));
281 }
282 AdditionalProperties::Schema { value } => {
283 if let Ok(value) = recurse(value, seen) {
284 values.insert(name, value);
285 }
286 }
287 AdditionalProperties::Forbidden => {}
288 }
289 }
290 let min = usize_constraint(schema, "minProperties", 0)?;
291 let max = usize_constraint(schema, "maxProperties", usize::MAX)?;
292 if values.len() < min || values.len() > max {
293 return Err("object sample does not fit property-count constraints".into());
294 }
295 Value::Object(values)
296 }
297 SchemaKind::Reference { reference } => {
298 if !reference.starts_with("#/components/schemas/") && reference.contains('/') {
299 return Err(format!("unresolved external sample reference {reference}"));
300 }
301 let name = reference
302 .rsplit('/')
303 .next()
304 .unwrap()
305 .replace("~1", "/")
306 .replace("~0", "~");
307 if !seen.insert(name.clone()) {
308 return Err(format!("required recursive sample reference {name}"));
309 }
310 let target = api
311 .schemas
312 .iter()
313 .find(|schema| schema.name == name)
314 .ok_or_else(|| format!("sample reference {name} not found"))?;
315 let result = recurse(&target.value, seen);
316 seen.remove(&name);
317 result?
318 }
319 SchemaKind::OneOf { variants } | SchemaKind::AnyOf { variants } => {
320 if variants.is_empty() {
321 return Err("empty sample union".into());
322 }
323 let index = choice % variants.len();
324 let variant = &variants[index];
325 let mut value = recurse(variant, seen)?;
326 if let Some(discriminator) = &schema.discriminator {
327 if let Some(object) = value.as_object_mut() {
328 let mapping = discriminator.mapping.iter().find(|(_, reference)| {
329 variant
330 .kind
331 .reference_name()
332 .is_some_and(|name| reference.rsplit('/').next() == Some(name))
333 });
334 if let Some((tag, _)) = mapping {
335 object.insert(
336 discriminator.property_name.clone(),
337 Value::String(tag.clone()),
338 );
339 }
340 }
341 }
342 value
343 }
344 SchemaKind::AllOf { variants } => {
345 let mut combined = Map::new();
346 for variant in variants {
347 let Value::Object(object) = recurse(variant, seen)? else {
348 return Err("non-object allOf requires custom fixture".into());
349 };
350 for (key, value) in object {
351 if combined
352 .get(&key)
353 .is_some_and(|existing| existing != &value)
354 {
355 return Err(format!("conflicting allOf sample property {key}"));
356 }
357 combined.insert(key, value);
358 }
359 }
360 Value::Object(combined)
361 }
362 SchemaKind::Not { .. } => return Err("not schemas require a custom fixture".into()),
363 };
364 Ok(value)
365}
366fn usize_constraint(schema: &SchemaValue, name: &str, default: usize) -> Result<usize, String> {
367 match schema.constraints.get(name) {
368 None => Ok(default),
369 Some(value) => value
370 .as_u64()
371 .and_then(|value| usize::try_from(value).ok())
372 .ok_or_else(|| format!("invalid {name} constraint")),
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use crate::{Field, Schema};
380 fn field(name: &str, value: SchemaValue, required: bool) -> Field {
381 Field {
382 name: name.into(),
383 value,
384 required,
385 annotations: Default::default(),
386 }
387 }
388 #[test]
389 fn creates_omission_null_enum_and_additional_property_samples() {
390 let mut enumeration = SchemaValue::new(SchemaKind::String);
391 enumeration.enum_values = vec![json!("first"), json!("second")];
392 let mut nullable = SchemaValue::new(SchemaKind::String);
393 nullable.nullable = true;
394 let schema = SchemaValue::new(SchemaKind::Object {
395 fields: vec![
396 field("state", enumeration, true),
397 field("note", nullable, false),
398 ],
399 additional_properties: AdditionalProperties::Any,
400 });
401 let report = schema_samples(&Api::default(), &schema, SampleOptions::default());
402 assert!(report.diagnostics.is_empty());
403 assert!(
404 report
405 .samples
406 .iter()
407 .any(|sample| sample.value.get("note").is_none())
408 );
409 assert!(
410 report
411 .samples
412 .iter()
413 .any(|sample| sample.value.get("note") == Some(&Value::Null))
414 );
415 assert!(
416 report
417 .samples
418 .iter()
419 .any(|sample| sample.value["state"] == "second")
420 );
421 assert!(
422 report
423 .samples
424 .iter()
425 .any(|sample| sample.value.get("additional_example").is_some())
426 );
427 }
428 #[test]
429 fn recursive_optional_fields_terminate_required_cycles_report_failure() {
430 let optional = SchemaValue::new(SchemaKind::Object {
431 fields: vec![field(
432 "next",
433 SchemaValue::reference("#/components/schemas/Node"),
434 false,
435 )],
436 additional_properties: AdditionalProperties::Forbidden,
437 });
438 let mut api = Api {
439 schemas: vec![Schema::new("Node", optional)],
440 ..Default::default()
441 };
442 let report = schema_samples(
443 &api,
444 &SchemaValue::reference("#/components/schemas/Node"),
445 SampleOptions::default(),
446 );
447 assert!(!report.samples.is_empty());
448 if let SchemaKind::Object { fields, .. } = &mut api.schemas[0].value.kind {
449 fields[0].required = true;
450 }
451 let report = schema_samples(
452 &api,
453 &SchemaValue::reference("#/components/schemas/Node"),
454 SampleOptions::default(),
455 );
456 assert!(report.samples.is_empty());
457 assert!(!report.diagnostics.is_empty());
458 }
459 #[test]
460 fn int64_fixtures_keep_digits_beyond_javascript_precision() {
461 let mut schema = SchemaValue::new(SchemaKind::Integer);
462 schema.format = Some("int64".into());
463 let report = schema_samples(&Api::default(), &schema, SampleOptions::default());
464 assert!(
465 report
466 .samples
467 .iter()
468 .any(|sample| sample.value.as_i64() == Some(9_007_199_254_740_993))
469 );
470 assert!(
471 report
472 .samples
473 .iter()
474 .any(|sample| sample.value.as_i64() == Some(-9_007_199_254_740_993))
475 );
476 }
477
478 #[test]
479 fn unions_bounds_and_unsupported_constraints_are_explicit() {
480 let schema = SchemaValue::new(SchemaKind::OneOf {
481 variants: vec![
482 SchemaValue::new(SchemaKind::String),
483 SchemaValue::new(SchemaKind::Integer),
484 ],
485 });
486 let report = schema_samples(
487 &Api::default(),
488 &schema,
489 SampleOptions {
490 max_samples: 2,
491 ..Default::default()
492 },
493 );
494 assert_eq!(report.samples.len(), 2);
495 assert!(report.samples.iter().any(|sample| sample.value.is_string()));
496 assert!(report.samples.iter().any(|sample| sample.value.is_number()));
497 let mut constrained = SchemaValue::new(SchemaKind::String);
498 constrained
499 .constraints
500 .insert("pattern".into(), json!("^[A-Z]+$"));
501 let report = schema_samples(&Api::default(), &constrained, SampleOptions::default());
502 assert!(report.samples.is_empty());
503 assert!(report.diagnostics[0].contains("pattern"));
504 }
505}