moon_zod/importers/from_json_schema.mbt

415 lines
11 KiB
Plaintext

///|
/// Convert JSON @core.Schema (draft-07) documents to moon_zod @core.Schema objects.
///
/// Two functions:
/// 1. json_schema_to_schema() - Parse JSON @core.Schema → @core.Schema @core.object (runtime-ready)
/// 2. json_schema_to_moon_zod() - Parse JSON @core.Schema → moon_zod source code (defined in combinators)
///|
/// **JSON @core.Schema → @core.Schema @core.object**
///
/// Parses a JSON @core.Schema (draft-07) document into a runtime @core.Schema @core.object.
/// Handles $defs, $ref, enum, type constraints, and all JSON @core.Schema keywords.
pub fn json_schema_to_schema(schema : Json) -> @core.Schema {
let defs_json = extract_defs(schema)
let defs_cache : Map[String, @core.Schema] = {}
let visiting : Array[String] = []
// First pass: Process all $defs to build cache
// This ensures all named schemas are available for $ref resolution
for def_name, _ in defs_json {
if !defs_cache.contains(def_name) {
process_json_def(def_name, defs_json, defs_cache, visiting)
}
}
json_to_schema_impl(schema, defs_json, defs_cache, visiting)
}
///|
/// Recursively process a single $def with cycle detection.
fn process_json_def(
name : String,
defs_json : Map[String, Json],
cache : Map[String, @core.Schema],
visiting : Array[String],
) -> Unit {
if cache.contains(name) {
return
}
if @core.value_in_array(name, visiting) {
// Cycle: use @core.null schema as placeholder
cache.set(name, @core.null().name(name))
return
}
visiting.push(name)
match defs_json.get(name) {
Some(def) => {
let schema = json_to_schema_impl(def, defs_json, cache, visiting)
cache.set(name, schema.name(name))
}
None => ()
}
// Remove from visiting
for i = 0; i < visiting.length(); i = i + 1 {
if visiting[i] == name {
let _ = visiting.remove(i)
break
}
}
}
///|
/// Convert JSON @core.Schema document to @core.Schema @core.object.
fn json_to_schema_impl(
json : Json,
defs_json : Map[String, Json],
defs_cache : Map[String, @core.Schema],
visiting : Array[String],
) -> @core.Schema {
match json {
Object(m) => {
// 1. Check $ref
match m.get("$ref") {
Some(String(ref_str)) => {
let ref_name = parse_ref_name(ref_str)
match defs_cache.get(ref_name) {
Some(s) => return s
None =>
// Ref not yet processed, check for forward ref or cycle
match defs_json.get(ref_name) {
Some(_) => {
// Check if this would create a cycle
if @core.value_in_array(ref_name, visiting) {
// Cycle detected
return @core.null().name(ref_name)
}
// Process the forward ref
process_json_def(ref_name, defs_json, defs_cache, visiting)
match defs_cache.get(ref_name) {
Some(s) => return s
None => return @core.string()
}
}
None => return @core.string()
}
}
}
_ => ()
}
// 2. Check const (for @core.literal values)
match m.get("const") {
Some(val) => return @core.literal(val)
_ => ()
}
// 3. Check enum
match m.get("enum") {
Some(Array(values)) => {
if values.length() == 0 {
return @core.string()
}
let strs : Array[String] = []
let all_numbers : Array[Double] = []
let mut has_non_numbers = false
for v in values {
match v {
String(s) => {
has_non_numbers = true
strs.push(s)
}
Number(n, ..) => all_numbers.push(n)
_ => has_non_numbers = true
}
}
// If all values are numbers, create a number schema with enum validation
if !has_non_numbers && all_numbers.length() > 0 {
let num_schema = @core.number()
let mut result_schema = num_schema
// Add rule to validate the number is in the allowed list
result_schema = @core.append_rule(
result_schema,
fn(json) {
match json {
Number(n, ..) => {
for allowed in all_numbers {
if n == allowed {
return true
}
}
false
}
_ => false
}
},
"Value must be one of the allowed numbers",
)
return result_schema
}
// If all values are strings, use string enum
if strs.length() == values.length() {
return @core.enum_values(strs)
}
// Mixed or non-string types: use union of literals
let parts = values.map(fn(v) { @core.literal(v) })
return @core.union(parts)
}
_ => ()
}
// 4. Type-based schema
if m.contains("type") {
let base = match m.get("type") {
Some(String("object")) =>
match m.get("properties") {
Some(Object(props)) => {
let fields : Map[String, @core.Schema] = {}
let required_set = parse_required(m.get("required"))
for key, field_json in props {
let mut field = json_to_schema_impl(
field_json, defs_json, defs_cache, visiting,
)
if !required_set.contains(key) {
field = field.optional()
}
fields.set(key, field)
}
let mut obj_schema = @core.object(fields)
obj_schema = match m.get("additionalProperties") {
Some(True) => obj_schema.passthrough()
Some(False) => obj_schema.strip()
_ => obj_schema
}
obj_schema
}
_ => @core.object({})
}
Some(String("array")) => {
let elem = match m.get("items") {
Some(items) =>
json_to_schema_impl(items, defs_json, defs_cache, visiting)
None => @core.string()
}
@core.array(elem)
}
Some(String("string")) => @core.string()
Some(String("number")) => @core.number()
Some(String("integer")) => @core.number().int()
Some(String("boolean")) => @core.boolean()
Some(String("null")) => @core.null()
_ => @core.string()
}
return apply_json_schema_constraints(base, m)
}
// 5. anyOf / allOf / oneOf
match m.get("anyOf") {
Some(Array(schemas)) => {
let parts = schemas.map(fn(s) {
json_to_schema_impl(s, defs_json, defs_cache, visiting)
})
return @core.union(parts)
}
_ => ()
}
match m.get("allOf") {
Some(Array(schemas)) => {
let parts = schemas.map(fn(s) {
json_to_schema_impl(s, defs_json, defs_cache, visiting)
})
return @core.intersection(parts)
}
_ => ()
}
match m.get("oneOf") {
Some(Array(schemas)) => {
let parts = schemas.map(fn(s) {
json_to_schema_impl(s, defs_json, defs_cache, visiting)
})
return @core.union(parts)
}
_ => ()
}
@core.string()
}
_ => @core.string()
}
}
///|
/// Parse the "required" @core.array from JSON @core.Schema @core.object.
fn parse_required(required_json : Json?) -> Map[String, Bool] {
let set : Map[String, Bool] = {}
match required_json {
Some(Array(reqs)) =>
for r in reqs {
match r {
String(s) => set.set(s, true)
_ => ()
}
}
_ => ()
}
set
}
///|
/// Apply JSON @core.Schema constraint keywords to a @core.Schema.
fn apply_json_schema_constraints(
schema : @core.Schema,
m : Map[String, Json],
) -> @core.Schema {
let mut result = schema
// String/Array length constraints
match m.get("minLength") {
Some(Number(v, ..)) => result = result.min(v.to_int())
_ => ()
}
match m.get("maxLength") {
Some(Number(v, ..)) => result = result.max(v.to_int())
_ => ()
}
match m.get("pattern") {
Some(String(p)) => result = result.regex(p)
_ => ()
}
match m.get("format") {
Some(String("email")) => result = result.email()
Some(String("uri")) => result = result.url()
Some(String("date-time")) => result = result.datetime()
Some(String("ipv4")) => result = result.ipv4()
Some(String("ipv6")) => result = result.ipv6()
Some(String("uuid")) => result = result.uuid()
_ => ()
}
// Number constraints
match m.get("minimum") {
Some(Number(v, ..)) => result = result.min(v.to_int())
_ => ()
}
match m.get("maximum") {
Some(Number(v, ..)) => result = result.max(v.to_int())
_ => ()
}
match m.get("exclusiveMinimum") {
Some(Number(v, ..)) =>
if v == 0.0 {
result = result.positive()
} else {
let int_val = v.to_int()
let is_integer = v == int_val.to_double()
let bound = if is_integer { int_val + 1 } else { int_val }
result = result.min(bound)
if !is_integer {
result = @core.append_rule(
result,
fn(json) {
match json {
Number(n, ..) => n > v
_ => false
}
},
"Value must be greater than \{v}",
)
}
}
_ => ()
}
match m.get("exclusiveMaximum") {
Some(Number(v, ..)) =>
if v == 0.0 {
result = result.negative()
} else {
let int_val = v.to_int()
let is_integer = v == int_val.to_double()
if is_integer {
result = result.max(int_val - 1)
} else {
result = @core.append_rule(
result,
fn(json) {
match json {
Number(n, ..) => n < v
_ => false
}
},
"Value must be less than \{v}",
)
}
}
_ => ()
}
match m.get("multipleOf") {
Some(Number(v, ..)) => result = result.multipleOf(v.to_int())
_ => ()
}
// Array constraints
match m.get("minItems") {
Some(Number(v, ..)) => result = result.min(v.to_int())
_ => ()
}
match m.get("maxItems") {
Some(Number(v, ..)) => result = result.max(v.to_int())
_ => ()
}
// Default value
match m.get("default") {
Some(val) => result = result.default(val)
_ => ()
}
result
}
// ─── Utility functions ───
///|
/// Extract $defs or definitions from JSON @core.Schema.
fn extract_defs(schema : Json) -> Map[String, Json] {
match schema {
Object(m) => {
match m.get("$defs") {
Some(Object(d)) => return d
_ => ()
}
match m.get("definitions") {
Some(Object(d)) => return d
_ => ()
}
{}
}
_ => {}
}
}
///|
/// Parse a $ref @core.string to extract the definition name.
fn parse_ref_name(ref_str : String) -> String {
let arr = ref_str.to_array()
let mut last = ""
for c in arr {
if c == '/' {
last = ""
} else {
last = last + c.to_string()
}
}
if last.is_empty() {
ref_str
} else {
last
}
}