From ba7f8e1bad892da7780700139ead667e3260bf6d Mon Sep 17 00:00:00 2001 From: "glm-5.2" Date: Sat, 15 Aug 2026 12:53:35 +0000 Subject: [PATCH] validate_json against consumer-provided JSON Schema (step 6) - AlkTypeEngine::compile gains a 4th param json_schema: Option<&Value>. When Some, a standard jsonschema::Validator is built from the consumer-provided JSON Schema and stored for the JSON-validation path. When None, validate_json returns AlkTypeError::Schema and is_valid_json returns false (D-BAST-007). - validate_json / is_valid_json signatures unchanged (per semver contract). Behavior: they now validate against the consumer JSON Schema, not a custom-keyword validator built from the alktype schema. The BAST document is not involved in this path. - build_validator repurposed (deferred decision #2): same signature, now builds a standard jsonschema::Validator with no custom keywords. Behavioral break, not a type break. Re-export kept. - Removed the 19 jsonschema::Keyword implementations and the 4 define_*_validator! macros (dead on the bytes path since step 5, now dead on the JSON path too). The is_rfc3339_timestamp helper lives on in bast_validation.rs (already copied there in step 5). - All compile call sites updated to pass None for json_schema (the layout/read/write/validate_bytes tests don't need JSON validation). - New tests: validate_json accepts/rejects against consumer JSON Schema, returns Schema error when no JSON Schema supplied, is_valid_json false when no schema, independence from BAST doc, malformed JSON Schema build error, nested object JSON Schema. Verification: - cargo test --release: 409 pass (332 lib + 77 integration) - cargo clippy --all-targets -- -D warnings: clean - cargo build --target wasm32-unknown-unknown --release: clean - cargo doc --no-deps: clean --- src/builder.rs | 2 +- src/engine.rs | 268 ++++++-- src/lib.rs | 7 +- src/macros.rs | 174 +---- src/validation.rs | 1207 +++-------------------------------- tests/engine_integration.rs | 34 +- tests/error_paths.rs | 4 +- tests/poc_roundtrip.rs | 6 +- 8 files changed, 352 insertions(+), 1350 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 4330c71..ecdc5b5 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -803,7 +803,7 @@ mod tests { } } }); - let engine = crate::AlkTypeEngine::compile(&doc, "ChunkHeader", crate::LayoutMode::Packed); + let engine = crate::AlkTypeEngine::compile(&doc, "ChunkHeader", crate::LayoutMode::Packed, None); assert!(engine.is_ok(), "chunk header schema should compile: {engine:?}"); } } \ No newline at end of file diff --git a/src/engine.rs b/src/engine.rs index 9912520..386c8f7 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,9 +1,16 @@ //! `AlkTypeEngine` — the compiled form of a BAST document. //! //! Combines the layout engine (both packed and aligned modes) and the -//! jsonschema validator into a single struct. Built once at schema load -//! time via [`AlkTypeEngine::compile`]. Used for repeated read/write/ -//! validate operations at access time. +//! two validators into a single struct. Built once at schema load time +//! via [`AlkTypeEngine::compile`]. Used for repeated read/write/validate +//! operations at access time. +//! +//! Two validators, two inputs (D-BAST-006, D-BAST-007): +//! - `validate_bytes` — the BAST-native validator walks the BAST type +//! tree over a materialized `Value` (no `jsonschema` involvement). +//! - `validate_json` — a standard `jsonschema::Validator` compiled from +//! a consumer-provided JSON Schema (no custom keywords, no BAST +//! involvement). //! //! See [validation.md](../../docs/architecture/validation.md) //! §"The AlkTypeEngine struct" and @@ -52,7 +59,7 @@ enum Layout { } /// The compiled form of a BAST document. Combines the layout engine -/// (both packed and aligned modes) and the jsonschema validator. +/// (both packed and aligned modes) and the two validators. /// /// Built once at schema load time via [`AlkTypeEngine::compile`]. /// Used for repeated read/write/validate operations at access time. @@ -60,11 +67,20 @@ enum Layout { /// The consumer selects the layout mode at construction time. The engine /// then exposes mode-appropriate accessors: [`AlkTypeEngine::offset_map`] /// for aligned mode, [`AlkTypeEngine::layout_builder`] and -/// [`AlkTypeEngine::sequential_reader`] for packed mode. The -/// jsonschema validator is mode-agnostic and always available. +/// [`AlkTypeEngine::sequential_reader`] for packed mode. +/// +/// Validation is split (D-BAST-006, D-BAST-007): +/// - [`AlkTypeEngine::validate_bytes`] uses the BAST-native validator +/// ([`bast_validation`]) — no `jsonschema` involvement, no external +/// JSON Schema required. +/// - [`AlkTypeEngine::validate_json`] / [`AlkTypeEngine::is_valid_json`] +/// use a standard `jsonschema::Validator` compiled at construction +/// time from a consumer-provided JSON Schema. If no JSON Schema was +/// supplied to [`AlkTypeEngine::compile`], the JSON-validation methods +/// return [`AlkTypeError::Schema`]. pub struct AlkTypeEngine { layout: Layout, - validator: jsonschema::Validator, + json_validator: Option, endian: Endian, bast_doc: Value, root_name: String, @@ -74,15 +90,25 @@ impl AlkTypeEngine { /// Compile a BAST document into a [`AlkTypeEngine`]. /// /// This is the expensive operation — it parses the BAST document - /// into a typed tree, computes the layout, and builds the jsonschema - /// validator. Call once at load time; use the returned engine for - /// repeated operations. + /// into a typed tree, computes the layout, and (optionally) builds a + /// standard `jsonschema::Validator` for the JSON-validation path. + /// Call once at load time; use the returned engine for repeated + /// operations. /// /// `bast_doc` is the raw BAST JSON `Value` (`{ "$defs": { ... } }`). /// `root_name` selects which `$defs` entry is the top-level type /// (D-BAST-001). The `mode` parameter selects the layout strategy. /// The same document can be compiled in either mode. /// + /// `json_schema` is an optional consumer-provided standard JSON + /// Schema (Draft 2020-12 or any draft `jsonschema` supports) used by + /// [`AlkTypeEngine::validate_json`] / [`AlkTypeEngine::is_valid_json`] + /// (D-BAST-007). Pass `None` when JSON validation is not needed; + /// calling the JSON-validation methods then returns + /// [`AlkTypeError::Schema`]. The JSON Schema is independent of the + /// BAST document — BAST describes bytes, not JSON shape. It may be + /// authored separately or derived from BAST via future codegen. + /// /// The engine retains a clone of the BAST `Value` so that /// [`AlkTypeEngine::sequential_reader`] and /// [`AlkTypeEngine::read_field`] can re-parse the typed tree on @@ -90,14 +116,15 @@ impl AlkTypeEngine { /// /// # Errors /// - /// Returns [`AlkTypeError::Schema`] if the document is malformed or - /// the root type is not a struct. The error is propagated from - /// [`BastDoc::new`], [`LayoutBuilder::new`], - /// [`OffsetMap::compute`], or [`validation::build_validator`]. + /// Returns [`AlkTypeError::Schema`] if the BAST document is malformed + /// or the root type is not a struct, propagated from [`BastDoc::new`], + /// [`LayoutBuilder::new`], [`OffsetMap::compute`], or + /// [`validation::build_validator`] (when `json_schema` is `Some`). pub fn compile( bast_doc: &Value, root_name: &str, mode: LayoutMode, + json_schema: Option<&Value>, ) -> Result { let doc = BastDoc::new(bast_doc, root_name)?; let root_def = doc.root_def(); @@ -121,10 +148,13 @@ impl AlkTypeEngine { Layout::Aligned { offset_map } } }; - let validator = validation::build_validator(bast_doc)?; + let json_validator = match json_schema { + Some(schema) => Some(validation::build_validator(schema)?), + None => None, + }; Ok(Self { layout, - validator, + json_validator, endian, bast_doc: bast_doc.clone(), root_name: root_name.to_string(), @@ -177,20 +207,50 @@ impl AlkTypeEngine { } } - /// Validate a JSON value against the schema. The jsonschema validator - /// is already compiled — this is a fast check. + /// Validate a JSON `Value` against the consumer-provided JSON Schema + /// supplied to [`AlkTypeEngine::compile`] (D-BAST-007). + /// + /// The validator is a standard `jsonschema::Validator` built at + /// construction time from a plain JSON Schema document — no custom + /// keywords, no BAST involvement. BAST describes bytes, not JSON + /// shape; a JSON value is validated by a JSON Schema, which is a + /// separate concern. /// /// Returns `Ok(())` if valid, `Err(AlkTypeError::Validation(...))` if - /// invalid. + /// the instance violates the JSON Schema, or + /// `Err(AlkTypeError::Schema(...))` if no JSON Schema was supplied to + /// [`AlkTypeEngine::compile`]. + /// + /// # Errors + /// + /// - [`AlkTypeError::Validation`] if the instance violates the JSON + /// Schema. The payload is the `jsonschema::ValidationError` (D-BAST-009 + /// — uniform with the `validate_bytes` path). + /// - [`AlkTypeError::Schema`] if `compile` was called with + /// `json_schema: None`. pub fn validate_json(&self, instance: &Value) -> Result<(), AlkTypeError> { - self.validator + let validator = self.json_validator.as_ref().ok_or_else(|| { + AlkTypeError::Schema( + "validate_json requires a JSON Schema supplied to AlkTypeEngine::compile \ + (json_schema was None)" + .to_string(), + ) + })?; + validator .validate(instance) .map_err(|e| AlkTypeError::Validation(e.to_owned())) } - /// Check if a JSON value is valid against the schema. + /// Check if a JSON `Value` is valid against the consumer-provided + /// JSON Schema supplied to [`AlkTypeEngine::compile`] (D-BAST-007). + /// + /// Returns `false` if the instance is invalid **or** if no JSON + /// Schema was supplied to [`AlkTypeEngine::compile`]. Use + /// [`AlkTypeEngine::validate_json`] if the distinction matters. pub fn is_valid_json(&self, instance: &Value) -> bool { - self.validator.is_valid(instance) + self.json_validator + .as_ref() + .is_some_and(|v| v.is_valid(instance)) } /// Validate a binary buffer against the schema by materializing a @@ -448,7 +508,7 @@ impl fmt::Debug for AlkTypeEngine { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("AlkTypeEngine") .field("layout", &self.layout) - .field("validator", &"") + .field("json_validator", &self.json_validator.as_ref().map(|_| "")) .field("endian", &self.endian) .field("root_name", &self.root_name) .finish() @@ -511,7 +571,7 @@ mod tests { #[test] fn compile_aligned_builds_offset_map() { let doc = fixed_struct_doc(); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned).expect("compile"); + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None).expect("compile"); assert_eq!(engine.mode(), LayoutMode::Aligned); assert!(engine.offset_map().is_some()); assert!(engine.layout_builder().is_none()); @@ -521,7 +581,7 @@ mod tests { #[test] fn compile_packed_builds_builder_and_reader() { let doc = fixed_struct_doc(); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed).expect("compile"); + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed, None).expect("compile"); assert_eq!(engine.mode(), LayoutMode::Packed); assert!(engine.layout_builder().is_some()); assert!(engine.sequential_reader().is_some()); @@ -539,7 +599,7 @@ mod tests { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed).expect("compile"); + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed, None).expect("compile"); assert_eq!(engine.endian(), Endian::Big); } @@ -553,7 +613,7 @@ mod tests { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed).expect("compile"); + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed, None).expect("compile"); assert_eq!(engine.endian(), Endian::Little); } @@ -571,7 +631,7 @@ mod tests { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned).expect("compile"); + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None).expect("compile"); let mut buf = vec![0u8; 8]; buf[0] = 0xAB; buf[4..8].copy_from_slice(&0x01020304u32.to_le_bytes()); @@ -595,7 +655,7 @@ mod tests { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned).expect("compile"); + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None).expect("compile"); let mut buf = vec![0u8; 32]; let len_bytes = 5u32.to_le_bytes(); buf[0..4].copy_from_slice(&len_bytes); @@ -616,7 +676,7 @@ mod tests { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed).expect("compile"); + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed, None).expect("compile"); let buf = [0u8; 4]; let err = engine.read_field(&buf, "id").unwrap_err(); assert!(matches!(err, AlkTypeError::Access { .. }), "got {err:?}"); @@ -632,7 +692,7 @@ mod tests { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned).expect("compile"); + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None).expect("compile"); let buf = [0u8; 4]; let err = engine.read_field(&buf, "missing").unwrap_err(); assert!(matches!(err, AlkTypeError::Offset { .. }), "got {err:?}"); @@ -650,7 +710,7 @@ mod tests { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned).expect("compile"); + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None).expect("compile"); let buf = [0u8; 8]; // `vals` itself is not in the offset map (only `vals[0]`/`vals[1]` // are). The lookup fails with `Offset`. Reading an individual @@ -679,7 +739,7 @@ mod tests { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned).expect("compile"); + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None).expect("compile"); let mut buf = vec![0u8; 8]; engine .write_field(&mut buf, "flag", &FieldValue::U8(0xAB)) @@ -709,7 +769,7 @@ mod tests { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned).expect("compile"); + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None).expect("compile"); let mut buf = vec![0u8; 32]; engine .write_field(&mut buf, "name", &FieldValue::String("hello")) @@ -730,7 +790,7 @@ mod tests { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed).expect("compile"); + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed, None).expect("compile"); let mut buf = [0u8; 4]; let err = engine .write_field(&mut buf, "id", &FieldValue::U32(1)) @@ -748,7 +808,7 @@ mod tests { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned).expect("compile"); + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None).expect("compile"); let mut buf = [0u8; 4]; let err = engine .write_field(&mut buf, "missing", &FieldValue::U32(1)) @@ -766,7 +826,7 @@ mod tests { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned).expect("compile"); + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None).expect("compile"); let mut buf = [0u8; 8]; let err = engine .write_field(&mut buf, "id", &FieldValue::Struct { start: 0, end: 4 }) @@ -777,7 +837,7 @@ mod tests { #[test] fn compile_returns_schema_error_for_invalid_top_level() { let doc = json!({ "type": "object", "properties": {} }); - let err = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed).unwrap_err(); + let err = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed, None).unwrap_err(); assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); } @@ -793,14 +853,14 @@ mod tests { "A": { "kind": "struct", "fields": [] } } }); - let err = AlkTypeEngine::compile(&doc, "U", LayoutMode::Packed).unwrap_err(); + let err = AlkTypeEngine::compile(&doc, "U", LayoutMode::Packed, None).unwrap_err(); assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); } #[test] fn debug_formats_without_panicking() { let doc = fixed_struct_doc(); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned).expect("compile"); + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None).expect("compile"); let s = format!("{engine:?}"); assert!(s.contains("AlkTypeEngine")); assert!(s.contains("Aligned")); @@ -857,7 +917,7 @@ mod tests { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned).expect("compile"); + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None).expect("compile"); let mut buf = vec![0u8; 12]; buf[0..4].copy_from_slice(&0xDEADBEEFu32.to_le_bytes()); buf[4] = 0x01; @@ -886,7 +946,7 @@ mod tests { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned).expect("compile"); + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None).expect("compile"); let mut buf = vec![0u8; 16]; let payload = [0xAA, 0xBB, 0xCC]; let len_bytes = 3u32.to_le_bytes(); @@ -908,7 +968,7 @@ mod tests { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned).expect("compile"); + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None).expect("compile"); let mut buf = vec![0u8; 32]; let stamp = "2026-07-20T15:30:00Z"; let len_bytes = (stamp.len() as u32).to_le_bytes(); @@ -947,7 +1007,7 @@ mod tests { #[test] fn validate_bytes_packed_accepts_valid_chunk_header() { let doc = chunk_header_doc(); - let engine = AlkTypeEngine::compile(&doc, "ChunkHeader", LayoutMode::Packed).expect("compile"); + let engine = AlkTypeEngine::compile(&doc, "ChunkHeader", LayoutMode::Packed, None).expect("compile"); let buf = [0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 12u8]; assert!(engine.validate_bytes(&buf).is_ok(), "valid header should pass"); } @@ -955,7 +1015,7 @@ mod tests { #[test] fn validate_bytes_packed_rejects_short_buffer() { let doc = chunk_header_doc(); - let engine = AlkTypeEngine::compile(&doc, "ChunkHeader", LayoutMode::Packed).expect("compile"); + let engine = AlkTypeEngine::compile(&doc, "ChunkHeader", LayoutMode::Packed, None).expect("compile"); let buf = [0u8; 4]; let err = engine.validate_bytes(&buf).unwrap_err(); assert!(matches!(err, AlkTypeError::Access { .. }), "got {err:?}"); @@ -964,7 +1024,7 @@ mod tests { #[test] fn validate_bytes_aligned_accepts_valid_chunk_header() { let doc = chunk_header_doc(); - let engine = AlkTypeEngine::compile(&doc, "ChunkHeader", LayoutMode::Aligned).expect("compile"); + let engine = AlkTypeEngine::compile(&doc, "ChunkHeader", LayoutMode::Aligned, None).expect("compile"); let buf = [0u8, 0u8, 0u8, 42u8, 0u8, 0u8, 0u8, 7u8]; assert!(engine.validate_bytes(&buf).is_ok(), "valid header should pass"); } @@ -972,7 +1032,7 @@ mod tests { #[test] fn validate_bytes_aligned_rejects_short_buffer() { let doc = chunk_header_doc(); - let engine = AlkTypeEngine::compile(&doc, "ChunkHeader", LayoutMode::Aligned).expect("compile"); + let engine = AlkTypeEngine::compile(&doc, "ChunkHeader", LayoutMode::Aligned, None).expect("compile"); let buf = [0u8; 6]; let err = engine.validate_bytes(&buf).unwrap_err(); assert!( @@ -995,7 +1055,7 @@ mod tests { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed).expect("compile"); + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed, None).expect("compile"); let mut buf = vec![0u8; 16]; buf[0] = 0xAB; buf[1..5].copy_from_slice(&0x01020304u32.to_le_bytes()); @@ -1018,10 +1078,120 @@ mod tests { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed).expect("compile"); + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed, None).expect("compile"); let mut buf = vec![0u8; 8]; buf[0..4].copy_from_slice(&42u32.to_le_bytes()); buf[4..8].copy_from_slice(&7u32.to_le_bytes()); assert!(engine.validate_bytes(&buf).is_ok()); } + + // ----- validate_json / is_valid_json tests (step 6, D-BAST-007) ----- + + fn uint32_struct_bast() -> Value { + json!({ + "$defs": { + "S": { + "kind": "struct", + "fields": [ { "name": "id", "kind": "uint32" } ] + } + } + }) + } + + fn id_json_schema() -> Value { + json!({ + "type": "object", + "properties": { + "id": { "type": "integer", "minimum": 0, "maximum": 4294967295u64 } + }, + "required": ["id"] + }) + } + + #[test] + fn validate_json_accepts_valid_instance() { + let bast = uint32_struct_bast(); + let schema = id_json_schema(); + let engine = + AlkTypeEngine::compile(&bast, "S", LayoutMode::Packed, Some(&schema)).expect("compile"); + assert!(engine.validate_json(&json!({"id": 42})).is_ok()); + assert!(engine.is_valid_json(&json!({"id": 42}))); + } + + #[test] + fn validate_json_rejects_invalid_instance() { + let bast = uint32_struct_bast(); + let schema = id_json_schema(); + let engine = + AlkTypeEngine::compile(&bast, "S", LayoutMode::Packed, Some(&schema)).expect("compile"); + let err = engine.validate_json(&json!({"id": -1})).unwrap_err(); + assert!(matches!(err, AlkTypeError::Validation(_)), "got {err:?}"); + assert!(!engine.is_valid_json(&json!({"id": -1}))); + assert!(!engine.is_valid_json(&json!({"id": "x"}))); + assert!(!engine.is_valid_json(&json!({}))); + } + + #[test] + fn validate_json_returns_schema_error_when_no_json_schema_supplied() { + let bast = uint32_struct_bast(); + let engine = AlkTypeEngine::compile(&bast, "S", LayoutMode::Packed, None).expect("compile"); + let err = engine.validate_json(&json!({"id": 42})).unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + #[test] + fn is_valid_json_returns_false_when_no_json_schema_supplied() { + let bast = uint32_struct_bast(); + let engine = AlkTypeEngine::compile(&bast, "S", LayoutMode::Packed, None).expect("compile"); + assert!(!engine.is_valid_json(&json!({"id": 42}))); + } + + #[test] + fn validate_json_independent_of_bast_document() { + let bast = uint32_struct_bast(); + let schema = json!({ + "type": "object", + "properties": { + "name": { "type": "string", "maxLength": 3 } + }, + "required": ["name"] + }); + let engine = + AlkTypeEngine::compile(&bast, "S", LayoutMode::Aligned, Some(&schema)).expect("compile"); + assert!(engine.validate_json(&json!({"name": "hi"})).is_ok()); + assert!(engine.validate_json(&json!({"name": "toolong"})).is_err()); + assert!(!engine.is_valid_json(&json!({"name": "toolong"}))); + } + + #[test] + fn compile_with_json_schema_returns_schema_error_for_malformed_json_schema() { + let bast = uint32_struct_bast(); + let bad_schema = json!({"type": "not-a-real-type"}); + let err = AlkTypeEngine::compile(&bast, "S", LayoutMode::Packed, Some(&bad_schema)) + .unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + #[test] + fn validate_json_validates_nested_object_json_schema() { + let bast = uint32_struct_bast(); + let schema = json!({ + "type": "object", + "properties": { + "packet": { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["a", "b"] } + }, + "required": ["kind"] + } + }, + "required": ["packet"] + }); + let engine = + AlkTypeEngine::compile(&bast, "S", LayoutMode::Packed, Some(&schema)).expect("compile"); + assert!(engine.validate_json(&json!({"packet": {"kind": "a"}})).is_ok()); + assert!(engine.validate_json(&json!({"packet": {"kind": "c"}})).is_err()); + assert!(!engine.is_valid_json(&json!({"packet": {"kind": "c"}}))); + } } \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 93c51e8..551b33e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,9 +22,10 @@ //! - **Validation** ([`validation`], [`bast_validation`]): two //! validators for two paths. `bast_validation` is the BAST-native //! validator for `validate_bytes` — a recursive walker over the BAST -//! type tree (D-BAST-006). `validation` holds the v0.1.0 custom-keyword -//! validators, still used by `validate_json` until step 6 reworks it -//! to use a consumer-provided standard JSON Schema. +//! type tree (D-BAST-006). `validation` builds a standard +//! `jsonschema::Validator` from a consumer-provided JSON Schema for +//! `validate_json` / `is_valid_json` (D-BAST-007) — no custom keywords, +//! no BAST involvement. //! - **Builder** ([`builder`]): Fluent Rust API for constructing alktype //! JSON Schemas at runtime, producing `serde_json::Value` (ADR-009). //! (Step 7 converts the builder to emit BAST JSON.) diff --git a/src/macros.rs b/src/macros.rs index bcda5ef..19cd8b8 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -1,173 +1,9 @@ -//! Macros for generating repetitive code across the 19 AlkType kinds. +//! Macros for generating repetitive read/write code across the +//! endian-sensitive fixed-size kinds. //! -//! These macros eliminate boilerplate in validation, data access, and -//! dispatch. Each macro takes a compact specification and generates the -//! full implementation, ensuring consistency across all types. - -// --------------------------------------------------------------------------- -// Validation macros -// --------------------------------------------------------------------------- - -/// Generate a signed integer validator struct and its factory closure. -#[macro_export] -macro_rules! define_int_validator { - ($validator_struct:ident, $factory_fn:ident, $keyword:literal, $min:literal, $max:literal) => { - struct $validator_struct; - impl jsonschema::Keyword for $validator_struct { - fn validate<'i>( - &self, - instance: &'i serde_json::Value, - ) -> Result<(), jsonschema::ValidationError<'i>> { - match instance.as_i64() { - Some(n) if ($min..=$max).contains(&n) => Ok(()), - _ => Err(jsonschema::ValidationError::custom(concat!( - "expected an integer in range [", - stringify!($min), - ", ", - stringify!($max), - "]" - ))), - } - } - fn is_valid(&self, instance: &serde_json::Value) -> bool { - instance - .as_i64() - .is_some_and(|n| ($min..=$max).contains(&n)) - } - } - - fn $factory_fn<'a>( - _parent: &'a serde_json::Map, - value: &'a serde_json::Value, - _path: jsonschema::paths::Location, - ) -> Result, jsonschema::ValidationError<'a>> { - if value.as_bool() == Some(true) { - Ok(Box::new($validator_struct)) - } else { - Err(jsonschema::ValidationError::schema(concat!( - $keyword, - " must be set to true" - ))) - } - } - }; -} - -/// Generate an unsigned integer validator struct and its factory closure. -#[macro_export] -macro_rules! define_uint_validator { - ($validator_struct:ident, $factory_fn:ident, $keyword:literal, $max:literal) => { - struct $validator_struct; - impl jsonschema::Keyword for $validator_struct { - fn validate<'i>( - &self, - instance: &'i serde_json::Value, - ) -> Result<(), jsonschema::ValidationError<'i>> { - match instance.as_u64() { - Some(n) if n <= $max => Ok(()), - _ => Err(jsonschema::ValidationError::custom(concat!( - "expected an unsigned integer in range [0, ", - stringify!($max), - "]" - ))), - } - } - fn is_valid(&self, instance: &serde_json::Value) -> bool { - instance.as_u64().is_some_and(|n| n <= $max) - } - } - - fn $factory_fn<'a>( - _parent: &'a serde_json::Map, - value: &'a serde_json::Value, - _path: jsonschema::paths::Location, - ) -> Result, jsonschema::ValidationError<'a>> { - if value.as_bool() == Some(true) { - Ok(Box::new($validator_struct)) - } else { - Err(jsonschema::ValidationError::schema(concat!( - $keyword, - " must be set to true" - ))) - } - } - }; -} - -/// Generate a float validator struct and its factory closure. -#[macro_export] -macro_rules! define_float_validator { - ($validator_struct:ident, $factory_fn:ident, $keyword:literal, $error_msg:literal) => { - struct $validator_struct; - impl jsonschema::Keyword for $validator_struct { - fn validate<'i>( - &self, - instance: &'i serde_json::Value, - ) -> Result<(), jsonschema::ValidationError<'i>> { - match instance.as_f64() { - Some(f) if f.is_finite() => Ok(()), - _ => Err(jsonschema::ValidationError::custom($error_msg)), - } - } - fn is_valid(&self, instance: &serde_json::Value) -> bool { - instance.as_f64().is_some_and(|f| f.is_finite()) - } - } - - fn $factory_fn<'a>( - _parent: &'a serde_json::Map, - value: &'a serde_json::Value, - _path: jsonschema::paths::Location, - ) -> Result, jsonschema::ValidationError<'a>> { - if value.as_bool() == Some(true) { - Ok(Box::new($validator_struct)) - } else { - Err(jsonschema::ValidationError::schema(concat!( - $keyword, - " must be set to true" - ))) - } - } - }; -} - -/// Generate a simple type-check validator (object/array/boolean) and its factory. -#[macro_export] -macro_rules! define_type_validator { - ($validator_struct:ident, $factory_fn:ident, $keyword:literal, $check_method:ident, $error_msg:literal) => { - struct $validator_struct; - impl jsonschema::Keyword for $validator_struct { - fn validate<'i>( - &self, - instance: &'i serde_json::Value, - ) -> Result<(), jsonschema::ValidationError<'i>> { - if instance.$check_method() { - Ok(()) - } else { - Err(jsonschema::ValidationError::custom($error_msg)) - } - } - fn is_valid(&self, instance: &serde_json::Value) -> bool { - instance.$check_method() - } - } - - fn $factory_fn<'a>( - _parent: &'a serde_json::Map, - value: &'a serde_json::Value, - _path: jsonschema::paths::Location, - ) -> Result, jsonschema::ValidationError<'a>> { - if value.as_bool() == Some(true) { - Ok(Box::new($validator_struct)) - } else { - Err(jsonschema::ValidationError::schema(concat!( - $keyword, - " must be set to true" - ))) - } - } - }; -} +//! These macros eliminate boilerplate in data access. Each macro takes a +//! compact specification and generates the full implementation, ensuring +//! consistency across all types. // --------------------------------------------------------------------------- // Data access macros diff --git a/src/validation.rs b/src/validation.rs index 650bacd..bf0ef8d 100644 --- a/src/validation.rs +++ b/src/validation.rs @@ -1,520 +1,56 @@ -//! Custom keyword validators for all 19 `AlkType:*` kinds, registered -//! via `jsonschema::options().with_keyword(...)`. +//! Build a standard `jsonschema::Validator` from a consumer-provided +//! JSON Schema document. //! -//! Per ADR-098: the `jsonschema` crate handles all structural validation; -//! the custom keywords only validate leaf type constraints. Each validator -//! is a small (~10 line) struct implementing [`jsonschema::Keyword`]. +//! Under the BAST pivot (D-BAST-007), `validate_json`/`is_valid_json` +//! validate a JSON `Value` against a standard JSON Schema the consumer +//! provides at [`AlkTypeEngine::compile`] time — not a custom-keyword +//! validator built from the alktype schema. BAST describes binary data +//! layouts; a JSON value is validated by a JSON Schema, which is a +//! separate concern authored independently (or derived from BAST via +//! future codegen). //! -//! The factory closures reject schemas where the keyword is not set to -//! `true` (returning [`jsonschema::ValidationError::schema`]). A few -//! factories read parent context (e.g. `maxLength`) to pass into the -//! validator struct. +//! This function is the helper that builds that validator. It registers +//! no custom keywords — a plain `jsonschema::Validator` over a plain +//! JSON Schema. Consumers may also call [`jsonschema`] directly; this +//! function exists so the engine's error mapping +//! (`jsonschema` build error → [`AlkTypeError::Schema`]) is reused. +//! +//! The `jsonschema` crate remains a direct dependency for this path and +//! for validating BAST documents against the BAST meta-schema +//! ([`crate::BAST_META_SCHEMA`]). The `validate_bytes` path does not +//! touch `jsonschema` — it uses the BAST-native validator +//! ([`crate::bast_validation`], D-BAST-006). +//! +//! [`AlkTypeEngine::compile`]: crate::engine::AlkTypeEngine::compile use crate::error::AlkTypeError; -use jsonschema::{Keyword, ValidationError}; -use serde_json::{Map, Value}; -use std::collections::HashMap; +use serde_json::Value; -/// Build a jsonschema validator with all 19 `AlkType:*` custom keywords -/// registered. +/// Build a standard `jsonschema::Validator` from a consumer-provided +/// JSON Schema document. /// -/// The returned validator can validate JSON representations of data -/// against the schema's type constraints. Structural validation -/// (`properties`, `required`, `items`, `enum`, ...) is handled by -/// jsonschema's built-in keywords; the custom keywords only check leaf -/// type constraints (range, finiteness, RFC 3339 shape, ...). +/// No custom keywords are registered — the schema is a plain JSON Schema +/// (Draft 2020-12 or any draft `jsonschema` supports). The returned +/// validator validates JSON `Value` instances for the `validate_json` +/// path (D-BAST-007). +/// +/// Consumers that only need a one-off validator may call +/// `jsonschema::options().build(schema)` directly; this function exists +/// so [`AlkTypeEngine::compile`] can map the build error to +/// [`AlkTypeError::Schema`] uniformly. /// /// # Errors /// /// Returns [`AlkTypeError::Schema`] if the schema is malformed or the -/// underlying jsonschema validator cannot be built. +/// underlying `jsonschema` validator cannot be built. +/// +/// [`AlkTypeEngine::compile`]: crate::engine::AlkTypeEngine::compile pub fn build_validator(schema: &Value) -> Result { jsonschema::options() - .with_keyword("AlkType:Float32", float32_factory) - .with_keyword("AlkType:Float64", float64_factory) - .with_keyword("AlkType:Int8", int8_factory) - .with_keyword("AlkType:Int16", int16_factory) - .with_keyword("AlkType:Int32", int32_factory) - .with_keyword("AlkType:Int64", int64_factory) - .with_keyword("AlkType:Uint8", uint8_factory) - .with_keyword("AlkType:Uint16", uint16_factory) - .with_keyword("AlkType:Uint32", uint32_factory) - .with_keyword("AlkType:Uint64", uint64_factory) - .with_keyword("AlkType:Boolean", boolean_factory) - .with_keyword("AlkType:String", string_factory) - .with_keyword("AlkType:Bytes", bytes_factory) - .with_keyword("AlkType:Enum", enum_factory) - .with_keyword("AlkType:Struct", struct_factory) - .with_keyword("AlkType:Union", union_factory) - .with_keyword("AlkType:Array", array_factory) - .with_keyword("AlkType:Record", record_factory) - .with_keyword("AlkType:Timestamp", timestamp_factory) .build(schema) .map_err(|e| AlkTypeError::Schema(format!("validator build failed: {e}"))) } -// --------------------------------------------------------------------------- -// Numeric validators (generated via macros) -// --------------------------------------------------------------------------- - -define_int_validator!(Int8Validator, int8_factory, "AlkType:Int8", -128, 127); -define_int_validator!(Int16Validator, int16_factory, "AlkType:Int16", -32768, 32767); -define_int_validator!(Int32Validator, int32_factory, "AlkType:Int32", -2147483648, 2147483647); -define_uint_validator!(Uint8Validator, uint8_factory, "AlkType:Uint8", 255); -define_uint_validator!(Uint16Validator, uint16_factory, "AlkType:Uint16", 65535); -define_uint_validator!(Uint32Validator, uint32_factory, "AlkType:Uint32", 4294967295); - -// Int64/Uint64 use the full i64/u64 range, so the macro's `n <= $max` check -// is always true (clippy: "comparison useless due to type limits"). Write -// them directly — the validator just checks that the JSON value is an -// integer in the right range. -struct Int64Validator; -impl Keyword for Int64Validator { - fn validate<'i>(&self, instance: &'i Value) -> Result<(), ValidationError<'i>> { - match instance.as_i64() { - Some(_) => Ok(()), - None => Err(ValidationError::custom("expected an i64 integer")), - } - } - fn is_valid(&self, instance: &Value) -> bool { - instance.as_i64().is_some() - } -} - -fn int64_factory<'a>( - _parent: &'a Map, - value: &'a Value, - _path: jsonschema::paths::Location, -) -> Result, ValidationError<'a>> { - if value.as_bool() == Some(true) { - Ok(Box::new(Int64Validator)) - } else { - Err(ValidationError::schema("AlkType:Int64 must be set to true")) - } -} - -struct Uint64Validator; -impl Keyword for Uint64Validator { - fn validate<'i>(&self, instance: &'i Value) -> Result<(), ValidationError<'i>> { - match instance.as_u64() { - Some(_) => Ok(()), - None => Err(ValidationError::custom("expected a u64 integer")), - } - } - fn is_valid(&self, instance: &Value) -> bool { - instance.as_u64().is_some() - } -} - -fn uint64_factory<'a>( - _parent: &'a Map, - value: &'a Value, - _path: jsonschema::paths::Location, -) -> Result, ValidationError<'a>> { - if value.as_bool() == Some(true) { - Ok(Box::new(Uint64Validator)) - } else { - Err(ValidationError::schema("AlkType:Uint64 must be set to true")) - } -} -define_float_validator!( - Float32Validator, - float32_factory, - "AlkType:Float32", - "expected a finite f32-compatible number" -); -define_float_validator!( - Float64Validator, - float64_factory, - "AlkType:Float64", - "expected a finite f64 number" -); - -// --------------------------------------------------------------------------- -// String and binary validators (hand-written: need maxLength from parent) -// --------------------------------------------------------------------------- - -struct StringValidator { - max_length: Option, -} -impl Keyword for StringValidator { - fn validate<'i>(&self, instance: &'i Value) -> Result<(), ValidationError<'i>> { - match instance.as_str() { - Some(s) => { - if let Some(max) = self.max_length { - if s.len() > max { - return Err(ValidationError::custom(format!( - "string byte length {} exceeds maxLength {max}", - s.len() - ))); - } - } - Ok(()) - } - None => Err(ValidationError::custom("expected a string")), - } - } - fn is_valid(&self, instance: &Value) -> bool { - instance - .as_str() - .is_some_and(|s| self.max_length.is_none_or(|max| s.len() <= max)) - } -} - -struct BytesValidator { - max_length: Option, -} -impl Keyword for BytesValidator { - fn validate<'i>(&self, instance: &'i Value) -> Result<(), ValidationError<'i>> { - match instance { - // String form: used by `validate_json` consumers that hold a - // JSON representation (e.g. deserialized from JSON, where bytes - // are conventionally a string). `maxLength` is the byte length - // of the string (UTF-8 byte count, matching `StringValidator`). - Value::String(s) => { - if let Some(max) = self.max_length { - if s.len() > max { - return Err(ValidationError::custom(format!( - "bytes length {} exceeds maxLength {max}", - s.len() - ))); - } - } - Ok(()) - } - // Array form: used by `validate_bytes` (the materializer - // produces `Value::Array` of `Value::Number` for `AlkType:Bytes` - // fields — one entry per byte, 0..=255). `maxLength` is the - // array length (max byte count). This is the round-trippable - // form for non-UTF-8 bytes (see OQ-007 resolution). - Value::Array(arr) => { - if let Some(max) = self.max_length { - if arr.len() > max { - return Err(ValidationError::custom(format!( - "bytes array length {} exceeds maxLength {max}", - arr.len() - ))); - } - } - // Each entry must be a non-negative integer 0..=255. - for (i, entry) in arr.iter().enumerate() { - let n = entry.as_u64().ok_or_else(|| { - ValidationError::custom(format!( - "bytes array entry {i} is not a non-negative integer" - )) - })?; - if n > 255 { - return Err(ValidationError::custom(format!( - "bytes array entry {i} = {n} is not a u8 (0..=255)" - ))); - } - } - Ok(()) - } - _ => Err(ValidationError::custom( - "expected a string or array of u8 for bytes", - )), - } - } - fn is_valid(&self, instance: &Value) -> bool { - match instance { - Value::String(s) => self.max_length.is_none_or(|max| s.len() <= max), - Value::Array(arr) => { - self.max_length.is_none_or(|max| arr.len() <= max) - && arr.iter().all(|e| e.as_u64().is_some_and(|n| n <= 255)) - } - _ => false, - } - } -} - -/// `AlkType:Enum` is a layout marker — the built-in `enum` keyword -/// handles value-membership validation. The custom keyword exists solely -/// for the layout engine to recognize the type as a fixed-size u32 index. -struct EnumValidator; -impl Keyword for EnumValidator { - fn validate<'i>(&self, _instance: &'i Value) -> Result<(), ValidationError<'i>> { - Ok(()) - } - fn is_valid(&self, _instance: &Value) -> bool { - true - } -} - -/// `AlkType:Union` validator: dispatches to the variant schema selected -/// by the discriminator value. -/// -/// The materializer (`src/materialize.rs`) reads the discriminator from -/// the binary buffer, looks up the variant in the union's `mapping`, and -/// produces a `Value` object of the form: -/// -/// ```json -/// { "__discriminator": , ...variant-fields } -/// ``` -/// -/// For byte-offset discriminators, `` is a number (the raw -/// discriminator integer). For field-name discriminators, it's the -/// stringified discriminator field value (the mapping key). The -/// `UnionValidator` reads `__discriminator`, looks up the corresponding -/// variant schema in the union's `mapping`, and validates the instance -/// against that variant schema (via a recursively-built sub-validator). -/// -/// This closes the OQ-008 gap: without variant dispatch, `validate_bytes` -/// on a union would only check that the materialized value is an object -/// (the read phase handles type-level checks like integer ranges), but -/// would NOT check the variant's field constraints (e.g. `maxLength` on -/// a `Bytes` field inside a variant struct). -/// -/// The sub-validators are built once at factory time (when the parent -/// validator tree is being constructed) and stored in the -/// `UnionValidator` struct. Each sub-validator is a full -/// `jsonschema::Validator` with all 19 AlkType custom keywords -/// registered (via [`build_validator`]), so nested AlkType kinds inside -/// variants are validated correctly. -struct UnionValidator { - /// Sub-validators keyed by the stringified discriminator value (the - /// mapping key). Each validator is built from the variant's schema - /// (with `$ref`s resolved against the parent union schema's `$defs` - /// when present). - variant_validators: HashMap, -} - -impl Keyword for UnionValidator { - fn validate<'i>(&self, instance: &'i Value) -> Result<(), ValidationError<'i>> { - let obj = instance.as_object().ok_or_else(|| { - ValidationError::custom("expected an object for union") - })?; - let disc = obj.get("__discriminator").ok_or_else(|| { - ValidationError::custom( - "union instance is missing the '__discriminator' field", - ) - })?; - // The mapping key is the stringified discriminator value. For - // byte-offset discriminators the materializer produces a number; - // for field-name discriminators it produces a string (the - // mapping key directly). - let key = match disc { - Value::String(s) => s.clone(), - Value::Number(n) => n.to_string(), - _ => { - return Err(ValidationError::custom(format!( - "union '__discriminator' must be a string or number, got {disc}" - ))); - } - }; - let validator = self.variant_validators.get(&key).ok_or_else(|| { - ValidationError::custom(format!( - "union discriminator value '{key}' not in mapping" - )) - })?; - validator.validate(instance).map_err(|e| { - ValidationError::custom(format!( - "union variant '{key}' failed validation: {e}" - )) - }) - } - fn is_valid(&self, instance: &Value) -> bool { - let Some(obj) = instance.as_object() else { - return false; - }; - let Some(disc) = obj.get("__discriminator") else { - return false; - }; - let key = match disc { - Value::String(s) => s.clone(), - Value::Number(n) => n.to_string(), - _ => return false, - }; - self.variant_validators - .get(&key) - .is_some_and(|v| v.is_valid(instance)) - } -} - -struct TimestampValidator; -impl Keyword for TimestampValidator { - fn validate<'i>(&self, instance: &'i Value) -> Result<(), ValidationError<'i>> { - match instance.as_str() { - Some(s) if is_rfc3339_timestamp(s) => Ok(()), - _ => Err(ValidationError::custom( - "expected an RFC 3339 timestamp string", - )), - } - } - fn is_valid(&self, instance: &Value) -> bool { - instance.as_str().is_some_and(is_rfc3339_timestamp) - } -} - -/// Simple RFC 3339 / ISO 8601 datetime check: `YYYY-MM-DDTHH:MM:SS` -/// optionally followed by `Z` or a timezone offset. -/// -/// **Non-strict.** This is a structural sanity check, not a strict RFC 3339 -/// validator. It does NOT validate: -/// - Day-of-month per month (Feb 31, Apr 31 pass). -/// - Seconds range (only hour and minute are range-checked). -/// - Leap seconds. -/// -/// For strict RFC 3339 validation, use a dedicated crate (`chrono`, `time`). -/// `AlkType:Timestamp` is a length-prefixed string at the binary level; -/// strict datetime validation is the consumer's responsibility if they -/// need it. See review #002, N1. -fn is_rfc3339_timestamp(s: &str) -> bool { - let parts: Vec<&str> = s.splitn(2, 'T').collect(); - if parts.len() != 2 { - return false; - } - let date_parts: Vec<&str> = parts[0].split('-').collect(); - if date_parts.len() != 3 { - return false; - } - let time_part = parts[1]; - let time_clean = if let Some(pos) = time_part.find(['Z', '+']) { - &time_part[..pos] - } else if let Some(pos) = time_part.rfind('-') { - if pos >= 8 { - &time_part[..pos] - } else { - time_part - } - } else { - time_part - }; - let time_parts: Vec<&str> = time_clean.split(':').collect(); - if time_parts.len() < 2 || time_parts.len() > 3 { - return false; - } - date_parts[0].parse::().is_ok_and(|y| y > 0) - && date_parts[1] - .parse::() - .is_ok_and(|m| (1..=12).contains(&m)) - && date_parts[2] - .parse::() - .is_ok_and(|d| (1..=31).contains(&d)) - && time_parts[0].parse::().is_ok_and(|h| h <= 23) - && time_parts[1].parse::().is_ok_and(|m| m <= 59) -} - -// --------------------------------------------------------------------------- -// Composite validators (generated via macros) -// --------------------------------------------------------------------------- - -define_type_validator!(StructValidator, struct_factory, "AlkType:Struct", is_object, "expected an object"); -define_type_validator!(ArrayValidator, array_factory, "AlkType:Array", is_array, "expected an array"); -define_type_validator!(RecordValidator, record_factory, "AlkType:Record", is_object, "expected an object for record"); -define_type_validator!(BooleanValidator, boolean_factory, "AlkType:Boolean", is_boolean, "expected a boolean"); - -// --------------------------------------------------------------------------- -// Factory closures for non-macro-generated validators -// --------------------------------------------------------------------------- - -fn string_factory<'a>( - parent: &'a Map, - value: &'a Value, - _path: jsonschema::paths::Location, -) -> Result, ValidationError<'a>> { - if !value.is_boolean() && !value.is_object() { - return Err(ValidationError::schema( - "AlkType:String must be set to true or an annotation object", - )); - } - let max_length = parent - .get("maxLength") - .and_then(Value::as_u64) - .map(|n| n as usize); - Ok(Box::new(StringValidator { max_length })) -} - -fn bytes_factory<'a>( - parent: &'a Map, - value: &'a Value, - _path: jsonschema::paths::Location, -) -> Result, ValidationError<'a>> { - if !value.is_boolean() && !value.is_object() { - return Err(ValidationError::schema( - "AlkType:Bytes must be set to true or an annotation object", - )); - } - let max_length = parent - .get("maxLength") - .and_then(Value::as_u64) - .map(|n| n as usize); - Ok(Box::new(BytesValidator { max_length })) -} - -fn enum_factory<'a>( - _parent: &'a Map, - value: &'a Value, - _path: jsonschema::paths::Location, -) -> Result, ValidationError<'a>> { - if value.as_bool() == Some(true) { - Ok(Box::new(EnumValidator)) - } else { - Err(ValidationError::schema("AlkType:Enum must be set to true")) - } -} - -fn timestamp_factory<'a>( - _parent: &'a Map, - value: &'a Value, - _path: jsonschema::paths::Location, -) -> Result, ValidationError<'a>> { - if value.as_bool() == Some(true) { - Ok(Box::new(TimestampValidator)) - } else { - Err(ValidationError::schema( - "AlkType:Timestamp must be set to true", - )) - } -} - -/// Build a `UnionValidator` with a sub-validator per mapping entry. -/// -/// The factory receives the union schema object as `parent` (the -/// `Map` containing `discriminator` and `mapping`). For -/// each entry in `mapping`, the variant schema is used to build a full -/// `jsonschema::Validator` via [`build_validator`] (so nested AlkType -/// kinds are validated). -/// -/// `$ref`s in the `mapping` are inlined by -/// [`crate::schema::inline_union_variant_refs`] during -/// `AlkTypeEngine::compile` (before `build_validator` is called), so the -/// factory sees full inline variant schemas — no `$ref` resolution is -/// needed here. -/// -/// The sub-validators are built once at factory time and reused for -/// every validation call. The `UnionValidator` dispatches on the -/// `__discriminator` field in the instance at validation time. -fn union_factory<'a>( - parent: &'a Map, - value: &'a Value, - _path: jsonschema::paths::Location, -) -> Result, ValidationError<'a>> { - if value.as_bool() != Some(true) { - return Err(ValidationError::schema( - "AlkType:Union must be set to true", - )); - } - let mapping = parent - .get("mapping") - .and_then(Value::as_object) - .ok_or_else(|| { - ValidationError::schema("AlkType:Union is missing 'mapping' object") - })?; - let mut variant_validators: HashMap = HashMap::new(); - for (key, variant_schema) in mapping.iter() { - // Variants are inlined by inline_union_variant_refs at compile - // time, so variant_schema is a full inline schema here. - let sub_validator = build_validator(variant_schema).map_err(|e| { - ValidationError::schema(format!( - "failed to build sub-validator for union variant '{key}': {e}" - )) - })?; - variant_validators.insert(key.clone(), sub_validator); - } - Ok(Box::new(UnionValidator { variant_validators })) -} - #[cfg(test)] mod tests { use super::*; @@ -525,335 +61,25 @@ mod tests { } #[test] - fn validates_valid_struct_instance() { + fn builds_standard_json_schema_validator() { let schema = json!({ - "AlkType:Struct": true, "type": "object", "properties": { - "id": { "AlkType:Uint32": true, "type": "integer" }, - "score": { "AlkType:Float32": true, "type": "number" }, - "flag": { "AlkType:Uint8": true, "type": "integer" }, - "count": { "AlkType:Uint16": true, "type": "integer" } + "id": { "type": "integer", "minimum": 0, "maximum": 4294967295u64 }, + "name": { "type": "string", "maxLength": 5 } }, - "required": ["id", "score", "flag", "count"] + "required": ["id", "name"] }); - let validator = validator_for(&schema); - let instance = json!({ - "id": 42, - "score": 3.5, - "flag": 1, - "count": 1000 - }); - assert!(validator.is_valid(&instance)); + let v = validator_for(&schema); + assert!(v.is_valid(&json!({"id": 42, "name": "hi"}))); + assert!(!v.is_valid(&json!({"id": -1, "name": "hi"}))); + assert!(!v.is_valid(&json!({"id": 42, "name": "toolong"}))); + assert!(!v.is_valid(&json!({"id": 42}))); } #[test] - fn rejects_uint32_out_of_range() { - let schema = json!({ - "AlkType:Struct": true, - "type": "object", - "properties": { "id": { "AlkType:Uint32": true, "type": "integer" } }, - "required": ["id"] - }); - let validator = validator_for(&schema); - assert!(!validator.is_valid(&json!({"id": -1}))); - assert!(!validator.is_valid(&json!({"id": 5_000_000_000u64}))); - } - - #[test] - fn validates_int8_range() { - let schema = json!({ - "AlkType:Struct": true, - "type": "object", - "properties": { "val": { "AlkType:Int8": true, "type": "integer" } }, - "required": ["val"] - }); - let validator = validator_for(&schema); - assert!(validator.is_valid(&json!({"val": 0}))); - assert!(validator.is_valid(&json!({"val": 127}))); - assert!(validator.is_valid(&json!({"val": -128}))); - assert!(!validator.is_valid(&json!({"val": 128}))); - assert!(!validator.is_valid(&json!({"val": -129}))); - } - - #[test] - fn validates_int16_and_int32_ranges() { - let schema = json!({ - "AlkType:Struct": true, - "type": "object", - "properties": { - "i16": { "AlkType:Int16": true, "type": "integer" }, - "i32": { "AlkType:Int32": true, "type": "integer" } - }, - "required": ["i16", "i32"] - }); - let validator = validator_for(&schema); - assert!(validator.is_valid(&json!({"i16": 32767, "i32": 2147483647}))); - assert!(validator.is_valid(&json!({"i16": -32768, "i32": -2147483648}))); - assert!(!validator.is_valid(&json!({"i16": 32768, "i32": 0}))); - assert!(!validator.is_valid(&json!({"i16": 0, "i32": 2147483648u64}))); - } - - #[test] - fn validates_uint16_and_uint32_ranges() { - let schema = json!({ - "AlkType:Struct": true, - "type": "object", - "properties": { - "u16": { "AlkType:Uint16": true, "type": "integer" }, - "u32": { "AlkType:Uint32": true, "type": "integer" } - }, - "required": ["u16", "u32"] - }); - let validator = validator_for(&schema); - assert!(validator.is_valid(&json!({"u16": 65535, "u32": 4294967295u64}))); - assert!(!validator.is_valid(&json!({"u16": 65536, "u32": 0}))); - assert!(!validator.is_valid(&json!({"u16": -1, "u32": 0}))); - } - - #[test] - fn validates_int64_range() { - let schema = json!({ - "AlkType:Struct": true, - "type": "object", - "properties": { "val": { "AlkType:Int64": true, "type": "integer" } }, - "required": ["val"] - }); - let validator = validator_for(&schema); - assert!(validator.is_valid(&json!({"val": 0}))); - assert!(validator.is_valid(&json!({"val": 9223372036854775807i64}))); - assert!(validator.is_valid(&json!({"val": -9223372036854775808i64}))); - assert!(!validator.is_valid(&json!({"val": "x"}))); - } - - #[test] - fn validates_uint64_range() { - let schema = json!({ - "AlkType:Struct": true, - "type": "object", - "properties": { "val": { "AlkType:Uint64": true, "type": "integer" } }, - "required": ["val"] - }); - let validator = validator_for(&schema); - assert!(validator.is_valid(&json!({"val": 0}))); - assert!(validator.is_valid(&json!({"val": 18446744073709551615u64}))); - assert!(!validator.is_valid(&json!({"val": -1}))); - assert!(!validator.is_valid(&json!({"val": "x"}))); - } - - #[test] - fn validates_float_finiteness() { - let schema = json!({ - "AlkType:Struct": true, - "type": "object", - "properties": { - "f32": { "AlkType:Float32": true, "type": "number" }, - "f64": { "AlkType:Float64": true, "type": "number" } - }, - "required": ["f32", "f64"] - }); - let validator = validator_for(&schema); - assert!(validator.is_valid(&json!({"f32": 3.5, "f64": 2.5}))); - assert!(validator.is_valid(&json!({"f32": 0, "f64": 0}))); - assert!(!validator.is_valid(&json!({"f32": "x", "f64": 0}))); - } - - #[test] - fn validates_boolean() { - let schema = json!({ - "AlkType:Struct": true, - "type": "object", - "properties": { "active": { "AlkType:Boolean": true, "type": "boolean" } }, - "required": ["active"] - }); - let validator = validator_for(&schema); - assert!(validator.is_valid(&json!({"active": true}))); - assert!(validator.is_valid(&json!({"active": false}))); - assert!(!validator.is_valid(&json!({"active": "yes"}))); - } - - #[test] - fn validates_string_and_maxlength() { - let schema = json!({ - "AlkType:Struct": true, - "type": "object", - "properties": { - "name": { "AlkType:String": true, "type": "string", "maxLength": 5 } - }, - "required": ["name"] - }); - let validator = validator_for(&schema); - assert!(validator.is_valid(&json!({"name": "hi"}))); - assert!(validator.is_valid(&json!({"name": "hello"}))); - assert!(!validator.is_valid(&json!({"name": "toolong"}))); - assert!(!validator.is_valid(&json!({"name": 42}))); - } - - #[test] - fn validates_bytes_and_maxlength() { - // No "type" keyword — the BytesValidator accepts both string - // (validate_json path) and array-of-u8 (validate_bytes path). - let schema = json!({ - "AlkType:Struct": true, - "type": "object", - "properties": { - "blob": { "AlkType:Bytes": true, "maxLength": 4 } - }, - "required": ["blob"] - }); - let validator = validator_for(&schema); - // String form (validate_json consumers). - assert!(validator.is_valid(&json!({"blob": "abcd"}))); - assert!(!validator.is_valid(&json!({"blob": "abcde"}))); - // Array form (validate_bytes materializer). - assert!(validator.is_valid(&json!({"blob": [65, 66, 67, 68]}))); - assert!(!validator.is_valid(&json!({"blob": [65, 66, 67, 68, 69]}))); - // Non-string, non-array is rejected. - assert!(!validator.is_valid(&json!({"blob": 42}))); - } - - #[test] - fn validates_bytes_array_rejects_out_of_range_entry() { - let schema = json!({ - "AlkType:Struct": true, - "type": "object", - "properties": { - "blob": { "AlkType:Bytes": true, "maxLength": 4 } - }, - "required": ["blob"] - }); - let validator = validator_for(&schema); - // 256 is not a u8. - assert!(!validator.is_valid(&json!({"blob": [65, 256]}))); - // Non-integer entry is rejected. - assert!(!validator.is_valid(&json!({"blob": [65, "x"]}))); - } - - #[test] - fn enum_validator_is_noop_and_builtin_enum_handles_membership() { - let schema = json!({ - "AlkType:Struct": true, - "type": "object", - "properties": { - "status": { - "AlkType:Enum": true, - "type": "string", - "enum": ["ok", "error", "pending"] - } - }, - "required": ["status"] - }); - let validator = validator_for(&schema); - assert!(validator.is_valid(&json!({"status": "ok"}))); - assert!(validator.is_valid(&json!({"status": "error"}))); - assert!(!validator.is_valid(&json!({"status": "unknown"}))); - } - - #[test] - fn validates_timestamp_rfc3339() { - let schema = json!({ - "AlkType:Struct": true, - "type": "object", - "properties": { - "created_at": { "AlkType:Timestamp": true, "type": "string" } - }, - "required": ["created_at"] - }); - let validator = validator_for(&schema); - assert!(validator.is_valid(&json!({"created_at": "2026-07-20T15:30:00Z"}))); - assert!(validator.is_valid(&json!({"created_at": "2026-07-20T15:30:00"}))); - assert!(validator.is_valid(&json!({"created_at": "2026-07-20T15:30:00+02:00"}))); - assert!(!validator.is_valid(&json!({"created_at": "not-a-date"}))); - } - - #[test] - fn validates_array_type() { - let schema = json!({ - "AlkType:Struct": true, - "type": "object", - "properties": { - "items": { - "AlkType:Array": true, - "type": "array", - "items": { "AlkType:Uint8": true, "type": "integer" } - } - }, - "required": ["items"] - }); - let validator = validator_for(&schema); - assert!(validator.is_valid(&json!({"items": [1, 2, 3]}))); - assert!(!validator.is_valid(&json!({"items": "not-array"}))); - } - - #[test] - fn validates_record_type() { - let schema = json!({ - "AlkType:Struct": true, - "type": "object", - "properties": { - "counts": { - "AlkType:Record": true, - "type": "object", - "additionalProperties": { "AlkType:Uint32": true, "type": "integer" } - } - }, - "required": ["counts"] - }); - let validator = validator_for(&schema); - assert!(validator.is_valid(&json!({"counts": {"a": 1, "b": 2}}))); - assert!(!validator.is_valid(&json!({"counts": "not-object"}))); - } - - #[test] - fn validates_union_type() { - // A struct with a union field. The union has a byte-offset - // discriminator and two variant structs. The validator dispatches - // on __discriminator and validates the variant fields. - let schema = json!({ - "AlkType:Struct": true, - "type": "object", - "properties": { - "packet": { - "AlkType:Union": true, - "type": "object", - "discriminator": { "kind": "byte", "offset": 0, "type": "AlkType:Uint8" }, - "mapping": { - "5": { - "AlkType:Struct": true, - "type": "object", - "properties": { "id": { "AlkType:Uint32": true, "type": "integer" } }, - "required": ["id"] - }, - "6": { - "AlkType:Struct": true, - "type": "object", - "properties": { "name": { "AlkType:String": true, "type": "string" } }, - "required": ["name"] - } - } - } - }, - "required": ["packet"] - }); - let validator = validator_for(&schema); - // Valid: disc=5, id present. - assert!(validator.is_valid(&json!({"packet": {"__discriminator": 5, "id": 42}}))); - // Valid: disc=6, name present. - assert!(validator.is_valid(&json!({"packet": {"__discriminator": 6, "name": "hi"}}))); - // Invalid: not an object. - assert!(!validator.is_valid(&json!({"packet": "not-object"}))); - // Invalid: missing __discriminator. - assert!(!validator.is_valid(&json!({"packet": {"id": 42}}))); - // Invalid: disc value not in mapping. - assert!(!validator.is_valid(&json!({"packet": {"__discriminator": 99, "id": 42}}))); - // Invalid: variant fields don't match the variant schema (disc=5 - // requires "id", but "name" is given). - assert!(!validator.is_valid(&json!({"packet": {"__discriminator": 5, "name": "hi"}}))); - } - - #[test] - fn build_validator_returns_schema_error_for_malformed_keyword() { - let schema = json!({"AlkType:Uint32": "not-a-bool"}); + fn build_validator_returns_schema_error_for_malformed_schema() { + let schema = json!({"type": "not-a-real-type"}); let err = build_validator(&schema).expect_err("should fail"); assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); } @@ -866,307 +92,76 @@ mod tests { } #[test] - fn validate_returns_ok_for_valid_int8() { - let schema = json!({"AlkType:Int8": true, "type": "integer"}); - let v = validator_for(&schema); - assert!(v.validate(&json!(0)).is_ok()); - assert!(v.validate(&json!(127)).is_ok()); - assert!(v.validate(&json!(-128)).is_ok()); - } - - #[test] - fn validate_returns_err_for_int8_out_of_range() { - let schema = json!({"AlkType:Int8": true, "type": "integer"}); - let v = validator_for(&schema); - let instance = json!(128); - let err = v.validate(&instance).expect_err("out of range"); - let msg = format!("{err}"); - assert!(msg.contains("integer in range"), "got: {msg}"); - } - - #[test] - fn validate_returns_err_for_int8_non_integer() { - let schema = json!({"AlkType:Int8": true, "type": "integer"}); - let v = validator_for(&schema); - assert!(v.validate(&json!("x")).is_err()); - } - - #[test] - fn validate_returns_ok_for_valid_uint32() { - let schema = json!({"AlkType:Uint32": true, "type": "integer"}); - let v = validator_for(&schema); - assert!(v.validate(&json!(0)).is_ok()); - assert!(v.validate(&json!(4294967295u64)).is_ok()); - } - - #[test] - fn validate_returns_err_for_uint32_negative() { - let schema = json!({"AlkType:Uint32": true, "type": "integer"}); - let v = validator_for(&schema); - assert!(v.validate(&json!(-1)).is_err()); - } - - #[test] - fn validate_returns_err_for_uint32_too_large() { - let schema = json!({"AlkType:Uint32": true, "type": "integer"}); - let v = validator_for(&schema); - assert!(v.validate(&json!(5_000_000_000u64)).is_err()); - } - - #[test] - fn validate_int64_ok_and_err() { - let schema = json!({"AlkType:Int64": true, "type": "integer"}); - let v = validator_for(&schema); - assert!(v.validate(&json!(0)).is_ok()); - assert!(v.validate(&json!(9223372036854775807i64)).is_ok()); - assert!(v.validate(&json!(-9223372036854775808i64)).is_ok()); - assert!(v.validate(&json!("x")).is_err()); - } - - #[test] - fn validate_uint64_ok_and_err() { - let schema = json!({"AlkType:Uint64": true, "type": "integer"}); - let v = validator_for(&schema); - assert!(v.validate(&json!(0)).is_ok()); - assert!(v.validate(&json!(18446744073709551615u64)).is_ok()); - assert!(v.validate(&json!(-1)).is_err()); - assert!(v.validate(&json!("x")).is_err()); - } - - #[test] - fn validate_float32_ok_and_err() { - let schema = json!({"AlkType:Float32": true, "type": "number"}); - let v = validator_for(&schema); - assert!(v.validate(&json!(3.5)).is_ok()); - assert!(v.validate(&json!(0)).is_ok()); - assert!(v.validate(&json!("x")).is_err()); - } - - #[test] - fn validate_float64_ok_and_err() { - let schema = json!({"AlkType:Float64": true, "type": "number"}); - let v = validator_for(&schema); - assert!(v.validate(&json!(2.5)).is_ok()); - assert!(v.validate(&json!(0)).is_ok()); - assert!(v.validate(&json!("x")).is_err()); - } - - #[test] - fn validate_boolean_ok_and_err() { - let schema = json!({"AlkType:Boolean": true, "type": "boolean"}); - let v = validator_for(&schema); - assert!(v.validate(&json!(true)).is_ok()); - assert!(v.validate(&json!(false)).is_ok()); - assert!(v.validate(&json!("yes")).is_err()); - } - - #[test] - fn validate_string_ok_and_maxlength_exceeded() { - let schema = json!({"AlkType:String": true, "type": "string", "maxLength": 5}); - let v = validator_for(&schema); - assert!(v.validate(&json!("hi")).is_ok()); - assert!(v.validate(&json!("hello")).is_ok()); - let long = json!("toolong"); - assert!(v.validate(&long).is_err()); - } - - #[test] - fn validate_string_non_string_is_err() { - let schema = json!({"AlkType:String": true, "type": "string"}); - let v = validator_for(&schema); - assert!(v.validate(&json!(42)).is_err()); - } - - #[test] - fn validate_bytes_ok_and_maxlength_exceeded() { - // No "type" keyword — the BytesValidator accepts both string and - // array-of-u8 forms. The "type": "string" form is still valid for - // schemas that want to restrict to the string form (validate_json - // consumers that never see validate_bytes materialization). - let schema = json!({"AlkType:Bytes": true, "maxLength": 4}); - let v = validator_for(&schema); - // String form. - assert!(v.validate(&json!("abcd")).is_ok()); - assert!(v.validate(&json!("abcde")).is_err()); - // Array form. - assert!(v.validate(&json!([65, 66, 67, 68])).is_ok()); - assert!(v.validate(&json!([65, 66, 67, 68, 69])).is_err()); - } - - #[test] - fn validate_bytes_non_string_non_array_is_err() { - let schema = json!({"AlkType:Bytes": true}); - let v = validator_for(&schema); - assert!(v.validate(&json!(42)).is_err()); - assert!(v.validate(&json!(true)).is_err()); - assert!(v.validate(&json!("ok")).is_ok()); - assert!(v.validate(&json!([0, 255])).is_ok()); - } - - #[test] - fn validate_enum_is_noop_ok() { + fn build_validator_accepts_draft_2020_12_schema() { let schema = json!({ - "AlkType:Enum": true, + "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string", - "enum": ["ok", "error"] + "minLength": 2 }); let v = validator_for(&schema); - assert!(v.validate(&json!("ok")).is_ok()); - assert!(v.validate(&json!("error")).is_ok()); + assert!(v.validate(&json!("hi")).is_ok()); + assert!(v.validate(&json!("x")).is_err()); } #[test] - fn validate_timestamp_ok_and_err() { - let schema = json!({"AlkType:Timestamp": true, "type": "string"}); - let v = validator_for(&schema); - assert!(v.validate(&json!("2026-07-20T15:30:00Z")).is_ok()); - assert!(v.validate(&json!("2026-07-20T15:30:00")).is_ok()); - assert!(v.validate(&json!("2026-07-20T15:30:00+02:00")).is_ok()); - assert!(v.validate(&json!("not-a-date")).is_err()); - } - - #[test] - fn validate_struct_ok_and_err() { - let schema = json!({"AlkType:Struct": true, "type": "object"}); - let v = validator_for(&schema); - assert!(v.validate(&json!({"x": 1})).is_ok()); - assert!(v.validate(&json!("not-object")).is_err()); - } - - #[test] - fn validate_union_ok_and_err() { - // A standalone union with a mapping. The validator dispatches on - // __discriminator and validates the variant fields. + fn build_validator_handles_enum_keyword() { let schema = json!({ - "AlkType:Union": true, - "type": "object", - "discriminator": { "kind": "byte", "offset": 0, "type": "AlkType:Uint8" }, - "mapping": { - "5": { - "AlkType:Struct": true, - "type": "object", - "properties": { "id": { "AlkType:Uint32": true, "type": "integer" } }, - "required": ["id"] - } - } + "type": "string", + "enum": ["ok", "error", "pending"] }); let v = validator_for(&schema); - // Valid: disc=5, id present. - assert!(v.validate(&json!({"__discriminator": 5, "id": 42})).is_ok()); - // Invalid: not an object. - assert!(v.validate(&json!("not-object")).is_err()); - // Invalid: missing __discriminator. - assert!(v.validate(&json!({"id": 42})).is_err()); - // Invalid: unknown discriminator. - assert!(v.validate(&json!({"__discriminator": 99, "id": 42})).is_err()); - // Invalid: variant fields don't match. - assert!(v.validate(&json!({"__discriminator": 5, "id": "not-int"})).is_err()); + assert!(v.is_valid(&json!("ok"))); + assert!(v.is_valid(&json!("error"))); + assert!(!v.is_valid(&json!("unknown"))); } #[test] - fn validate_array_ok_and_err() { - let schema = json!({"AlkType:Array": true, "type": "array"}); + fn build_validator_handles_nested_objects_and_arrays() { + let schema = json!({ + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { "type": "integer" }, + "minItems": 1 + } + }, + "required": ["items"] + }); let v = validator_for(&schema); - assert!(v.validate(&json!([1, 2, 3])).is_ok()); - assert!(v.validate(&json!("not-array")).is_err()); + assert!(v.is_valid(&json!({"items": [1, 2, 3]}))); + assert!(!v.is_valid(&json!({"items": []}))); + assert!(!v.is_valid(&json!({"items": [1, "x"]}))); } #[test] - fn validate_record_ok_and_err() { - let schema = json!({"AlkType:Record": true, "type": "object"}); + fn build_validator_handles_additional_properties() { + let schema = json!({ + "type": "object", + "properties": { "a": { "type": "integer" } }, + "additionalProperties": { "type": "string" } + }); let v = validator_for(&schema); + assert!(v.is_valid(&json!({"a": 1, "extra": "str"}))); + assert!(!v.is_valid(&json!({"a": 1, "extra": 5}))); + } + + #[test] + fn validate_returns_err_for_invalid_instance() { + let schema = json!({"type": "integer", "minimum": 0}); + let v = validator_for(&schema); + assert!(v.validate(&json!(-1)).is_err()); + assert!(v.validate(&json!("x")).is_err()); + assert!(v.validate(&json!(5)).is_ok()); + } + + #[test] + fn empty_schema_accepts_anything() { + let schema = json!({}); + let v = validator_for(&schema); + assert!(v.validate(&json!(42)).is_ok()); + assert!(v.validate(&json!("hi")).is_ok()); assert!(v.validate(&json!({"a": 1})).is_ok()); - assert!(v.validate(&json!("not-object")).is_err()); + assert!(v.validate(&json!([1, 2])).is_ok()); } - - #[test] - fn factory_rejects_string_keyword_not_bool_or_object() { - let schema = json!({"AlkType:String": 42}); - let err = build_validator(&schema).expect_err("should reject"); - assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); - } - - #[test] - fn factory_rejects_bytes_keyword_not_bool_or_object() { - let schema = json!({"AlkType:Bytes": 42}); - let err = build_validator(&schema).expect_err("should reject"); - assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); - } - - #[test] - fn factory_rejects_enum_keyword_not_true() { - let schema = json!({"AlkType:Enum": false}); - let err = build_validator(&schema).expect_err("should reject"); - assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); - } - - #[test] - fn factory_rejects_timestamp_keyword_not_true() { - let schema = json!({"AlkType:Timestamp": false}); - let err = build_validator(&schema).expect_err("should reject"); - assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); - } - - #[test] - fn factory_rejects_int64_keyword_not_true() { - let schema = json!({"AlkType:Int64": false}); - let err = build_validator(&schema).expect_err("should reject"); - assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); - } - - #[test] - fn factory_rejects_uint64_keyword_not_true() { - let schema = json!({"AlkType:Uint64": false}); - let err = build_validator(&schema).expect_err("should reject"); - assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); - } - - #[test] - fn factory_rejects_struct_keyword_not_true() { - let schema = json!({"AlkType:Struct": false}); - let err = build_validator(&schema).expect_err("should reject"); - assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); - } - - #[test] - fn factory_rejects_union_keyword_not_true() { - let schema = json!({"AlkType:Union": false}); - let err = build_validator(&schema).expect_err("should reject"); - assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); - } - - #[test] - fn factory_rejects_array_keyword_not_true() { - let schema = json!({"AlkType:Array": false}); - let err = build_validator(&schema).expect_err("should reject"); - assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); - } - - #[test] - fn factory_rejects_record_keyword_not_true() { - let schema = json!({"AlkType:Record": false}); - let err = build_validator(&schema).expect_err("should reject"); - assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); - } - - #[test] - fn factory_rejects_boolean_keyword_not_true() { - let schema = json!({"AlkType:Boolean": false}); - let err = build_validator(&schema).expect_err("should reject"); - assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); - } - - #[test] - fn factory_accepts_string_keyword_as_object_annotation() { - let schema = json!({"AlkType:String": {"encoding": "length-prefixed"}, "type": "string"}); - let v = validator_for(&schema); - assert!(v.validate(&json!("hi")).is_ok()); - } - - #[test] - fn factory_accepts_bytes_keyword_as_object_annotation() { - let schema = json!({"AlkType:Bytes": {"encoding": "offset-indirect"}, "type": "string"}); - let v = validator_for(&schema); - assert!(v.validate(&json!("hi")).is_ok()); - } -} +} \ No newline at end of file diff --git a/tests/engine_integration.rs b/tests/engine_integration.rs index 0b0cb45..13c22d8 100644 --- a/tests/engine_integration.rs +++ b/tests/engine_integration.rs @@ -31,7 +31,7 @@ fn mixed_fixed_struct_doc() -> serde_json::Value { #[test] fn compile_aligned_builds_engine_with_offset_map() -> Result<(), AlkTypeError> { let doc = mixed_fixed_struct_doc(); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned)?; + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None)?; assert_eq!(engine.mode(), LayoutMode::Aligned); assert!(engine.offset_map().is_some()); assert!(engine.layout_builder().is_none()); @@ -42,7 +42,7 @@ fn compile_aligned_builds_engine_with_offset_map() -> Result<(), AlkTypeError> { #[test] fn compile_packed_builds_engine_with_builder_and_reader() -> Result<(), AlkTypeError> { let doc = mixed_fixed_struct_doc(); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed)?; + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed, None)?; assert_eq!(engine.mode(), LayoutMode::Packed); assert!(engine.offset_map().is_none()); assert!(engine.layout_builder().is_some()); @@ -66,21 +66,21 @@ fn compile_resolves_ref_fields() -> Result<(), AlkTypeError> { } } }); - let _engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed)?; + let _engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed, None)?; Ok(()) } #[test] fn compile_returns_schema_error_when_no_defs() { let doc = json!({ "type": "object", "properties": {} }); - let err = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned).unwrap_err(); + let err = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None).unwrap_err(); assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); } #[test] fn compile_returns_schema_error_for_missing_root() { let doc = json!({ "$defs": { "Other": { "kind": "struct", "fields": [] } } }); - let err = AlkTypeEngine::compile(&doc, "Missing", LayoutMode::Aligned).unwrap_err(); + let err = AlkTypeEngine::compile(&doc, "Missing", LayoutMode::Aligned, None).unwrap_err(); assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); } @@ -95,7 +95,7 @@ fn endian_parsed_from_schema_big() -> Result<(), AlkTypeError> { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed)?; + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed, None)?; assert_eq!(engine.endian(), Endian::Big); Ok(()) } @@ -110,7 +110,7 @@ fn endian_defaults_to_little() -> Result<(), AlkTypeError> { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed)?; + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed, None)?; assert_eq!(engine.endian(), Endian::Little); Ok(()) } @@ -140,7 +140,7 @@ fn read_write_aligned_round_trips_all_fixed_size_kinds() -> Result<(), AlkTypeEr "E": { "kind": "enum", "values": ["A", "B", "C"] } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned)?; + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None)?; let offset_map = engine.offset_map().expect("aligned mode has offset_map"); let mut buffer = vec![0u8; offset_map.total_size()]; @@ -194,7 +194,7 @@ fn read_write_aligned_round_trips_string() -> Result<(), AlkTypeError> { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned)?; + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None)?; let offset_map = engine.offset_map().expect("aligned mode has offset_map"); let mut buffer = vec![0u8; offset_map.total_size() + 64]; engine.write_field(&mut buffer, "name", &FieldValue::String("hello world"))?; @@ -215,7 +215,7 @@ fn read_write_aligned_round_trips_bytes() -> Result<(), AlkTypeError> { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned)?; + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None)?; let offset_map = engine.offset_map().expect("aligned mode has offset_map"); let payload = b"the quick brown fox".to_vec(); let mut buffer = vec![0u8; offset_map.total_size() + payload.len()]; @@ -237,7 +237,7 @@ fn read_field_returns_access_error_in_packed_mode() -> Result<(), AlkTypeError> } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed)?; + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed, None)?; let buffer = [0u8; 4]; let err = engine.read_field(&buffer, "id").unwrap_err(); assert!(matches!(err, AlkTypeError::Access { .. }), "got {err:?}"); @@ -254,7 +254,7 @@ fn write_field_returns_access_error_in_packed_mode() -> Result<(), AlkTypeError> } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed)?; + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed, None)?; let mut buffer = [0u8; 4]; let err = engine .write_field(&mut buffer, "id", &FieldValue::U32(1)) @@ -273,7 +273,7 @@ fn read_field_returns_offset_error_for_missing_path() -> Result<(), AlkTypeError } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned)?; + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None)?; let buffer = [0u8; 8]; let err = engine.read_field(&buffer, "missing").unwrap_err(); assert!(matches!(err, AlkTypeError::Offset { .. }), "got {err:?}"); @@ -290,7 +290,7 @@ fn write_field_returns_offset_error_for_missing_path() -> Result<(), AlkTypeErro } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned)?; + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None)?; let mut buffer = [0u8; 8]; let err = engine .write_field(&mut buffer, "missing", &FieldValue::U32(1)) @@ -311,7 +311,7 @@ fn read_field_returns_error_for_composite_types() -> Result<(), AlkTypeError> { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned)?; + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None)?; let buffer = [0u8; 8]; let err = engine.read_field(&buffer, "vals").unwrap_err(); assert!( @@ -331,7 +331,7 @@ fn write_field_returns_access_error_for_composite_value() -> Result<(), AlkTypeE } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned)?; + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None)?; let mut buffer = [0u8; 8]; let err = engine .write_field(&mut buffer, "id", &FieldValue::Struct { start: 0, end: 4 }) @@ -361,7 +361,7 @@ fn read_field_aligned_reads_nested_struct_byte_range() -> Result<(), AlkTypeErro } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned)?; + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None)?; let offset_map = engine.offset_map().expect("aligned mode"); let mut buffer = vec![0u8; offset_map.total_size()]; diff --git a/tests/error_paths.rs b/tests/error_paths.rs index 412872a..f9bea6b 100644 --- a/tests/error_paths.rs +++ b/tests/error_paths.rs @@ -154,14 +154,14 @@ fn write_string_buffer_too_short_returns_access_error() { #[test] fn compile_missing_defs_returns_schema_error() { let doc = json!({ "type": "object", "properties": {} }); - let err = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned).unwrap_err(); + let err = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None).unwrap_err(); assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); } #[test] fn compile_missing_root_returns_schema_error() { let doc = json!({ "$defs": { "Other": { "kind": "struct", "fields": [] } } }); - let err = AlkTypeEngine::compile(&doc, "Missing", LayoutMode::Aligned).unwrap_err(); + let err = AlkTypeEngine::compile(&doc, "Missing", LayoutMode::Aligned, None).unwrap_err(); assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); } diff --git a/tests/poc_roundtrip.rs b/tests/poc_roundtrip.rs index e451c48..5f2be1c 100644 --- a/tests/poc_roundtrip.rs +++ b/tests/poc_roundtrip.rs @@ -87,7 +87,7 @@ fn fixed_size_round_trip_via_engine_aligned() -> Result<(), AlkTypeError> { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned)?; + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None)?; let offset_map = engine.offset_map().expect("aligned mode has offset_map"); let mut buffer = vec![0u8; offset_map.total_size()]; @@ -129,7 +129,7 @@ fn string_round_trip_via_engine_aligned() -> Result<(), AlkTypeError> { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned)?; + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None)?; let offset_map = engine.offset_map().expect("aligned mode has offset_map"); let mut buffer = vec![0u8; offset_map.total_size() + 64]; engine.write_field(&mut buffer, "name", &FieldValue::String("hello"))?; @@ -254,7 +254,7 @@ fn nested_struct_round_trip_via_engine_aligned() -> Result<(), AlkTypeError> { } } }); - let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned)?; + let engine = AlkTypeEngine::compile(&doc, "S", LayoutMode::Aligned, None)?; let offset_map = engine.offset_map().expect("aligned mode"); assert_eq!(offset_map.get("header.version").unwrap().start, 0);