Skip to content

Commit c888d48

Browse files
authored
feat: schema prefixItems support (#226)
1 parent cf5a47f commit c888d48

File tree

4 files changed

+326
-6
lines changed

4 files changed

+326
-6
lines changed

crates/oas3/src/spec/schema.rs

Lines changed: 191 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,12 +165,26 @@ pub struct ObjectSchema {
165165
///
166166
/// Omitting this keyword has the same assertion behavior as an empty schema.
167167
///
168+
/// This keyword can be either:
169+
/// - A boolean value: `false` means no additional items allowed, `true` means any additional items allowed
170+
/// - A schema object: validates all additional items against this schema
171+
///
168172
/// See <https://json-schema.org/draft/2020-12/json-schema-core#name-items>.
169173
#[serde(skip_serializing_if = "Option::is_none")]
170-
pub items: Option<Box<ObjectOrReference<ObjectSchema>>>,
174+
pub items: Option<Box<Schema>>,
175+
176+
/// Validation succeeds if each element of the instance validates against the
177+
/// schema at the same position, if any.
178+
///
179+
/// This keyword does not constrain the length of the array.
180+
/// If the array is longer than this keyword's value,
181+
/// this keyword validates only the prefix of matching length.
182+
///
183+
/// See <https://json-schema.org/draft/2020-12/json-schema-core#name-prefixitems>.
184+
#[serde(rename = "prefixItems", default, skip_serializing_if = "Vec::is_empty")]
185+
pub prefix_items: Vec<ObjectOrReference<ObjectSchema>>,
171186

172187
// TODO: missing fields
173-
// - prefixItems
174188
// - contains
175189

176190
// #########################################################################
@@ -657,4 +671,179 @@ mod tests {
657671
assert!(schema.discriminator.is_some());
658672
assert_eq!(2, schema.discriminator.unwrap().mapping.unwrap().len());
659673
}
674+
675+
#[test]
676+
fn prefix_items_basic() {
677+
let spec = indoc::indoc! {"
678+
type: array
679+
prefixItems:
680+
- type: string
681+
- $ref: '#/components/schemas/Age'
682+
- type: integer
683+
"};
684+
let schema = serde_yaml::from_str::<ObjectSchema>(spec).unwrap();
685+
686+
assert_eq!(schema.prefix_items.len(), 3);
687+
688+
// Check first schema (inline)
689+
if let ObjectOrReference::Object(first_schema) = &schema.prefix_items[0] {
690+
assert_eq!(
691+
first_schema.schema_type,
692+
Some(TypeSet::Single(Type::String))
693+
);
694+
} else {
695+
panic!("Expected inline schema for first prefixItems element");
696+
}
697+
698+
// Check second schema (reference)
699+
if let ObjectOrReference::Ref { ref_path } = &schema.prefix_items[1] {
700+
assert_eq!(ref_path, "#/components/schemas/Age");
701+
} else {
702+
panic!("Expected reference for second prefixItems element");
703+
}
704+
705+
// Check third schema (inline)
706+
if let ObjectOrReference::Object(third_schema) = &schema.prefix_items[2] {
707+
assert_eq!(
708+
third_schema.schema_type,
709+
Some(TypeSet::Single(Type::Integer))
710+
);
711+
} else {
712+
panic!("Expected inline schema for third prefixItems element");
713+
}
714+
}
715+
716+
#[test]
717+
fn prefix_items_with_items() {
718+
let spec = indoc::indoc! {"
719+
type: array
720+
prefixItems:
721+
- type: string
722+
items:
723+
type: number
724+
"};
725+
let schema = serde_yaml::from_str::<ObjectSchema>(spec).unwrap();
726+
727+
assert_eq!(schema.prefix_items.len(), 1);
728+
assert!(schema.items.is_some());
729+
730+
// Check prefixItems
731+
if let ObjectOrReference::Object(prefix_schema) = &schema.prefix_items[0] {
732+
assert_eq!(
733+
prefix_schema.schema_type,
734+
Some(TypeSet::Single(Type::String))
735+
);
736+
} else {
737+
panic!("Expected inline schema for prefixItems element");
738+
}
739+
740+
// Check items
741+
if let Some(items_box) = &schema.items {
742+
if let Schema::Object(obj_ref) = items_box.as_ref() {
743+
if let ObjectOrReference::Object(items_schema) = obj_ref.as_ref() {
744+
assert_eq!(
745+
items_schema.schema_type,
746+
Some(TypeSet::Single(Type::Number))
747+
);
748+
} else {
749+
panic!("Expected inline schema for items");
750+
}
751+
} else {
752+
panic!("Expected object schema for items");
753+
}
754+
} else {
755+
panic!("Expected items to be present");
756+
}
757+
}
758+
759+
#[test]
760+
fn prefix_items_empty() {
761+
let spec = indoc::indoc! {"
762+
type: array
763+
prefixItems: []
764+
"};
765+
let schema = serde_yaml::from_str::<ObjectSchema>(spec).unwrap();
766+
767+
assert_eq!(schema.prefix_items.len(), 0);
768+
}
769+
770+
#[test]
771+
fn prefix_items_serialization_round_trip() {
772+
let spec = indoc::indoc! {"
773+
type: array
774+
prefixItems:
775+
- type: string
776+
minLength: 5
777+
- type: integer
778+
minimum: 0
779+
items:
780+
type: boolean
781+
"};
782+
783+
// Deserialize from YAML
784+
let original = serde_yaml::from_str::<ObjectSchema>(spec).unwrap();
785+
786+
// Serialize to YAML
787+
let serialized = serde_yaml::to_string(&original).unwrap();
788+
789+
// Deserialize back
790+
let round_tripped = serde_yaml::from_str::<ObjectSchema>(&serialized).unwrap();
791+
792+
// Compare structures (not YAML strings)
793+
assert_eq!(original, round_tripped);
794+
}
795+
796+
#[test]
797+
fn items_object_schema_still_works() {
798+
let spec = indoc::indoc! {"
799+
type: array
800+
items:
801+
type: string
802+
minLength: 5
803+
"};
804+
let schema = serde_yaml::from_str::<ObjectSchema>(spec).unwrap();
805+
806+
assert!(schema.items.is_some());
807+
808+
if let Some(items) = &schema.items {
809+
match items.as_ref() {
810+
Schema::Object(obj_ref) => {
811+
if let ObjectOrReference::Object(items_schema) = obj_ref.as_ref() {
812+
assert_eq!(
813+
items_schema.schema_type,
814+
Some(TypeSet::Single(Type::String))
815+
);
816+
assert_eq!(items_schema.min_length, Some(5));
817+
} else {
818+
panic!("Expected inline schema");
819+
}
820+
}
821+
_ => panic!("Expected object schema for items"),
822+
}
823+
} else {
824+
panic!("Expected items to be present");
825+
}
826+
}
827+
828+
#[test]
829+
fn items_boolean_serialization_round_trip() {
830+
let spec = indoc::indoc! {"
831+
type: array
832+
prefixItems:
833+
- type: string
834+
items: false
835+
"};
836+
837+
// Deserialize from YAML
838+
let original = serde_yaml::from_str::<ObjectSchema>(spec).unwrap();
839+
840+
// Serialize to YAML
841+
let serialized = serde_yaml::to_string(&original).unwrap();
842+
843+
// Deserialize back
844+
let round_tripped = serde_yaml::from_str::<ObjectSchema>(&serialized).unwrap();
845+
846+
// Compare structures (not YAML strings)
847+
assert_eq!(original, round_tripped);
848+
}
660849
}

crates/roast/src/validation/validator.rs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,22 @@ impl ValidationTree {
8484
trace!("adding array validators");
8585

8686
if let Some(schema_ref) = schema.items.as_ref() {
87-
let sub_schema = schema_ref.resolve(spec).unwrap();
88-
let vls = ValidationTree::from_schema(&sub_schema, spec).unwrap();
89-
90-
valtree.branch = ValidationBranch::Array(Box::new(vls))
87+
match schema_ref.as_ref() {
88+
oas3::spec::Schema::Boolean(oas3::spec::BooleanSchema(false)) => {
89+
// items: false means no additional items allowed
90+
// For now, we treat this as having no array validation
91+
// TODO: A more complete implementation would validate array length against prefixItems
92+
}
93+
oas3::spec::Schema::Boolean(oas3::spec::BooleanSchema(true)) => {
94+
// items: true means any additional items allowed
95+
// No additional validation needed
96+
}
97+
oas3::spec::Schema::Object(obj_ref) => {
98+
let sub_schema = obj_ref.resolve(spec).unwrap();
99+
let vls = ValidationTree::from_schema(&sub_schema, spec).unwrap();
100+
valtree.branch = ValidationBranch::Array(Box::new(vls))
101+
}
102+
}
91103
}
92104
}
93105

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
openapi: 3.1.0
2+
info:
3+
title: prefixItems Test API
4+
version: 1.0.0
5+
paths: {}
6+
components:
7+
schemas:
8+
PersonTuple:
9+
type: array
10+
description: A tuple representing [firstName, lastName, age]
11+
prefixItems:
12+
- type: string
13+
description: First name
14+
minLength: 1
15+
- type: string
16+
description: Last name
17+
minLength: 1
18+
- type: integer
19+
description: Age
20+
minimum: 0
21+
maximum: 150
22+
minItems: 3
23+
maxItems: 3
24+
25+
ExtendableTuple:
26+
type: array
27+
description: A tuple with fixed prefix and flexible additional items
28+
prefixItems:
29+
- type: string
30+
enum: ["header", "body", "footer"]
31+
- type: integer
32+
items:
33+
type: string
34+
description: Additional string items allowed after the prefix
35+
36+
ComplexTuple:
37+
type: array
38+
description: A tuple using component references
39+
prefixItems:
40+
- $ref: '#/components/schemas/Header'
41+
- $ref: '#/components/schemas/Payload'
42+
- type: object
43+
properties:
44+
checksum:
45+
type: string
46+
pattern: '^[a-f0-9]{64}$'
47+
required: [checksum]
48+
items: false
49+
50+
EmptyPrefixArray:
51+
type: array
52+
prefixItems: []
53+
items:
54+
type: number
55+
description: Array with empty prefixItems - all items follow the items schema
56+
57+
ValidatedTuple:
58+
type: array
59+
prefixItems:
60+
- type: string
61+
pattern: '^[A-Z]{2}$'
62+
description: Two-letter country code
63+
- type: number
64+
multipleOf: 0.01
65+
description: Price with cents
66+
- type: boolean
67+
description: Is available
68+
items:
69+
type: object
70+
additionalProperties: true
71+
description: Additional metadata objects
72+
73+
Header:
74+
type: object
75+
properties:
76+
version:
77+
type: string
78+
pattern: '^\d+\.\d+\.\d+$'
79+
timestamp:
80+
type: integer
81+
description: Unix timestamp
82+
required: [version, timestamp]
83+
84+
Payload:
85+
type: object
86+
properties:
87+
data:
88+
type: string
89+
encoding:
90+
type: string
91+
enum: ["utf-8", "base64", "hex"]
92+
required: [data, encoding]
93+
94+
# Test boolean items schemas
95+
BooleanItemsFalse:
96+
type: array
97+
description: Array that allows no items (empty array only)
98+
items: false
99+
100+
BooleanItemsTrue:
101+
type: array
102+
description: Array that allows any items
103+
items: true
104+
105+
MixedBooleanItems:
106+
type: array
107+
description: Tuple with fixed prefix and no additional items
108+
prefixItems:
109+
- type: string
110+
enum: ["v1", "v2", "v3"]
111+
- type: number
112+
minimum: 0
113+
items: false

integration-tests/src/samples.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ mod tests {
7474
validate_sample(input, Format::Yaml);
7575
}
7676

77+
#[test]
78+
fn test_schema_prefix_items_yaml() {
79+
let input = include_str!("../samples/pass/schema_prefix_items.yaml");
80+
validate_sample(input, Format::Yaml);
81+
}
82+
7783
/// Describes the format of the text input.
7884
enum Format {
7985
Json,

0 commit comments

Comments
 (0)