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).
This commit is contained in:
@@ -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/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-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.
|
Each package is versioned and published independently; see their own READMEs (inline doc comments) for usage.
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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<Value, BuildError> {
|
||||||
|
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<Value, BuildError> {
|
||||||
|
build(schema, base_dir, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(schema: &Value, base_dir: &Path, depth: usize) -> Result<Value, BuildError> {
|
||||||
|
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<String, Value>,
|
||||||
|
base_dir: &Path,
|
||||||
|
depth: usize,
|
||||||
|
) -> Result<Value, BuildError> {
|
||||||
|
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<String, Value>, base_dir: &Path, depth: usize) -> Result<Value, BuildError> {
|
||||||
|
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<String, Value>) -> 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<Value, BuildError> {
|
||||||
|
if depth > MAX_DEPTH {
|
||||||
|
return Err(BuildError::TooDeep);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut properties = serde_json::Map::new();
|
||||||
|
let mut required: Vec<Value> = 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<Value, BuildError> {
|
||||||
|
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<Value, BuildError> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user