From fed19b260f2bd0dc84c9b4d0b73239aa5693f77c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Sat, 5 Sep 2026 16:59:12 +0000 Subject: [PATCH] Add continuum-schema-instance: build JSON instances from a JSON Schema The Rust equivalent of json-schema-faker, scoped to what's actually useful in Rust: resolves $ref (relative to whichever file references it)/allOf, fills const/enum/default, and builds a value only for properties listed in required - everything else is left out. For code that has to construct a raw serde_json::Value against a schema it doesn't own, instead of a typed struct. Verified against continuum-schemas' real bambulab schemas manually, and against local fixtures in tests (round-tripped through the jsonschema crate to confirm the output actually validates). --- README.md | 1 + packages/rust-schema-instance/Cargo.toml | 12 + packages/rust-schema-instance/src/lib.rs | 268 ++++++++++++++++++ .../rust-schema-instance/tests/build_test.rs | 64 +++++ .../fixtures/common/envelope.schema.json | 11 + .../fixtures/events/item_created.schema.json | 15 + .../fixtures/events/item_deleted.schema.json | 13 + 7 files changed, 384 insertions(+) create mode 100644 packages/rust-schema-instance/Cargo.toml create mode 100644 packages/rust-schema-instance/src/lib.rs create mode 100644 packages/rust-schema-instance/tests/build_test.rs create mode 100644 packages/rust-schema-instance/tests/fixtures/common/envelope.schema.json create mode 100644 packages/rust-schema-instance/tests/fixtures/events/item_created.schema.json create mode 100644 packages/rust-schema-instance/tests/fixtures/events/item_deleted.schema.json diff --git a/README.md b/README.md index ee82ebc..85d9822 100644 --- a/README.md +++ b/README.md @@ -6,5 +6,6 @@ Shared, dependency-light building blocks used across the Continuum cloud and edg - **`packages/ts-core`** - TypeScript package consumed by `continuum-backend` (and other Bun/Node services). Provides a structured logger, a Supabase JWT verification helper, and ElysiaJS-compatible HTTP error transformers. - **`packages/rust-core`** - Rust crate consumed by `continuum-proxy` and `continuum-ai-worker`. Provides a shared `thiserror`-based error type, `tracing` initialization, and G-code checksum/header parsing helpers. +- **`packages/rust-schema-instance`** - Rust crate consumed by `continuum-proxy` and `continuum-ai-worker`. Builds a `serde_json::Value` instance from a JSON Schema file (resolves `$ref`/`allOf`, fills `const`/`enum`/`default`, only builds `required` properties) - for code that has to construct a raw JSON message against some other system's schema instead of a typed struct. Each package is versioned and published independently; see their own READMEs (inline doc comments) for usage. diff --git a/packages/rust-schema-instance/Cargo.toml b/packages/rust-schema-instance/Cargo.toml new file mode 100644 index 0000000..5662cea --- /dev/null +++ b/packages/rust-schema-instance/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "continuum-schema-instance" +version = "0.1.0" +edition = "2021" +description = "Builds a JSON instance from a JSON Schema - fills const/enum/default, only builds required properties, everything else is left out" + +[dependencies] +serde_json = "1" +thiserror = "1.0" + +[dev-dependencies] +jsonschema = "0.53.0" diff --git a/packages/rust-schema-instance/src/lib.rs b/packages/rust-schema-instance/src/lib.rs new file mode 100644 index 0000000..3fc4e0b --- /dev/null +++ b/packages/rust-schema-instance/src/lib.rs @@ -0,0 +1,268 @@ +//! Builds a `serde_json::Value` instance from a JSON Schema file - the +//! Rust equivalent of what `json-schema-faker` is used for in JS/TS, +//! adapted to what's actually useful in Rust: this crate doesn't invent +//! random data for you (a typed struct already forces you to supply every +//! field it requires at compile time - there's nothing left for a faker +//! to do there). It's for code that has to build a raw `serde_json::Value` +//! against a schema it doesn't own - some other system's wire format - +//! instead of a typed struct: fill in the one or two fields you actually +//! have a value for, and let this fill in everything else the schema +//! already determines on its own. +//! +//! What it does, walking a schema node: +//! - `$ref` - loads the referenced file, resolved relative to the +//! directory of whichever file is doing the referencing (so a chain of +//! refs across several files each resolves against its own location, +//! not the original caller's). Only whole-file refs are supported - a +//! `#/json/pointer` fragment is an error, not silently ignored. +//! - `allOf` - merges every branch's `properties`/`required` into one +//! combined object schema before building it. Branches are resolved +//! (following their own `$ref`s) before merging, but a merged +//! property's *own* schema is then built relative to the outermost +//! file's directory, not the branch it came from - harmless for every +//! schema in this project today (no property schema anywhere uses +//! `$ref`), but worth knowing if you add one that does. +//! - `oneOf`/`anyOf` - deterministically picks the *first* listed +//! variant. If you need a specific one, point at that variant's own +//! schema file directly instead of the dispatcher. +//! - `const` / `enum` / `default` - used as-is (enum takes its first +//! value). +//! - `object` - builds a value **only** for properties listed in +//! `required`; anything not required is left out of the result +//! entirely. This is the "skip the rest" behavior - equivalent to +//! `json-schema-faker`'s `alwaysFakeOptionals: false` (its default). +//! - `array` - builds `minItems` copies of `items` (zero if unset). +//! - anything else - a simple placeholder for the declared `type` +//! (respecting `minLength`/`minimum` where trivial), so the result is +//! always schema-shaped even before you fill in the fields you actually +//! care about. +//! +//! Typical use: build the skeleton, then set the one or two fields you +//! actually have a value for with plain `serde_json::Value` indexing - +//! there's no separate "overrides" API here, because `value["field"] = +//! json!(...)` already does that job in one line: +//! +//! ```no_run +//! # use std::path::Path; +//! # use serde_json::json; +//! let mut message = continuum_schema_instance::build_instance_from_file( +//! Path::new("schemas/some_event.schema.json"), +//! ).unwrap(); +//! message["some_field"] = json!("some value"); +//! ``` + +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +/// Guards against a `$ref` cycle (or just a schema nested deeper than any +/// real one here goes) turning into infinite recursion. +const MAX_DEPTH: usize = 64; + +#[derive(Debug, thiserror::Error)] +pub enum BuildError { + #[error("failed to read schema file {0}: {1}")] + Io(PathBuf, #[source] std::io::Error), + #[error("failed to parse schema file {0}: {1}")] + Json(PathBuf, #[source] serde_json::Error), + #[error("invalid schema: {0}")] + InvalidSchema(String), + #[error("schema nested too deep (possible $ref cycle)")] + TooDeep, + #[error("schema is `false` - always fails validation, nothing to build")] + AlwaysInvalid, +} + +/// Loads `path` as a JSON Schema and builds an instance of it. `$ref`s +/// inside it resolve relative to `path`'s own directory. +pub fn build_instance_from_file(path: &Path) -> Result { + let schema = load_schema_file(path)?; + let base_dir = path.parent().unwrap_or_else(|| Path::new(".")); + build(&schema, base_dir, 0) +} + +/// Same as [`build_instance_from_file`], but for a schema you've already +/// parsed - `base_dir` is where any `$ref` inside it resolves from. +pub fn build_instance(schema: &Value, base_dir: &Path) -> Result { + build(schema, base_dir, 0) +} + +fn build(schema: &Value, base_dir: &Path, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err(BuildError::TooDeep); + } + let depth = depth + 1; + + if let Some(allowed) = schema.as_bool() { + return if allowed { Ok(Value::Null) } else { Err(BuildError::AlwaysInvalid) }; + } + let obj = schema + .as_object() + .ok_or_else(|| BuildError::InvalidSchema("a schema must be a JSON object or boolean".into()))?; + + if let Some(r) = obj.get("$ref").and_then(Value::as_str) { + let (resolved, new_base) = load_ref(r, base_dir)?; + return build(&resolved, &new_base, depth); + } + + if let Some(branches) = obj.get("allOf").and_then(Value::as_array) { + let merged = merge_all_of(branches, base_dir, depth)?; + return build(&merged, base_dir, depth); + } + + if let Some(variants) = + obj.get("oneOf").and_then(Value::as_array).or_else(|| obj.get("anyOf").and_then(Value::as_array)) + { + let first = + variants.first().ok_or_else(|| BuildError::InvalidSchema("oneOf/anyOf with no variants".into()))?; + return build(first, base_dir, depth); + } + + if let Some(c) = obj.get("const") { + return Ok(c.clone()); + } + + if let Some(values) = obj.get("enum").and_then(Value::as_array) { + return values.first().cloned().ok_or_else(|| BuildError::InvalidSchema("enum with no values".into())); + } + + if let Some(d) = obj.get("default") { + return Ok(d.clone()); + } + + let ty = obj.get("type").and_then(Value::as_str); + + if ty == Some("object") || obj.contains_key("properties") { + return build_object(obj, base_dir, depth); + } + + if ty == Some("array") { + return build_array(obj, base_dir, depth); + } + + Ok(placeholder_for_type(ty, obj)) +} + +/// Builds a value only for properties listed in `required` - everything +/// else in `properties` is left out entirely. +fn build_object( + obj: &serde_json::Map, + base_dir: &Path, + depth: usize, +) -> Result { + let properties = obj.get("properties").and_then(Value::as_object); + let required = obj.get("required").and_then(Value::as_array); + + let mut result = serde_json::Map::new(); + if let Some(required) = required { + for name in required { + let Some(name) = name.as_str() else { continue }; + let prop_schema = properties.and_then(|p| p.get(name)).ok_or_else(|| { + BuildError::InvalidSchema(format!("'{name}' is required but has no schema in properties")) + })?; + result.insert(name.to_string(), build(prop_schema, base_dir, depth)?); + } + } + Ok(Value::Object(result)) +} + +fn build_array(obj: &serde_json::Map, base_dir: &Path, depth: usize) -> Result { + let Some(items_schema) = obj.get("items") else { + return Ok(Value::Array(Vec::new())); + }; + let min_items = obj.get("minItems").and_then(Value::as_u64).unwrap_or(0); + + let mut items = Vec::new(); + for _ in 0..min_items { + items.push(build(items_schema, base_dir, depth)?); + } + Ok(Value::Array(items)) +} + +/// A schema with no `const`/`enum`/`default` still needs *something* +/// there if it's required - the simplest value satisfying its declared +/// type (and, where trivial, `minLength`/`minimum`). +fn placeholder_for_type(ty: Option<&str>, obj: &serde_json::Map) -> Value { + match ty { + Some("string") => { + let min_length = obj.get("minLength").and_then(Value::as_u64).unwrap_or(0) as usize; + Value::String("x".repeat(min_length)) + } + Some("integer") => Value::from(obj.get("minimum").and_then(Value::as_i64).unwrap_or(0)), + Some("number") => Value::from(obj.get("minimum").and_then(Value::as_f64).unwrap_or(0.0)), + Some("boolean") => Value::Bool(false), + _ => Value::Null, + } +} + +/// Resolves each `allOf` branch (following its own `$ref`/nested `allOf`) +/// down to a plain object schema, then unions their `properties` (later +/// branches win on a key clash) and `required` (deduplicated) into one +/// combined object schema. +fn merge_all_of(branches: &[Value], base_dir: &Path, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err(BuildError::TooDeep); + } + + let mut properties = serde_json::Map::new(); + let mut required: Vec = Vec::new(); + + for branch in branches { + let resolved = resolve_schema_object(branch, base_dir, depth + 1)?; + if let Some(props) = resolved.get("properties").and_then(Value::as_object) { + for (k, v) in props { + properties.insert(k.clone(), v.clone()); + } + } + if let Some(req) = resolved.get("required").and_then(Value::as_array) { + for r in req { + if !required.contains(r) { + required.push(r.clone()); + } + } + } + } + + Ok(serde_json::json!({ "type": "object", "properties": properties, "required": required })) +} + +/// Follows `$ref`/`allOf` (without building anything yet) until it lands +/// on a plain object schema - the schema-level equivalent of `build`, +/// used only while merging `allOf` branches. +fn resolve_schema_object(schema: &Value, base_dir: &Path, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err(BuildError::TooDeep); + } + let obj = schema + .as_object() + .ok_or_else(|| BuildError::InvalidSchema("an allOf branch must be a JSON object schema".into()))?; + + if let Some(r) = obj.get("$ref").and_then(Value::as_str) { + let (resolved, new_base) = load_ref(r, base_dir)?; + return resolve_schema_object(&resolved, &new_base, depth + 1); + } + if let Some(branches) = obj.get("allOf").and_then(Value::as_array) { + return merge_all_of(branches, base_dir, depth + 1); + } + Ok(schema.clone()) +} + +fn load_ref(r: &str, base_dir: &Path) -> Result<(Value, PathBuf), BuildError> { + if let Some(fragment) = r.split('#').nth(1) { + if !fragment.is_empty() { + return Err(BuildError::InvalidSchema(format!( + "'{r}' - in-document JSON Pointer refs aren't supported, only whole-file refs" + ))); + } + } + let file_part = r.split('#').next().unwrap_or(r); + let target = base_dir.join(file_part); + let schema = load_schema_file(&target)?; + let new_base = target.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from(".")); + Ok((schema, new_base)) +} + +fn load_schema_file(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|err| BuildError::Io(path.to_path_buf(), err))?; + serde_json::from_str(&text).map_err(|err| BuildError::Json(path.to_path_buf(), err)) +} diff --git a/packages/rust-schema-instance/tests/build_test.rs b/packages/rust-schema-instance/tests/build_test.rs new file mode 100644 index 0000000..ef532d1 --- /dev/null +++ b/packages/rust-schema-instance/tests/build_test.rs @@ -0,0 +1,64 @@ +use std::path::PathBuf; + +use continuum_schema_instance::build_instance_from_file; +use serde_json::json; + +fn fixture(relative: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(relative) +} + +/// Validates `instance` against the real schema file - closes the loop: +/// this confirms the builder's output is actually accepted by a real JSON +/// Schema validator (the `jsonschema` crate), not just "looks right". +fn assert_valid(schema_path: &PathBuf, instance: &serde_json::Value) { + let schema_text = std::fs::read_to_string(schema_path).unwrap(); + let mut schema: serde_json::Value = serde_json::from_str(&schema_text).unwrap(); + // These fixtures have no `$id` (real schemas in continuum-schemas do, + // as an absolute https:// URL) - the `jsonschema` crate resolves a + // relative `$ref` against the schema's own `$id`, so give it one + // pointing at this file's real location for the relative `$ref`s + // inside to resolve from during this check. + let absolute_path = schema_path.canonicalize().unwrap(); + schema["$id"] = json!(format!("file://{}", absolute_path.display())); + + let validator = jsonschema::validator_for(&schema).expect("schema itself should compile"); + let errors: Vec<_> = validator.iter_errors(instance).collect(); + assert!(errors.is_empty(), "instance {instance} did not validate: {errors:?}"); +} + +#[test] +fn required_fields_are_filled_and_optional_fields_are_left_out() { + let path = fixture("events/item_created.schema.json"); + let mut instance = build_instance_from_file(&path).unwrap(); + + // "type" has no const at the envelope level, but item_created's own + // allOf branch overrides it to one - that const wins. + assert_eq!(instance["type"], json!("item_created")); + // "event_id"/"item_id" are required with no const - each gets a + // placeholder, ready to be overridden. + assert!(instance.get("event_id").is_some()); + assert!(instance.get("item_id").is_some()); + // "note" is optional and correctly left out entirely. + assert_eq!(instance.as_object().unwrap().len(), 3); + + assert_valid(&path, &instance); + + // The one field the caller actually has a value for - plain + // serde_json::Value indexing, no special "overrides" API needed. + instance["item_id"] = json!("item-42"); + assert_valid(&path, &instance); +} + +#[test] +fn allof_merge_lets_a_later_branch_override_an_earlier_ones_property() { + let path = fixture("events/item_deleted.schema.json"); + let instance = build_instance_from_file(&path).unwrap(); + + // "type" is defined twice across the allOf: a plain string in the + // shared envelope, then overridden to `const: "item_deleted"` in this + // event's own branch - the later branch has to win. + assert_eq!(instance["type"], json!("item_deleted")); + assert_eq!(instance.as_object().unwrap().len(), 2); // event_id, type + + assert_valid(&path, &instance); +} diff --git a/packages/rust-schema-instance/tests/fixtures/common/envelope.schema.json b/packages/rust-schema-instance/tests/fixtures/common/envelope.schema.json new file mode 100644 index 0000000..231b23d --- /dev/null +++ b/packages/rust-schema-instance/tests/fixtures/common/envelope.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "EventEnvelope", + "type": "object", + "properties": { + "event_id": { "type": "string" }, + "type": { "type": "string" }, + "note": { "type": "string" } + }, + "required": ["event_id", "type"] +} diff --git a/packages/rust-schema-instance/tests/fixtures/events/item_created.schema.json b/packages/rust-schema-instance/tests/fixtures/events/item_created.schema.json new file mode 100644 index 0000000..6a47e10 --- /dev/null +++ b/packages/rust-schema-instance/tests/fixtures/events/item_created.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ItemCreatedEvent", + "allOf": [ + { "$ref": "../common/envelope.schema.json" }, + { + "type": "object", + "properties": { + "type": { "const": "item_created" }, + "item_id": { "type": "string" } + }, + "required": ["item_id"] + } + ] +} diff --git a/packages/rust-schema-instance/tests/fixtures/events/item_deleted.schema.json b/packages/rust-schema-instance/tests/fixtures/events/item_deleted.schema.json new file mode 100644 index 0000000..4b55768 --- /dev/null +++ b/packages/rust-schema-instance/tests/fixtures/events/item_deleted.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ItemDeletedEvent", + "allOf": [ + { "$ref": "../common/envelope.schema.json" }, + { + "type": "object", + "properties": { + "type": { "const": "item_deleted" } + } + } + ] +}