Skip to main content

poolster_core/
samples.rs

1//! Deterministic, bounded wire fixtures for serialization consumer plugins.
2//! These are structural round-trip inputs, not a complete JSON Schema validator.
3use 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
34/// Produce minimal, full, nullable and enum/union alternatives. Bounds apply to
35/// nesting, sample count and array length. Impossible required recursion yields
36/// a diagnostic rather than an invalid null placeholder.
37pub 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    // Never claim fixtures satisfy constraints we do not implement.
95    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}