From 6f0b048e14d6e311a1daaae14339a900cd836f21 Mon Sep 17 00:00:00 2001 From: Tim Diekmann <21277928+TimDiekmann@users.noreply.github.com> Date: Fri, 11 Oct 2024 18:56:01 +0200 Subject: [PATCH] H-3425: Provide a simple `is_valid` function (#5379) --- Cargo.lock | 8 +- .../src/schema/data_type/constraint/any_of.rs | 21 +- .../src/schema/data_type/constraint/array.rs | 149 ++++++++------- .../schema/data_type/constraint/boolean.rs | 32 +++- .../src/schema/data_type/constraint/mod.rs | 114 ++++++----- .../src/schema/data_type/constraint/null.rs | 32 +++- .../src/schema/data_type/constraint/number.rs | 180 +++++++++++------- .../src/schema/data_type/constraint/object.rs | 83 +++++++- .../src/schema/data_type/constraint/string.rs | 134 +++++++------ .../rust/src/schema/data_type/mod.rs | 42 ++-- .../type-system/rust/src/schema/mod.rs | 11 +- .../@local/hash-validation/src/entity_type.rs | 5 +- 12 files changed, 497 insertions(+), 314 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3fd14c3f34f..5bcaceb96de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3401,7 +3401,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.71", + "syn 2.0.79", ] [[package]] @@ -8572,7 +8572,7 @@ checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95" dependencies = [ "proc-macro2", "quote", - "syn 2.0.71", + "syn 2.0.79", "synstructure", ] @@ -8614,7 +8614,7 @@ checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.71", + "syn 2.0.79", "synstructure", ] @@ -8657,5 +8657,5 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.71", + "syn 2.0.79", ] diff --git a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/any_of.rs b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/any_of.rs index eefbe5ce014..56607f0027a 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/any_of.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/any_of.rs @@ -2,7 +2,7 @@ use error_stack::{Report, ReportSink, ResultExt}; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; -use crate::schema::{ConstraintError, SingleValueSchema}; +use crate::schema::{ConstraintError, SingleValueSchema, data_type::constraint::Constraint}; #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] @@ -15,15 +15,16 @@ pub struct AnyOfConstraints { pub any_of: Vec, } -impl AnyOfConstraints { - /// Checks if the provided value is valid against any of the schemas in the `any_of` list. - /// - /// # Errors - /// - /// - [`AnyOf`] if the value is not valid against any of the schemas. - /// - /// [`AnyOf`]: ConstraintError::AnyOf - pub fn validate_value(&self, value: &JsonValue) -> Result<(), Report> { +impl Constraint for AnyOfConstraints { + type Error = ConstraintError; + + fn is_valid(&self, value: &JsonValue) -> bool { + self.any_of + .iter() + .any(|schema| schema.constraints.is_valid(value)) + } + + fn validate_value(&self, value: &JsonValue) -> Result<(), Report> { let mut status = ReportSink::::new(); for schema in &self.any_of { if status diff --git a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/array.rs b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/array.rs index b757f41000b..99790cf5c79 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/array.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/array.rs @@ -6,10 +6,7 @@ use thiserror::Error; use crate::schema::{ ConstraintError, JsonSchemaValueType, NumberSchema, StringSchema, ValueLabel, - data_type::constraint::{ - boolean::validate_boolean_value, number::validate_number_value, - string::validate_string_value, - }, + data_type::constraint::{Constraint, boolean::BooleanSchema}, }; #[derive(Debug, Error)] @@ -41,17 +38,27 @@ pub enum ArrayTypeTag { #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] #[serde(tag = "type", rename_all = "camelCase")] pub enum ArrayItemConstraints { - Boolean, + Boolean(BooleanSchema), Number(NumberSchema), String(StringSchema), } -impl ArrayItemConstraints { - pub fn validate_value(&self, value: &JsonValue) -> Result<(), Report> { +impl Constraint for ArrayItemConstraints { + type Error = ConstraintError; + + fn is_valid(&self, value: &JsonValue) -> bool { + match self { + Self::Boolean(schema) => schema.is_valid(value), + Self::Number(schema) => schema.is_valid(value), + Self::String(schema) => schema.is_valid(value), + } + } + + fn validate_value(&self, value: &JsonValue) -> Result<(), Report> { match self { - Self::Boolean => validate_boolean_value(value), - Self::Number(schema) => validate_number_value(value, schema), - Self::String(schema) => validate_string_value(value, schema), + Self::Boolean(schema) => schema.validate_value(value), + Self::Number(schema) => schema.validate_value(value), + Self::String(schema) => schema.validate_value(value), } } } @@ -97,21 +104,46 @@ pub enum ArraySchema { Tuple(TupleConstraints), } -impl ArraySchema { - /// Validates the provided value against the number schema. - /// - /// # Errors - /// - /// - [`ValueConstraint`] if the value does not match the expected constraints. - /// - /// [`ValueConstraint`]: ConstraintError::ValueConstraint - pub fn validate_value(&self, array: &[JsonValue]) -> Result<(), Report> { +impl Constraint for ArraySchema { + type Error = ConstraintError; + + fn is_valid(&self, value: &JsonValue) -> bool { + if let JsonValue::Array(array) = value { + self.is_valid(array.as_slice()) + } else { + false + } + } + + fn validate_value(&self, value: &JsonValue) -> Result<(), Report> { + if let JsonValue::Array(array) = value { + self.validate_value(array.as_slice()) + } else { + bail!(ConstraintError::InvalidType { + actual: JsonSchemaValueType::from(value), + expected: JsonSchemaValueType::Array, + }); + } + } +} + +impl Constraint<[JsonValue]> for ArraySchema { + type Error = ConstraintError; + + fn is_valid(&self, value: &[JsonValue]) -> bool { + match self { + Self::Constrained(constraints) => constraints.is_valid(value), + Self::Tuple(constraints) => constraints.is_valid(value), + } + } + + fn validate_value(&self, value: &[JsonValue]) -> Result<(), Report> { match self { Self::Constrained(constraints) => constraints - .validate_value(array) + .validate_value(value) .change_context(ConstraintError::ValueConstraint)?, Self::Tuple(constraints) => constraints - .validate_value(array) + .validate_value(value) .change_context(ConstraintError::ValueConstraint)?, } Ok(()) @@ -126,23 +158,21 @@ pub struct ArrayConstraints { pub items: Option, } -impl ArrayConstraints { - /// Validates the provided value against the array constraints. - /// - /// # Errors - /// - /// - [`Items`] if the value does not match the expected item constraints. - /// - /// [`Items`]: ArrayValidationError::Items - pub fn validate_value( - &self, - values: &[JsonValue], - ) -> Result<(), Report<[ArrayValidationError]>> { +impl Constraint<[JsonValue]> for ArrayConstraints { + type Error = [ArrayValidationError]; + + fn is_valid(&self, value: &[JsonValue]) -> bool { + self.items.as_ref().map_or(true, |items| { + value.iter().all(|value| items.constraints.is_valid(value)) + }) + } + + fn validate_value(&self, value: &[JsonValue]) -> Result<(), Report<[ArrayValidationError]>> { let mut status = ReportSink::new(); if let Some(items) = &self.items { status.attempt( - values + value .iter() .map(|value| items.constraints.validate_value(value)) .try_collect_reports::>() @@ -168,25 +198,26 @@ pub struct TupleConstraints { pub prefix_items: Vec, } -impl TupleConstraints { - /// Validates the provided value against the tuple constraints. - /// - /// # Errors - /// - /// - [`MinItems`] if the value has too few items. - /// - [`MaxItems`] if the value has too many items. - /// - [`PrefixItems`] if the value does not match the expected item constraints. - /// - /// [`MinItems`]: ArrayValidationError::MinItems - /// [`MaxItems`]: ArrayValidationError::MaxItems - /// [`PrefixItems`]: ArrayValidationError::PrefixItems - pub fn validate_value( - &self, - values: &[JsonValue], - ) -> Result<(), Report<[ArrayValidationError]>> { +impl Constraint<[JsonValue]> for TupleConstraints { + type Error = [ArrayValidationError]; + + fn is_valid(&self, value: &[JsonValue]) -> bool { + let num_values = value.len(); + let num_prefix_items = self.prefix_items.len(); + if num_values != num_prefix_items { + return false; + } + + self.prefix_items + .iter() + .zip(value) + .all(|(schema, value)| schema.constraints.is_valid(value)) + } + + fn validate_value(&self, value: &[JsonValue]) -> Result<(), Report<[ArrayValidationError]>> { let mut status = ReportSink::new(); - let num_values = values.len(); + let num_values = value.len(); let num_prefix_items = self.prefix_items.len(); if num_values != num_prefix_items { status.capture(if num_values < num_prefix_items { @@ -205,7 +236,7 @@ impl TupleConstraints { status.attempt( self.prefix_items .iter() - .zip(values) + .zip(value) .map(|(schema, value)| schema.constraints.validate_value(value)) .try_collect_reports::>() .change_context(ArrayValidationError::PrefixItems), @@ -215,20 +246,6 @@ impl TupleConstraints { } } -pub(crate) fn validate_array_value( - value: &JsonValue, - schema: &ArraySchema, -) -> Result<(), Report> { - if let JsonValue::Array(string) = value { - schema.validate_value(string) - } else { - bail!(ConstraintError::InvalidType { - actual: JsonSchemaValueType::from(value), - expected: JsonSchemaValueType::Array, - }); - } -} - #[cfg(test)] mod tests { use serde_json::{from_value, json}; diff --git a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/boolean.rs b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/boolean.rs index ffbacea9dc5..b1fab79abfb 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/boolean.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/boolean.rs @@ -2,21 +2,35 @@ use error_stack::{Report, bail}; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; -use crate::schema::{ConstraintError, JsonSchemaValueType}; +use crate::schema::{Constraint, ConstraintError, JsonSchemaValueType}; #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] #[serde(rename_all = "camelCase")] pub enum BooleanTypeTag { Boolean, } -pub(crate) fn validate_boolean_value(value: &JsonValue) -> Result<(), Report> { - if value.is_boolean() { - Ok(()) - } else { - bail!(ConstraintError::InvalidType { - actual: JsonSchemaValueType::from(value), - expected: JsonSchemaValueType::Boolean, - }) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BooleanSchema; + +impl Constraint for BooleanSchema { + type Error = ConstraintError; + + fn is_valid(&self, value: &JsonValue) -> bool { + value.is_boolean() + } + + fn validate_value(&self, value: &JsonValue) -> Result<(), Report> { + if value.is_boolean() { + Ok(()) + } else { + bail!(ConstraintError::InvalidType { + actual: JsonSchemaValueType::from(value), + expected: JsonSchemaValueType::Boolean, + }); + } } } diff --git a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/mod.rs b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/mod.rs index 5bd01cede52..50bab635ae7 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/mod.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/mod.rs @@ -14,24 +14,44 @@ use serde_json::Value as JsonValue; pub use self::{ any_of::AnyOfConstraints, array::{ArrayConstraints, ArraySchema, ArrayTypeTag, ArrayValidationError, TupleConstraints}, - boolean::BooleanTypeTag, + boolean::{BooleanSchema, BooleanTypeTag}, error::ConstraintError, - null::NullTypeTag, + null::{NullSchema, NullTypeTag}, number::{NumberConstraints, NumberSchema, NumberTypeTag, NumberValidationError}, - object::ObjectTypeTag, + object::{ObjectConstraints, ObjectSchema, ObjectTypeTag, ObjectValidationError}, string::{ StringConstraints, StringFormat, StringFormatError, StringSchema, StringTypeTag, StringValidationError, }, }; -use crate::schema::{ - ValueLabel, - data_type::constraint::{ - array::validate_array_value, boolean::validate_boolean_value, null::validate_null_value, - number::validate_number_value, object::validate_object_value, - string::validate_string_value, - }, -}; +use crate::schema::ValueLabel; + +pub trait Constraint: Sized { + type Error: ?Sized; + + /// Checks if the provided value is valid against this constraint. + /// + /// In comparison to [`validate_value`], this method does not return the specific error that + /// occurred, but only if the value is valid or not. This can be used to check if a value is + /// valid without needing to handle the specific error. This method is faster than + /// [`validate_value`] as it does not need to construct a [`Report`]. + /// + /// [`validate_value`]: Self::validate_value + #[must_use] + fn is_valid(&self, value: &V) -> bool; + + /// Validates the provided value against this schema. + /// + /// If you only need to check if a value is valid without needing to handle the specific error, + /// you should use [`is_valid`] instead. + /// + /// [`is_valid`]: Self::is_valid + /// + /// # Errors + /// + /// Returns an error if the value is not valid against the schema. + fn validate_value(&self, value: &V) -> Result<(), Report>; +} #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] @@ -41,17 +61,17 @@ pub enum ValueConstraints { AnyOf(AnyOfConstraints), } -impl ValueConstraints { - /// Forwards the value validation to the appropriate schema. - /// - /// # Errors - /// - /// - For [`Typed`] schemas, see [`SingleValueConstraints::validate_value`]. - /// - For [`AnyOf`] schemas, see [`AnyOfConstraints::validate_value`]. - /// - /// [`Typed`]: Self::Typed - /// [`AnyOf`]: Self::AnyOf - pub fn validate_value(&self, value: &JsonValue) -> Result<(), Report> { +impl Constraint for ValueConstraints { + type Error = ConstraintError; + + fn is_valid(&self, value: &JsonValue) -> bool { + match self { + Self::Typed(constraints) => constraints.is_valid(value), + Self::AnyOf(constraints) => constraints.is_valid(value), + } + } + + fn validate_value(&self, value: &JsonValue) -> Result<(), Report> { match self { Self::Typed(constraints) => constraints.validate_value(value), Self::AnyOf(constraints) => constraints.validate_value(value), @@ -63,34 +83,36 @@ impl ValueConstraints { #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] #[serde(tag = "type", rename_all = "camelCase")] pub enum SingleValueConstraints { - Null, - Boolean, + Null(NullSchema), + Boolean(BooleanSchema), Number(NumberSchema), String(StringSchema), Array(ArraySchema), - Object, + Object(ObjectSchema), } -impl SingleValueConstraints { - /// Validates the provided value against the constraints. - /// - /// # Errors - /// - /// - [`InvalidType`] if the value does not match the expected type. - /// - [`ValueConstraint`] if the value does not match the expected constraints. - /// - [`AnyOf`] if the value does not match any of the expected schemas. - /// - /// [`InvalidType`]: ConstraintError::InvalidType - /// [`ValueConstraint`]: ConstraintError::ValueConstraint - /// [`AnyOf`]: ConstraintError::AnyOf - pub fn validate_value(&self, value: &JsonValue) -> Result<(), Report> { +impl Constraint for SingleValueConstraints { + type Error = ConstraintError; + + fn is_valid(&self, value: &JsonValue) -> bool { + match self { + Self::Null(schema) => schema.is_valid(value), + Self::Boolean(schema) => schema.is_valid(value), + Self::Number(schema) => schema.is_valid(value), + Self::String(schema) => schema.is_valid(value), + Self::Array(schema) => schema.is_valid(value), + Self::Object(schema) => schema.is_valid(value), + } + } + + fn validate_value(&self, value: &JsonValue) -> Result<(), Report> { match self { - Self::Null => validate_null_value(value), - Self::Boolean => validate_boolean_value(value), - Self::Number(schema) => validate_number_value(value, schema), - Self::String(schema) => validate_string_value(value, schema), - Self::Array(array) => validate_array_value(value, array), - Self::Object => validate_object_value(value), + Self::Null(schema) => schema.validate_value(value), + Self::Boolean(schema) => schema.validate_value(value), + Self::Number(schema) => schema.validate_value(value), + Self::String(schema) => schema.validate_value(value), + Self::Array(schema) => schema.validate_value(value), + Self::Object(schema) => schema.validate_value(value), } } } @@ -136,7 +158,7 @@ mod tests { use error_stack::Frame; use serde_json::Value as JsonValue; - use crate::schema::data_type::constraint::ValueConstraints; + use crate::schema::data_type::constraint::{Constraint, ValueConstraints}; pub(crate) fn read_schema(schema: &JsonValue) -> ValueConstraints { let parsed = serde_json::from_value(schema.clone()).expect("Failed to parse schema"); @@ -148,6 +170,7 @@ mod tests { } pub(crate) fn check_constraints(schema: &ValueConstraints, value: &JsonValue) { + assert!(schema.is_valid(value)); schema .validate_value(value) .expect("Failed to validate value"); @@ -158,6 +181,7 @@ mod tests { value: &JsonValue, expected_errors: impl IntoIterator, ) { + assert!(!schema.is_valid(value)); let err = schema .validate_value(value) .expect_err("Expected validation error"); diff --git a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/null.rs b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/null.rs index 537c9344520..61f3e665009 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/null.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/null.rs @@ -2,21 +2,35 @@ use error_stack::{Report, bail}; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; -use crate::schema::{ConstraintError, JsonSchemaValueType}; +use crate::schema::{Constraint, ConstraintError, JsonSchemaValueType}; #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] #[serde(rename_all = "camelCase")] pub enum NullTypeTag { Null, } -pub(crate) fn validate_null_value(value: &JsonValue) -> Result<(), Report> { - if value.is_null() { - Ok(()) - } else { - bail!(ConstraintError::InvalidType { - actual: JsonSchemaValueType::from(value), - expected: JsonSchemaValueType::Null, - }); +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct NullSchema; + +impl Constraint for NullSchema { + type Error = ConstraintError; + + fn is_valid(&self, value: &JsonValue) -> bool { + value.is_null() + } + + fn validate_value(&self, value: &JsonValue) -> Result<(), Report> { + if value.is_null() { + Ok(()) + } else { + bail!(ConstraintError::InvalidType { + actual: JsonSchemaValueType::from(value), + expected: JsonSchemaValueType::Null, + }); + } } } diff --git a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/number.rs b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/number.rs index 4df1be08cd9..b51fd7d799b 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/number.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/number.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Number as JsonNumber, Value as JsonValue, json}; use thiserror::Error; -use crate::schema::{ConstraintError, JsonSchemaValueType}; +use crate::schema::{ConstraintError, JsonSchemaValueType, data_type::constraint::Constraint}; #[expect( clippy::trivially_copy_pass_by_ref, @@ -108,44 +108,79 @@ fn float_multiple_of(lhs: f64, rhs: f64) -> bool { (quotient - quotient.round()).abs() < f64::EPSILON } -impl NumberSchema { - /// Validates the provided value against the number schema. - /// - /// # Errors - /// - /// - [`InvalidConstValue`] if the value is not equal to the expected value. - /// - [`InvalidEnumValue`] if the value is not one of the expected values. - /// - [`ValueConstraint`] if the value does not match the expected constraints. - /// - /// [`InvalidConstValue`]: ConstraintError::InvalidConstValue - /// [`InvalidEnumValue`]: ConstraintError::InvalidEnumValue - /// [`ValueConstraint`]: ConstraintError::ValueConstraint - pub fn validate_value(&self, number: &JsonNumber) -> Result<(), Report> { - let Some(float) = number.as_f64() else { - bail!( - Report::new(NumberValidationError::InsufficientPrecision { - actual: number.clone() +impl Constraint for NumberSchema { + type Error = ConstraintError; + + fn is_valid(&self, value: &JsonValue) -> bool { + if let JsonValue::Number(number) = value { + self.is_valid(number) + } else { + false + } + } + + fn validate_value(&self, value: &JsonValue) -> Result<(), Report> { + if let JsonValue::Number(number) = value { + self.validate_value(number) + } else { + bail!(ConstraintError::InvalidType { + actual: JsonSchemaValueType::from(value), + expected: JsonSchemaValueType::Number, + }); + } + } +} + +impl Constraint for NumberSchema { + type Error = ConstraintError; + + fn is_valid(&self, value: &JsonNumber) -> bool { + value + .as_f64() + .map_or(false, |number| self.is_valid(&number)) + } + + fn validate_value(&self, value: &JsonNumber) -> Result<(), Report> { + value.as_f64().map_or_else( + || { + Err(Report::new(NumberValidationError::InsufficientPrecision { + actual: value.clone(), }) - .change_context(ConstraintError::ValueConstraint) - ); - }; + .change_context(ConstraintError::ValueConstraint)) + }, + |number| self.validate_value(&number), + ) + } +} + +impl Constraint for NumberSchema { + type Error = ConstraintError; + + fn is_valid(&self, value: &f64) -> bool { + match self { + Self::Constrained(constraints) => constraints.is_valid(value), + Self::Const { r#const } => float_eq(*value, *r#const), + Self::Enum { r#enum } => r#enum.iter().any(|expected| float_eq(*value, *expected)), + } + } + fn validate_value(&self, value: &f64) -> Result<(), Report> { match self { Self::Constrained(constraints) => constraints - .validate_value(float) + .validate_value(value) .change_context(ConstraintError::ValueConstraint)?, Self::Const { r#const } => { - if !float_eq(float, *r#const) { + if !float_eq(*value, *r#const) { bail!(ConstraintError::InvalidConstValue { - actual: JsonValue::Number(number.clone()), + actual: json!(*value), expected: json!(*r#const), }); } } Self::Enum { r#enum } => { - if !r#enum.iter().any(|expected| float_eq(float, *expected)) { + if !r#enum.iter().any(|expected| float_eq(*value, *expected)) { bail!(ConstraintError::InvalidEnumValue { - actual: JsonValue::Number(number.clone()), + actual: json!(*value), expected: r#enum.iter().map(|value| json!(*value)).collect(), }); } @@ -155,20 +190,6 @@ impl NumberSchema { } } -pub(crate) fn validate_number_value( - value: &JsonValue, - schema: &NumberSchema, -) -> Result<(), Report> { - if let JsonValue::Number(number) = value { - schema.validate_value(number) - } else { - bail!(ConstraintError::InvalidType { - actual: JsonSchemaValueType::from(value), - expected: JsonSchemaValueType::Number, - }); - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -185,36 +206,49 @@ pub struct NumberConstraints { pub multiple_of: Option, } -impl NumberConstraints { - /// Validates the provided value against the number constraints. - /// - /// # Errors - /// - /// - [`Minimum`] if the value is less than the minimum value. - /// - [`Maximum`] if the value is greater than the maximum value. - /// - [`ExclusiveMinimum`] if the value is less than or equal to the minimum value. - /// - [`ExclusiveMaximum`] if the value is greater than or equal to the maximum value. - /// - [`MultipleOf`] if the value is not a multiple of the expected value. - /// - /// [`Minimum`]: NumberValidationError::Minimum - /// [`Maximum`]: NumberValidationError::Maximum - /// [`ExclusiveMinimum`]: NumberValidationError::ExclusiveMinimum - /// [`ExclusiveMaximum`]: NumberValidationError::ExclusiveMaximum - /// [`MultipleOf`]: NumberValidationError::MultipleOf - pub fn validate_value(&self, number: f64) -> Result<(), Report<[NumberValidationError]>> { +impl Constraint for NumberConstraints { + type Error = [NumberValidationError]; + + fn is_valid(&self, value: &f64) -> bool { + if let Some(minimum) = self.minimum { + if self.exclusive_minimum && float_less_eq(*value, minimum) + || float_less(*value, minimum) + { + return false; + } + } + + if let Some(maximum) = self.maximum { + if self.exclusive_maximum && float_less_eq(maximum, *value) + || float_less(maximum, *value) + { + return false; + } + } + + if let Some(expected) = self.multiple_of { + if !float_multiple_of(*value, expected) { + return false; + } + } + + true + } + + fn validate_value(&self, value: &f64) -> Result<(), Report<[NumberValidationError]>> { let mut status = ReportSink::new(); if let Some(minimum) = self.minimum { if self.exclusive_minimum { - if float_less_eq(number, minimum) { + if float_less_eq(*value, minimum) { status.capture(NumberValidationError::ExclusiveMinimum { - actual: number, + actual: *value, expected: minimum, }); } - } else if float_less(number, minimum) { + } else if float_less(*value, minimum) { status.capture(NumberValidationError::Minimum { - actual: number, + actual: *value, expected: minimum, }); } @@ -222,24 +256,24 @@ impl NumberConstraints { if let Some(maximum) = self.maximum { if self.exclusive_maximum { - if float_less_eq(maximum, number) { + if float_less_eq(maximum, *value) { status.capture(NumberValidationError::ExclusiveMaximum { - actual: number, + actual: *value, expected: maximum, }); } - } else if float_less(maximum, number) { + } else if float_less(maximum, *value) { status.capture(NumberValidationError::Maximum { - actual: number, + actual: *value, expected: maximum, }); } } if let Some(expected) = self.multiple_of { - if !float_multiple_of(number, expected) { + if !float_multiple_of(*value, expected) { status.capture(NumberValidationError::MultipleOf { - actual: number, + actual: *value, expected, }); } @@ -414,10 +448,10 @@ mod tests { "const": 50.0, })); - check_constraints(&number_schema, &json!(50)); - check_constraints_error(&number_schema, &json!(10), [ + check_constraints(&number_schema, &json!(50.0)); + check_constraints_error(&number_schema, &json!(10.0), [ ConstraintError::InvalidConstValue { - actual: json!(10), + actual: json!(10.0), expected: json!(50.0), }, ]); @@ -430,10 +464,10 @@ mod tests { "enum": [20.0, 50.0], })); - check_constraints(&number_schema, &json!(50)); - check_constraints_error(&number_schema, &json!(10), [ + check_constraints(&number_schema, &json!(50.0)); + check_constraints_error(&number_schema, &json!(10.0), [ ConstraintError::InvalidEnumValue { - actual: json!(10), + actual: json!(10.0), expected: vec![json!(20.0), json!(50.0)], }, ]); diff --git a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/object.rs b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/object.rs index d27590bcb5e..c81a9d8a760 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/object.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/object.rs @@ -1,8 +1,14 @@ -use error_stack::{Report, bail}; +use error_stack::{Report, ResultExt, bail}; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; +use thiserror::Error; -use crate::schema::{ConstraintError, JsonSchemaValueType}; +type JsonObject = serde_json::Map; + +use crate::schema::{Constraint, ConstraintError, JsonSchemaValueType}; + +#[derive(Debug, Error)] +pub enum ObjectValidationError {} #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] @@ -11,13 +17,72 @@ pub enum ObjectTypeTag { Object, } -pub(crate) fn validate_object_value(value: &JsonValue) -> Result<(), Report> { - if value.is_object() { +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] +#[serde(untagged, rename_all = "camelCase", deny_unknown_fields)] +pub enum ObjectSchema { + Constrained(ObjectConstraints), +} + +impl Constraint for ObjectSchema { + type Error = ConstraintError; + + fn is_valid(&self, value: &JsonValue) -> bool { + if let JsonValue::Object(object) = value { + self.is_valid(object) + } else { + false + } + } + + fn validate_value(&self, value: &JsonValue) -> Result<(), Report> { + if let JsonValue::Object(object) = value { + self.validate_value(object) + } else { + bail!(ConstraintError::InvalidType { + actual: JsonSchemaValueType::from(value), + expected: JsonSchemaValueType::Object, + }); + } + } +} + +impl Constraint for ObjectSchema { + type Error = ConstraintError; + + fn is_valid(&self, value: &JsonObject) -> bool { + match self { + Self::Constrained(constraints) => constraints.is_valid(value), + } + } + + fn validate_value(&self, value: &JsonObject) -> Result<(), Report> { + match self { + Self::Constrained(constraints) => constraints + .validate_value(value) + .change_context(ConstraintError::ValueConstraint)?, + } + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[expect( + clippy::empty_structs_with_brackets, + reason = "This struct is a placeholder for future functionality." +)] +pub struct ObjectConstraints {} + +impl Constraint for ObjectConstraints { + type Error = [ObjectValidationError]; + + fn is_valid(&self, _value: &JsonObject) -> bool { + true + } + + fn validate_value(&self, _value: &JsonObject) -> Result<(), Report<[ObjectValidationError]>> { Ok(()) - } else { - bail!(ConstraintError::InvalidType { - actual: JsonSchemaValueType::from(value), - expected: JsonSchemaValueType::Object, - }); } } diff --git a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/string.rs b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/string.rs index 11b03ad4598..27cea9de133 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/string.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/string.rs @@ -14,7 +14,7 @@ use thiserror::Error; use url::{Host, Url}; use uuid::Uuid; -use crate::schema::{ConstraintError, JsonSchemaValueType}; +use crate::schema::{ConstraintError, JsonSchemaValueType, data_type::constraint::Constraint}; #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] @@ -221,35 +221,57 @@ pub enum StringSchema { }, } -impl StringSchema { - /// Validates the provided value against the string schema. - /// - /// # Errors - /// - /// - [`InvalidConstValue`] if the value is not equal to the expected value. - /// - [`InvalidEnumValue`] if the value is not one of the expected values. - /// - [`ValueConstraint`] if the value does not match the expected constraints. - /// - /// [`InvalidConstValue`]: ConstraintError::InvalidConstValue - /// [`InvalidEnumValue`]: ConstraintError::InvalidEnumValue - /// [`ValueConstraint`]: ConstraintError::ValueConstraint - pub fn validate_value(&self, string: &str) -> Result<(), Report> { +impl Constraint for StringSchema { + type Error = ConstraintError; + + fn is_valid(&self, value: &JsonValue) -> bool { + if let JsonValue::String(string) = value { + self.is_valid(string.as_str()) + } else { + false + } + } + + fn validate_value(&self, value: &JsonValue) -> Result<(), Report> { + if let JsonValue::String(string) = value { + self.validate_value(string.as_str()) + } else { + bail!(ConstraintError::InvalidType { + actual: JsonSchemaValueType::from(value), + expected: JsonSchemaValueType::String, + }); + } + } +} + +impl Constraint for StringSchema { + type Error = ConstraintError; + + fn is_valid(&self, value: &str) -> bool { + match self { + Self::Constrained(constraints) => constraints.is_valid(value), + Self::Const { r#const } => value == r#const, + Self::Enum { r#enum } => r#enum.contains(value), + } + } + + fn validate_value(&self, value: &str) -> Result<(), Report> { match self { Self::Constrained(constraints) => constraints - .validate_value(string) + .validate_value(value) .change_context(ConstraintError::ValueConstraint)?, Self::Const { r#const } => { - if string != *r#const { + if value != *r#const { bail!(ConstraintError::InvalidConstValue { - actual: JsonValue::String(string.to_owned()), + actual: JsonValue::String(value.to_owned()), expected: JsonValue::String(r#const.clone()), }); } } Self::Enum { r#enum } => { - if !r#enum.contains(string) { + if !r#enum.contains(value) { bail!(ConstraintError::InvalidEnumValue { - actual: JsonValue::String(string.to_owned()), + actual: JsonValue::String(value.to_owned()), expected: r#enum.iter().cloned().map(JsonValue::String).collect(), }); } @@ -278,51 +300,65 @@ pub struct StringConstraints { pub format: Option, } -impl StringConstraints { - /// Validates the provided value against the string constraints. - /// - /// # Errors - /// - /// - [`MinLength`] if the value is shorter than the minimum length. - /// - [`MaxLength`] if the value is longer than the maximum length. - /// - [`Pattern`] if the value does not match the expected [`Regex`]. - /// - [`Format`] if the value does not match the expected [`StringFormat`]. - /// - /// [`MinLength`]: StringValidationError::MinLength - /// [`MaxLength`]: StringValidationError::MaxLength - /// [`Pattern`]: StringValidationError::Pattern - /// [`Format`]: StringValidationError::Format - pub fn validate_value(&self, string: &str) -> Result<(), Report<[StringValidationError]>> { +impl Constraint for StringConstraints { + type Error = [StringValidationError]; + + fn is_valid(&self, value: &str) -> bool { + if let Some(expected) = self.min_length { + if value.len() < expected { + return false; + } + } + if let Some(expected) = self.max_length { + if value.len() > expected { + return false; + } + } + if let Some(expected) = &self.pattern { + if !expected.is_match(value) { + return false; + } + } + if let Some(expected) = self.format { + if expected.validate(value).is_err() { + return false; + } + } + + true + } + + fn validate_value(&self, value: &str) -> Result<(), Report<[StringValidationError]>> { let mut status = ReportSink::new(); if let Some(expected) = self.min_length { - if string.len() < expected { + if value.len() < expected { status.capture(StringValidationError::MinLength { - actual: string.to_owned(), + actual: value.to_owned(), expected, }); } } if let Some(expected) = self.max_length { - if string.len() > expected { + if value.len() > expected { status.capture(StringValidationError::MaxLength { - actual: string.to_owned(), + actual: value.to_owned(), expected, }); } } if let Some(expected) = &self.pattern { - if !expected.is_match(string) { + if !expected.is_match(value) { status.capture(StringValidationError::Pattern { - actual: string.to_owned(), + actual: value.to_owned(), expected: expected.clone(), }); } } if let Some(expected) = self.format { - if let Err(error) = expected.validate(string) { + if let Err(error) = expected.validate(value) { status.append(error.change_context(StringValidationError::Format { - actual: string.to_owned(), + actual: value.to_owned(), expected, })); } @@ -332,20 +368,6 @@ impl StringConstraints { } } -pub(crate) fn validate_string_value( - value: &JsonValue, - schema: &StringSchema, -) -> Result<(), Report> { - if let JsonValue::String(string) = value { - schema.validate_value(string) - } else { - bail!(ConstraintError::InvalidType { - actual: JsonSchemaValueType::from(value), - expected: JsonSchemaValueType::String, - }); - } -} - #[cfg(test)] mod tests { use serde_json::{from_value, json}; diff --git a/libs/@blockprotocol/type-system/rust/src/schema/data_type/mod.rs b/libs/@blockprotocol/type-system/rust/src/schema/data_type/mod.rs index 34e26dbc357..b85ffab53a3 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/data_type/mod.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/data_type/mod.rs @@ -5,8 +5,9 @@ pub use self::{ closed::{ClosedDataType, DataTypeResolveData, InheritanceDepth, ResolvedDataType}, constraint::{ AnyOfConstraints, ArrayConstraints, ArraySchema, ArrayTypeTag, ArrayValidationError, - BooleanTypeTag, ConstraintError, NullTypeTag, NumberConstraints, NumberSchema, - NumberTypeTag, NumberValidationError, ObjectTypeTag, SingleValueConstraints, + BooleanSchema, BooleanTypeTag, Constraint, ConstraintError, NullSchema, NullTypeTag, + NumberConstraints, NumberSchema, NumberTypeTag, NumberValidationError, ObjectConstraints, + ObjectSchema, ObjectTypeTag, ObjectValidationError, SingleValueConstraints, SingleValueSchema, StringConstraints, StringFormat, StringFormatError, StringSchema, StringTypeTag, StringValidationError, TupleConstraints, }, @@ -165,12 +166,12 @@ mod raw { use super::{DataTypeSchemaTag, DataTypeTag, ValueSchemaMetadata}; use crate::{ schema::{ - ArrayTypeTag, BooleanTypeTag, DataTypeReference, NullTypeTag, NumberTypeTag, - ObjectTypeTag, StringTypeTag, + ArrayTypeTag, BooleanTypeTag, DataTypeReference, NullSchema, NullTypeTag, + NumberTypeTag, ObjectTypeTag, StringTypeTag, data_type::constraint::{ - AnyOfConstraints, ArrayConstraints, ArraySchema, NumberConstraints, NumberSchema, - SingleValueConstraints, StringConstraints, StringSchema, TupleConstraints, - ValueConstraints, + AnyOfConstraints, ArrayConstraints, ArraySchema, BooleanSchema, NumberConstraints, + NumberSchema, ObjectConstraints, ObjectSchema, SingleValueConstraints, + StringConstraints, StringSchema, TupleConstraints, ValueConstraints, }, }, url::VersionedUrl, @@ -268,6 +269,8 @@ mod raw { base: DataTypeBase, #[serde(flatten)] metadata: ValueSchemaMetadata, + #[serde(flatten)] + constraints: ObjectConstraints, }, Array { r#type: ArrayTypeTag, @@ -336,7 +339,7 @@ mod raw { } => ( base, metadata, - ValueConstraints::Typed(SingleValueConstraints::Null), + ValueConstraints::Typed(SingleValueConstraints::Null(NullSchema)), ), DataType::Boolean { r#type: _, @@ -345,7 +348,7 @@ mod raw { } => ( base, metadata, - ValueConstraints::Typed(SingleValueConstraints::Boolean), + ValueConstraints::Typed(SingleValueConstraints::Boolean(BooleanSchema)), ), DataType::Number { r#type: _, @@ -423,10 +426,13 @@ mod raw { r#type: _, base, metadata, + constraints, } => ( base, metadata, - ValueConstraints::Typed(SingleValueConstraints::Object), + ValueConstraints::Typed(SingleValueConstraints::Object( + ObjectSchema::Constrained(constraints), + )), ), DataType::Array { r#type: _, @@ -660,22 +666,6 @@ impl OntologyTypeResolver { } } -impl DataType { - /// Validates the given JSON value against the constraints of this data type. - /// - /// Returns a [`Report`] of any constraint errors found. - /// - /// # Errors - /// - /// Returns an error if the JSON value is not a valid instance of the data type. - pub fn validate_constraints(&self, value: &JsonValue) -> Result<(), Report> { - match &self.constraints { - ValueConstraints::Typed(typed_schema) => typed_schema.validate_value(value), - ValueConstraints::AnyOf(constraints) => constraints.validate_value(value), - } - } -} - #[cfg(test)] mod tests { use serde_json::json; diff --git a/libs/@blockprotocol/type-system/rust/src/schema/mod.rs b/libs/@blockprotocol/type-system/rust/src/schema/mod.rs index d282fd3d4e6..980fbf00e23 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/mod.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/mod.rs @@ -17,11 +17,12 @@ pub use self::{ array::{PropertyArraySchema, PropertyValueArray, ValueOrArray}, data_type::{ AnyOfConstraints, ArrayConstraints, ArraySchema, ArrayTypeTag, ArrayValidationError, - BooleanTypeTag, ClosedDataType, ConstraintError, ConversionDefinition, - ConversionExpression, ConversionValue, Conversions, DataType, DataTypeEdge, DataTypeId, - DataTypeReference, DataTypeResolveData, DataTypeValidator, InheritanceDepth, - JsonSchemaValueType, NullTypeTag, NumberConstraints, NumberSchema, NumberTypeTag, - NumberValidationError, ObjectTypeTag, OntologyTypeResolver, Operator, ResolvedDataType, + BooleanSchema, BooleanTypeTag, ClosedDataType, Constraint, ConstraintError, + ConversionDefinition, ConversionExpression, ConversionValue, Conversions, DataType, + DataTypeEdge, DataTypeId, DataTypeReference, DataTypeResolveData, DataTypeValidator, + InheritanceDepth, JsonSchemaValueType, NullSchema, NullTypeTag, NumberConstraints, + NumberSchema, NumberTypeTag, NumberValidationError, ObjectConstraints, ObjectSchema, + ObjectTypeTag, ObjectValidationError, OntologyTypeResolver, Operator, ResolvedDataType, SingleValueConstraints, SingleValueSchema, StringConstraints, StringFormat, StringFormatError, StringSchema, StringTypeTag, StringValidationError, TupleConstraints, ValidateDataTypeError, ValueLabel, ValueSchemaMetadata, Variable, diff --git a/libs/@local/hash-validation/src/entity_type.rs b/libs/@local/hash-validation/src/entity_type.rs index 1cb8c0e8363..782ee70b1a0 100644 --- a/libs/@local/hash-validation/src/entity_type.rs +++ b/libs/@local/hash-validation/src/entity_type.rs @@ -25,7 +25,7 @@ use serde_json::Value as JsonValue; use thiserror::Error; use type_system::{ schema::{ - ClosedEntityType, DataTypeReference, JsonSchemaValueType, PropertyObjectSchema, + ClosedEntityType, Constraint, DataTypeReference, JsonSchemaValueType, PropertyObjectSchema, PropertyType, PropertyTypeReference, PropertyValueArray, PropertyValueSchema, PropertyValues, ValueOrArray, }, @@ -278,7 +278,8 @@ impl EntityVisitor for ValueValidator { status.attempt( data_type .schema - .validate_constraints(value) + .constraints + .validate_value(value) .change_context(TraversalError::ConstraintUnfulfilled), );