///|
/// ajv 风格入口:把一份 JSON Schema 文档编译为可复用的 `Schema`。
///
/// 布尔模式 `true` / `false` 也是合法的 schema(2020-12)。
///
/// ```moonbit nocheck
/// let v = compile(
/// @json.parse!(#|{ "type": "string", "minLength": 1 }#|),
/// )
/// v.check(Json::string("hi")) // true
/// ```
pub fn compile(
schema : Json,
options? : CompileOptions = CompileOptions::new(),
) -> Schema raise CompileError {
let ctx : Ctx = {
root: schema,
strict: options.strict,
assert_format: options.assert_format,
locale: options.locale,
cache: Map([], capacity=16),
}
let node = compile_node(schema, ctx, ["#"])
{ node, ctx, }
}
///|
fn compile_schema(
v : Json,
ctx : Ctx,
spath : Array[String],
) -> Schema raise CompileError {
let node = compile_node(v, ctx, spath)
{ node, ctx, }
}
///|
fn compile_node(
v : Json,
ctx : Ctx,
spath : Array[String],
) -> Node raise CompileError {
match v {
True => BoolNode(true)
False => BoolNode(false)
Object(_) => SchemaNode(compile_keywords(v, ctx, spath))
_ =>
raise InvalidSchema(
schema_pointer(spath),
"a schema must be an object or a boolean",
)
}
}
///|
fn compile_subschema(
kw_name : String,
v : Json,
ctx : Ctx,
spath : Array[String],
) -> Schema raise CompileError {
spath.push(kw_name)
let s = compile_schema(v, ctx, spath)
ignore(spath.pop())
s
}
///|
fn compile_schema_list(
kw_name : String,
v : Json,
ctx : Ctx,
spath : Array[String],
) -> Array[Schema] raise CompileError {
let path = schema_pointer(spath)
guard v is Array(arr) else {
raise InvalidSchema(path, "\"\{kw_name}\" must be an array of schemas")
}
let list : Array[Schema] = []
for i in 0.. Array[String] raise CompileError {
let path = schema_pointer(spath)
guard v is Array(arr) else {
raise InvalidSchema(path, "\"\{kw_name}\" must be an array of strings")
}
let list : Array[String] = []
for i in 0.. Map[String, Array[String]] raise CompileError {
guard v is Object(m) else {
raise InvalidSchema(
schema_pointer(spath),
"\"\{kw_name}\" must be an object",
)
}
let out : Map[String, Array[String]] = Map([], capacity=16)
for k, arr in m {
spath.push(kw_name)
spath.push(k)
out[k] = compile_string_list(kw_name, arr, spath)
ignore(spath.pop())
ignore(spath.pop())
}
out
}
///|
fn compile_schema_map(
kw_name : String,
v : Json,
ctx : Ctx,
spath : Array[String],
) -> Map[String, Schema] raise CompileError {
guard v is Object(m) else {
raise InvalidSchema(
schema_pointer(spath),
"\"\{kw_name}\" must be an object",
)
}
let out : Map[String, Schema] = Map([], capacity=16)
for k, sub in m {
spath.push(kw_name)
spath.push(k)
out[k] = compile_schema(sub, ctx, spath)
ignore(spath.pop())
ignore(spath.pop())
}
out
}
///|
fn compile_count(
kw_name : String,
v : Json,
spath : Array[String],
) -> Int raise CompileError {
let path = schema_pointer(spath)
guard v is Number(d, ..) else {
raise InvalidSchema(path, "\"\{kw_name}\" must be a non-negative integer")
}
if d.is_nan() || !(d >= 0.0 && d <= 9.0e15 && d == d.floor()) {
raise InvalidSchema(path, "\"\{kw_name}\" must be a non-negative integer")
}
d.to_int()
}
///|
fn compile_number(
kw_name : String,
v : Json,
spath : Array[String],
) -> Double raise CompileError {
let path = schema_pointer(spath)
guard v is Number(d, ..) else {
raise InvalidSchema(path, "\"\{kw_name}\" must be a number")
}
d
}
///|
fn get_str(
kw_name : String,
v : Json,
path : String,
) -> String raise CompileError {
guard v is String(s) else {
raise InvalidSchema(path, "\"\{kw_name}\" must be a string")
}
s
}
///|
fn has_str_prefix(s : String, prefix : String) -> Bool {
if s.length() < prefix.length() {
return false
}
let pc = prefix.to_array()
let sc = s.to_array()
for i in 0.. Keywords raise CompileError {
let path = schema_pointer(spath)
let kw = Keywords::empty()
let map = match v {
Object(m) => m
_ => raise InvalidSchema(path, "internal: expected an object schema")
}
for name, kv in map {
match name {
// 元数据与 annotation:合法但不参与校验
// ($id 例外:根节点忽略,子节点意味着 base URI 变更,见下方专门分支)
"$schema"
| "$comment"
| "$defs"
| "definitions"
| "title"
| "description"
| "default"
| "examples"
| "deprecated"
| "readOnly"
| "writeOnly"
| "contentEncoding"
| "contentMediaType"
| "contentSchema" => ()
"type" =>
match kv {
String(s) => kw.typ = Some([JsonType::parse(s, path)])
Array(a) => {
let ts : Array[JsonType] = []
for i in 0.. ts.push(JsonType::parse(s, path))
_ =>
raise InvalidSchema(
path, "\"type\" array must contain type names",
)
}
}
kw.typ = Some(ts)
}
_ =>
raise InvalidSchema(
path, "\"type\" must be a string or an array of strings",
)
}
"enum" =>
match kv {
Array(a) => kw.enum_ = Some(a)
_ => raise InvalidSchema(path, "\"enum\" must be a non-empty array")
}
"const" => kw.const_ = Some(kv)
"multipleOf" => {
let d = compile_number("multipleOf", kv, spath)
if !(d > 0.0) {
raise InvalidSchema(path, "\"multipleOf\" must be > 0")
}
kw.multiple_of = Some(d)
}
"maximum" => kw.maximum = Some(compile_number("maximum", kv, spath))
"exclusiveMaximum" =>
kw.exclusive_maximum = Some(
compile_number("exclusiveMaximum", kv, spath),
)
"minimum" => kw.minimum = Some(compile_number("minimum", kv, spath))
"exclusiveMinimum" =>
kw.exclusive_minimum = Some(
compile_number("exclusiveMinimum", kv, spath),
)
"minLength" => kw.min_length = Some(compile_count("minLength", kv, spath))
"maxLength" => kw.max_length = Some(compile_count("maxLength", kv, spath))
"format" => kw.format = Some(get_str("format", kv, path))
"pattern" => {
let p = get_str("pattern", kv, path)
let re = compile_regex(p, path)
kw.pattern = Some((re, p))
}
"minItems" => kw.min_items = Some(compile_count("minItems", kv, spath))
"maxItems" => kw.max_items = Some(compile_count("maxItems", kv, spath))
"uniqueItems" =>
match kv {
True => kw.unique_items = Some(true)
False => kw.unique_items = Some(false)
_ => raise InvalidSchema(path, "\"uniqueItems\" must be a boolean")
}
"prefixItems" =>
kw.prefix_items = Some(
compile_schema_list("prefixItems", kv, ctx, spath),
)
"items" =>
match kv {
// draft-07 的数组形式在 2020-12 已更名为 prefixItems,直接指出避免歧义
Array(_) =>
raise InvalidSchema(
path, "\"items\" must be a single schema in 2020-12; the array form was renamed to \"prefixItems\"",
)
_ => kw.items = Some(compile_subschema("items", kv, ctx, spath))
}
"contains" =>
kw.contains = Some(compile_subschema("contains", kv, ctx, spath))
"minContains" =>
kw.min_contains = Some(compile_count("minContains", kv, spath))
"maxContains" =>
kw.max_contains = Some(compile_count("maxContains", kv, spath))
"minProperties" =>
kw.min_properties = Some(compile_count("minProperties", kv, spath))
"maxProperties" =>
kw.max_properties = Some(compile_count("maxProperties", kv, spath))
"required" =>
kw.required = Some(compile_string_list("required", kv, spath))
"properties" =>
kw.properties = Some(compile_schema_map("properties", kv, ctx, spath))
"patternProperties" =>
match kv {
Object(m) => {
let arr : Array[(@string.Regex, String, Schema)] = []
for pk, sub in m {
let re = compile_regex(pk, path)
spath.push("patternProperties")
spath.push(pk)
arr.push((re, pk, compile_schema(sub, ctx, spath)))
ignore(spath.pop())
ignore(spath.pop())
}
kw.pattern_properties = Some(arr)
}
_ =>
raise InvalidSchema(path, "\"patternProperties\" must be an object")
}
"propertyNames" =>
kw.property_names = Some(
compile_subschema("propertyNames", kv, ctx, spath),
)
"additionalProperties" =>
match kv {
True => ()
False => kw.additional_closed = Some(true)
_ =>
kw.additional = Some(
compile_subschema("additionalProperties", kv, ctx, spath),
)
}
"dependentRequired" =>
kw.dependent_required = Some(
compile_string_map("dependentRequired", kv, spath),
)
"dependentSchemas" =>
kw.dependent_schemas = Some(
compile_schema_map("dependentSchemas", kv, ctx, spath),
)
// 引擎扩展:跨字段动态规则。表达式在编译期解析,语法错误即刻暴露;
// 校验期路径相对声明本关键词的节点,引用的路径全部存在才参与判定
"x-rules" =>
match kv {
Array(a) => {
let srcs : Array[String] = []
let exprs : Array[@rules.Expr] = []
for i in 0..
raise InvalidSchema(
path, "\"x-rules\" must be an array of rule strings",
)
}
"allOf" => kw.all_of = Some(compile_schema_list("allOf", kv, ctx, spath))
"anyOf" => kw.any_of = Some(compile_schema_list("anyOf", kv, ctx, spath))
"oneOf" => kw.one_of = Some(compile_schema_list("oneOf", kv, ctx, spath))
"not" => kw.not_ = Some(compile_subschema("not", kv, ctx, spath))
"if" => kw.if_ = Some(compile_subschema("if", kv, ctx, spath))
"then" => kw.then_ = Some(compile_subschema("then", kv, ctx, spath))
"else" => kw.else_ = Some(compile_subschema("else", kv, ctx, spath))
// 2020-12:$ref 可与其他关键词并存,校验时先解析 ref,再验证其余关键词。
// 仅支持 JSON Pointer fragment("#"/"#/...");命名 fragment($anchor
// 引用如 "#foo")在编译期明确拒绝,避免静默误判。
"$ref" => {
let r = get_str("$ref", kv, path)
if !has_str_prefix(r, "#") {
raise InvalidSchema(
path,
"only local \"$ref\" targets (\"#/...\") are supported in v0, got \"\{r}\"",
)
}
if r != "#" && !has_str_prefix(r, "#/") {
raise InvalidSchema(
path,
"named fragment \"$ref\" targets (e.g. \"#foo\", $anchor refs) are not supported in v0, got \"\{r}\"",
)
}
kw.ref_ = Some(r)
}
// 仅根节点允许 $id(当作 annotation);子 schema 中的 $id 意味着
// base URI 变更,会改变 $ref 解析语义,v0 不支持,strict 下明确拒绝
"$id" =>
if spath.length() > 1 && ctx.strict {
raise UnsupportedKeyword(path, "$id (base URI change)")
}
other =>
// strict 模式(ajv 风格):不认识的关键词直接报错,`x-` 前缀的厂商扩展除外
if ctx.strict && !has_str_prefix(other, "x-") {
raise UnsupportedKeyword(path, other)
}
}
}
kw
}
///|
/// 编译期解析跨字段规则表达式,语法错误包装为 InvalidSchema 即刻暴露。
fn compile_rule(src : String, path : String) -> @rules.Expr raise CompileError {
let r : @rules.Expr? = Some(@rules.compile_expr(src)) catch {
// compile_expr 只会抛 ExprSyntax
ExprSyntax(m) => raise InvalidSchema(path, "invalid rule \"\{src}\": \{m}")
}
match r {
Some(e) => e
None => raise InvalidSchema(path, "invalid rule \"\{src}\"")
}
}
///|
/// 编译期把正则表达式解析为 `@string.Regex`,非法模式立即报错。
fn compile_regex(p : String, path : String) -> @string.Regex raise CompileError {
let parsed : @string.Regex? = Some(@string.Regex::Regex(p)) catch {
_ => None
}
match parsed {
Some(re) => re
None => raise InvalidSchema(path, "invalid regular expression \"\{p}\"")
}
}