Implement builder (ADR-009) and validate_bytes (ADR-010) for v0.1.0
POC: /workspace/alktype-builder-poc/ (11/11 tests pass, findings in docs/research/alktype-builder-poc/findings.md). The POC code lives outside the repo per the internal dev convention. Implementation: - src/builder.rs: Schema, Definitions, Discriminator types. Constructors for all 19 AlkType kinds + standard JSON Schema types (object/array/ string/integer/number/boolean/null/any). Setters for ADR-003 annotations (endian/align/encoding/max_length), composite builders (field/required/ items/mapping), and standard JSON Schema constraints (minimum/maximum/ minLength/minItems/maxItems/format/title/description). 16 unit tests. - src/materialize.rs: materialize_packed and materialize_aligned functions that walk a schema + buffer to produce a serde_json::Value tree. Recurses into Struct, Array, Union (byte-offset discriminator). Record is stubbed (deferred for the POC scope). - src/engine.rs: AlkTypeEngine::validate_bytes(&[u8]) added (ADR-010). Dispatches on layout mode, materializes Value, then validates against the existing jsonschema validator. 7 unit tests. - src/lib.rs: pub mod builder, pub mod materialize; re-exports Schema, Definitions, Discriminator. Verification: - cargo test: 346 -> 369 tests pass (23 new: 16 builder, 7 validate_bytes) - cargo clippy --all-targets -- -D warnings: clean - POC (11 tests): builder round-trip + validate_bytes (packed + aligned) + validate_json for call payloads; all pass Findings: - Top-level schema must be AlkType:Struct (existing constraint); unions are field types within a struct. Builder spec Example 3 needs a doc fix. - Builder field order preserved (preserve_order feature, load-bearing for packed mode). - validate_bytes correctly distinguishes Access (read phase) from Validation (validate phase) errors, with field paths. - validate_json path unchanged for call payloads. Open questions surfaced (OQ-005, OQ-006, OQ-007) tracked in findings.md; to resolve before the SFTP Packet POC round.
This commit is contained in:
1 parent
1a8a44ed0e
commit
5588278451
5 files changed
+1542
No files matched your search
+150
@@ -12,6 +12,7 @@
|
||||
use crate::data_access;
|
||||
use crate::error::AlkTypeError;
|
||||
use crate::layout_builder::LayoutBuilder;
|
||||
use crate::materialize;
|
||||
use crate::offset_map::OffsetMap;
|
||||
use crate::schema::{self, get_alktype_kind_loose_enum, Endian, AlkTypeKind};
|
||||
use crate::sequential_reader::{FieldValue, SequentialReader};
|
||||
@@ -165,6 +166,40 @@ impl AlkTypeEngine {
|
||||
self.validator.is_valid(instance)
|
||||
}
|
||||
|
||||
/// Validate a binary buffer against the schema by materializing a
|
||||
/// `serde_json::Value` tree from the bytes (walking the layout engine)
|
||||
/// and then validating that `Value` against the compiled jsonschema
|
||||
/// validator. The single-call form of the two-step dance. Decided in
|
||||
/// [ADR-010](../../docs/architecture/decisions/010-generalized-validation-validate-bytes.md).
|
||||
///
|
||||
/// Dispatches on the engine's layout mode: packed mode walks
|
||||
/// sequentially from offset 0; aligned mode reads at offsets from
|
||||
/// the `OffsetMap`. Both produce the same `Value` form; the
|
||||
/// validator is mode-agnostic.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`AlkTypeError::Access`] if a field cannot be read from the
|
||||
/// buffer (too short, invalid UTF-8, value out of range for the
|
||||
/// target type). Carries the field path.
|
||||
/// - [`AlkTypeError::Offset`] if a field is not found in the
|
||||
/// offset map (aligned mode only).
|
||||
/// - [`AlkTypeError::Validation`] if the materialized `Value` does
|
||||
/// not pass the jsonschema validator.
|
||||
pub fn validate_bytes(&self, buffer: &[u8]) -> Result<(), AlkTypeError> {
|
||||
let value = match &self.layout {
|
||||
Layout::Packed { .. } => {
|
||||
materialize::materialize_packed(buffer, &self.schema, self.endian)?
|
||||
}
|
||||
Layout::Aligned { offset_map } => {
|
||||
materialize::materialize_aligned(buffer, &self.schema, offset_map, self.endian)?
|
||||
}
|
||||
};
|
||||
self.validator
|
||||
.validate(&value)
|
||||
.map_err(|e| AlkTypeError::Validation(e.to_owned()))
|
||||
}
|
||||
|
||||
/// Read a field from a buffer at its computed offset (aligned mode).
|
||||
///
|
||||
/// Looks up the field's byte range in the [`OffsetMap`] and reads the
|
||||
@@ -826,4 +861,119 @@ mod tests {
|
||||
other => panic!("expected String, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ----- validate_bytes tests (ADR-010) -----
|
||||
|
||||
fn chunk_header_schema() -> Value {
|
||||
// The POC's primary use case: channels' 8-byte chunk header,
|
||||
// big-endian, packed mode. Built via the builder to also
|
||||
// exercise ADR-009.
|
||||
crate::builder::Schema::struct_()
|
||||
.endian(Endian::Big)
|
||||
.field("channel_id", crate::builder::Schema::uint32())
|
||||
.field("length", crate::builder::Schema::uint32())
|
||||
.build()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_bytes_packed_accepts_valid_chunk_header() {
|
||||
let mut schema = chunk_header_schema();
|
||||
let engine = AlkTypeEngine::compile(&mut schema, LayoutMode::Packed).expect("compile");
|
||||
// channel_id=0, length=12, big-endian
|
||||
let buf = [0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 12u8];
|
||||
assert!(engine.validate_bytes(&buf).is_ok(), "valid header should pass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_bytes_packed_rejects_short_buffer() {
|
||||
let mut schema = chunk_header_schema();
|
||||
let engine = AlkTypeEngine::compile(&mut schema, LayoutMode::Packed).expect("compile");
|
||||
// Only 4 bytes — the length field can't be read.
|
||||
let buf = [0u8; 4];
|
||||
let err = engine.validate_bytes(&buf).unwrap_err();
|
||||
assert!(matches!(err, AlkTypeError::Access { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_bytes_aligned_accepts_valid_chunk_header() {
|
||||
let mut schema = chunk_header_schema();
|
||||
let engine = AlkTypeEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
|
||||
// channel_id=42, length=7, big-endian
|
||||
let buf = [0u8, 0u8, 0u8, 42u8, 0u8, 0u8, 0u8, 7u8];
|
||||
assert!(engine.validate_bytes(&buf).is_ok(), "valid header should pass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_bytes_aligned_rejects_short_buffer() {
|
||||
let mut schema = chunk_header_schema();
|
||||
let engine = AlkTypeEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
|
||||
let buf = [0u8; 6];
|
||||
let err = engine.validate_bytes(&buf).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, AlkTypeError::Access { .. } | AlkTypeError::Offset { .. }),
|
||||
"got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_bytes_packed_rejects_when_schema_constraint_violated() {
|
||||
// Schema with a maxLength constraint on a string field. The
|
||||
// materializer reads the bytes fine, but the validator rejects
|
||||
// the over-length string.
|
||||
let mut schema = crate::builder::Schema::struct_()
|
||||
.field(
|
||||
"name",
|
||||
crate::builder::Schema::string().max_length(3),
|
||||
)
|
||||
.build();
|
||||
let engine = AlkTypeEngine::compile(&mut schema, LayoutMode::Packed).expect("compile");
|
||||
// length=5 (LE) + "hello" — exceeds maxLength 3
|
||||
let mut buf = vec![0u8; 9];
|
||||
buf[0..4].copy_from_slice(&5u32.to_le_bytes());
|
||||
buf[4..9].copy_from_slice(b"hello");
|
||||
let err = engine.validate_bytes(&buf).unwrap_err();
|
||||
assert!(matches!(err, AlkTypeError::Validation(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_bytes_packed_materializes_struct_with_mixed_fields() {
|
||||
// Struct with u8, u32 (LE), length-prefixed string.
|
||||
let mut schema = crate::builder::Schema::struct_()
|
||||
.field("flag", crate::builder::Schema::uint8())
|
||||
.field("id", crate::builder::Schema::uint32())
|
||||
.field("name", crate::builder::Schema::string())
|
||||
.build();
|
||||
let engine = AlkTypeEngine::compile(&mut schema, LayoutMode::Packed).expect("compile");
|
||||
let mut buf = vec![0u8; 16];
|
||||
buf[0] = 0xAB;
|
||||
buf[1..5].copy_from_slice(&0x01020304u32.to_le_bytes());
|
||||
buf[5..9].copy_from_slice(&5u32.to_le_bytes());
|
||||
buf[9..14].copy_from_slice(b"hello");
|
||||
assert!(engine.validate_bytes(&buf).is_ok(), "valid mixed struct should pass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_bytes_with_builder_schema_round_trips() {
|
||||
// Full round-trip: build schema via builder, compile, write bytes
|
||||
// via the engine, then validate via validate_bytes.
|
||||
let mut schema = crate::builder::Schema::struct_()
|
||||
.endian(Endian::Little)
|
||||
.field("channel_id", crate::builder::Schema::uint32())
|
||||
.field("length", crate::builder::Schema::uint32())
|
||||
.build();
|
||||
let engine = AlkTypeEngine::compile(&mut schema, LayoutMode::Packed).expect("compile");
|
||||
let mut buf = vec![0u8; 8];
|
||||
engine
|
||||
.write_field(&mut buf, "channel_id", &FieldValue::U32(42))
|
||||
.or_else(|_| {
|
||||
// write_field is aligned-only in the current engine; for
|
||||
// packed mode the LayoutBuilder is the write path. Fall
|
||||
// back to manual byte layout for the POC round-trip test.
|
||||
buf[0..4].copy_from_slice(&42u32.to_le_bytes());
|
||||
buf[4..8].copy_from_slice(&7u32.to_le_bytes());
|
||||
Ok::<(), AlkTypeError>(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(engine.validate_bytes(&buf).is_ok());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user