diff --git a/apps/hash-ai-worker-ts/src/activities/shared/dereference-entity-type.ts b/apps/hash-ai-worker-ts/src/activities/shared/dereference-entity-type.ts index 89385671851..5945f62e57f 100644 --- a/apps/hash-ai-worker-ts/src/activities/shared/dereference-entity-type.ts +++ b/apps/hash-ai-worker-ts/src/activities/shared/dereference-entity-type.ts @@ -1,4 +1,5 @@ import type { + DataType, EntityType, OneOfSchema, PropertyType, @@ -12,7 +13,6 @@ import { atLeastOne, extractVersion } from "@blockprotocol/type-system"; import { typedEntries } from "@local/advanced-types/typed-entries"; import type { BaseUrl, - CustomDataType, EntityTypeMetadata, } from "@local/hash-graph-types/ontology"; import type { Subgraph } from "@local/hash-subgraph"; @@ -29,7 +29,7 @@ import { import { generateSimplifiedTypeId } from "../infer-entities/shared/generate-simplified-type-id.js"; -type MinimalDataType = Omit; +type MinimalDataType = Omit; type MinimalPropertyObject = PropertyValueObject< ValueOrArray diff --git a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util.ts b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util.ts index 9551791cd9a..2684b8298c2 100644 --- a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util.ts +++ b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import type { Conversions, + DataType, DataTypeReference, EntityType, OneOfSchema, @@ -33,7 +34,6 @@ import type { PropertyObjectWithMetadata } from "@local/hash-graph-types/entity" import type { BaseUrl, ConstructDataTypeParams, - CustomDataType, DataTypeWithMetadata, EntityTypeWithMetadata, PropertyTypeWithMetadata, @@ -413,7 +413,9 @@ type BaseCreateTypeIfNotExistsParameters = { export const generateSystemDataTypeSchema = ({ dataTypeId, ...rest -}: ConstructDataTypeParams & { dataTypeId: VersionedUrl }): CustomDataType => { +}: ConstructDataTypeParams & { + dataTypeId: VersionedUrl; +}): DataType => { return { $id: dataTypeId, $schema: DATA_TYPE_META_SCHEMA, diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/editor-specs.ts b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/editor-specs.ts index b8cdb0a3f76..31cf429a8cf 100644 --- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/editor-specs.ts +++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-editor/properties-section/property-table/cells/value-cell/editor-specs.ts @@ -1,3 +1,4 @@ +import type { DataType } from "@blockprotocol/type-system"; import type { IconDefinition } from "@fortawesome/free-solid-svg-icons"; import { fa100, @@ -14,7 +15,6 @@ import { faSquareCheck, faText, } from "@hashintel/design-system"; -import type { CustomDataType } from "@local/hash-graph-types/ontology"; import type { CustomIcon } from "../../../../../../../../../components/grid/utils/custom-grid-icons"; import type { EditorType } from "./types"; @@ -87,7 +87,7 @@ const identifierTypeTitles = ["URL", "URI"]; export const getEditorSpecs = ( editorType: EditorType, - dataType?: CustomDataType, + dataType?: DataType, ): EditorSpec => { switch (editorType) { case "boolean": diff --git a/apps/hash-frontend/src/pages/shared/data-types-context.tsx b/apps/hash-frontend/src/pages/shared/data-types-context.tsx index 6f51061ab86..e144c0eda9a 100644 --- a/apps/hash-frontend/src/pages/shared/data-types-context.tsx +++ b/apps/hash-frontend/src/pages/shared/data-types-context.tsx @@ -64,7 +64,7 @@ export const DataTypesContextProvider = ({ children }: PropsWithChildren) => { ]?.inner.schema as DataTypeWithMetadata["schema"] | undefined; if (!dataType) { - return formatDataValue(value?.toString() ?? "null", null); + return formatDataValue(value?.toString() ?? "null", undefined); } return formatDataValue(value, dataType); diff --git a/apps/hash-graph/libs/api/openapi/models/data_type.json b/apps/hash-graph/libs/api/openapi/models/data_type.json index f3d88d7d353..2702137fb05 100644 --- a/apps/hash-graph/libs/api/openapi/models/data_type.json +++ b/apps/hash-graph/libs/api/openapi/models/data_type.json @@ -37,6 +37,6 @@ ] } }, - "required": ["$schema", "kind", "$id", "title", "type"], + "required": ["$schema", "kind", "$id", "title"], "additionalProperties": true } diff --git a/apps/hash-graph/libs/api/openapi/models/update_data_type.json b/apps/hash-graph/libs/api/openapi/models/update_data_type.json index fff9bdb1023..a82d63033c8 100644 --- a/apps/hash-graph/libs/api/openapi/models/update_data_type.json +++ b/apps/hash-graph/libs/api/openapi/models/update_data_type.json @@ -28,6 +28,6 @@ ] } }, - "required": ["$schema", "kind", "title", "type"], + "required": ["$schema", "kind", "title"], "additionalProperties": true } diff --git a/apps/hash-graph/libs/api/src/rest/data_type.rs b/apps/hash-graph/libs/api/src/rest/data_type.rs index 53ad76f42e7..2e46b18e5fb 100644 --- a/apps/hash-graph/libs/api/src/rest/data_type.rs +++ b/apps/hash-graph/libs/api/src/rest/data_type.rs @@ -285,7 +285,7 @@ enum LoadExternalDataTypeRequest { #[serde(rename_all = "camelCase")] Create { #[schema(value_type = VAR_DATA_TYPE)] - schema: DataType, + schema: Box, relationships: Vec, #[serde( default, @@ -373,7 +373,7 @@ where Ok(Json( store .create_data_type(actor_id, CreateDataTypeParams { - schema, + schema: *schema, classification: OntologyTypeClassificationMetadata::External { fetched_at: OffsetDateTime::now_utc(), }, diff --git a/apps/hash-graph/libs/api/src/rest/json_schemas/data_type.json b/apps/hash-graph/libs/api/src/rest/json_schemas/data_type.json index f3d88d7d353..2702137fb05 100644 --- a/apps/hash-graph/libs/api/src/rest/json_schemas/data_type.json +++ b/apps/hash-graph/libs/api/src/rest/json_schemas/data_type.json @@ -37,6 +37,6 @@ ] } }, - "required": ["$schema", "kind", "$id", "title", "type"], + "required": ["$schema", "kind", "$id", "title"], "additionalProperties": true } diff --git a/apps/hash-graph/libs/api/src/rest/json_schemas/update_data_type.json b/apps/hash-graph/libs/api/src/rest/json_schemas/update_data_type.json index fff9bdb1023..a82d63033c8 100644 --- a/apps/hash-graph/libs/api/src/rest/json_schemas/update_data_type.json +++ b/apps/hash-graph/libs/api/src/rest/json_schemas/update_data_type.json @@ -28,6 +28,6 @@ ] } }, - "required": ["$schema", "kind", "title", "type"], + "required": ["$schema", "kind", "title"], "additionalProperties": true } diff --git a/libs/@blockprotocol/type-system/rust/Cargo.toml b/libs/@blockprotocol/type-system/rust/Cargo.toml index 26a2dddd457..31cd7e8a7de 100644 --- a/libs/@blockprotocol/type-system/rust/Cargo.toml +++ b/libs/@blockprotocol/type-system/rust/Cargo.toml @@ -16,7 +16,8 @@ crate-type = ["cdylib", "rlib"] [dependencies] # Public workspace dependencies -error-stack = { workspace = true, public = true, features = ["unstable"]} +error-stack = { workspace = true, public = true, features = ["unstable"] } +codec = { workspace = true, public = true, features = ["serde"] } # Public third-party dependencies bytes = { workspace = true, public = true } @@ -29,7 +30,6 @@ utoipa = { workspace = true, public = true, features = ["url"], optional = true uuid = { workspace = true, public = true, features = ["std"] } # Private workspace dependencies -codec = { workspace = true, features = ["serde"] } futures = { workspace = true } # Private third-party dependencies diff --git a/libs/@blockprotocol/type-system/rust/src/schema/array/raw.rs b/libs/@blockprotocol/type-system/rust/src/schema/array/raw.rs index b37e78cbd66..2a93e6f1262 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/array/raw.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/array/raw.rs @@ -1,12 +1,6 @@ use serde::{Deserialize, Serialize}; -/// Will serialize as a constant value `"array"` -#[derive(Serialize, Deserialize)] -#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] -#[serde(rename_all = "camelCase")] -enum ArrayTypeTag { - Array, -} +use crate::schema::data_type::ArrayTypeTag; #[derive(Deserialize)] #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] 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 new file mode 100644 index 00000000000..b7b8d386a23 --- /dev/null +++ b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/any_of.rs @@ -0,0 +1,49 @@ +use error_stack::{Report, ReportSink, ResultExt}; +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; + +use crate::schema::{ConstraintError, ValueLabel, data_type::constraint::SimpleTypedValueSchema}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AnyOfConstraints { + #[cfg_attr( + target_arch = "wasm32", + tsify(type = "[SimpleTypedValueSchema, ...SimpleTypedValueSchema[]]") + )] + 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> { + let mut status = ReportSink::::new(); + for schema in &self.any_of { + if let Err(error) = schema.constraints.validate_value(value) { + status.capture(error); + } else { + return Ok(()); + } + } + status.finish().change_context(ConstraintError::AnyOf) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AnyOfSchema { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "ValueLabel::is_empty")] + pub label: ValueLabel, + #[serde(flatten)] + pub constraints: AnyOfConstraints, +} 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 31f975891de..d86f4bbdf77 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 @@ -1,26 +1,13 @@ -use error_stack::{Report, ReportSink}; +use codec::serde::constant::ConstBool; +use error_stack::{Report, ReportSink, ResultExt, TryReportIteratorExt, bail}; use serde::{Deserialize, Serialize}; -use serde_json::{Value as JsonValue, json}; +use serde_json::Value as JsonValue; use thiserror::Error; -use crate::schema::{DataTypeLabel, data_type::constraint::ValueConstraints}; +use crate::schema::{ConstraintError, data_type::constraint::SimpleValueSchema}; #[derive(Debug, Error)] pub enum ArrayValidationError { - #[error( - "the provided value is not equal to the expected value, expected `{}` to be equal \ - to `{}`", json!(actual), json!(expected) - )] - InvalidConstValue { - actual: Vec, - expected: Vec, - }, - #[error("the provided value is not one of the expected values, expected `{}` to be one of `{}`", json!(actual), json!(expected))] - InvalidEnumValue { - actual: Vec, - expected: Vec>, - }, - #[error( "The length of the array is too short, expected `{actual}` to be greater than or equal to \ `{expected}`" @@ -38,123 +25,309 @@ pub enum ArrayValidationError { } #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged, deny_unknown_fields)] -pub enum ItemsConstraints { - Boolean(bool), - Value(Box), +#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] +#[serde(rename_all = "camelCase")] +pub enum ArrayTypeTag { + Array, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] +#[serde(untagged, rename_all = "camelCase")] +pub enum ArraySchema { + Constrained(ArrayConstraints), + Tuple(TupleConstraints), + // TODO: Remove + // see https://linear.app/hash/issue/H-3368/remove-const-from-array-constraints + Const { r#const: [JsonValue; 0] }, +} + +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> { + match self { + Self::Constrained(constraints) => constraints + .validate_value(array) + .change_context(ConstraintError::ValueConstraint)?, + Self::Tuple(constraints) => constraints + .validate_value(array) + .change_context(ConstraintError::ValueConstraint)?, + Self::Const { r#const } => { + if array != *r#const { + bail!(ConstraintError::InvalidConstValue { + actual: JsonValue::Array(array.to_vec()), + expected: JsonValue::Array(r#const.to_vec()), + }); + } + } + } + Ok(()) + } } #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ArraySchema { +pub struct ArrayConstraints { #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(default, skip_serializing_if = "DataTypeLabel::is_empty")] - pub label: DataTypeLabel, + pub items: Option, +} - #[serde(default, skip_serializing_if = "Option::is_none")] - #[cfg_attr(target_arch = "wasm32", tsify(type = "JsonValue[]"))] - pub r#const: Option>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - #[cfg_attr( - target_arch = "wasm32", - tsify(type = "[JsonValue[], ...JsonValue[][]]") - )] - pub r#enum: Vec>, +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]>> { + let mut status = ReportSink::new(); - #[serde(default, skip_serializing_if = "Option::is_none")] - pub min_items: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_items: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[cfg_attr(target_arch = "wasm32", tsify(type = "ValueConstraints | boolean"))] - pub items: Option, + if let Some(items) = &self.items { + status.attempt( + values + .iter() + .map(|value| items.validate_value(value)) + .try_collect_reports::>() + .change_context(ArrayValidationError::Items), + ); + } + + status.finish() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TupleConstraints { + #[cfg_attr(target_arch = "wasm32", tsify(type = "false"))] + pub items: ConstBool, #[serde(default, skip_serializing_if = "Vec::is_empty")] #[cfg_attr( target_arch = "wasm32", - tsify(type = "[ValueConstraints, ...ValueConstraints[]]") + tsify(type = "[SimpleValueSchema, ...SimpleValueSchema[]]") )] - pub prefix_items: Vec, + pub prefix_items: Vec, } -impl ArraySchema { +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]>> { - let mut validation_status = ReportSink::new(); - - if let Some(expected) = &self.r#const { - if expected != values { - validation_status.capture(ArrayValidationError::InvalidConstValue { - expected: expected.clone(), - actual: values.to_owned(), - }); - } - } + let mut status = ReportSink::new(); - if !self.r#enum.is_empty() && !self.r#enum.iter().any(|expected| expected == values) { - validation_status.capture(ArrayValidationError::InvalidEnumValue { - expected: self.r#enum.clone(), - actual: values.to_owned(), + let num_values = values.len(); + let num_prefix_items = self.prefix_items.len(); + if num_values != num_prefix_items { + status.capture(if num_values < num_prefix_items { + ArrayValidationError::MinItems { + actual: num_values, + expected: num_prefix_items, + } + } else { + ArrayValidationError::MaxItems { + actual: num_values, + expected: num_prefix_items, + } }); } - let num_values = values.len(); + status.attempt( + self.prefix_items + .iter() + .zip(values) + .map(|(schema, value)| schema.validate_value(value)) + .try_collect_reports::>() + .change_context(ArrayValidationError::PrefixItems), + ); - let mut values = values.iter(); + status.finish() + } +} - let mut item_status = ReportSink::new(); - for (value, constraint) in values - .by_ref() - .take(self.prefix_items.len()) - .zip(&self.prefix_items) - { - if let Err(error) = constraint.validate_value(value) { - item_status.append(error); - } - } +#[cfg(test)] +mod tests { + use serde_json::{from_value, json}; - let expected_num_items = self.prefix_items.len().max(self.min_items.unwrap_or(0)); - if num_values < expected_num_items { - validation_status.capture(ArrayValidationError::MinItems { - actual: num_values, - expected: expected_num_items, - }); - } - if let Some(max_items) = self.max_items { - if num_values > max_items { - validation_status.capture(ArrayValidationError::MaxItems { - actual: num_values, - expected: max_items, - }); - } - } + use super::*; + use crate::schema::{ + NumberValidationError, + data_type::constraint::{ + ValueConstraints, + tests::{check_constraints, check_constraints_error, read_schema}, + }, + }; - match &self.items { - None | Some(ItemsConstraints::Boolean(true)) => {} - Some(ItemsConstraints::Boolean(false)) => { - if values.next().is_some() { - validation_status.capture(ArrayValidationError::MaxItems { - actual: num_values, - expected: self.prefix_items.len(), - }); - } - } - Some(ItemsConstraints::Value(items)) => { - for value in values { - if let Err(error) = items.validate_value(value) { - item_status.append(error); - } - } - } - } + #[test] + fn unconstrained() { + let array_schema = read_schema(&json!({ + "type": "array", + })); - if let Err(error) = item_status.finish() { - validation_status.append(error.change_context(ArrayValidationError::Items)); - } + check_constraints(&array_schema, &json!([])); + check_constraints(&array_schema, &json!([1, 2, 3])); + check_constraints(&array_schema, &json!([1, "2", true])); + } + + #[test] + fn simple_array() { + let array_schema = read_schema(&json!({ + "type": "array", + "items": { + "type": "number", + "description": "A number", + "minimum": 0.0 + }, + })); + + check_constraints(&array_schema, &json!([])); + check_constraints(&array_schema, &json!([1, 2, 3])); + check_constraints_error(&array_schema, &json!([1, "2", true]), [ + ArrayValidationError::Items, + ]); + check_constraints_error(&array_schema, &json!([1, -2, 0]), [ + ArrayValidationError::Items, + ]); + check_constraints_error(&array_schema, &json!([1, -2, -4]), [ + NumberValidationError::Minimum { + actual: -2.0, + expected: 0.0, + }, + NumberValidationError::Minimum { + actual: -4.0, + expected: 0.0, + }, + ]); + } + + #[test] + fn simple_tuple() { + let array_schema = read_schema(&json!({ + "type": "array", + "items": false, + "prefixItems": [{ + "type": "number", + "description": "A number", + "maximum": 10.0 + }], + })); + + check_constraints_error(&array_schema, &json!([]), [ + ArrayValidationError::MinItems { + actual: 0, + expected: 1, + }, + ]); + check_constraints_error(&array_schema, &json!([1, 2, 3]), [ + ArrayValidationError::MaxItems { + actual: 3, + expected: 1, + }, + ]); + check_constraints(&array_schema, &json!([1])); + check_constraints_error(&array_schema, &json!([15]), [ + NumberValidationError::Maximum { + actual: 15.0, + expected: 10.0, + }, + ]); + } + + #[test] + fn empty_array() { + let array_schema = read_schema(&json!({ + "type": "array", + "items": false, + })); + + check_constraints(&array_schema, &json!([])); + check_constraints_error(&array_schema, &json!([null]), [ + ArrayValidationError::MaxItems { + actual: 1, + expected: 0, + }, + ]); + } + + #[test] + fn missing_type() { + from_value::(json!({ + "items": {"type": "number"}, + })) + .expect_err("Deserialized number schema without type"); + } + + #[test] + fn missing_nested_type() { + from_value::(json!({ + "type": "array", + "items": {}, + })) + .expect_err("Deserialized number schema without nested type"); + } + + #[test] + fn additional_array_properties() { + from_value::(json!({ + "type": "array", + "items": {"type": "number"}, + "additional": false, + })) + .expect_err("Deserialized array schema with additional properties"); + } + + #[test] + fn additional_tuple_properties() { + from_value::(json!({ + "type": "array", + "items": false, + "additional": false, + })) + .expect_err("Deserialized array schema with additional properties"); + } + + #[test] + fn additional_nested_properties() { + from_value::(json!({ + "type": "array", + "items": { + "type": "number", + "additional": false, + }, + })) + .expect_err("Deserialized array schema with additional nested properties"); + } - validation_status.finish() + #[test] + fn mixed() { + from_value::(json!({ + "type": "array", + "items": {"type": "number"}, + "prefixItems": [{"type": "number"}], + })) + .expect_err("Deserialized array schema with mixed properties"); } } 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 3b0207a7da0..6a0a2d4b598 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 @@ -1,62 +1,8 @@ -use std::collections::HashSet; - -use error_stack::{Report, ReportSink}; use serde::{Deserialize, Serialize}; -use serde_json::json; -use thiserror::Error; - -use crate::schema::DataTypeLabel; - -#[derive(Debug, Error)] -pub enum BooleanValidationError { - #[error( - "the provided value is not equal to the expected value, expected `{actual}` to be equal \ - to `{expected}`" - )] - InvalidConstValue { actual: bool, expected: bool }, - #[error("the provided value is not one of the expected values, expected `{actual}` to be one of `{}`", json!(expected))] - InvalidEnumValue { - actual: bool, - expected: HashSet, - }, -} #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct BooleanSchema { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(default, skip_serializing_if = "DataTypeLabel::is_empty")] - pub label: DataTypeLabel, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub r#const: Option, - #[serde(default, skip_serializing_if = "HashSet::is_empty")] - #[cfg_attr(target_arch = "wasm32", tsify(type = "[boolean, ...boolean[]]"))] - pub r#enum: HashSet, -} - -impl BooleanSchema { - pub fn validate_value(&self, boolean: bool) -> Result<(), Report<[BooleanValidationError]>> { - let mut status = ReportSink::new(); - - if let Some(expected) = &self.r#const { - if *expected != boolean { - status.capture(BooleanValidationError::InvalidConstValue { - expected: *expected, - actual: boolean, - }); - } - } - - if !self.r#enum.is_empty() && !self.r#enum.contains(&boolean) { - status.capture(BooleanValidationError::InvalidEnumValue { - expected: self.r#enum.clone(), - actual: boolean, - }); - } - - status.finish() - } +#[serde(rename_all = "camelCase")] +pub enum BooleanTypeTag { + Boolean, } diff --git a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/error.rs b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/error.rs index 7498054589b..7cb1e8ae49e 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/error.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/error.rs @@ -1,12 +1,23 @@ -use core::net::AddrParseError; - -use iso8601_duration::ParseDurationError; +use serde_json::{Value as JsonValue, json}; use thiserror::Error; use crate::schema::JsonSchemaValueType; #[derive(Debug, Error)] pub enum ConstraintError { + #[error( + "the provided value is not equal to the expected value, expected `{actual}` to be equal \ + to `{expected}`" + )] + InvalidConstValue { + actual: JsonValue, + expected: JsonValue, + }, + #[error("the provided value is not one of the expected values, expected `{actual}` to be one of `{}`", json!(expected))] + InvalidEnumValue { + actual: JsonValue, + expected: Vec, + }, #[error("the value does not match the expected constraints")] ValueConstraint, #[error( @@ -17,26 +28,6 @@ pub enum ConstraintError { actual: JsonSchemaValueType, expected: JsonSchemaValueType, }, -} - -#[derive(Debug, Error)] -pub enum StringFormatError { - #[error(transparent)] - Url(url::ParseError), - #[error(transparent)] - Uuid(uuid::Error), - #[error(transparent)] - Regex(regex::Error), - #[error(transparent)] - Email(email_address::Error), - #[error(transparent)] - IpAddress(AddrParseError), - #[error("The value does not match the date-time format `YYYY-MM-DDTHH:MM:SS.sssZ`")] - DateTime, - #[error("The value does not match the date format `YYYY-MM-DD`")] - Date, - #[error("The value does not match the time format `HH:MM:SS.sss`")] - Time, - #[error("{0:?}")] - Duration(ParseDurationError), + #[error("None of the provided values match the expected values")] + AnyOf, } 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 9b057904153..29599457808 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 @@ -1,3 +1,4 @@ +mod any_of; mod array; mod boolean; mod error; @@ -6,32 +7,216 @@ mod number; mod object; mod string; -use error_stack::{Report, ResultExt, bail}; +use error_stack::{Report, bail}; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; -pub(crate) use self::{ - array::ArraySchema, boolean::BooleanSchema, error::ConstraintError, null::NullSchema, - number::NumberSchema, object::ObjectSchema, string::StringSchema, +pub use self::{ + any_of::{AnyOfConstraints, AnyOfSchema}, + array::{ArrayConstraints, ArraySchema, ArrayTypeTag, ArrayValidationError, TupleConstraints}, + boolean::BooleanTypeTag, + error::ConstraintError, + null::NullTypeTag, + number::{NumberConstraints, NumberSchema, NumberTypeTag, NumberValidationError}, + object::ObjectTypeTag, + string::{ + StringConstraints, StringFormat, StringFormatError, StringSchema, StringTypeTag, + StringValidationError, + }, }; -use crate::schema::JsonSchemaValueType; +use crate::schema::{JsonSchemaValueType, ValueLabel}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SimpleTypedValueSchema { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "ValueLabel::is_empty")] + pub label: ValueLabel, + #[serde(flatten)] + pub constraints: SimpleTypedValueConstraint, +} + +#[cfg(target_arch = "wasm32")] +#[expect( + dead_code, + reason = "Used to export type to TypeScript to prevent Tsify generating interfaces" +)] +mod wasm { + use super::*; + + #[derive(tsify::Tsify)] + #[serde(untagged)] + enum SimpleTypedValueSchema { + Schema { + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(default)] + label: ValueLabel, + #[serde(flatten)] + constraints: SimpleTypedValueConstraint, + }, + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] +#[serde(untagged, rename_all = "camelCase")] +pub enum SimpleValueSchema { + Typed(SimpleTypedValueSchema), + #[serde(skip)] + AnyOf(AnyOfSchema), +} + +impl SimpleValueSchema { + /// Forwards the value validation to the appropriate schema. + /// + /// # Errors + /// + /// - For [`Typed`] schemas, see [`TypedValueConstraints::validate_value`]. + /// - For [`AnyOf`] schemas, see [`AnyOfConstraints::validate_value`]. + /// + /// [`Typed`]: Self::Typed + /// [`AnyOf`]: Self::AnyOf + pub fn validate_value(&self, value: &JsonValue) -> Result<(), Report> { + match self { + Self::Typed(schema) => schema.constraints.validate_value(value), + Self::AnyOf(schema) => schema.constraints.validate_value(value), + } + } +} #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] #[serde(tag = "type", rename_all = "camelCase")] +pub enum SimpleTypedValueConstraint { + Null, + Boolean, + Number(NumberSchema), + String(StringSchema), + Array, + Object, +} + +impl SimpleTypedValueConstraint { + pub fn validate_value(&self, value: &JsonValue) -> Result<(), Report> { + match self { + Self::Null => { + if value.is_null() { + Ok(()) + } else { + bail!(ConstraintError::InvalidType { + actual: JsonSchemaValueType::from(value), + expected: JsonSchemaValueType::Null, + }) + } + } + Self::Boolean => { + if value.is_boolean() { + Ok(()) + } else { + bail!(ConstraintError::InvalidType { + actual: JsonSchemaValueType::from(value), + expected: JsonSchemaValueType::Boolean, + }) + } + } + Self::Number(schema) => { + if let JsonValue::Number(number) = value { + schema.validate_value(number) + } else { + bail!(ConstraintError::InvalidType { + actual: JsonSchemaValueType::from(value), + expected: JsonSchemaValueType::Number, + }) + } + } + Self::String(schema) => { + if let JsonValue::String(string) = value { + schema.validate_value(string) + } else { + bail!(ConstraintError::InvalidType { + actual: JsonSchemaValueType::from(value), + expected: JsonSchemaValueType::String, + }) + } + } + Self::Array => { + if value.is_array() { + Ok(()) + } else { + bail!(ConstraintError::InvalidType { + actual: JsonSchemaValueType::from(value), + expected: JsonSchemaValueType::Array, + }) + } + } + Self::Object => { + if value.is_object() { + Ok(()) + } else { + bail!(ConstraintError::InvalidType { + actual: JsonSchemaValueType::from(value), + expected: JsonSchemaValueType::Object, + }) + } + } + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged, rename_all = "camelCase")] pub enum ValueConstraints { - Null(NullSchema), - Boolean(BooleanSchema), + Typed(TypedValueConstraints), + #[serde(skip)] + AnyOf(AnyOfConstraints), +} + +impl ValueConstraints { + /// Forwards the value validation to the appropriate schema. + /// + /// # Errors + /// + /// - For [`Typed`] schemas, see [`TypedValueConstraints::validate_value`]. + /// - For [`AnyOf`] schemas, see [`SimpleTypedValueConstraint::validate_value`]. + /// + /// [`Typed`]: Self::Typed + /// [`AnyOf`]: Self::AnyOf + pub fn validate_value(&self, value: &JsonValue) -> Result<(), Report> { + match self { + Self::Typed(constraints) => constraints.validate_value(value), + Self::AnyOf(constraints) => constraints.validate_value(value), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum TypedValueConstraints { + Null, + Boolean, Number(NumberSchema), String(StringSchema), Array(ArraySchema), - Object(ObjectSchema), + Object, } -impl ValueConstraints { +impl TypedValueConstraints { + /// 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> { match self { - Self::Null(_) => { + Self::Null => { if value.is_null() { Ok(()) } else { @@ -41,11 +226,9 @@ impl ValueConstraints { }) } } - Self::Boolean(schema) => { - if let JsonValue::Bool(boolean) = value { - schema - .validate_value(*boolean) - .change_context(ConstraintError::ValueConstraint) + Self::Boolean => { + if value.is_boolean() { + Ok(()) } else { bail!(ConstraintError::InvalidType { actual: JsonSchemaValueType::from(value), @@ -55,9 +238,7 @@ impl ValueConstraints { } Self::Number(schema) => { if let JsonValue::Number(number) = value { - schema - .validate_value(number) - .change_context(ConstraintError::ValueConstraint) + schema.validate_value(number) } else { bail!(ConstraintError::InvalidType { actual: JsonSchemaValueType::from(value), @@ -67,9 +248,7 @@ impl ValueConstraints { } Self::String(schema) => { if let JsonValue::String(string) = value { - schema - .validate_value(string) - .change_context(ConstraintError::ValueConstraint) + schema.validate_value(string) } else { bail!(ConstraintError::InvalidType { actual: JsonSchemaValueType::from(value), @@ -79,9 +258,7 @@ impl ValueConstraints { } Self::Array(schema) => { if let JsonValue::Array(array) = value { - schema - .validate_value(array) - .change_context(ConstraintError::ValueConstraint) + schema.validate_value(array) } else { bail!(ConstraintError::InvalidType { actual: JsonSchemaValueType::from(value), @@ -89,11 +266,9 @@ impl ValueConstraints { }) } } - Self::Object(schema) => { - if let JsonValue::Object(object) = value { - schema - .validate_value(object) - .change_context(ConstraintError::ValueConstraint) + Self::Object => { + if value.is_object() { + Ok(()) } else { bail!(ConstraintError::InvalidType { actual: JsonSchemaValueType::from(value), @@ -104,3 +279,50 @@ impl ValueConstraints { } } } + +#[cfg(test)] +mod tests { + use core::fmt::Display; + use std::collections::HashSet; + + use error_stack::Frame; + use serde_json::Value as JsonValue; + + use crate::schema::data_type::constraint::ValueConstraints; + + pub(crate) fn read_schema(schema: &JsonValue) -> ValueConstraints { + let parsed = serde_json::from_value(schema.clone()).expect("Failed to parse schema"); + assert_eq!( + serde_json::to_value(&parsed).expect("Could not serialize schema"), + *schema + ); + parsed + } + + pub(crate) fn check_constraints(schema: &ValueConstraints, value: &JsonValue) { + schema + .validate_value(value) + .expect("Failed to validate value"); + } + + pub(crate) fn check_constraints_error( + schema: &ValueConstraints, + value: &JsonValue, + expected_errors: impl IntoIterator, + ) { + let err = schema + .validate_value(value) + .expect_err("Expected validation error"); + let errors = expected_errors + .into_iter() + .map(|error| error.to_string()) + .collect::>(); + let actual_errors = err + .frames() + .filter_map(Frame::downcast_ref::) + .map(ToString::to_string) + .collect::>(); + + assert_eq!(errors, actual_errors); + } +} 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 104f1011205..d3d367e36e0 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 @@ -1,19 +1,8 @@ use serde::{Deserialize, Serialize}; -use crate::schema::DataTypeLabel; - #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct NullSchema { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(default, skip_serializing_if = "DataTypeLabel::is_empty")] - pub label: DataTypeLabel, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub r#const: Option<()>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - #[cfg_attr(target_arch = "wasm32", tsify(type = "[null]"))] - pub r#enum: Vec<()>, +#[serde(rename_all = "camelCase")] +pub enum NullTypeTag { + 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 22daee3b106..347bf452919 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 @@ -1,9 +1,9 @@ -use error_stack::{Report, ReportSink, bail}; +use error_stack::{Report, ReportSink, ResultExt, bail}; use serde::{Deserialize, Serialize}; -use serde_json::{Number as JsonNumber, json}; +use serde_json::{Number as JsonNumber, Value as JsonValue, json}; use thiserror::Error; -use crate::schema::DataTypeLabel; +use crate::schema::ConstraintError; #[expect( clippy::trivially_copy_pass_by_ref, @@ -22,14 +22,6 @@ pub enum NumberValidationError { )] InsufficientPrecision { actual: JsonNumber }, - #[error( - "the provided value is not equal to the expected value, expected `{actual}` to be equal \ - to `{expected}`" - )] - InvalidConstValue { actual: f64, expected: f64 }, - #[error("the provided value is not one of the expected values, expected `{actual}` to be one of `{}`", json!(expected))] - InvalidEnumValue { actual: f64, expected: Vec }, - #[error( "the provided value is not greater than or equal to the minimum value, expected \ `{actual}` to be greater than or equal to `{expected}`" @@ -58,31 +50,25 @@ pub enum NumberValidationError { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct NumberSchema { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(default, skip_serializing_if = "DataTypeLabel::is_empty")] - pub label: DataTypeLabel, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub r#const: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - #[cfg_attr(target_arch = "wasm32", tsify(type = "[number, ...number[]]"))] - pub r#enum: Vec, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub minimum: Option, - #[serde(default, skip_serializing_if = "is_false")] - pub exclusive_minimum: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub maximum: Option, - #[serde(default, skip_serializing_if = "is_false")] - pub exclusive_maximum: bool, +#[serde(rename_all = "camelCase")] +pub enum NumberTypeTag { + Number, +} - #[serde(default, skip_serializing_if = "Option::is_none")] - pub multiple_of: Option, +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] +#[serde(untagged, rename_all = "camelCase", deny_unknown_fields)] +pub enum NumberSchema { + Constrained(NumberConstraints), + Const { + r#const: f64, + }, + Enum { + #[cfg_attr(target_arch = "wasm32", tsify(type = "[number, ...number[]]"))] + r#enum: Vec, + }, } #[expect( @@ -110,51 +96,111 @@ fn float_less(lhs: f64, rhs: f64) -> bool { float_less_eq(lhs, rhs) && !float_eq(lhs, rhs) } +#[expect( + clippy::float_arithmetic, + reason = "Validation requires floating point arithmetic" +)] +fn float_multiple_of(lhs: f64, rhs: f64) -> bool { + if float_eq(rhs, 0.0) { + return false; + } + let quotient = lhs / rhs; + (quotient - quotient.round()).abs() < f64::EPSILON +} + impl NumberSchema { - pub fn validate_value( - &self, - number: &JsonNumber, - ) -> Result<(), Report<[NumberValidationError]>> { + /// 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![NumberValidationError::InsufficientPrecision { - actual: number.clone() - },]; + bail!( + Report::new(NumberValidationError::InsufficientPrecision { + actual: number.clone() + }) + .change_context(ConstraintError::ValueConstraint) + ); }; - let mut status = ReportSink::new(); - - if let Some(expected) = self.r#const { - if float_eq(expected, float) { - status.capture(NumberValidationError::InvalidConstValue { - expected, - actual: float, - }); + match self { + Self::Constrained(constraints) => constraints + .validate_value(float) + .change_context(ConstraintError::ValueConstraint)?, + Self::Const { r#const } => { + if !float_eq(float, *r#const) { + bail!(ConstraintError::InvalidConstValue { + actual: JsonValue::Number(number.clone()), + expected: json!(*r#const), + }); + } + } + Self::Enum { r#enum } => { + if !r#enum.iter().any(|expected| float_eq(float, *expected)) { + bail!(ConstraintError::InvalidEnumValue { + actual: JsonValue::Number(number.clone()), + expected: r#enum.iter().map(|value| json!(*value)).collect(), + }); + } } } + Ok(()) + } +} - if !self.r#enum.is_empty() - && !self - .r#enum - .iter() - .any(|expected| float_eq(float, *expected)) - { - status.capture(NumberValidationError::InvalidEnumValue { - expected: self.r#enum.clone(), - actual: float.to_owned(), - }); - } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct NumberConstraints { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub minimum: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub exclusive_minimum: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub maximum: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub exclusive_maximum: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + 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]>> { + let mut status = ReportSink::new(); if let Some(minimum) = self.minimum { if self.exclusive_minimum { - if float_less_eq(float, minimum) { + if float_less_eq(number, minimum) { status.capture(NumberValidationError::ExclusiveMinimum { - actual: float, + actual: number, expected: minimum, }); } - } else if float_less(float, minimum) { - status.capture(NumberValidationError::ExclusiveMinimum { - actual: float, + } else if float_less(number, minimum) { + status.capture(NumberValidationError::Minimum { + actual: number, expected: minimum, }); } @@ -162,29 +208,24 @@ impl NumberSchema { if let Some(maximum) = self.maximum { if self.exclusive_maximum { - if float_less_eq(maximum, float) { + if float_less_eq(maximum, number) { status.capture(NumberValidationError::ExclusiveMaximum { - actual: float, + actual: number, expected: maximum, }); } - } else if float_less(maximum, float) { + } else if float_less(maximum, number) { status.capture(NumberValidationError::Maximum { - actual: float, + actual: number, expected: maximum, }); } } if let Some(expected) = self.multiple_of { - #[expect( - clippy::float_arithmetic, - clippy::modulo_arithmetic, - reason = "Validation requires floating point arithmetic" - )] - if !float_eq(float % expected, 0.0) { + if !float_multiple_of(number, expected) { status.capture(NumberValidationError::MultipleOf { - actual: float, + actual: number, expected, }); } @@ -196,7 +237,16 @@ impl NumberSchema { #[cfg(test)] mod tests { + use serde_json::{from_value, json}; + use super::*; + use crate::schema::{ + JsonSchemaValueType, NumberValidationError, + data_type::constraint::{ + ValueConstraints, + tests::{check_constraints, check_constraints_error, read_schema}, + }, + }; #[test] #[expect(clippy::float_cmp, reason = "Test case for float_eq")] @@ -228,4 +278,189 @@ mod tests { assert!(float_less_eq(1.0, 1.0 - f64::EPSILON / 2.0)); assert!(!float_less_eq(1.0, 1.0 - f64::EPSILON)); } + + #[test] + fn compare_modulo() { + assert!(float_multiple_of(10.0, 5.0)); + assert!(!float_multiple_of(10.0, 3.0)); + assert!(float_multiple_of(10.0, 2.5)); + assert!(float_multiple_of(1e9, 1e6)); + assert!(float_multiple_of(0.0001, 0.00001)); + assert!(float_multiple_of(-10.0, -5.0)); + assert!(float_multiple_of(-10.0, 5.0)); + assert!(!float_multiple_of(10.0, 0.0)); + assert!(float_multiple_of(0.0, 5.0)); + assert!(!float_multiple_of(0.1, 0.03)); + } + + #[test] + fn unconstrained() { + let number_schema = read_schema(&json!({ + "type": "number", + })); + + check_constraints(&number_schema, &json!(0)); + check_constraints_error(&number_schema, &json!("NaN"), [ + ConstraintError::InvalidType { + actual: JsonSchemaValueType::String, + expected: JsonSchemaValueType::Number, + }, + ]); + } + + #[test] + fn simple_number() { + let number_schema = read_schema(&json!({ + "type": "number", + "minimum": 0.0, + "maximum": 10.0, + })); + + check_constraints(&number_schema, &json!(0)); + check_constraints(&number_schema, &json!(10)); + check_constraints_error(&number_schema, &json!("2"), [ + ConstraintError::InvalidType { + actual: JsonSchemaValueType::String, + expected: JsonSchemaValueType::Number, + }, + ]); + check_constraints_error(&number_schema, &json!(-2), [ + NumberValidationError::Minimum { + actual: -2.0, + expected: 0.0, + }, + ]); + check_constraints_error(&number_schema, &json!(15), [ + NumberValidationError::Maximum { + actual: 15.0, + expected: 10.0, + }, + ]); + } + + #[test] + fn simple_number_exclusive() { + let number_schema = read_schema(&json!({ + "type": "number", + "minimum": 0.0, + "exclusiveMinimum": true, + "maximum": 10.0, + "exclusiveMaximum": true, + })); + + check_constraints(&number_schema, &json!(0.1)); + check_constraints(&number_schema, &json!(0.9)); + check_constraints_error(&number_schema, &json!("2"), [ + ConstraintError::InvalidType { + actual: JsonSchemaValueType::String, + expected: JsonSchemaValueType::Number, + }, + ]); + check_constraints_error(&number_schema, &json!(0), [ + NumberValidationError::ExclusiveMinimum { + actual: 0.0, + expected: 0.0, + }, + ]); + check_constraints_error(&number_schema, &json!(10), [ + NumberValidationError::ExclusiveMaximum { + actual: 10.0, + expected: 10.0, + }, + ]); + } + + #[test] + fn multiple_of() { + let number_schema = read_schema(&json!({ + "type": "number", + "multipleOf": 0.1, + })); + + check_constraints(&number_schema, &json!(0.1)); + check_constraints(&number_schema, &json!(0.9)); + check_constraints_error(&number_schema, &json!("2"), [ + ConstraintError::InvalidType { + actual: JsonSchemaValueType::String, + expected: JsonSchemaValueType::Number, + }, + ]); + check_constraints_error(&number_schema, &json!(0.11), [ + NumberValidationError::MultipleOf { + actual: 0.11, + expected: 0.1, + }, + ]); + } + + #[test] + fn constant() { + let number_schema = read_schema(&json!({ + "type": "number", + "const": 50.0, + })); + + check_constraints(&number_schema, &json!(50)); + check_constraints_error(&number_schema, &json!(10), [ + ConstraintError::InvalidConstValue { + actual: json!(10), + expected: json!(50.0), + }, + ]); + } + + #[test] + fn enumeration() { + let number_schema = read_schema(&json!({ + "type": "number", + "enum": [20.0, 50.0], + })); + + check_constraints(&number_schema, &json!(50)); + check_constraints_error(&number_schema, &json!(10), [ + ConstraintError::InvalidEnumValue { + actual: json!(10), + expected: vec![json!(20.0), json!(50.0)], + }, + ]); + } + + #[test] + fn missing_type() { + from_value::(json!({ + "minimum": 0.0, + })) + .expect_err("Deserialized number schema without type"); + } + + #[test] + fn additional_number_properties() { + from_value::(json!({ + "type": "number", + "additional": false, + })) + .expect_err("Deserialized number schema with additional properties"); + } + + #[test] + fn mixed() { + from_value::(json!({ + "type": "number", + "const": 50, + "minimum": 0, + })) + .expect_err("Deserialized number schema with mixed properties"); + from_value::(json!({ + "type": "number", + "enum": [50], + "minimum": 0, + })) + .expect_err("Deserialized number schema with mixed properties"); + from_value::(json!({ + "type": "number", + "const": 50, + "enum": [50], + })) + .expect_err("Deserialized number schema with mixed properties"); + } } 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 a3966e0cc76..e27a93ef13b 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,70 +1,8 @@ -use error_stack::{Report, ReportSink}; use serde::{Deserialize, Serialize}; -use serde_json::{Map as JsonMap, Value as JsonValue, json}; -use thiserror::Error; - -use crate::schema::DataTypeLabel; - -#[derive(Debug, Error)] -pub enum ObjectValidationError { - #[error( - "the provided value is not equal to the expected value, expected `{}` to be equal \ - to `{}`", json!(actual), json!(expected) - )] - InvalidConstValue { - actual: JsonMap, - expected: JsonMap, - }, - #[error("the provided value is not one of the expected values, expected `{}` to be one of `{}`", json!(actual), json!(expected))] - InvalidEnumValue { - actual: JsonMap, - expected: Vec>, - }, -} #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ObjectSchema { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(default, skip_serializing_if = "DataTypeLabel::is_empty")] - pub label: DataTypeLabel, - - #[serde(default, skip_serializing_if = "Option::is_none")] - #[cfg_attr(target_arch = "wasm32", tsify(type = "Record"))] - pub r#const: Option>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - #[cfg_attr( - target_arch = "wasm32", - tsify(type = "[Record, ...Record[]]") - )] - pub r#enum: Vec>, -} - -impl ObjectSchema { - pub fn validate_value( - &self, - object: &JsonMap, - ) -> Result<(), Report<[ObjectValidationError]>> { - let mut validation_status = ReportSink::new(); - - if let Some(expected) = &self.r#const { - if expected != object { - validation_status.capture(ObjectValidationError::InvalidConstValue { - expected: expected.clone(), - actual: object.clone(), - }); - } - } - - if !self.r#enum.is_empty() && !self.r#enum.iter().any(|expected| expected == object) { - validation_status.capture(ObjectValidationError::InvalidEnumValue { - expected: self.r#enum.clone(), - actual: object.clone(), - }); - } - - validation_status.finish() - } +#[serde(rename_all = "camelCase")] +pub enum ObjectTypeTag { + 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 6adf6b1d02a..424a5c3c65f 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 @@ -1,20 +1,20 @@ use core::{ - net::{Ipv4Addr, Ipv6Addr}, + net::{AddrParseError, Ipv4Addr, Ipv6Addr}, str::FromStr, }; use std::{collections::HashSet, sync::OnceLock}; use email_address::EmailAddress; -use error_stack::{Report, ReportSink}; -use iso8601_duration::Duration; +use error_stack::{Report, ReportSink, ResultExt, bail}; +use iso8601_duration::{Duration, ParseDurationError}; use regex::Regex; use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde_json::Value as JsonValue; use thiserror::Error; use url::{Host, Url}; use uuid::Uuid; -use crate::schema::{DataTypeLabel, data_type::constraint::error::StringFormatError}; +use crate::schema::ConstraintError; #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] @@ -51,7 +51,55 @@ impl StringFormat { } } +#[derive(Debug, Error)] +pub enum StringFormatError { + #[error(transparent)] + Url(url::ParseError), + #[error(transparent)] + Uuid(uuid::Error), + #[error(transparent)] + Regex(regex::Error), + #[error(transparent)] + Email(email_address::Error), + #[error(transparent)] + IpAddress(AddrParseError), + #[error("The value does not match the date-time format `YYYY-MM-DDTHH:MM:SS.sssZ`")] + DateTime, + #[error("The value does not match the date format `YYYY-MM-DD`")] + Date, + #[error("The value does not match the time format `HH:MM:SS.sss`")] + Time, + #[error("{0:?}")] + Duration(ParseDurationError), +} + impl StringFormat { + /// Validates the provided value against the string format. + /// + /// # Errors + /// + /// - [`Url`] if the value is not a valid URL. + /// - [`IpAddress`] if the value is not a valid IP address as specified by [`Ipv4Addr`] or + /// [`Ipv6Addr`]. + /// - [`Uuid`] if the value is not a valid [UUID][uuid::Uuid]. + /// - [`Regex`] if the value is not a valid [regular expression][regex::Regex]. + /// - [`Email`] if the value is not a valid [email address][email_address::EmailAddress]. + /// - [`Date`] if the value is not a valid date in the format `YYYY-MM-DD`. + /// - [`Time`] if the value is not a valid time in the format `HH:MM:SS.sss`. + /// - [`DateTime`] if the value is not a valid date-time in the format + /// `YYYY-MM-DDTHH:MM:SS.sssZ`. + /// - [`Duration`] if the value is not a valid [ISO 8601 duration][iso8601_duration::Duration]. + /// + /// [`Url`]: StringFormatError::Url + /// [`IpAddress`]: StringFormatError::IpAddress + /// [`Uuid`]: StringFormatError::Uuid + /// [`Regex`]: StringFormatError::Regex + /// [`Email`]: StringFormatError::Email + /// [`Date`]: StringFormatError::Date + /// [`Time`]: StringFormatError::Time + /// [`DateTime`]: StringFormatError::DateTime + /// [`Duration`]: StringFormatError::Duration + #[expect(clippy::missing_panics_doc)] pub fn validate(self, value: &str) -> Result<(), Report> { // Only the simplest date format are supported in all three, RFC-3339, ISO-8601 and HTML const DATE_REGEX_STRING: &str = r"(?P\d{4})-(?P\d{2})-(?P\d{2})"; @@ -125,18 +173,7 @@ impl StringFormat { } #[derive(Debug, Error)] -pub enum ArrayValidationError { - #[error( - "the provided value is not equal to the expected value, expected `{actual}` to be equal \ - to `{expected}`" - )] - InvalidConstValue { actual: String, expected: String }, - #[error("the provided value is not one of the expected values, expected `{actual}` to be one of `{}`", json!(expected))] - InvalidEnumValue { - actual: String, - expected: HashSet, - }, - +pub enum StringValidationError { #[error( "the provided value is not greater than or equal to the minimum length, expected \ the length of `{}` to be greater than or equal to `{expected}` but it is `{}`", @@ -166,19 +203,67 @@ pub enum ArrayValidationError { #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct StringSchema { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(default, skip_serializing_if = "DataTypeLabel::is_empty")] - pub label: DataTypeLabel, +#[serde(rename_all = "camelCase")] +pub enum StringTypeTag { + String, +} - #[serde(default, skip_serializing_if = "Option::is_none")] - pub r#const: Option, - #[serde(default, skip_serializing_if = "HashSet::is_empty")] - #[cfg_attr(target_arch = "wasm32", tsify(type = "[string, ...string[]]"))] - pub r#enum: HashSet, +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] +#[serde(untagged, rename_all = "camelCase", deny_unknown_fields)] +pub enum StringSchema { + Constrained(StringConstraints), + Const { + r#const: String, + }, + Enum { + #[cfg_attr(target_arch = "wasm32", tsify(type = "[string, ...string[]]"))] + r#enum: HashSet, + }, +} +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> { + match self { + Self::Constrained(constraints) => constraints + .validate_value(string) + .change_context(ConstraintError::ValueConstraint)?, + Self::Const { r#const } => { + if string != *r#const { + bail!(ConstraintError::InvalidConstValue { + actual: JsonValue::String(string.to_owned()), + expected: JsonValue::String(r#const.clone()), + }); + } + } + Self::Enum { r#enum } => { + if !r#enum.contains(string) { + bail!(ConstraintError::InvalidEnumValue { + actual: JsonValue::String(string.to_owned()), + expected: r#enum.iter().cloned().map(JsonValue::String).collect(), + }); + } + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StringConstraints { #[serde(default, skip_serializing_if = "Option::is_none")] pub min_length: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -194,29 +279,26 @@ pub struct StringSchema { pub format: Option, } -impl StringSchema { - pub fn validate_value(&self, string: &str) -> Result<(), Report<[ArrayValidationError]>> { +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]>> { let mut status = ReportSink::new(); - if let Some(expected) = &self.r#const { - if expected != string { - status.capture(ArrayValidationError::InvalidConstValue { - expected: expected.clone(), - actual: string.to_owned(), - }); - } - } - - if !self.r#enum.is_empty() && !self.r#enum.contains(string) { - status.capture(ArrayValidationError::InvalidEnumValue { - expected: self.r#enum.clone(), - actual: string.to_owned(), - }); - } - if let Some(expected) = self.min_length { if string.len() < expected { - status.capture(ArrayValidationError::MinLength { + status.capture(StringValidationError::MinLength { actual: string.to_owned(), expected, }); @@ -224,7 +306,7 @@ impl StringSchema { } if let Some(expected) = self.max_length { if string.len() > expected { - status.capture(ArrayValidationError::MaxLength { + status.capture(StringValidationError::MaxLength { actual: string.to_owned(), expected, }); @@ -232,7 +314,7 @@ impl StringSchema { } if let Some(expected) = &self.pattern { if !expected.is_match(string) { - status.capture(ArrayValidationError::Pattern { + status.capture(StringValidationError::Pattern { actual: string.to_owned(), expected: expected.clone(), }); @@ -240,7 +322,7 @@ impl StringSchema { } if let Some(expected) = self.format { if let Err(error) = expected.validate(string) { - status.append(error.change_context(ArrayValidationError::Format { + status.append(error.change_context(StringValidationError::Format { actual: string.to_owned(), expected, })); @@ -250,3 +332,128 @@ impl StringSchema { status.finish() } } + +#[cfg(test)] +mod tests { + use serde_json::{from_value, json}; + + use super::*; + use crate::schema::{ + JsonSchemaValueType, + data_type::constraint::{ + ValueConstraints, + tests::{check_constraints, check_constraints_error, read_schema}, + }, + }; + #[test] + fn unconstrained() { + let string_schema = read_schema(&json!({ + "type": "string", + })); + + check_constraints(&string_schema, &json!("NaN")); + check_constraints_error(&string_schema, &json!(10), [ConstraintError::InvalidType { + actual: JsonSchemaValueType::Number, + expected: JsonSchemaValueType::String, + }]); + } + + #[test] + fn simple_string() { + let string_schema = read_schema(&json!({ + "type": "string", + "minLength": 5, + "maxLength": 10, + })); + + check_constraints(&string_schema, &json!("12345")); + check_constraints(&string_schema, &json!("1234567890")); + check_constraints_error(&string_schema, &json!(2), [ConstraintError::InvalidType { + actual: JsonSchemaValueType::Number, + expected: JsonSchemaValueType::String, + }]); + check_constraints_error(&string_schema, &json!("1234"), [ + StringValidationError::MinLength { + actual: "1234".to_owned(), + expected: 5, + }, + ]); + check_constraints_error(&string_schema, &json!("12345678901"), [ + StringValidationError::MaxLength { + actual: "12345678901".to_owned(), + expected: 10, + }, + ]); + } + + #[test] + fn constant() { + let string_schema = read_schema(&json!({ + "type": "string", + "const": "foo", + })); + + check_constraints(&string_schema, &json!("foo")); + check_constraints_error(&string_schema, &json!("bar"), [ + ConstraintError::InvalidConstValue { + actual: json!("bar"), + expected: json!("foo"), + }, + ]); + } + + #[test] + fn enumeration() { + let string_schema = read_schema(&json!({ + "type": "string", + "enum": ["foo"], + })); + + check_constraints(&string_schema, &json!("foo")); + check_constraints_error(&string_schema, &json!("bar"), [ + ConstraintError::InvalidEnumValue { + actual: json!("bar"), + expected: vec![json!("foo")], + }, + ]); + } + + #[test] + fn missing_type() { + from_value::(json!({ + "minLength": 0.0, + })) + .expect_err("Deserialized string schema without type"); + } + + #[test] + fn additional_string_properties() { + from_value::(json!({ + "type": "string", + "additional": false, + })) + .expect_err("Deserialized string schema with additional properties"); + } + + #[test] + fn mixed() { + from_value::(json!({ + "type": "string", + "const": "foo", + "minLength": 5, + })) + .expect_err("Deserialized string schema with mixed properties"); + from_value::(json!({ + "type": "string", + "enum": ["foo", "bar"], + "minLength": 5, + })) + .expect_err("Deserialized string schema with mixed properties"); + from_value::(json!({ + "type": "string", + "const": "bar", + "enum": ["foo", "bar"], + })) + .expect_err("Deserialized string schema with mixed properties"); + } +} diff --git a/libs/@blockprotocol/type-system/rust/src/schema/data_type/conversion.rs b/libs/@blockprotocol/type-system/rust/src/schema/data_type/conversion.rs index fb763461f8c..4fb8c9fa48b 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/data_type/conversion.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/data_type/conversion.rs @@ -191,14 +191,7 @@ mod codec { use serde::{Deserialize, Serialize}; use super::{ConversionExpression, ConversionValue, Operator, Variable}; - - #[derive(Serialize, Deserialize)] - #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] - #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] - #[serde(rename_all = "camelCase")] - pub(super) enum NumberTypeTag { - Number, - } + use crate::schema::data_type::constraint::NumberTypeTag; #[derive(Serialize, Deserialize)] #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] 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 fa61374ccb8..1a7937a66cd 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 @@ -3,6 +3,13 @@ mod conversion; pub use self::{ closed::{ClosedDataType, ClosedDataTypeMetadata}, + constraint::{ + AnyOfConstraints, AnyOfSchema, ArrayConstraints, ArraySchema, ArrayTypeTag, + ArrayValidationError, BooleanTypeTag, ConstraintError, NullTypeTag, NumberConstraints, + NumberSchema, NumberTypeTag, NumberValidationError, ObjectTypeTag, StringConstraints, + StringFormat, StringFormatError, StringSchema, StringTypeTag, StringValidationError, + TupleConstraints, TypedValueConstraints, + }, conversion::{ ConversionDefinition, ConversionExpression, ConversionValue, Conversions, Operator, Variable, @@ -25,10 +32,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use thiserror::Error; -use crate::{ - schema::data_type::constraint::{ConstraintError, ValueConstraints}, - url::VersionedUrl, -}; +use crate::{schema::data_type::constraint::ValueConstraints, url::VersionedUrl}; #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] @@ -58,14 +62,14 @@ impl fmt::Display for JsonSchemaValueType { #[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] #[serde(rename_all = "camelCase")] -pub struct DataTypeLabel { +pub struct ValueLabel { #[serde(default, skip_serializing_if = "Option::is_none")] pub left: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub right: Option, } -impl DataTypeLabel { +impl ValueLabel { #[must_use] pub const fn is_empty(&self) -> bool { self.left.is_none() && self.right.is_none() @@ -100,24 +104,29 @@ pub enum DataTypeSchemaTag { } mod raw { - use serde::Deserialize; + use std::collections::HashSet; + + use serde::{Deserialize, Serialize}; + use serde_json::Value as JsonValue; use super::{DataTypeSchemaTag, DataTypeTag}; use crate::{ schema::{ - DataTypeReference, + ArrayTypeTag, BooleanTypeTag, DataTypeReference, NullTypeTag, NumberTypeTag, + ObjectTypeTag, StringTypeTag, ValueLabel, data_type::constraint::{ - ArraySchema, BooleanSchema, NullSchema, NumberSchema, ObjectSchema, StringSchema, + AnyOfConstraints, ArrayConstraints, ArraySchema, NumberConstraints, NumberSchema, + StringConstraints, StringSchema, TupleConstraints, TypedValueConstraints, ValueConstraints, }, }, url::VersionedUrl, }; - #[derive(Deserialize)] + #[derive(Serialize, Deserialize)] #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] #[serde(rename_all = "camelCase", deny_unknown_fields)] - pub struct UnconstrainedDataType { + pub struct ValueSchemaMetadata { #[serde(rename = "$schema")] schema: DataTypeSchemaTag, kind: DataTypeTag, @@ -126,10 +135,10 @@ mod raw { title: String, #[serde(default, skip_serializing_if = "Option::is_none")] title_plural: Option, - #[cfg_attr( - target_arch = "wasm32", - tsify(type = "[DataTypeReference, ...DataTypeReference[]]") - )] + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(default, skip_serializing_if = "ValueLabel::is_empty")] + label: ValueLabel, #[serde(default, skip_serializing_if = "Vec::is_empty")] all_of: Vec, @@ -137,57 +146,209 @@ mod raw { r#abstract: bool, } - #[derive(Deserialize)] + #[derive(Serialize, Deserialize)] #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] - #[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)] + #[serde(untagged, rename_all = "camelCase", deny_unknown_fields)] pub enum DataType { Null { + r#type: NullTypeTag, #[serde(flatten)] - schema: NullSchema, - #[serde(flatten)] - common: UnconstrainedDataType, + common: ValueSchemaMetadata, }, Boolean { + r#type: BooleanTypeTag, #[serde(flatten)] - schema: BooleanSchema, - #[serde(flatten)] - common: UnconstrainedDataType, + common: ValueSchemaMetadata, }, Number { + r#type: NumberTypeTag, + #[serde(flatten)] + common: ValueSchemaMetadata, + #[serde(flatten)] + constraints: NumberConstraints, + }, + NumberConst { + r#type: NumberTypeTag, #[serde(flatten)] - schema: NumberSchema, + common: ValueSchemaMetadata, + r#const: f64, + }, + NumberEnum { + r#type: NumberTypeTag, #[serde(flatten)] - common: UnconstrainedDataType, + common: ValueSchemaMetadata, + #[cfg_attr(target_arch = "wasm32", tsify(type = "[number, ...number[]]"))] + r#enum: Vec, }, String { + r#type: StringTypeTag, #[serde(flatten)] - schema: StringSchema, + common: ValueSchemaMetadata, #[serde(flatten)] - common: UnconstrainedDataType, + constraints: StringConstraints, }, - Array { + StringConst { + r#type: StringTypeTag, #[serde(flatten)] - schema: ArraySchema, + common: ValueSchemaMetadata, + r#const: String, + }, + StringEnum { + r#type: StringTypeTag, #[serde(flatten)] - common: UnconstrainedDataType, + common: ValueSchemaMetadata, + #[cfg_attr(target_arch = "wasm32", tsify(type = "[string, ...string[]]"))] + r#enum: HashSet, }, Object { + r#type: ObjectTypeTag, + #[serde(flatten)] + common: ValueSchemaMetadata, + }, + Array { + r#type: ArrayTypeTag, #[serde(flatten)] - schema: ObjectSchema, + common: ValueSchemaMetadata, #[serde(flatten)] - common: UnconstrainedDataType, + constraints: ArrayConstraints, + }, + Tuple { + r#type: ArrayTypeTag, + #[serde(flatten)] + common: ValueSchemaMetadata, + #[serde(flatten)] + constraints: TupleConstraints, + }, + ArrayConst { + r#type: ArrayTypeTag, + #[serde(flatten)] + common: ValueSchemaMetadata, + r#const: [JsonValue; 0], + }, + #[serde(skip)] + AnyOf { + #[serde(flatten)] + common: ValueSchemaMetadata, + #[serde(flatten)] + constraints: AnyOfConstraints, }, } impl From for super::DataType { + #[expect( + clippy::too_many_lines, + reason = "The conversion is only required to allow `deny_unknown_fields` in serde. \ + The better option would be to manually implement the deserialization logic, \ + however, this is quite straightforward and would be a lot of code for \ + little benefit." + )] fn from(value: DataType) -> Self { let (common, constraints) = match value { - DataType::Null { schema, common } => (common, ValueConstraints::Null(schema)), - DataType::Boolean { schema, common } => (common, ValueConstraints::Boolean(schema)), - DataType::Number { schema, common } => (common, ValueConstraints::Number(schema)), - DataType::String { schema, common } => (common, ValueConstraints::String(schema)), - DataType::Array { schema, common } => (common, ValueConstraints::Array(schema)), - DataType::Object { schema, common } => (common, ValueConstraints::Object(schema)), + DataType::Null { r#type: _, common } => { + (common, ValueConstraints::Typed(TypedValueConstraints::Null)) + } + DataType::Boolean { r#type: _, common } => ( + common, + ValueConstraints::Typed(TypedValueConstraints::Boolean), + ), + DataType::Number { + r#type: _, + common, + constraints, + } => ( + common, + ValueConstraints::Typed(TypedValueConstraints::Number( + NumberSchema::Constrained(constraints), + )), + ), + DataType::NumberConst { + r#type: _, + common, + r#const, + } => ( + common, + ValueConstraints::Typed(TypedValueConstraints::Number(NumberSchema::Const { + r#const, + })), + ), + DataType::NumberEnum { + r#type: _, + common, + r#enum, + } => ( + common, + ValueConstraints::Typed(TypedValueConstraints::Number(NumberSchema::Enum { + r#enum, + })), + ), + DataType::String { + r#type: _, + common, + constraints, + } => ( + common, + ValueConstraints::Typed(TypedValueConstraints::String( + StringSchema::Constrained(constraints), + )), + ), + DataType::StringConst { + r#type: _, + common, + r#const, + } => ( + common, + ValueConstraints::Typed(TypedValueConstraints::String(StringSchema::Const { + r#const, + })), + ), + DataType::StringEnum { + r#type: _, + common, + r#enum, + } => ( + common, + ValueConstraints::Typed(TypedValueConstraints::String(StringSchema::Enum { + r#enum, + })), + ), + DataType::Object { r#type: _, common } => ( + common, + ValueConstraints::Typed(TypedValueConstraints::Object), + ), + DataType::Array { + r#type: _, + common, + constraints, + } => ( + common, + ValueConstraints::Typed(TypedValueConstraints::Array( + ArraySchema::Constrained(constraints), + )), + ), + DataType::Tuple { + r#type: _, + common, + constraints, + } => ( + common, + ValueConstraints::Typed(TypedValueConstraints::Array(ArraySchema::Tuple( + constraints, + ))), + ), + DataType::ArrayConst { + r#type: _, + common, + r#const, + } => ( + common, + ValueConstraints::Typed(TypedValueConstraints::Array(ArraySchema::Const { + r#const, + })), + ), + DataType::AnyOf { + common, + constraints: any_of, + } => (common, ValueConstraints::AnyOf(any_of)), }; Self { @@ -196,6 +357,8 @@ mod raw { id: common.id, title: common.title, title_plural: common.title_plural, + description: common.description, + label: common.label, all_of: common.all_of, r#abstract: common.r#abstract, constraints, @@ -215,6 +378,10 @@ pub struct DataType { pub title: String, #[serde(skip_serializing_if = "Option::is_none")] pub title_plural: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "ValueLabel::is_empty")] + pub label: ValueLabel, #[serde(skip_serializing_if = "Vec::is_empty")] pub all_of: Vec, @@ -513,14 +680,30 @@ impl DataType { /// /// 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> { - self.constraints.validate_value(value) + match &self.constraints { + ValueConstraints::Typed(typed_schema) => typed_schema.validate_value(value), + ValueConstraints::AnyOf(constraints) => constraints.validate_value(value), + } } } #[cfg(test)] mod tests { use super::*; - use crate::utils::tests::{JsonEqualityCheck, ensure_validation_from_str}; + use crate::utils::tests::{ + JsonEqualityCheck, ensure_failed_deserialization, ensure_validation_from_str, + }; + + #[tokio::test] + #[ignore = "AnyOf constraint is not exposed"] + async fn value() { + ensure_validation_from_str::( + graph_test_data::data_type::VALUE_V1, + DataTypeValidator, + JsonEqualityCheck::Yes, + ) + .await; + } #[tokio::test] async fn text() { @@ -581,4 +764,23 @@ mod tests { ) .await; } + + #[test] + fn additional_properties() { + // The error is suboptimal, but most importantly, it does error. + ensure_failed_deserialization::( + serde_json::json!({ + "$schema": "https://blockprotocol.org/types/modules/graph/0.3/schema/data-type", + "kind": "dataType", + "$id": "https://blockprotocol.org/@blockprotocol/types/data-type/value/v/1", + "title": "Value", + "description": "A value that can be stored in a graph", + "anyOf": [ + { "type": "null" } + ], + "additional": false + }), + &"data did not match any variant of untagged enum DataType", + ); + } } diff --git a/libs/@blockprotocol/type-system/rust/src/schema/mod.rs b/libs/@blockprotocol/type-system/rust/src/schema/mod.rs index a9db7d6392b..62f4baf1253 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/mod.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/mod.rs @@ -16,10 +16,14 @@ mod one_of; pub use self::{ array::{PropertyArraySchema, PropertyValueArray, ValueOrArray}, data_type::{ - ClosedDataType, ClosedDataTypeMetadata, ConversionDefinition, ConversionExpression, - ConversionValue, Conversions, DataType, DataTypeLabel, DataTypeReference, - DataTypeValidator, JsonSchemaValueType, OntologyTypeResolver, Operator, - ValidateDataTypeError, Variable, + AnyOfConstraints, AnyOfSchema, ArrayConstraints, ArraySchema, ArrayTypeTag, + ArrayValidationError, BooleanTypeTag, ClosedDataType, ClosedDataTypeMetadata, + ConstraintError, ConversionDefinition, ConversionExpression, ConversionValue, Conversions, + DataType, DataTypeReference, DataTypeValidator, JsonSchemaValueType, NullTypeTag, + NumberConstraints, NumberSchema, NumberTypeTag, NumberValidationError, ObjectTypeTag, + OntologyTypeResolver, Operator, StringConstraints, StringFormat, StringFormatError, + StringSchema, StringTypeTag, StringValidationError, TupleConstraints, + TypedValueConstraints, ValidateDataTypeError, ValueLabel, Variable, }, entity_type::{ ClosedEntityType, ClosedEntityTypeSchemaData, EntityType, EntityTypeReference, diff --git a/libs/@blockprotocol/type-system/rust/src/schema/object/raw.rs b/libs/@blockprotocol/type-system/rust/src/schema/object/raw.rs index 96c18e5cd5f..8923b98500f 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/object/raw.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/object/raw.rs @@ -2,14 +2,7 @@ use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Serialize}; -use crate::url::BaseUrl; - -#[derive(Serialize, Deserialize)] -#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] -#[serde(rename_all = "camelCase")] -enum ObjectTypeTag { - Object, -} +use crate::{schema::data_type::ObjectTypeTag, url::BaseUrl}; #[derive(Deserialize)] #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] diff --git a/libs/@hashintel/type-editor/src/shared/data-types-options-context.tsx b/libs/@hashintel/type-editor/src/shared/data-types-options-context.tsx index b0564e45ebc..bb685ce94d4 100644 --- a/libs/@hashintel/type-editor/src/shared/data-types-options-context.tsx +++ b/libs/@hashintel/type-editor/src/shared/data-types-options-context.tsx @@ -1,7 +1,9 @@ import type { + ArrayConstraints, ArraySchema, DataType, - ValueConstraints, + SimpleValueSchema, + TupleConstraints, VersionedUrl, } from "@blockprotocol/type-system/slim"; import { @@ -170,25 +172,40 @@ export type DataTypesContextValue = { export const DataTypesOptionsContext = createContext(null); +const isArrayConstraints = ( + schema: ArraySchema, +): schema is ArrayConstraints => { + // TODO: Remove `"items" in schema` check when `const` is not allowed on arrays + // see https://linear.app/hash/issue/H-3368/remove-const-from-array-constraints + return ( + "items" in schema && schema.items !== undefined && schema.items !== false + ); +}; + +const isTupleConstraints = ( + schema: ArraySchema, +): schema is TupleConstraints => { + // TODO: Remove `"items" in schema` check when `const` is not allowed on arrays + // see https://linear.app/hash/issue/H-3368/remove-const-from-array-constraints + return "items" in schema && schema.items === false; +}; + const getArrayDataTypeDisplay = ( - dataType: Pick, + dataType: ArraySchema, ): Omit => { - let items: [ValueConstraints, ...ValueConstraints[]]; + let items: [SimpleValueSchema, ...SimpleValueSchema[]]; - if (dataType.items === undefined || dataType.items === true) { - // We have no constraints on the items in the array, so we can't determine the type - return expectedValuesDisplayMap.array; - } else if (dataType.prefixItems) { - // We have prefixItems, so we can determine the type of the array. If `items` is set and is - // not false, we should include it in the list of items - items = [ - ...dataType.prefixItems, - ...(dataType.items ? [dataType.items] : []), - ]; - } else if (dataType.items === false) { - // We don't have prefixItems, but we have a constraint that disallows additional items, so this - // is always an empty list + if (isTupleConstraints(dataType)) { + if (!dataType.prefixItems) { + return expectedValuesDisplayMap.emptyList; + } + items = dataType.prefixItems; + // TODO: Remove when `const` is not allowed on arrays + // see https://linear.app/hash/issue/H-3368/remove-const-from-array-constraints + } else if (!isArrayConstraints(dataType)) { return expectedValuesDisplayMap.emptyList; + } else if (!dataType.items) { + return expectedValuesDisplayMap.mixedArray; } else { return expectedValuesDisplayMap[ `${dataType.items.type}Array` as keyof typeof expectedValuesDisplayMap @@ -198,15 +215,9 @@ const getArrayDataTypeDisplay = ( const itemTypes = items.map((item) => item.type); if (new Set(itemTypes).size === 1) { - const itemDataType = items[0]; - - if (Array.isArray(itemDataType.type)) { - return expectedValuesDisplayMap.mixedArray; - } else { - return expectedValuesDisplayMap[ - `${itemDataType.type}Array` as keyof typeof expectedValuesDisplayMap - ]; - } + return expectedValuesDisplayMap[ + `${items[0].type}Array` as keyof typeof expectedValuesDisplayMap + ]; } return expectedValuesDisplayMap.mixedArray; diff --git a/libs/@local/codec/src/serde/constant.rs b/libs/@local/codec/src/serde/constant.rs new file mode 100644 index 00000000000..b6e95c6dad2 --- /dev/null +++ b/libs/@local/codec/src/serde/constant.rs @@ -0,0 +1,51 @@ +use core::fmt; + +use serde::{ + de::{self, Deserialize, Deserializer, Unexpected, Visitor}, + ser::{Serialize, Serializer}, +}; + +/// A `bool` constant. +/// +/// Deserialization fails if the value is not `B`. +#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash, Default)] +pub struct ConstBool; + +impl Serialize for ConstBool { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_bool(V) + } +} + +impl<'de, const V: bool> Deserialize<'de> for ConstBool { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_bool(ConstBoolVisitor::) + } +} + +struct ConstBoolVisitor; + +impl Visitor<'_> for ConstBoolVisitor { + type Value = ConstBool; + + fn expecting(&self, fmt: &mut fmt::Formatter) -> fmt::Result { + write!(fmt, "{V}") + } + + fn visit_bool(self, boolean: bool) -> Result + where + E: de::Error, + { + if boolean == V { + Ok(ConstBool::) + } else { + Err(E::invalid_value(Unexpected::Bool(boolean), &self)) + } + } +} diff --git a/libs/@local/codec/src/serde/mod.rs b/libs/@local/codec/src/serde/mod.rs index 29c97d18910..7b89bade481 100644 --- a/libs/@local/codec/src/serde/mod.rs +++ b/libs/@local/codec/src/serde/mod.rs @@ -1,3 +1,4 @@ +pub mod constant; pub mod string_hash_map; mod size_hint; diff --git a/libs/@local/hash-graph-types/typescript/src/ontology.ts b/libs/@local/hash-graph-types/typescript/src/ontology.ts index 0401745900a..a13fd27e7ec 100644 --- a/libs/@local/hash-graph-types/typescript/src/ontology.ts +++ b/libs/@local/hash-graph-types/typescript/src/ontology.ts @@ -10,7 +10,6 @@ import type { DataType, EntityType, PropertyType, - VersionedUrl, } from "@blockprotocol/type-system/slim"; import type { Brand } from "@local/advanced-types/brand"; import type { DistributiveOmit } from "@local/advanced-types/distribute"; @@ -85,127 +84,8 @@ type OntologyElementMetadata = Subtype< OwnedOntologyElementMetadata | ExternalOntologyElementMetadata >; -/** - * Non-exhaustive list of possible values for 'format' - * - * The presence of a format in this list does _NOT_ mean that: - * 1. The Graph will validate it - * 2. The frontend will treat it differently for input or display - * - * @see https://json-schema.org/understanding-json-schema/reference/string - */ -type StringFormat = - | "date" - | "time" - | "date-time" - | "duration" - | "email" - | "hostname" - | "ipv4" - | "ipv6" - | "regex" - | "uri" - | "uuid"; - -export type StringConstraint = { - format?: StringFormat; - minLength?: number; // Int - maxLength?: number; // Int - pattern?: string; // RegExp - type: "string"; -}; - -export type NumberConstraint = { - minimum?: number; - maximum?: number; - exclusiveMinimum?: boolean; - exclusiveMaximum?: boolean; - multipleOf?: number; - type: "number"; -}; - -export type BooleanConstraint = { - type: "boolean"; -}; - -export type NullConstraint = { - type: "null"; -}; - -export type ObjectConstraint = { - type: "object"; -}; - -export type StringEnumConstraint = { - enum: [string, ...string[]]; - type: "string"; -}; - -export type NumberEnumConstraint = { - enum: [number, ...number[]]; - type: "number"; -}; - -/** @see https://json-schema.org/understanding-json-schema/reference/enum */ -export type EnumConstraint = StringEnumConstraint | NumberEnumConstraint; - -export type StringConstConstraint = { - const: string; - type: "string"; -}; - -export type NumberConstConstraint = { - const: number; - type: "number"; -}; - -export type ConstConstraint = StringConstConstraint | NumberConstConstraint; - -type ValueLabel = { - left?: string; - right?: string; -}; - -export type SingleValueConstraint = - | BooleanConstraint - | NullConstraint - | ObjectConstraint - | StringConstraint - | NumberConstraint - | EnumConstraint - | ConstConstraint; - -export type ArrayConstraint = { - type: "array"; - items: ValueConstraint; -}; - -/** @see https://json-schema.org/understanding-json-schema/reference/array#tuple-validation */ -export type TupleConstraint = { - type: "array"; - items: false; // disallow additional items; - prefixItems: [ValueConstraint, ...ValueConstraint[]]; -}; - -export type ValueConstraint = ( - | SingleValueConstraint - | ArrayConstraint - | TupleConstraint -) & { description?: string; label?: ValueLabel }; - -export type CustomDataType = Subtype< - DataType, - { - description?: string; - $id: VersionedUrl; - kind: "dataType"; - $schema: "https://blockprotocol.org/types/modules/graph/0.3/schema/data-type"; - title: string; - } & ValueConstraint ->; - export type ConstructDataTypeParams = DistributiveOmit< - CustomDataType, + DataType, "$id" | "kind" | "$schema" >; @@ -225,7 +105,7 @@ export type EntityTypeMetadata = EditableOntologyElementMetadata & export type DataTypeWithMetadata = Subtype< DataTypeWithMetadataBp, { - schema: CustomDataType; + schema: DataType; metadata: OntologyElementMetadata; } >; diff --git a/libs/@local/hash-isomorphic-utils/src/data-types.ts b/libs/@local/hash-isomorphic-utils/src/data-types.ts index dd2e97555cb..0db5fedead1 100644 --- a/libs/@local/hash-isomorphic-utils/src/data-types.ts +++ b/libs/@local/hash-isomorphic-utils/src/data-types.ts @@ -1,5 +1,5 @@ import type { JsonValue } from "@blockprotocol/core"; -import type { ValueConstraint } from "@local/hash-graph-types/ontology"; +import type { DataType, SimpleValueSchema } from "@blockprotocol/type-system"; import { getJsonSchemaTypeFromValue } from "@local/hash-subgraph/stdlib"; export type FormattedValuePart = { @@ -13,7 +13,7 @@ const createFormattedParts = ({ schema, }: { inner: string | FormattedValuePart[]; - schema: Pick | null; + schema?: Pick; }): FormattedValuePart[] => { const { left = "", right = "" } = schema?.label ?? {}; @@ -38,7 +38,7 @@ const createFormattedParts = ({ export const formatDataValue = ( value: JsonValue, - schema: ValueConstraint | null, + schema?: DataType | SimpleValueSchema, ): FormattedValuePart[] => { const { type } = schema ?? { type: getJsonSchemaTypeFromValue(value), @@ -72,8 +72,12 @@ export const formatDataValue = ( const innerValue: string = value .map((inner, index) => { - if (isTuple && index < schema.prefixItems.length) { - return formatDataValue(inner, schema.prefixItems[index]!); + if ( + isTuple && + schema.prefixItems && + index < schema.prefixItems.length + ) { + return formatDataValue(inner, schema.prefixItems[index]); } if (schema && !schema.items) { @@ -83,7 +87,7 @@ export const formatDataValue = ( ); } - return formatDataValue(inner, schema?.items ?? null); + return formatDataValue(inner, schema?.items); }) .join(""); diff --git a/libs/@local/hash-subgraph/tests/compatibility.test/map-vertices.ts b/libs/@local/hash-subgraph/tests/compatibility.test/map-vertices.ts index 532564ee44e..079565ba01a 100644 --- a/libs/@local/hash-subgraph/tests/compatibility.test/map-vertices.ts +++ b/libs/@local/hash-subgraph/tests/compatibility.test/map-vertices.ts @@ -1,4 +1,5 @@ import type { + DataType, EntityType, OneOfSchema, PropertyType, @@ -43,7 +44,6 @@ import type { } from "@local/hash-graph-types/entity"; import type { BaseUrl, - CustomDataType, DataTypeMetadata, EntityTypeMetadata, OntologyProvenance, @@ -62,7 +62,7 @@ import type { } from "../../src/main.js"; import { isEntityId } from "../../src/main.js"; -const mapDataType = (dataType: DataTypeGraphApi): CustomDataType => { +const mapDataType = (dataType: DataTypeGraphApi): DataType => { const idResult = validateVersionedUrl(dataType.$id); if (idResult.type === "Err") { throw new Error( @@ -74,7 +74,7 @@ const mapDataType = (dataType: DataTypeGraphApi): CustomDataType => { const { inner: $id } = idResult; return { - ...(dataType as CustomDataType), + ...(dataType as DataType), $id, }; }; diff --git a/tests/hash-graph-test-data/rust/src/data_type/empty_list.json b/tests/hash-graph-test-data/rust/src/data_type/empty_list.json index d6b1d26bf29..fd7da05548f 100644 --- a/tests/hash-graph-test-data/rust/src/data_type/empty_list.json +++ b/tests/hash-graph-test-data/rust/src/data_type/empty_list.json @@ -5,6 +5,6 @@ "title": "Empty List", "description": "An Empty List", "type": "array", - "const": [], + "items": false, "abstract": false } diff --git a/tests/hash-graph-test-data/rust/src/data_type/mod.rs b/tests/hash-graph-test-data/rust/src/data_type/mod.rs index dff097a693d..9532ccfe182 100644 --- a/tests/hash-graph-test-data/rust/src/data_type/mod.rs +++ b/tests/hash-graph-test-data/rust/src/data_type/mod.rs @@ -1,3 +1,5 @@ +pub const VALUE_V1: &str = include_str!("value.json"); + pub const BOOLEAN_V1: &str = include_str!("boolean.json"); pub const EMPTY_LIST_V1: &str = include_str!("empty_list.json"); pub const NULL_V1: &str = include_str!("null.json"); diff --git a/tests/hash-graph-test-data/rust/src/data_type/value.json b/tests/hash-graph-test-data/rust/src/data_type/value.json new file mode 100644 index 00000000000..6722754606b --- /dev/null +++ b/tests/hash-graph-test-data/rust/src/data_type/value.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://blockprotocol.org/types/modules/graph/0.3/schema/data-type", + "kind": "dataType", + "$id": "https://blockprotocol.org/@blockprotocol/types/data-type/value/v/1", + "title": "Value", + "description": "A value that can be stored in a graph", + "anyOf": [{ "type": "null" }] +}