1use crate as sdk;
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use sdk::{
11 AgentError, AgentErrorKind, AttemptId, ContentHash, OutputContract, OutputMode,
12 OutputSchemaDialect, OutputSchemaId, OutputSchemaRef, PrivacyClass, RetryClassification,
13 SchemaVersion, ValidationAttemptId,
14 domain::ContentRef,
15 structured_output::{
16 VALIDATION_RECORD_SCHEMA_VERSION, ValidationErrorCode, ValidationErrorSummary,
17 ValidationRecord, ValidationRecordKind,
18 },
19};
20
21#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
22pub struct OutputCandidate {
25 pub source_attempt_id: AttemptId,
27 pub candidate_content_ref: ContentRef,
29 pub text: String,
31 pub privacy: PrivacyClass,
34}
35
36impl OutputCandidate {
37 pub fn new(
41 source_attempt_id: AttemptId,
42 candidate_content_ref: ContentRef,
43 text: impl Into<String>,
44 ) -> Self {
45 Self {
46 source_attempt_id,
47 candidate_content_ref,
48 text: text.into(),
49 privacy: PrivacyClass::ContentRefsOnly,
50 }
51 }
52
53 pub fn with_privacy(mut self, privacy: PrivacyClass) -> Self {
57 self.privacy = privacy;
58 self
59 }
60}
61
62pub trait StructuredOutputValidator {
67 fn validate_candidate(
71 &self,
72 contract: &OutputContract,
73 validation_attempt_id: ValidationAttemptId,
74 candidate: &OutputCandidate,
75 ) -> Result<ValidationSuccess, Box<ValidationErrorReport>>;
76}
77
78#[derive(Clone, Debug, Eq, PartialEq)]
79pub struct JsonSchemaSubsetValidator {
82 pub schema_limits: HostileSchemaLimits,
84}
85
86impl JsonSchemaSubsetValidator {
87 pub fn new(schema_limits: HostileSchemaLimits) -> Self {
91 Self { schema_limits }
92 }
93}
94
95impl Default for JsonSchemaSubsetValidator {
96 fn default() -> Self {
97 Self::new(HostileSchemaLimits::default())
98 }
99}
100
101impl StructuredOutputValidator for JsonSchemaSubsetValidator {
102 fn validate_candidate(
103 &self,
104 contract: &OutputContract,
105 validation_attempt_id: ValidationAttemptId,
106 candidate: &OutputCandidate,
107 ) -> Result<ValidationSuccess, Box<ValidationErrorReport>> {
108 let mut schema_errors = ErrorCollector::new(contract.validation.max_errors_returned);
109
110 if contract.dialect != OutputSchemaDialect::JsonSchema2020_12Subset {
111 schema_errors.push(ValidationErrorSummary::new(
112 ValidationErrorCode::UnsupportedDialect,
113 "/dialect",
114 "unsupported structured output schema dialect",
115 ));
116 }
117
118 if contract.mode != OutputMode::FinalOnly {
119 schema_errors.push(ValidationErrorSummary::new(
120 ValidationErrorCode::SchemaContractViolation,
121 "/mode",
122 "incremental structured output validation is not owned by this phase",
123 ));
124 }
125
126 if let Err(error) = contract.validate_shape() {
127 schema_errors.push(ValidationErrorSummary::new(
128 ValidationErrorCode::SchemaContractViolation,
129 "/",
130 error.context().message,
131 ));
132 }
133
134 let inline_schema = match &contract.schema {
135 OutputSchemaRef::InlineJson {
136 redacted_schema, ..
137 } => Some(redacted_schema),
138 _ => {
139 schema_errors.push(ValidationErrorSummary::new(
140 ValidationErrorCode::UnsupportedSchemaRef,
141 "/schema",
142 "schema content must be inline for local validation in this phase",
143 ));
144 None
145 }
146 };
147
148 if let Some(schema) = inline_schema {
149 validate_schema_limits(schema, &self.schema_limits, &mut schema_errors);
150 }
151
152 if !contract.validation.semantic_validators.is_empty() {
153 schema_errors.push(ValidationErrorSummary::new(
154 ValidationErrorCode::SemanticValidatorUnavailable,
155 "/validation/semantic_validators",
156 "host semantic validators are referenced but no local validator was supplied",
157 ));
158 }
159
160 if !schema_errors.is_empty() {
161 let schema_rejected = schema_errors
162 .errors
163 .iter()
164 .any(|error| error.code.is_schema_rejection());
165 return Err(Box::new(ValidationErrorReport::new(
166 contract,
167 validation_attempt_id,
168 candidate,
169 schema_errors.into_errors(),
170 schema_rejected,
171 )));
172 }
173
174 if candidate.text.len() as u64 > contract.validation.max_candidate_bytes {
175 return Err(Box::new(ValidationErrorReport::new(
176 contract,
177 validation_attempt_id,
178 candidate,
179 vec![ValidationErrorSummary::new(
180 ValidationErrorCode::CandidateTooLarge,
181 "/",
182 "candidate exceeds configured structured output byte limit",
183 )],
184 false,
185 )));
186 }
187
188 let value = match serde_json::from_str::<Value>(&candidate.text) {
189 Ok(value) => value,
190 Err(_) => {
191 return Err(Box::new(ValidationErrorReport::new(
192 contract,
193 validation_attempt_id,
194 candidate,
195 vec![ValidationErrorSummary::new(
196 ValidationErrorCode::InvalidJson,
197 "/",
198 "candidate is not valid JSON",
199 )],
200 false,
201 )));
202 }
203 };
204
205 let schema = inline_schema.expect("inline schema checked above");
206 let mut candidate_errors = ErrorCollector::new(contract.validation.max_errors_returned);
207 validate_value_against_schema(
208 schema,
209 &value,
210 "/",
211 contract.validation.allow_additional_properties,
212 &mut candidate_errors,
213 );
214
215 if !candidate_errors.is_empty() {
216 return Err(Box::new(ValidationErrorReport::new(
217 contract,
218 validation_attempt_id,
219 candidate,
220 candidate_errors.into_errors(),
221 false,
222 )));
223 }
224
225 let record = validation_record_succeeded(
226 contract,
227 validation_attempt_id.clone(),
228 candidate,
229 "structured output candidate validated locally",
230 );
231
232 Ok(ValidationSuccess {
233 schema_id: contract.schema_id.clone(),
234 schema_version: contract.schema_version,
235 schema_fingerprint: contract.schema_fingerprint(),
236 validation_attempt_id,
237 source_attempt_id: candidate.source_attempt_id.clone(),
238 candidate_content_ref: candidate.candidate_content_ref.clone(),
239 canonical_value: value,
240 privacy: candidate.privacy,
241 record,
242 })
243 }
244}
245
246#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
247pub struct HostileSchemaLimits {
250 pub max_schema_bytes: usize,
253 pub max_object_depth: usize,
256 pub max_properties_per_object: usize,
259 pub max_enum_values_per_field: usize,
262 pub max_string_pattern_bytes: usize,
265 pub allow_remote_refs: bool,
268 pub allow_custom_formats: bool,
271}
272
273impl Default for HostileSchemaLimits {
274 fn default() -> Self {
275 Self {
276 max_schema_bytes: 64 * 1024,
277 max_object_depth: 24,
278 max_properties_per_object: 256,
279 max_enum_values_per_field: 512,
280 max_string_pattern_bytes: 2 * 1024,
281 allow_remote_refs: false,
282 allow_custom_formats: false,
283 }
284 }
285}
286
287#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
288pub struct ValidationSuccess {
291 pub schema_id: OutputSchemaId,
293 pub schema_version: SchemaVersion,
295 pub schema_fingerprint: ContentHash,
298 pub validation_attempt_id: ValidationAttemptId,
301 pub source_attempt_id: AttemptId,
303 pub candidate_content_ref: ContentRef,
305 pub canonical_value: Value,
307 pub privacy: PrivacyClass,
310 pub record: ValidationRecord,
312}
313
314#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
315pub struct ValidationErrorReport {
318 pub schema_id: OutputSchemaId,
320 pub schema_version: SchemaVersion,
322 pub schema_fingerprint: ContentHash,
325 pub validation_attempt_id: ValidationAttemptId,
328 pub source_attempt_id: AttemptId,
330 pub candidate_content_ref: ContentRef,
332 pub errors: Vec<ValidationErrorSummary>,
335 pub redacted_error_summary: String,
338 pub schema_rejected: bool,
341 pub retry_exhausted: bool,
344 pub privacy: PrivacyClass,
347 pub record: ValidationRecord,
349}
350
351impl ValidationErrorReport {
352 fn new(
353 contract: &OutputContract,
354 validation_attempt_id: ValidationAttemptId,
355 candidate: &OutputCandidate,
356 errors: Vec<ValidationErrorSummary>,
357 schema_rejected: bool,
358 ) -> Self {
359 let redacted_error_summary = summarize_errors(&errors);
360 let record = if schema_rejected {
361 validation_record_schema_rejected(
362 contract,
363 validation_attempt_id.clone(),
364 candidate,
365 redacted_error_summary.clone(),
366 errors.clone(),
367 )
368 } else {
369 validation_record_failed(
370 contract,
371 validation_attempt_id.clone(),
372 candidate,
373 redacted_error_summary.clone(),
374 errors.clone(),
375 )
376 };
377
378 Self {
379 schema_id: contract.schema_id.clone(),
380 schema_version: contract.schema_version,
381 schema_fingerprint: contract.schema_fingerprint(),
382 validation_attempt_id,
383 source_attempt_id: candidate.source_attempt_id.clone(),
384 candidate_content_ref: candidate.candidate_content_ref.clone(),
385 errors,
386 redacted_error_summary,
387 schema_rejected,
388 retry_exhausted: false,
389 privacy: candidate.privacy,
390 record,
391 }
392 }
393
394 pub fn as_agent_error(&self) -> AgentError {
397 let mut error = AgentError::new(
398 AgentErrorKind::StructuredOutputFailure,
399 if self.retry_exhausted || self.schema_rejected {
400 RetryClassification::NotRetryable
401 } else {
402 RetryClassification::RepairNeeded
403 },
404 self.redacted_error_summary.clone(),
405 );
406 error = error.with_policy_ref(sdk::PolicyRef::with_kind(
407 sdk::PolicyKind::RuntimePackage,
408 self.schema_id.as_str(),
409 ));
410 error
411 }
412}
413
414#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
415pub struct TerminalValidationFailure {
418 pub schema_id: OutputSchemaId,
420 pub schema_version: SchemaVersion,
422 pub validation_attempts: Vec<ValidationAttemptId>,
426 pub repair_attempts: Vec<sdk::RepairAttemptId>,
429 pub source_attempt_ids: Vec<AttemptId>,
432 pub redacted_error_summary: String,
435 pub candidate_content_ref: ContentRef,
437 pub retry_exhausted: bool,
440 pub privacy: PrivacyClass,
443 pub record: ValidationRecord,
445}
446
447impl TerminalValidationFailure {
448 pub fn from_reports(
452 reports: &[ValidationErrorReport],
453 repair_attempts: Vec<sdk::RepairAttemptId>,
454 retry_exhausted: bool,
455 ) -> Self {
456 let last = reports
457 .last()
458 .expect("terminal validation failure needs at least one report");
459 let validation_attempts = reports
460 .iter()
461 .map(|report| report.validation_attempt_id.clone())
462 .collect::<Vec<_>>();
463 let mut source_attempt_ids = Vec::new();
464 for report in reports {
465 if !source_attempt_ids.contains(&report.source_attempt_id) {
466 source_attempt_ids.push(report.source_attempt_id.clone());
467 }
468 }
469 let redacted_error_summary = if retry_exhausted {
470 format!(
471 "structured output validation failed after {} validation attempt(s) and {} repair attempt(s): {}",
472 validation_attempts.len(),
473 repair_attempts.len(),
474 last.redacted_error_summary
475 )
476 } else {
477 last.redacted_error_summary.clone()
478 };
479 let record = validation_record_terminal_failure(
480 last,
481 validation_attempts.clone(),
482 repair_attempts.clone(),
483 source_attempt_ids.clone(),
484 redacted_error_summary.clone(),
485 retry_exhausted,
486 );
487 Self {
488 schema_id: last.schema_id.clone(),
489 schema_version: last.schema_version,
490 validation_attempts,
491 repair_attempts,
492 source_attempt_ids,
493 redacted_error_summary,
494 candidate_content_ref: last.candidate_content_ref.clone(),
495 retry_exhausted,
496 privacy: last.privacy,
497 record,
498 }
499 }
500
501 pub fn as_agent_error(&self) -> AgentError {
504 AgentError::new(
505 AgentErrorKind::StructuredOutputFailure,
506 RetryClassification::NotRetryable,
507 self.redacted_error_summary.clone(),
508 )
509 }
510}
511
512fn validation_record_base(
513 contract: &OutputContract,
514 record_kind: ValidationRecordKind,
515 validation_attempt_id: ValidationAttemptId,
516 candidate: &OutputCandidate,
517 redacted_summary: String,
518) -> ValidationRecord {
519 ValidationRecord {
520 record_schema_version: VALIDATION_RECORD_SCHEMA_VERSION,
521 record_kind,
522 schema_id: contract.schema_id.clone(),
523 output_schema_version: contract.schema_version,
524 schema_fingerprint: contract.schema_fingerprint(),
525 validation_attempt_id,
526 source_attempt_id: candidate.source_attempt_id.clone(),
527 candidate_content_ref: candidate.candidate_content_ref.clone(),
528 privacy: candidate.privacy,
529 redacted_summary,
530 errors: Vec::new(),
531 validation_attempts: Vec::new(),
532 repair_attempts: Vec::new(),
533 source_attempt_ids: Vec::new(),
534 retry_exhausted: None,
535 }
536}
537
538fn validation_record_succeeded(
539 contract: &OutputContract,
540 validation_attempt_id: ValidationAttemptId,
541 candidate: &OutputCandidate,
542 redacted_summary: impl Into<String>,
543) -> ValidationRecord {
544 validation_record_base(
545 contract,
546 ValidationRecordKind::ValidationSucceeded,
547 validation_attempt_id,
548 candidate,
549 redacted_summary.into(),
550 )
551}
552
553fn validation_record_failed(
554 contract: &OutputContract,
555 validation_attempt_id: ValidationAttemptId,
556 candidate: &OutputCandidate,
557 redacted_summary: String,
558 errors: Vec<ValidationErrorSummary>,
559) -> ValidationRecord {
560 let mut record = validation_record_base(
561 contract,
562 ValidationRecordKind::ValidationFailed,
563 validation_attempt_id,
564 candidate,
565 redacted_summary,
566 );
567 record.errors = errors;
568 record
569}
570
571fn validation_record_schema_rejected(
572 contract: &OutputContract,
573 validation_attempt_id: ValidationAttemptId,
574 candidate: &OutputCandidate,
575 redacted_summary: String,
576 errors: Vec<ValidationErrorSummary>,
577) -> ValidationRecord {
578 let mut record = validation_record_base(
579 contract,
580 ValidationRecordKind::SchemaRejected,
581 validation_attempt_id,
582 candidate,
583 redacted_summary,
584 );
585 record.errors = errors;
586 record
587}
588
589fn validation_record_terminal_failure(
590 last_report: &ValidationErrorReport,
591 validation_attempts: Vec<ValidationAttemptId>,
592 repair_attempts: Vec<sdk::RepairAttemptId>,
593 source_attempt_ids: Vec<AttemptId>,
594 redacted_summary: String,
595 retry_exhausted: bool,
596) -> ValidationRecord {
597 ValidationRecord {
598 record_schema_version: VALIDATION_RECORD_SCHEMA_VERSION,
599 record_kind: ValidationRecordKind::TerminalFailure,
600 schema_id: last_report.schema_id.clone(),
601 output_schema_version: last_report.schema_version,
602 schema_fingerprint: last_report.schema_fingerprint.clone(),
603 validation_attempt_id: last_report.validation_attempt_id.clone(),
604 source_attempt_id: last_report.source_attempt_id.clone(),
605 candidate_content_ref: last_report.candidate_content_ref.clone(),
606 privacy: last_report.privacy,
607 redacted_summary,
608 errors: last_report.errors.clone(),
609 validation_attempts,
610 repair_attempts,
611 source_attempt_ids,
612 retry_exhausted: Some(retry_exhausted),
613 }
614}
615
616struct ErrorCollector {
617 max: usize,
618 errors: Vec<ValidationErrorSummary>,
619}
620
621impl ErrorCollector {
622 fn new(max_errors_returned: u16) -> Self {
623 Self {
624 max: usize::from(max_errors_returned.max(1)),
625 errors: Vec::new(),
626 }
627 }
628
629 fn push(&mut self, error: ValidationErrorSummary) {
630 if self.errors.len() < self.max {
631 self.errors.push(error);
632 }
633 }
634
635 fn is_empty(&self) -> bool {
636 self.errors.is_empty()
637 }
638
639 fn into_errors(self) -> Vec<ValidationErrorSummary> {
640 self.errors
641 }
642}
643
644fn summarize_errors(errors: &[ValidationErrorSummary]) -> String {
645 match errors {
646 [] => "structured output validation produced no errors".to_string(),
647 [error] => format!(
648 "structured output validation failed: {} at {}",
649 error.redacted_summary, error.path
650 ),
651 [first, rest @ ..] => format!(
652 "structured output validation failed with {} error(s): {} at {}",
653 rest.len() + 1,
654 first.redacted_summary,
655 first.path
656 ),
657 }
658}
659
660fn validate_schema_limits(
661 schema: &Value,
662 limits: &HostileSchemaLimits,
663 errors: &mut ErrorCollector,
664) {
665 let schema_bytes = serde_json::to_vec(schema).expect("serde_json::Value serializes");
666 if schema_bytes.len() > limits.max_schema_bytes {
667 errors.push(ValidationErrorSummary::new(
668 ValidationErrorCode::HostileSchema,
669 "/schema",
670 "schema exceeds maximum byte limit",
671 ));
672 }
673 inspect_schema_node(schema, "/", 0, limits, errors);
674}
675
676fn inspect_schema_node(
677 value: &Value,
678 path: &str,
679 depth: usize,
680 limits: &HostileSchemaLimits,
681 errors: &mut ErrorCollector,
682) {
683 if depth > limits.max_object_depth {
684 errors.push(ValidationErrorSummary::new(
685 ValidationErrorCode::HostileSchema,
686 path,
687 "schema exceeds maximum object depth",
688 ));
689 return;
690 }
691
692 match value {
693 Value::Object(fields) => {
694 if let Some(Value::String(reference)) = fields.get("$ref") {
695 let is_remote = reference.starts_with("http://")
696 || reference.starts_with("https://")
697 || !reference.starts_with('#');
698 if is_remote && !limits.allow_remote_refs {
699 errors.push(ValidationErrorSummary::new(
700 ValidationErrorCode::HostileSchema,
701 join_path(path, "$ref"),
702 "remote or external schema references are denied",
703 ));
704 } else {
705 errors.push(ValidationErrorSummary::new(
706 ValidationErrorCode::SchemaContractViolation,
707 join_path(path, "$ref"),
708 "local schema references are not supported by this validator phase",
709 ));
710 }
711 }
712
713 if fields.contains_key("format") && !limits.allow_custom_formats {
714 errors.push(ValidationErrorSummary::new(
715 ValidationErrorCode::HostileSchema,
716 join_path(path, "format"),
717 "custom format validators are disabled by default",
718 ));
719 }
720
721 if let Some(Value::String(pattern)) = fields.get("pattern") {
722 if pattern.len() > limits.max_string_pattern_bytes {
723 errors.push(ValidationErrorSummary::new(
724 ValidationErrorCode::HostileSchema,
725 join_path(path, "pattern"),
726 "schema string pattern exceeds maximum byte limit",
727 ));
728 }
729 }
730
731 if let Some(Value::Object(properties)) = fields.get("properties") {
732 if properties.len() > limits.max_properties_per_object {
733 errors.push(ValidationErrorSummary::new(
734 ValidationErrorCode::HostileSchema,
735 join_path(path, "properties"),
736 "schema object has too many properties",
737 ));
738 }
739 }
740
741 if let Some(Value::Array(values)) = fields.get("enum") {
742 if values.len() > limits.max_enum_values_per_field {
743 errors.push(ValidationErrorSummary::new(
744 ValidationErrorCode::HostileSchema,
745 join_path(path, "enum"),
746 "schema enum has too many values",
747 ));
748 }
749 }
750
751 for (field, child) in fields {
752 inspect_schema_node(child, &join_path(path, field), depth + 1, limits, errors);
753 }
754 }
755 Value::Array(items) => {
756 for (index, child) in items.iter().enumerate() {
757 inspect_schema_node(
758 child,
759 &join_path(path, &index.to_string()),
760 depth + 1,
761 limits,
762 errors,
763 );
764 }
765 }
766 _ => {}
767 }
768}
769
770fn validate_value_against_schema(
771 schema: &Value,
772 value: &Value,
773 path: &str,
774 allow_additional_properties: bool,
775 errors: &mut ErrorCollector,
776) {
777 if let Some(enum_values) = schema.get("enum").and_then(Value::as_array) {
778 if !enum_values.iter().any(|enum_value| enum_value == value) {
779 errors.push(ValidationErrorSummary::new(
780 ValidationErrorCode::EnumMismatch,
781 path,
782 "candidate value is not one of the allowed schema values",
783 ));
784 return;
785 }
786 }
787
788 let schema_type = schema.get("type").and_then(Value::as_str);
789 match schema_type {
790 Some("object") => {
791 validate_object_schema(schema, value, path, allow_additional_properties, errors)
792 }
793 Some("array") => {
794 validate_array_schema(schema, value, path, allow_additional_properties, errors)
795 }
796 Some("string") => validate_string_schema(schema, value, path, errors),
797 Some("integer") => {
798 if value.as_i64().is_none() && value.as_u64().is_none() {
799 errors.push(type_mismatch(path, "integer"));
800 }
801 }
802 Some("number") => {
803 if !value.is_number() {
804 errors.push(type_mismatch(path, "number"));
805 }
806 }
807 Some("boolean") => {
808 if !value.is_boolean() {
809 errors.push(type_mismatch(path, "boolean"));
810 }
811 }
812 Some("null") => {
813 if !value.is_null() {
814 errors.push(type_mismatch(path, "null"));
815 }
816 }
817 Some(_) => errors.push(ValidationErrorSummary::new(
818 ValidationErrorCode::SchemaContractViolation,
819 path,
820 "schema type is outside the supported local subset",
821 )),
822 None => {
823 if schema.get("properties").is_some() || schema.get("required").is_some() {
824 validate_object_schema(schema, value, path, allow_additional_properties, errors);
825 }
826 }
827 }
828}
829
830fn validate_object_schema(
831 schema: &Value,
832 value: &Value,
833 path: &str,
834 allow_additional_properties: bool,
835 errors: &mut ErrorCollector,
836) {
837 let Some(object) = value.as_object() else {
838 errors.push(type_mismatch(path, "object"));
839 return;
840 };
841
842 let properties = schema.get("properties").and_then(Value::as_object);
843
844 if let Some(required) = schema.get("required").and_then(Value::as_array) {
845 for required_field in required.iter().filter_map(Value::as_str) {
846 if !object.contains_key(required_field) {
847 errors.push(ValidationErrorSummary::new(
848 ValidationErrorCode::MissingRequiredField,
849 join_path(path, required_field),
850 "required schema field is missing",
851 ));
852 }
853 }
854 }
855
856 if let Some(properties) = properties {
857 for (field, field_schema) in properties {
858 if let Some(field_value) = object.get(field) {
859 validate_value_against_schema(
860 field_schema,
861 field_value,
862 &join_path(path, field),
863 allow_additional_properties,
864 errors,
865 );
866 }
867 }
868 }
869
870 let schema_denies_additional = schema
871 .get("additionalProperties")
872 .and_then(Value::as_bool)
873 .is_some_and(|allowed| !allowed);
874 let denies_additional = schema_denies_additional || !allow_additional_properties;
875 if denies_additional {
876 for field in object.keys() {
877 let known = properties.is_some_and(|properties| properties.contains_key(field));
878 if !known {
879 errors.push(ValidationErrorSummary::new(
880 ValidationErrorCode::AdditionalPropertyDenied,
881 path,
882 "candidate contains an additional property denied by schema",
883 ));
884 }
885 }
886 }
887}
888
889fn validate_array_schema(
890 schema: &Value,
891 value: &Value,
892 path: &str,
893 allow_additional_properties: bool,
894 errors: &mut ErrorCollector,
895) {
896 let Some(items) = value.as_array() else {
897 errors.push(type_mismatch(path, "array"));
898 return;
899 };
900 if let Some(item_schema) = schema.get("items").filter(|item| item.is_object()) {
901 for (index, item) in items.iter().enumerate() {
902 validate_value_against_schema(
903 item_schema,
904 item,
905 &join_path(path, &index.to_string()),
906 allow_additional_properties,
907 errors,
908 );
909 }
910 }
911}
912
913fn validate_string_schema(schema: &Value, value: &Value, path: &str, errors: &mut ErrorCollector) {
914 let Some(text) = value.as_str() else {
915 errors.push(type_mismatch(path, "string"));
916 return;
917 };
918
919 if let Some(min) = schema.get("minLength").and_then(Value::as_u64) {
920 if text.chars().count() < min as usize {
921 errors.push(ValidationErrorSummary::new(
922 ValidationErrorCode::MinLengthViolation,
923 path,
924 "candidate string is shorter than the schema minimum",
925 ));
926 }
927 }
928 if let Some(max) = schema.get("maxLength").and_then(Value::as_u64) {
929 if text.chars().count() > max as usize {
930 errors.push(ValidationErrorSummary::new(
931 ValidationErrorCode::MaxLengthViolation,
932 path,
933 "candidate string is longer than the schema maximum",
934 ));
935 }
936 }
937}
938
939fn type_mismatch(path: &str, expected: &str) -> ValidationErrorSummary {
940 ValidationErrorSummary::new(
941 ValidationErrorCode::TypeMismatch,
942 path,
943 format!("candidate value does not match schema type {expected}"),
944 )
945}
946
947fn join_path(parent: &str, child: &str) -> String {
948 if parent == "/" {
949 format!("/{child}")
950 } else {
951 format!("{parent}/{child}")
952 }
953}