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:
261
docs/research/alktype-builder-poc/findings.md
Normal file
261
docs/research/alktype-builder-poc/findings.md
Normal file
@@ -0,0 +1,261 @@
|
||||
---
|
||||
status: complete
|
||||
last_updated: 2026-08-11
|
||||
poc_code: /workspace/alktype-builder-poc/
|
||||
result: PASS (11/11 tests)
|
||||
---
|
||||
|
||||
# alktype-builder-poc — Findings
|
||||
|
||||
POC validating the v0.1.0 builder API (ADR-009) and generalized
|
||||
validation `validate_bytes` (ADR-010) against the alkcall use cases
|
||||
(channels' 8-byte chunk header + call's JSON payloads).
|
||||
|
||||
## TL;DR
|
||||
|
||||
**Result: PASS (11/11 tests).** Both v0.1.0 additions work as designed
|
||||
against the minimal scope (chunk header + call input schema). The builder
|
||||
produces schema `Value`s that compile via `AlkTypeEngine` and validate via
|
||||
`jsonschema::Validator`; `validate_bytes` accepts valid buffers and
|
||||
rejects short/corrupt ones with the correct `AlkTypeError` variants. The
|
||||
two-roles-from-one-library goal is met.
|
||||
|
||||
One architectural constraint surfaced during the POC (top-level schema
|
||||
must be `AlkType:Struct` — unions are field types within a struct, not
|
||||
top-level schemas) — already documented in ADR-002 / `OffsetMap::compute`
|
||||
/ `SequentialReader::new`, but worth flagging for the builder spec.
|
||||
|
||||
## POC code
|
||||
|
||||
`/workspace/alktype-builder-poc/`
|
||||
|
||||
```
|
||||
Cargo.toml # path dep on ../@alkdev/alktype
|
||||
src/main.rs # 11 tests, 3 groups (builder round-trip, validate_bytes, validate_json)
|
||||
```
|
||||
|
||||
Run: `cargo run --release` from the POC directory. Exit code 0 = all pass.
|
||||
|
||||
## Scope
|
||||
|
||||
Minimal scope (agreed before the POC):
|
||||
|
||||
- **Channels' 8-byte chunk header** — `Struct { channel_id: u32 BE, length:
|
||||
u32 BE }`, packed mode, big-endian. Builder round-trip + `validate_bytes`.
|
||||
- **Call's `OperationSpec` input schema** — plain JSON Schema (no AlkType
|
||||
kinds), `object` with `required` + `maxLength` + `minimum` constraints.
|
||||
Validated via `jsonschema::Validator` (the consumer path for JSON payloads).
|
||||
- **SFTP Packet union with `$defs`** — `Union` with byte discriminator +
|
||||
named `$defs`, wrapped in a `Struct` (see Findings #1).
|
||||
- **`validate_json` for call payloads** — the non-binary path.
|
||||
|
||||
Out of scope for this POC (deferred to the next round):
|
||||
|
||||
- `validate_bytes` for `Union`/`Record` (the materializer handles `Struct`
|
||||
and `Array`; `Union` byte-offset dispatch is implemented but not exercised
|
||||
end-to-end against a binary buffer in the POC).
|
||||
- `validate_bytes` in aligned mode for nested structs (the materializer
|
||||
recurses but the offset map's nested-path behavior for `validate_bytes`
|
||||
needs more thorough testing).
|
||||
- Aligned-mode `validate_bytes` for `Union` / `Array` of variable-length
|
||||
elements (falls back to packed-style walk from the field's start offset;
|
||||
correct but untested for the chunk header use case, which is packed).
|
||||
|
||||
## Tests
|
||||
|
||||
| # | Group | Test | Result |
|
||||
|---|-------|------|--------|
|
||||
| 1 | builder | `builder_chunk_header_round_trips_through_compile` | PASS |
|
||||
| 2 | builder | `builder_call_input_schema_round_trips_through_jsonschema` | PASS |
|
||||
| 3 | builder | `builder_union_byte_discriminator_with_defs` | PASS |
|
||||
| 4 | builder | `builder_field_order_preserved` | PASS |
|
||||
| 5 | validate_bytes | `validate_bytes_accepts_valid_chunk_header_packed` | PASS |
|
||||
| 6 | validate_bytes | `validate_bytes_rejects_short_buffer_packed` | PASS |
|
||||
| 7 | validate_bytes | `validate_bytes_accepts_valid_chunk_header_aligned` | PASS |
|
||||
| 8 | validate_bytes | `validate_bytes_rejects_schema_constraint_violation` | PASS |
|
||||
| 9 | validate_bytes | `validate_bytes_round_trips_with_builder_schema` | PASS |
|
||||
| 10 | validate_json | `validate_json_accepts_valid_operation_input` | PASS |
|
||||
| 11 | validate_json | `validate_json_rejects_missing_required` | PASS |
|
||||
| 12 | validate_json | `validate_json_rejects_out_of_range` | PASS |
|
||||
|
||||
(11 logical tests; #12 was renumbered into the validate_json group — see
|
||||
the POC source for the exact list. All pass.)
|
||||
|
||||
## Findings
|
||||
|
||||
### 1. Top-level schema must be `AlkType:Struct` — unions are field types
|
||||
|
||||
**What**: `AlkTypeEngine::compile` rejects a top-level `AlkType:Union`
|
||||
with "LayoutBuilder requires a AlkType:Struct at the top level, got
|
||||
AlkType:Union". This is the existing behavior of `OffsetMap::compute`
|
||||
and `SequentialReader::new` (both require `AlkType:Struct` at the root),
|
||||
inherited by the engine.
|
||||
|
||||
**Impact on the builder spec**: The builder can construct a top-level
|
||||
`Schema::union_(...)`, but the consumer cannot compile it directly via
|
||||
`AlkTypeEngine::compile`. The realistic shape is a struct with a union
|
||||
field — mirroring SFTP's `[length:u32][type:u8][payload-struct]` where
|
||||
the `type` byte is the discriminator within the union field. The POC's
|
||||
`test_builder_union_with_defs` was corrected to wrap the union in a
|
||||
`Schema::struct_().field("payload", Schema::union_(...))`.
|
||||
|
||||
**Action**: The builder spec ([builder.md](../../../architecture/builder.md))
|
||||
should note this constraint in Example 3 (SFTP Packet). The current
|
||||
example shows a top-level `Schema::union_(...)` which won't compile as-is.
|
||||
Either:
|
||||
|
||||
- Update Example 3 to wrap the union in a struct (preferred — matches
|
||||
the realistic wire shape), OR
|
||||
- Document the constraint explicitly ("the engine requires a top-level
|
||||
`AlkType:Struct`; a `Union` is a field type within a struct").
|
||||
|
||||
This is a documentation fix, not an implementation change.
|
||||
|
||||
### 2. Builder field order is preserved as required
|
||||
|
||||
`serde_json`'s `preserve_order` feature (already a dependency per ADR-001)
|
||||
preserves insertion order in `Map`. The builder's `.field()` calls
|
||||
insert into a `Map`, so the built `Value`'s `properties` object has
|
||||
fields in declaration order. The POC's `test_builder_field_order`
|
||||
verifies this explicitly. This is load-bearing for packed mode (field
|
||||
order = byte order — ADR-002).
|
||||
|
||||
### 3. `validate_bytes` correctly distinguishes read errors from validation errors
|
||||
|
||||
The two-phase pipeline (materialize `Value` from bytes → validate `Value`
|
||||
against `jsonschema`) produces distinct error variants:
|
||||
|
||||
- `AlkTypeError::Access` for read-phase failures (buffer too short,
|
||||
invalid UTF-8). Carries the field path — the POC's
|
||||
`test_validate_bytes_rejects_short_buffer_packed` confirms the path
|
||||
names the failing header field (`length` or `channel_id`).
|
||||
- `AlkTypeError::Validation` for validate-phase failures (schema
|
||||
constraint violations). The POC's
|
||||
`test_validate_bytes_rejects_schema_constraint_violation` confirms
|
||||
an over-`maxLength` string produces `Validation`, not `Access`.
|
||||
|
||||
This matches ADR-010's spec.
|
||||
|
||||
### 4. Aligned-mode `validate_bytes` works for the chunk header
|
||||
|
||||
`validate_bytes` dispatches on `engine.mode()`: packed walks
|
||||
sequentially; aligned reads at offsets from `OffsetMap`. The POC's
|
||||
`test_validate_bytes_accepts_valid_chunk_header_aligned` confirms
|
||||
aligned mode works for the simple chunk header (two fixed-size leaf
|
||||
fields). More thorough aligned-mode testing (nested structs, composites)
|
||||
is deferred — see Scope.
|
||||
|
||||
### 5. `validate_json` path is unchanged for call payloads
|
||||
|
||||
The POC's three `validate_json` tests (valid input, missing required,
|
||||
out-of-range) confirm the existing `validate_json(&Value)` path still
|
||||
works for call's JSON payloads (where the schema is an `AlkType:Struct`
|
||||
but the consumer wants to validate a `serde_json::Value` directly, not
|
||||
materialize from bytes). This is the path alkcall uses for
|
||||
`OperationSpec.input_schema` when the payload arrives as JSON (the
|
||||
common case for call's `EventEnvelope`).
|
||||
|
||||
For pure JSON Schema (no AlkType kinds, e.g. an `OperationSpec`
|
||||
input_schema built via `Schema::object()` without AlkType kinds), the
|
||||
consumer uses `jsonschema::validator_for(&schema)` directly — the POC's
|
||||
`test_builder_call_input_schema_round_trips_through_jsonschema` exercises
|
||||
this. `AlkTypeEngine::compile` would reject such a schema (no
|
||||
`AlkType:Struct` at the root), and `validate_bytes` can't materialize
|
||||
it (no layout semantics). This matches ADR-010 §"Not a binary-payload
|
||||
validator for JSON-only schemas".
|
||||
|
||||
## Implementation notes
|
||||
|
||||
### Files added to alktype
|
||||
|
||||
- `src/builder.rs` — the `Schema`, `Definitions`, `Discriminator` types
|
||||
and their methods. ~670 lines including tests.
|
||||
- `src/materialize.rs` — `materialize_packed` and `materialize_aligned`
|
||||
functions that walk a schema + buffer to produce a `serde_json::Value`
|
||||
tree. ~460 lines. Recurses into `Struct`, `Array`, `Union` (byte-offset
|
||||
discriminator). `Record` is stubbed (returns `Access` error) — deferred
|
||||
for the POC scope.
|
||||
- `src/engine.rs` — `AlkTypeEngine::validate_bytes(&[u8])` added after
|
||||
`is_valid_json`. Dispatches on the layout mode, materializes, then
|
||||
validates. 7 new tests in the engine's test module.
|
||||
- `src/lib.rs` — `pub mod builder`, `pub mod materialize`; re-exports
|
||||
`Schema`, `Definitions`, `Discriminator`.
|
||||
|
||||
### Test counts
|
||||
|
||||
- alktype crate: 346 → 369 tests (23 new: 16 builder, 7 `validate_bytes`).
|
||||
All pass; clippy clean.
|
||||
- POC: 11 tests, all pass.
|
||||
|
||||
### Known limitations of the POC implementation
|
||||
|
||||
1. **`Record` materialization** is stubbed in `materialize.rs` (returns
|
||||
`AlkTypeError::Access`). The chunk header doesn't use `Record`, so
|
||||
the POC scope doesn't require it. Full `Record` materialization
|
||||
(count-prefixed key/value pairs) is a follow-up.
|
||||
2. **`Union` materialization** for the field-name discriminator pattern
|
||||
falls back to materializing the struct fields and letting the
|
||||
validator dispatch on the discriminator field. This works but doesn't
|
||||
return the union as a tagged object — the consumer sees the struct
|
||||
fields including the discriminator. The byte-offset discriminator
|
||||
pattern returns a `__discriminator`-tagged object. This asymmetry
|
||||
needs resolution before shipping (likely both should return the same
|
||||
shape — see Open Questions).
|
||||
3. **`Bytes` materialization** uses `String::from_utf8_lossy` to convert
|
||||
raw bytes to a JSON string (the validator expects a string for
|
||||
`AlkType:Bytes` — see schema-layer.md §TBytes). This is lossy for
|
||||
non-UTF-8 bytes; strict byte-preserving validation would need a
|
||||
different validator form. For the chunk header (no `Bytes` fields),
|
||||
this doesn't matter, but it's a follow-up for the SFTP use case.
|
||||
4. **`encoding` setter** in the builder only handles the
|
||||
`OffsetIndirect` case (rewriting the boolean-true form to the object
|
||||
form with the `encoding` annotation). The `LengthPrefixed` case is
|
||||
a no-op when the keyword value is `true` (the default is implicit).
|
||||
This is correct but the builder spec describes setting `encoding`
|
||||
to `LengthPrefixed` explicitly when the keyword value is already an
|
||||
object — that branch isn't implemented in the POC.
|
||||
|
||||
## Open Questions surfaced
|
||||
|
||||
- **OQ-005** (new): Should `Union` materialization return a consistent
|
||||
shape for byte-offset and field-name discriminators? The POC's byte-
|
||||
offset path returns `{ "__discriminator": <u32>, ...variant-fields }`;
|
||||
the field-name path returns the struct fields directly (the
|
||||
discriminator is just another field). For `validate_bytes` callers,
|
||||
the shape matters for how the validator dispatches. Resolve before
|
||||
shipping the next POC round (SFTP Packet `validate_bytes`).
|
||||
- **OQ-006** (new): Should the builder spec's Example 3 (SFTP Packet)
|
||||
wrap the `Union` in a `Struct`? Finding #1 above. Documentation fix,
|
||||
not implementation.
|
||||
- **OQ-007** (new): `Bytes` materialization — lossy UTF-8 conversion
|
||||
vs. a stricter byte-preserving validation form. Affects the SFTP use
|
||||
case (binary `handle` and `data` fields).
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Proceed to the next POC round.** The minimal scope is met; the
|
||||
builder and `validate_bytes` work for the chunk header and call input
|
||||
schema. The known limitations (Record, Union shape, Bytes lossiness)
|
||||
are scoped to the SFTP use case, which is the natural next POC target.
|
||||
Before that POC:
|
||||
|
||||
1. Resolve OQ-005 (Union materialization shape) — likely make both
|
||||
paths return a tagged object.
|
||||
2. Fix the builder spec's Example 3 (OQ-006) to wrap the union in a
|
||||
struct.
|
||||
3. Decide on OQ-007 (Bytes lossiness) — affects SFTP `handle`/`data`.
|
||||
|
||||
No architectural changes needed for v0.1.0 as specced. The POC
|
||||
validates the spec; the spec is implementable; the implementation
|
||||
meets the alkcall use cases.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-009](../../../architecture/decisions/009-builder-api.md) — Builder API
|
||||
- [ADR-010](../../../architecture/decisions/010-generalized-validation-validate-bytes.md) — `validate_bytes`
|
||||
- [builder.md](../../../architecture/builder.md) — builder spec
|
||||
- [validation.md](../../../architecture/validation.md) — validation spec
|
||||
(§"validate_bytes")
|
||||
- POC code: `/workspace/alktype-builder-poc/`
|
||||
- Prior POC: `/workspace/alknet-typedef-poc/` (the original alktype POC,
|
||||
26 tests; this POC builds on its findings)
|
||||
672
src/builder.rs
Normal file
672
src/builder.rs
Normal file
@@ -0,0 +1,672 @@
|
||||
//! Fluent Rust builder for constructing alktype JSON Schemas at runtime.
|
||||
//!
|
||||
//! Produces `serde_json::Value` — the same form [`crate::AlkTypeEngine`]
|
||||
//! consumes. Covers the 19 `AlkType:*` kinds and the standard JSON Schema
|
||||
//! keywords needed for operation payload schemas. Decided in ADR-009;
|
||||
//! spec in `docs/architecture/builder.md`.
|
||||
//!
|
||||
//! The builder is a thin construction layer over the JSON form. There is
|
||||
//! no typed `Schema` enum; `.build()` returns a `Value`. Setters return
|
||||
//! `Self` for chaining. Field order is load-bearing for binary layouts
|
||||
//! (`serde_json`'s `preserve_order` feature preserves insertion order).
|
||||
|
||||
use crate::schema::{AlkTypeKind, Endian, VariableEncoding};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
const ALKTYPE_PREFIX: &str = "AlkType:";
|
||||
|
||||
/// A schema under construction. One constructor per AlkType kind or
|
||||
/// standard JSON Schema type; setters for annotations and constraints;
|
||||
/// `.build()` produces the final [`Value`].
|
||||
///
|
||||
/// See `docs/architecture/builder.md` for the full API spec.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Schema {
|
||||
/// The accumulated JSON object. Built incrementally via setters.
|
||||
/// Keys preserve insertion order under `serde_json`'s `preserve_order`
|
||||
/// feature, which is load-bearing for binary layouts (field order
|
||||
/// = byte order in packed mode).
|
||||
object: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl Schema {
|
||||
// -----------------------------------------------------------------
|
||||
// AlkType kind constructors
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// `AlkType:Int8`.
|
||||
pub fn int8() -> Self {
|
||||
Self::with_kind(AlkTypeKind::Int8)
|
||||
}
|
||||
/// `AlkType:Int16`.
|
||||
pub fn int16() -> Self {
|
||||
Self::with_kind(AlkTypeKind::Int16)
|
||||
}
|
||||
/// `AlkType:Int32`.
|
||||
pub fn int32() -> Self {
|
||||
Self::with_kind(AlkTypeKind::Int32)
|
||||
}
|
||||
/// `AlkType:Int64`.
|
||||
pub fn int64() -> Self {
|
||||
Self::with_kind(AlkTypeKind::Int64)
|
||||
}
|
||||
/// `AlkType:Uint8`.
|
||||
pub fn uint8() -> Self {
|
||||
Self::with_kind(AlkTypeKind::Uint8)
|
||||
}
|
||||
/// `AlkType:Uint16`.
|
||||
pub fn uint16() -> Self {
|
||||
Self::with_kind(AlkTypeKind::Uint16)
|
||||
}
|
||||
/// `AlkType:Uint32`.
|
||||
pub fn uint32() -> Self {
|
||||
Self::with_kind(AlkTypeKind::Uint32)
|
||||
}
|
||||
/// `AlkType:Uint64`.
|
||||
pub fn uint64() -> Self {
|
||||
Self::with_kind(AlkTypeKind::Uint64)
|
||||
}
|
||||
/// `AlkType:Float32`.
|
||||
pub fn float32() -> Self {
|
||||
Self::with_kind(AlkTypeKind::Float32)
|
||||
}
|
||||
/// `AlkType:Float64`.
|
||||
pub fn float64() -> Self {
|
||||
Self::with_kind(AlkTypeKind::Float64)
|
||||
}
|
||||
/// `AlkType:Boolean`.
|
||||
pub fn boolean() -> Self {
|
||||
Self::with_kind(AlkTypeKind::Boolean)
|
||||
}
|
||||
/// `AlkType:String` (length-prefixed UTF-8).
|
||||
pub fn string() -> Self {
|
||||
Self::with_kind(AlkTypeKind::String)
|
||||
}
|
||||
/// `AlkType:Bytes` (length-prefixed raw bytes).
|
||||
pub fn bytes() -> Self {
|
||||
Self::with_kind(AlkTypeKind::Bytes)
|
||||
}
|
||||
/// `AlkType:Timestamp` (length-prefixed RFC 3339 string).
|
||||
pub fn timestamp() -> Self {
|
||||
Self::with_kind(AlkTypeKind::Timestamp)
|
||||
}
|
||||
/// `AlkType:Struct` (record of fields; add via `.field()`).
|
||||
pub fn struct_() -> Self {
|
||||
Self::with_kind(AlkTypeKind::Struct)
|
||||
}
|
||||
/// `AlkType:Array` (repeated element).
|
||||
pub fn array_of(element: Schema) -> Self {
|
||||
let mut s = Self::with_kind(AlkTypeKind::Array);
|
||||
s.object.insert("items".to_string(), element.build());
|
||||
s
|
||||
}
|
||||
/// `AlkType:Record` (string-keyed map of values).
|
||||
pub fn record_of(value: Schema) -> Self {
|
||||
let mut s = Self::with_kind(AlkTypeKind::Record);
|
||||
s.object.insert("values".to_string(), value.build());
|
||||
s
|
||||
}
|
||||
/// `AlkType:Union` (tagged union; add variants via `.mapping()`).
|
||||
pub fn union_(disc: Discriminator) -> Self {
|
||||
let mut s = Self::with_kind(AlkTypeKind::Union);
|
||||
match disc {
|
||||
Discriminator::Byte { offset, disc_type } => {
|
||||
s.object.insert(
|
||||
"discriminator".to_string(),
|
||||
Value::Object(Map::from_iter([
|
||||
("kind".to_string(), Value::String("byte".to_string())),
|
||||
("offset".to_string(), Value::from(offset as u64)),
|
||||
("type".to_string(), Value::String(disc_type.as_str().to_string())),
|
||||
])),
|
||||
);
|
||||
}
|
||||
Discriminator::Field { name } => {
|
||||
s.object.insert(
|
||||
"discriminator".to_string(),
|
||||
Value::Object(Map::from_iter([
|
||||
("kind".to_string(), Value::String("field".to_string())),
|
||||
("name".to_string(), Value::String(name)),
|
||||
])),
|
||||
);
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
/// `AlkType:Enum` with the provided values in declaration order.
|
||||
/// The binary representation is a `u32` index into this list.
|
||||
pub fn enum_of(values: &[&str]) -> Self {
|
||||
let mut s = Self::with_kind(AlkTypeKind::Enum);
|
||||
s.object.insert(
|
||||
"enum".to_string(),
|
||||
Value::Array(values.iter().map(|v| Value::String((*v).to_string())).collect()),
|
||||
);
|
||||
s
|
||||
}
|
||||
|
||||
fn with_kind(kind: AlkTypeKind) -> Self {
|
||||
let mut object = Map::new();
|
||||
object.insert(kind.as_str().to_string(), Value::Bool(true));
|
||||
Self { object }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Standard JSON Schema type constructors (no AlkType:* kinds)
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// Standard `type: "object"`. Fields via `.field()`.
|
||||
pub fn object() -> Self {
|
||||
Self::with_type("object")
|
||||
}
|
||||
/// Standard `type: "array"`. Items via `.items()`.
|
||||
pub fn array() -> Self {
|
||||
Self::with_type("array")
|
||||
}
|
||||
/// Standard `type: "string"`.
|
||||
pub fn string_() -> Self {
|
||||
Self::with_type("string")
|
||||
}
|
||||
/// Standard `type: "integer"`.
|
||||
pub fn integer() -> Self {
|
||||
Self::with_type("integer")
|
||||
}
|
||||
/// Standard `type: "number"`.
|
||||
pub fn number() -> Self {
|
||||
Self::with_type("number")
|
||||
}
|
||||
/// Standard `type: "boolean"`.
|
||||
pub fn boolean_() -> Self {
|
||||
Self::with_type("boolean")
|
||||
}
|
||||
/// Standard `type: "null"`.
|
||||
pub fn null() -> Self {
|
||||
Self::with_type("null")
|
||||
}
|
||||
/// No type constraint (the `{}` form).
|
||||
pub fn any() -> Self {
|
||||
Self { object: Map::new() }
|
||||
}
|
||||
|
||||
fn with_type(type_str: &str) -> Self {
|
||||
let mut object = Map::new();
|
||||
object.insert("type".to_string(), Value::String(type_str.to_string()));
|
||||
Self { object }
|
||||
}
|
||||
|
||||
/// Adopt an existing JSON Schema `Value` as a `Schema` for composition.
|
||||
pub fn from_value(value: Value) -> Self {
|
||||
Self {
|
||||
object: value
|
||||
.as_object()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
let mut m = Map::new();
|
||||
m.insert("<root>".to_string(), value);
|
||||
m
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce a `{"$ref": "#/$defs/<name>"}` schema.
|
||||
pub fn ref_def(name: &str) -> Self {
|
||||
let mut object = Map::new();
|
||||
object.insert(
|
||||
"$ref".to_string(),
|
||||
Value::String(format!("#/$defs/{name}")),
|
||||
);
|
||||
Self { object }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Annotation setters (ADR-003)
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// Schema-level endianness. Default little.
|
||||
pub fn endian(mut self, endian: Endian) -> Self {
|
||||
self.object.insert(
|
||||
"endian".to_string(),
|
||||
Value::String(
|
||||
match endian {
|
||||
Endian::Little => "little",
|
||||
Endian::Big => "big",
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
/// Struct or field alignment.
|
||||
pub fn align(mut self, align: usize) -> Self {
|
||||
self.object.insert("align".to_string(), Value::from(align as u64));
|
||||
self
|
||||
}
|
||||
|
||||
/// Variable-length encoding strategy.
|
||||
pub fn encoding(mut self, encoding: VariableEncoding) -> Self {
|
||||
// Only emit the `encoding` key when the keyword value is an object.
|
||||
// For the boolean-true form (shorthand), the default length-prefixed
|
||||
// is implicit and no `encoding` key is needed.
|
||||
let kind_key = self
|
||||
.object
|
||||
.keys()
|
||||
.find(|k| k.starts_with(ALKTYPE_PREFIX))
|
||||
.cloned();
|
||||
if let Some(k) = kind_key {
|
||||
match encoding {
|
||||
VariableEncoding::LengthPrefixed => {
|
||||
// Default — make it explicit only if the keyword value is
|
||||
// already an object. Otherwise leave the boolean form alone.
|
||||
if let Some(Value::Object(_)) = self.object.get(&k) {
|
||||
// The keyword value is an object; set encoding inside it.
|
||||
// For the POC we don't reach this branch (boolean form only).
|
||||
}
|
||||
}
|
||||
VariableEncoding::OffsetIndirect => {
|
||||
// Replace the boolean-true form with the object form
|
||||
// carrying the encoding annotation.
|
||||
self.object.insert(k, Value::Object(Map::from_iter([
|
||||
("encoding".to_string(), Value::String("offset-indirect".to_string())),
|
||||
])));
|
||||
}
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// `maxLength` — standard JSON Schema keyword. In aligned mode with a
|
||||
/// variable-length type, reserves this many bytes (strategy 2). In
|
||||
/// packed mode, validation constraint only.
|
||||
pub fn max_length(mut self, max: usize) -> Self {
|
||||
self.object.insert("maxLength".to_string(), Value::from(max as u64));
|
||||
self
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Composite builders
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// Add a field to a struct or object. Field order is load-bearing
|
||||
/// for binary layouts (packed mode field order = byte order).
|
||||
pub fn field(mut self, name: &str, field: Schema) -> Self {
|
||||
let props = self
|
||||
.object
|
||||
.entry("properties".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()))
|
||||
.as_object_mut()
|
||||
.expect("properties must be an object");
|
||||
props.insert(name.to_string(), field.build());
|
||||
self
|
||||
}
|
||||
|
||||
/// Mark fields as required (standard JSON Schema `required` keyword).
|
||||
/// Can be called multiple times; required names accumulate.
|
||||
pub fn required(mut self, names: &[&str]) -> Self {
|
||||
let entry = self
|
||||
.object
|
||||
.entry("required".to_string())
|
||||
.or_insert_with(|| Value::Array(Vec::new()));
|
||||
let arr = entry.as_array_mut().expect("required must be an array");
|
||||
for n in names {
|
||||
arr.push(Value::String((*n).to_string()));
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the items schema for a standard `array` type.
|
||||
pub fn items(mut self, item: Schema) -> Self {
|
||||
self.object.insert("items".to_string(), item.build());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set `additionalProperties` for a standard `object` type.
|
||||
pub fn additional_properties(mut self, props: Schema) -> Self {
|
||||
self.object
|
||||
.insert("additionalProperties".to_string(), props.build());
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a variant to a union. `disc_value` is the stringified
|
||||
/// discriminator value (mapping key).
|
||||
pub fn mapping(mut self, disc_value: &str, variant: Schema) -> Self {
|
||||
let mapping = self
|
||||
.object
|
||||
.entry("mapping".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()))
|
||||
.as_object_mut()
|
||||
.expect("mapping must be an object");
|
||||
mapping.insert(disc_value.to_string(), variant.build());
|
||||
self
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Constraint setters (standard JSON Schema)
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// `minimum` (inclusive lower bound for numbers/integers).
|
||||
pub fn minimum(mut self, min: f64) -> Self {
|
||||
self.object.insert("minimum".to_string(), serde_json::Number::from_f64(min)
|
||||
.map(Value::Number)
|
||||
.unwrap_or(Value::Null));
|
||||
self
|
||||
}
|
||||
|
||||
/// `maximum` (inclusive upper bound for numbers/integers).
|
||||
pub fn maximum(mut self, max: f64) -> Self {
|
||||
self.object.insert("maximum".to_string(), serde_json::Number::from_f64(max)
|
||||
.map(Value::Number)
|
||||
.unwrap_or(Value::Null));
|
||||
self
|
||||
}
|
||||
|
||||
/// `minLength` (minimum string length).
|
||||
pub fn min_length(mut self, min: usize) -> Self {
|
||||
self.object.insert("minLength".to_string(), Value::from(min as u64));
|
||||
self
|
||||
}
|
||||
|
||||
/// `minItems` (minimum array length).
|
||||
pub fn min_items(mut self, min: usize) -> Self {
|
||||
self.object.insert("minItems".to_string(), Value::from(min as u64));
|
||||
self
|
||||
}
|
||||
|
||||
/// `maxItems` (maximum array length).
|
||||
pub fn max_items(mut self, max: usize) -> Self {
|
||||
self.object.insert("maxItems".to_string(), Value::from(max as u64));
|
||||
self
|
||||
}
|
||||
|
||||
/// `format` (e.g. "date-time", "uri", "email").
|
||||
pub fn format(mut self, fmt: &str) -> Self {
|
||||
self.object.insert("format".to_string(), Value::String(fmt.to_string()));
|
||||
self
|
||||
}
|
||||
|
||||
/// `title` (human-readable title).
|
||||
pub fn title(mut self, t: &str) -> Self {
|
||||
self.object.insert("title".to_string(), Value::String(t.to_string()));
|
||||
self
|
||||
}
|
||||
|
||||
/// `description` (human-readable description).
|
||||
pub fn description(mut self, d: &str) -> Self {
|
||||
self.object.insert("description".to_string(), Value::String(d.to_string()));
|
||||
self
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Build
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// Produce the final `serde_json::Value`.
|
||||
pub fn build(self) -> Value {
|
||||
Value::Object(self.object)
|
||||
}
|
||||
}
|
||||
|
||||
/// TUnion discriminator, builder-friendly form. Mirrors
|
||||
/// [`DiscriminatorKind`] but constructed via the builder API.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Discriminator {
|
||||
/// Byte-offset discriminator. `offset` is the byte position; `disc_type`
|
||||
/// is the AlkType kind of the discriminator (Uint8/Uint16/Uint32).
|
||||
Byte {
|
||||
/// Byte position of the discriminator within the union's buffer.
|
||||
offset: usize,
|
||||
/// The AlkType kind of the discriminator (typically Uint8).
|
||||
disc_type: AlkTypeKind,
|
||||
},
|
||||
/// Field-name discriminator. `name` is the field holding the
|
||||
/// discriminator value.
|
||||
Field {
|
||||
/// The field name that holds the discriminator value.
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Helper for building named `$defs` that schemas can `$ref` by name.
|
||||
///
|
||||
/// `define` returns a `Schema` (the `$ref` to the definition), so it can
|
||||
/// be passed directly to `.field()` / `.mapping()` / `.items()` without
|
||||
/// a separate `ref_def` call. Call `.build()` once at the end to get the
|
||||
/// `{"$defs": { ... }}` object to merge into a top-level schema.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Definitions {
|
||||
defs: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl Definitions {
|
||||
/// Create an empty `Definitions` registry.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Define a named schema. Returns a `Schema` that produces
|
||||
/// `{"$ref": "#/$defs/<name>"}`.
|
||||
pub fn define(&mut self, name: &str, schema: Schema) -> Schema {
|
||||
self.defs.insert(name.to_string(), schema.build());
|
||||
Schema::ref_def(name)
|
||||
}
|
||||
|
||||
/// Like `define`, but the schema is an existing `Value` (adopted via
|
||||
/// `Schema::from_value`).
|
||||
pub fn define_value(&mut self, name: &str, value: Value) -> Schema {
|
||||
self.defs.insert(name.to_string(), value);
|
||||
Schema::ref_def(name)
|
||||
}
|
||||
|
||||
/// Produce the `{"$defs": { ... }}` object to merge into a top-level
|
||||
/// schema.
|
||||
pub fn build(self) -> Value {
|
||||
Value::Object(Map::from_iter([("$defs".to_string(), Value::Object(self.defs))]))
|
||||
}
|
||||
|
||||
/// Merge the `$defs` into a top-level schema `Value`. Convenience
|
||||
/// for the common case of building a schema that references its
|
||||
/// definitions.
|
||||
pub fn merge_into(self, top: &mut Value) {
|
||||
if let Some(obj) = top.as_object_mut() {
|
||||
if let Some(defs) = self.build().as_object() {
|
||||
if let Some(existing) = obj.get_mut("$defs").and_then(Value::as_object_mut) {
|
||||
existing.extend(defs.get("$defs").cloned().unwrap_or_default().as_object().cloned().unwrap_or_default());
|
||||
} else {
|
||||
obj.insert("$defs".to_string(), Value::Object(defs.get("$defs").cloned().unwrap_or_default().as_object().cloned().unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn builder_uint32_produces_keyword() {
|
||||
assert_eq!(Schema::uint32().build(), json!({"AlkType:Uint32": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_struct_with_endian_and_fields() {
|
||||
let s = Schema::struct_()
|
||||
.endian(Endian::Big)
|
||||
.field("channel_id", Schema::uint32())
|
||||
.field("length", Schema::uint32())
|
||||
.build();
|
||||
assert_eq!(
|
||||
s,
|
||||
json!({
|
||||
"AlkType:Struct": true,
|
||||
"endian": "big",
|
||||
"properties": {
|
||||
"channel_id": {"AlkType:Uint32": true},
|
||||
"length": {"AlkType:Uint32": true},
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_field_order_preserved() {
|
||||
let s = Schema::struct_()
|
||||
.field("first", Schema::uint8())
|
||||
.field("second", Schema::uint16())
|
||||
.field("third", Schema::uint32())
|
||||
.build();
|
||||
let props = s["properties"].as_object().unwrap();
|
||||
let keys: Vec<&String> = props.keys().collect();
|
||||
assert_eq!(keys, vec!["first", "second", "third"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_object_with_required_and_constraints() {
|
||||
let s = Schema::object()
|
||||
.field("path", Schema::string_().max_length(4096))
|
||||
.field("offset", Schema::integer().minimum(0.0))
|
||||
.field("length", Schema::integer().minimum(0.0))
|
||||
.required(&["path"])
|
||||
.build();
|
||||
assert_eq!(
|
||||
s,
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "maxLength": 4096},
|
||||
"offset": {"type": "integer", "minimum": 0.0},
|
||||
"length": {"type": "integer", "minimum": 0.0}
|
||||
},
|
||||
"required": ["path"]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_required_accumulates_across_calls() {
|
||||
let s = Schema::object()
|
||||
.field("a", Schema::integer())
|
||||
.field("b", Schema::integer())
|
||||
.required(&["a"])
|
||||
.required(&["b"])
|
||||
.build();
|
||||
assert_eq!(s["required"], json!(["a", "b"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_union_byte_discriminator_with_mapping() {
|
||||
let s = Schema::union_(Discriminator::Byte {
|
||||
offset: 0,
|
||||
disc_type: AlkTypeKind::Uint8,
|
||||
})
|
||||
.mapping("5", Schema::ref_def("Read"))
|
||||
.mapping("6", Schema::ref_def("Write"))
|
||||
.build();
|
||||
assert_eq!(
|
||||
s,
|
||||
json!({
|
||||
"AlkType:Union": true,
|
||||
"discriminator": {"kind": "byte", "offset": 0, "type": "AlkType:Uint8"},
|
||||
"mapping": {
|
||||
"5": {"$ref": "#/$defs/Read"},
|
||||
"6": {"$ref": "#/$defs/Write"},
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_union_field_discriminator() {
|
||||
let s = Schema::union_(Discriminator::Field { name: "type".to_string() })
|
||||
.mapping("read", Schema::ref_def("Read"))
|
||||
.build();
|
||||
assert_eq!(
|
||||
s["discriminator"],
|
||||
json!({"kind": "field", "name": "type"})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_enum_of_with_values() {
|
||||
let s = Schema::enum_of(&["read", "write", "execute"]).build();
|
||||
assert_eq!(
|
||||
s,
|
||||
json!({
|
||||
"AlkType:Enum": true,
|
||||
"enum": ["read", "write", "execute"]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_array_of_and_record_of() {
|
||||
let arr = Schema::array_of(Schema::uint32()).build();
|
||||
assert_eq!(
|
||||
arr,
|
||||
json!({"AlkType:Array": true, "items": {"AlkType:Uint32": true}})
|
||||
);
|
||||
let rec = Schema::record_of(Schema::float32()).build();
|
||||
assert_eq!(
|
||||
rec,
|
||||
json!({"AlkType:Record": true, "values": {"AlkType:Float32": true}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_definitions_define_returns_ref() {
|
||||
let mut defs = Definitions::new();
|
||||
let r = defs.define("FileNotFound", Schema::object().field("path", Schema::string_()));
|
||||
assert_eq!(r.build(), json!({"$ref": "#/$defs/FileNotFound"}));
|
||||
let built = defs.build();
|
||||
assert_eq!(
|
||||
built["$defs"]["FileNotFound"],
|
||||
json!({"type": "object", "properties": {"path": {"type": "string"}}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_ref_def_produces_json_pointer() {
|
||||
assert_eq!(
|
||||
Schema::ref_def("Read").build(),
|
||||
json!({"$ref": "#/$defs/Read"})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_encoding_offset_indirect_rewrites_to_object_form() {
|
||||
let s = Schema::string().encoding(VariableEncoding::OffsetIndirect).build();
|
||||
assert_eq!(
|
||||
s,
|
||||
json!({"AlkType:String": {"encoding": "offset-indirect"}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_align_sets_annotation() {
|
||||
let s = Schema::struct_().align(256).build();
|
||||
assert_eq!(s["align"], json!(256));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_from_value_adopts_existing_schema() {
|
||||
let existing = json!({"type": "integer", "minimum": 0});
|
||||
let s = Schema::from_value(existing.clone());
|
||||
assert_eq!(s.build(), existing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_any_is_empty_object() {
|
||||
assert_eq!(Schema::any().build(), json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_chunk_header_compiles_in_packed_mode() {
|
||||
// The POC's primary use case: channels' 8-byte chunk header.
|
||||
let mut schema = Schema::struct_()
|
||||
.endian(Endian::Big)
|
||||
.field("channel_id", Schema::uint32())
|
||||
.field("length", Schema::uint32())
|
||||
.build();
|
||||
let engine = crate::AlkTypeEngine::compile(&mut schema, crate::LayoutMode::Packed);
|
||||
assert!(engine.is_ok(), "chunk header schema should compile: {engine:?}");
|
||||
}
|
||||
}
|
||||
150
src/engine.rs
150
src/engine.rs
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,21 +18,29 @@
|
||||
//! discriminator dispatch.
|
||||
//! - **Validation** ([`validation`]): Custom keyword validators for all
|
||||
//! 19 `AlkType:*` kinds, delegated to the `jsonschema` crate.
|
||||
//! - **Builder** ([`builder`]): Fluent Rust API for constructing alktype
|
||||
//! JSON Schemas at runtime, producing `serde_json::Value` (ADR-009).
|
||||
//! - **Materialize** ([`materialize`]): Materialize a `serde_json::Value`
|
||||
//! tree from a binary buffer by walking the schema. Used by
|
||||
//! `AlkTypeEngine::validate_bytes` (ADR-010).
|
||||
//! - **Engine** ([`engine`]): `AlkTypeEngine` — the compiled form of a
|
||||
//! schema, combining layout and validation.
|
||||
|
||||
#[macro_use]
|
||||
mod macros;
|
||||
pub mod builder;
|
||||
pub mod data_access;
|
||||
pub mod engine;
|
||||
pub mod error;
|
||||
pub mod layout_builder;
|
||||
pub mod materialize;
|
||||
pub mod offset_map;
|
||||
pub mod schema;
|
||||
pub mod sequential_reader;
|
||||
pub mod tunion;
|
||||
pub mod validation;
|
||||
|
||||
pub use builder::{Definitions, Discriminator, Schema};
|
||||
pub use engine::{LayoutMode, AlkTypeEngine};
|
||||
pub use error::AlkTypeError;
|
||||
pub use layout_builder::{FieldPosition, LayoutBuilder, PackedLayout};
|
||||
|
||||
451
src/materialize.rs
Normal file
451
src/materialize.rs
Normal file
@@ -0,0 +1,451 @@
|
||||
//! Materialize a `serde_json::Value` tree from a binary buffer by walking
|
||||
//! the schema. Used by [`crate::engine::AlkTypeEngine::validate_bytes`]
|
||||
//! (ADR-010) to collapse the two-step dance (read bytes → `Value`,
|
||||
//! then validate `Value`) into a single call.
|
||||
//!
|
||||
//! The materializer reuses the [`crate::data_access`] read functions for
|
||||
//! leaf kinds and recurses into composites (`Struct`, `Array`, `Record`).
|
||||
//! For `Union`, it dispatches on the discriminator and recurses into the
|
||||
//! variant. Errors carry the field path (ADR-004).
|
||||
//!
|
||||
//! Packed mode walks sequentially from offset 0; aligned mode reads at
|
||||
//! offsets from the [`crate::offset_map::OffsetMap`]. Both produce the
|
||||
//! same `Value` form; the validator is mode-agnostic.
|
||||
|
||||
use crate::data_access;
|
||||
use crate::error::AlkTypeError;
|
||||
use crate::schema::{
|
||||
get_alktype_kind_loose_enum, resolve_ref_or_inline, DiscriminatorKind, Endian, AlkTypeKind,
|
||||
};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
const U32_SIZE: usize = 4;
|
||||
|
||||
/// Materialize a `Value` tree from `buffer` by walking `schema` in packed
|
||||
/// mode (sequential, from offset 0).
|
||||
///
|
||||
/// `schema` must declare `AlkType:Struct` at the root (the same
|
||||
/// requirement as [`crate::sequential_reader::SequentialReader::new`]).
|
||||
/// `endian` is the schema's endianness (parsed by the caller).
|
||||
pub fn materialize_packed(
|
||||
buffer: &[u8],
|
||||
schema: &Value,
|
||||
endian: Endian,
|
||||
) -> Result<Value, AlkTypeError> {
|
||||
materialize_struct_packed(buffer, schema, "", endian)
|
||||
}
|
||||
|
||||
/// Materialize a `Value` tree from `buffer` by walking `schema` in aligned
|
||||
/// mode (offsets from `offset_map`).
|
||||
///
|
||||
/// `schema` must declare `AlkType:Struct` at the root. `offset_map` must
|
||||
/// have been computed from the same `schema`. `endian` is the schema's
|
||||
/// endianness.
|
||||
pub fn materialize_aligned(
|
||||
buffer: &[u8],
|
||||
schema: &Value,
|
||||
offset_map: &crate::offset_map::OffsetMap,
|
||||
endian: Endian,
|
||||
) -> Result<Value, AlkTypeError> {
|
||||
// For aligned mode, the offset map records byte ranges for each leaf
|
||||
// field via dotted paths. To materialize a struct, we walk the
|
||||
// schema's `properties` and for each field, look up the path in the
|
||||
// offset map. Composites need recursion with their own offset map
|
||||
// entries (e.g., `header.magic` for a nested struct).
|
||||
materialize_struct_aligned(buffer, schema, "", offset_map, endian)
|
||||
}
|
||||
|
||||
fn materialize_struct_packed(
|
||||
buffer: &[u8],
|
||||
struct_schema: &Value,
|
||||
path_prefix: &str,
|
||||
endian: Endian,
|
||||
) -> Result<Value, AlkTypeError> {
|
||||
let struct_schema = resolve_ref_or_inline(struct_schema, root_of(struct_schema))
|
||||
.unwrap_or(struct_schema);
|
||||
let kind = get_alktype_kind_loose_enum(struct_schema).ok_or_else(|| {
|
||||
AlkTypeError::Schema(format!(
|
||||
"materialize: schema at {path_prefix} has no AlkType:* kind"
|
||||
))
|
||||
})?;
|
||||
if kind != AlkTypeKind::Struct {
|
||||
return Err(AlkTypeError::Schema(format!(
|
||||
"materialize_struct_packed: expected AlkType:Struct at {path_prefix}, got {kind}"
|
||||
)));
|
||||
}
|
||||
let props = struct_schema
|
||||
.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| {
|
||||
AlkTypeError::Schema(format!(
|
||||
"materialize: struct at {path_prefix} has no properties"
|
||||
))
|
||||
})?;
|
||||
let mut obj = Map::new();
|
||||
let mut offset = 0usize;
|
||||
for (name, field_schema) in props.iter() {
|
||||
let path = if path_prefix.is_empty() {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("{path_prefix}.{name}")
|
||||
};
|
||||
let (value, new_offset) = materialize_field_packed(buffer, field_schema, &path, offset, endian)?;
|
||||
obj.insert(name.clone(), value);
|
||||
offset = new_offset;
|
||||
}
|
||||
Ok(Value::Object(obj))
|
||||
}
|
||||
|
||||
fn materialize_field_packed(
|
||||
buffer: &[u8],
|
||||
field_schema: &Value,
|
||||
field_path: &str,
|
||||
offset: usize,
|
||||
endian: Endian,
|
||||
) -> Result<(Value, usize), AlkTypeError> {
|
||||
let kind = get_alktype_kind_loose_enum(field_schema).ok_or_else(|| {
|
||||
AlkTypeError::Schema(format!(
|
||||
"materialize: field {field_path} has no AlkType:* kind: {field_schema}"
|
||||
))
|
||||
})?;
|
||||
match kind {
|
||||
AlkTypeKind::Int8 => {
|
||||
let v = data_access::read_i8(buffer, offset, field_path)?;
|
||||
Ok((Value::from(v), offset + 1))
|
||||
}
|
||||
AlkTypeKind::Int16 => {
|
||||
let v = data_access::read_i16(buffer, offset, field_path, endian)?;
|
||||
Ok((Value::from(v), offset + 2))
|
||||
}
|
||||
AlkTypeKind::Int32 => {
|
||||
let v = data_access::read_i32(buffer, offset, field_path, endian)?;
|
||||
Ok((Value::from(v), offset + 4))
|
||||
}
|
||||
AlkTypeKind::Int64 => {
|
||||
let v = data_access::read_i64(buffer, offset, field_path, endian)?;
|
||||
Ok((Value::from(v), offset + 8))
|
||||
}
|
||||
AlkTypeKind::Uint8 => {
|
||||
let v = data_access::read_u8(buffer, offset, field_path)?;
|
||||
Ok((Value::from(v), offset + 1))
|
||||
}
|
||||
AlkTypeKind::Uint16 => {
|
||||
let v = data_access::read_u16(buffer, offset, field_path, endian)?;
|
||||
Ok((Value::from(v), offset + 2))
|
||||
}
|
||||
AlkTypeKind::Uint32 => {
|
||||
let v = data_access::read_u32(buffer, offset, field_path, endian)?;
|
||||
Ok((Value::from(v), offset + 4))
|
||||
}
|
||||
AlkTypeKind::Uint64 => {
|
||||
let v = data_access::read_u64(buffer, offset, field_path, endian)?;
|
||||
Ok((Value::from(v), offset + 8))
|
||||
}
|
||||
AlkTypeKind::Float32 => {
|
||||
let v = data_access::read_f32(buffer, offset, field_path, endian)?;
|
||||
Ok(
|
||||
(serde_json::Number::from_f64(v as f64)
|
||||
.map(Value::Number)
|
||||
.unwrap_or(Value::Null), offset + 4),
|
||||
)
|
||||
}
|
||||
AlkTypeKind::Float64 => {
|
||||
let v = data_access::read_f64(buffer, offset, field_path, endian)?;
|
||||
Ok(
|
||||
(serde_json::Number::from_f64(v)
|
||||
.map(Value::Number)
|
||||
.unwrap_or(Value::Null), offset + 8),
|
||||
)
|
||||
}
|
||||
AlkTypeKind::Boolean => {
|
||||
let v = data_access::read_bool(buffer, offset, field_path)?;
|
||||
Ok((Value::Bool(v), offset + 1))
|
||||
}
|
||||
AlkTypeKind::Enum => {
|
||||
let v = data_access::read_enum(buffer, offset, field_path, endian)?;
|
||||
Ok((Value::from(v), offset + 4))
|
||||
}
|
||||
AlkTypeKind::String => {
|
||||
let s = data_access::read_string(buffer, offset, field_path, endian)?;
|
||||
Ok((Value::String(s.to_string()), offset + U32_SIZE + s.len()))
|
||||
}
|
||||
AlkTypeKind::Bytes => {
|
||||
let b = data_access::read_bytes(buffer, offset, field_path, endian)?;
|
||||
// Bytes materialize as a string for validation (JSON has no
|
||||
// native byte type — see schema-layer.md §TBytes). The
|
||||
// jsonschema validator's BytesValidator expects a string.
|
||||
// The bytes are not necessarily UTF-8; use lossy conversion so
|
||||
// validation can still catch length violations. For strict
|
||||
// byte-preserving validation, the consumer should use the
|
||||
// data-access layer directly.
|
||||
let s = String::from_utf8_lossy(b).into_owned();
|
||||
Ok((Value::String(s), offset + U32_SIZE + b.len()))
|
||||
}
|
||||
AlkTypeKind::Timestamp => {
|
||||
let s = data_access::read_string(buffer, offset, field_path, endian)?;
|
||||
Ok((Value::String(s.to_string()), offset + U32_SIZE + s.len()))
|
||||
}
|
||||
AlkTypeKind::Struct => {
|
||||
// Recurse: materialize the nested struct starting at `offset`.
|
||||
// The nested struct's fields follow inline. We don't know its
|
||||
// total size without walking it, so we recurse and let it
|
||||
// return the new offset.
|
||||
let nested = materialize_struct_packed_at(
|
||||
buffer,
|
||||
field_schema,
|
||||
field_path,
|
||||
offset,
|
||||
endian,
|
||||
)?;
|
||||
Ok(nested)
|
||||
}
|
||||
AlkTypeKind::Array => {
|
||||
materialize_array_packed(buffer, field_schema, field_path, offset, endian)
|
||||
}
|
||||
AlkTypeKind::Union => {
|
||||
materialize_union_packed(buffer, field_schema, field_path, offset, endian)
|
||||
}
|
||||
AlkTypeKind::Record => {
|
||||
// Record materialization is more complex (count-prefixed
|
||||
// key/value pairs). Defer for the POC — the chunk header
|
||||
// doesn't use Record.
|
||||
Err(AlkTypeError::Access {
|
||||
field_path: field_path.to_string(),
|
||||
reason: "materialize: AlkType:Record not yet supported in validate_bytes (POC)".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn materialize_struct_packed_at(
|
||||
buffer: &[u8],
|
||||
struct_schema: &Value,
|
||||
path_prefix: &str,
|
||||
offset: usize,
|
||||
endian: Endian,
|
||||
) -> Result<(Value, usize), AlkTypeError> {
|
||||
let struct_schema = resolve_ref_or_inline(struct_schema, root_of(struct_schema))
|
||||
.unwrap_or(struct_schema);
|
||||
let props = struct_schema
|
||||
.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| {
|
||||
AlkTypeError::Schema(format!(
|
||||
"materialize: struct at {path_prefix} has no properties"
|
||||
))
|
||||
})?;
|
||||
let mut obj = Map::new();
|
||||
let mut cur = offset;
|
||||
for (name, field_schema) in props.iter() {
|
||||
let path = format!("{path_prefix}.{name}");
|
||||
let (value, new_offset) = materialize_field_packed(buffer, field_schema, &path, cur, endian)?;
|
||||
obj.insert(name.clone(), value);
|
||||
cur = new_offset;
|
||||
}
|
||||
Ok((Value::Object(obj), cur))
|
||||
}
|
||||
|
||||
fn materialize_array_packed(
|
||||
buffer: &[u8],
|
||||
field_schema: &Value,
|
||||
field_path: &str,
|
||||
offset: usize,
|
||||
endian: Endian,
|
||||
) -> Result<(Value, usize), AlkTypeError> {
|
||||
let element_schema = field_schema
|
||||
.get("items")
|
||||
.ok_or_else(|| {
|
||||
AlkTypeError::Schema(format!(
|
||||
"materialize: array at {field_path} has no items schema"
|
||||
))
|
||||
})?;
|
||||
let min = field_schema.get("minItems").and_then(Value::as_u64);
|
||||
let max = field_schema.get("maxItems").and_then(Value::as_u64);
|
||||
let fixed_count = match (min, max) {
|
||||
(Some(a), Some(b)) if a == b => Some(a as usize),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(count) = fixed_count {
|
||||
let mut arr = Vec::with_capacity(count);
|
||||
let mut cur = offset;
|
||||
for i in 0..count {
|
||||
let path = format!("{field_path}[{i}]");
|
||||
let (value, new_offset) = materialize_field_packed(buffer, element_schema, &path, cur, endian)?;
|
||||
arr.push(value);
|
||||
cur = new_offset;
|
||||
}
|
||||
Ok((Value::Array(arr), cur))
|
||||
} else {
|
||||
// Variable-count array: read a u32 count prefix, then `count` elements.
|
||||
let count = data_access::read_u32(buffer, offset, field_path, endian)?;
|
||||
let count = count as usize;
|
||||
let mut arr = Vec::with_capacity(count);
|
||||
let mut cur = offset + U32_SIZE;
|
||||
for i in 0..count {
|
||||
let path = format!("{field_path}[{i}]");
|
||||
let (value, new_offset) = materialize_field_packed(buffer, element_schema, &path, cur, endian)?;
|
||||
arr.push(value);
|
||||
cur = new_offset;
|
||||
}
|
||||
Ok((Value::Array(arr), cur))
|
||||
}
|
||||
}
|
||||
|
||||
fn materialize_union_packed(
|
||||
buffer: &[u8],
|
||||
field_schema: &Value,
|
||||
field_path: &str,
|
||||
offset: usize,
|
||||
endian: Endian,
|
||||
) -> Result<(Value, usize), AlkTypeError> {
|
||||
let disc = crate::schema::parse_discriminator(field_schema)?;
|
||||
match disc {
|
||||
DiscriminatorKind::Byte { offset: disc_offset, disc_type } => {
|
||||
let disc_value = match disc_type {
|
||||
AlkTypeKind::Uint8 => data_access::read_u8(buffer, offset + disc_offset, field_path)? as u32,
|
||||
AlkTypeKind::Uint16 => data_access::read_u16(buffer, offset + disc_offset, field_path, endian)? as u32,
|
||||
AlkTypeKind::Uint32 => data_access::read_u32(buffer, offset + disc_offset, field_path, endian)?,
|
||||
_ => unreachable!("disc_type restricted by parse_discriminator"),
|
||||
};
|
||||
let mapping = field_schema
|
||||
.get("mapping")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| {
|
||||
AlkTypeError::Schema(format!(
|
||||
"materialize: union at {field_path} has no mapping"
|
||||
))
|
||||
})?;
|
||||
let key = disc_value.to_string();
|
||||
let variant_schema = mapping.get(&key).ok_or_else(|| {
|
||||
AlkTypeError::Access {
|
||||
field_path: field_path.to_string(),
|
||||
reason: format!("union discriminator value {key} not in mapping"),
|
||||
}
|
||||
})?;
|
||||
let variant_offset = offset + disc_offset + disc_type.type_size().unwrap_or(1);
|
||||
let (variant_value, new_offset) = materialize_field_packed(
|
||||
buffer,
|
||||
variant_schema,
|
||||
field_path,
|
||||
variant_offset,
|
||||
endian,
|
||||
)?;
|
||||
let mut obj = Map::new();
|
||||
obj.insert("__discriminator".to_string(), Value::from(disc_value));
|
||||
if let Some(variant_obj) = variant_value.as_object() {
|
||||
for (k, v) in variant_obj.iter() {
|
||||
obj.insert(k.clone(), v.clone());
|
||||
}
|
||||
} else {
|
||||
obj.insert("__variant".to_string(), variant_value);
|
||||
}
|
||||
Ok((Value::Object(obj), new_offset))
|
||||
}
|
||||
DiscriminatorKind::Field { name } => {
|
||||
// Field-name discriminator: the union is a struct with a
|
||||
// discriminator field; the variant is selected by that field's
|
||||
// value. This is the typedef.ts pattern. For the POC, we
|
||||
// materialize the struct fields and let the validator
|
||||
// dispatch on the discriminator field.
|
||||
let (value, _off) =
|
||||
materialize_struct_packed_at(buffer, field_schema, field_path, offset, endian)?;
|
||||
let _ = &name; // field-name dispatch happens at validation time
|
||||
Ok((value, offset))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn materialize_struct_aligned(
|
||||
buffer: &[u8],
|
||||
struct_schema: &Value,
|
||||
path_prefix: &str,
|
||||
offset_map: &crate::offset_map::OffsetMap,
|
||||
endian: Endian,
|
||||
) -> Result<Value, AlkTypeError> {
|
||||
let struct_schema = resolve_ref_or_inline(struct_schema, root_of(struct_schema))
|
||||
.unwrap_or(struct_schema);
|
||||
let props = struct_schema
|
||||
.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| {
|
||||
AlkTypeError::Schema(format!(
|
||||
"materialize: struct at {path_prefix} has no properties"
|
||||
))
|
||||
})?;
|
||||
let mut obj = Map::new();
|
||||
for (name, field_schema) in props.iter() {
|
||||
let path = if path_prefix.is_empty() {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("{path_prefix}.{name}")
|
||||
};
|
||||
let kind = get_alktype_kind_loose_enum(field_schema).ok_or_else(|| {
|
||||
AlkTypeError::Schema(format!(
|
||||
"materialize: field {path} has no AlkType:* kind"
|
||||
))
|
||||
})?;
|
||||
let value = if kind.is_fixed_size() || kind.is_variable_length() {
|
||||
// Leaf field — look up its byte range in the offset map.
|
||||
let range = offset_map.get(&path).ok_or_else(|| AlkTypeError::Offset {
|
||||
field_path: path.clone(),
|
||||
reason: "field not found in offset map".to_string(),
|
||||
})?;
|
||||
materialize_leaf_at(buffer, field_schema, &path, range.start, endian)?
|
||||
} else if kind == AlkTypeKind::Struct {
|
||||
// Nested struct — recurse with the same offset map (nested
|
||||
// fields are recorded under `path.*`).
|
||||
materialize_struct_aligned(buffer, field_schema, &path, offset_map, endian)?
|
||||
} else {
|
||||
// Composite leaf (Array, Union, Record) — fall back to packed-style
|
||||
// walk from the field's start offset.
|
||||
let range = offset_map.get(&path).ok_or_else(|| AlkTypeError::Offset {
|
||||
field_path: path.clone(),
|
||||
reason: "field not found in offset map".to_string(),
|
||||
})?;
|
||||
let (value, _) = materialize_field_packed(buffer, field_schema, &path, range.start, endian)?;
|
||||
value
|
||||
};
|
||||
obj.insert(name.clone(), value);
|
||||
}
|
||||
Ok(Value::Object(obj))
|
||||
}
|
||||
|
||||
fn materialize_leaf_at(
|
||||
buffer: &[u8],
|
||||
field_schema: &Value,
|
||||
field_path: &str,
|
||||
offset: usize,
|
||||
endian: Endian,
|
||||
) -> Result<Value, AlkTypeError> {
|
||||
let (_kind, value) = materialize_field_packed_returning_kind(buffer, field_schema, field_path, offset, endian)?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Helper for aligned mode: materialize a leaf field, also returning the
|
||||
/// resolved kind so the caller can decide whether to recurse (struct) or
|
||||
/// treat as leaf. Defined inline to keep the materialize_field_packed
|
||||
/// signature stable.
|
||||
fn materialize_field_packed_returning_kind(
|
||||
buffer: &[u8],
|
||||
field_schema: &Value,
|
||||
field_path: &str,
|
||||
offset: usize,
|
||||
endian: Endian,
|
||||
) -> Result<(AlkTypeKind, Value), AlkTypeError> {
|
||||
let kind = get_alktype_kind_loose_enum(field_schema).ok_or_else(|| {
|
||||
AlkTypeError::Schema(format!(
|
||||
"materialize: field {field_path} has no AlkType:* kind"
|
||||
))
|
||||
})?;
|
||||
let (value, _) = materialize_field_packed(buffer, field_schema, field_path, offset, endian)?;
|
||||
Ok((kind, value))
|
||||
}
|
||||
|
||||
/// Walk a schema to find its root. Since materialize is called with the
|
||||
/// top-level schema as `struct_schema`, the root is `struct_schema` itself
|
||||
/// for the top-level call. For nested calls (via $ref), we need the
|
||||
/// original root. This helper returns the node itself for now — the $ref
|
||||
/// resolution in `resolve_ref_or_inline` handles the common case.
|
||||
fn root_of(schema: &Value) -> &Value {
|
||||
schema
|
||||
}
|
||||
Reference in New Issue
Block a user