Compare commits
5 Commits
cd1e2498e7
...
f6ad1f4e60
| Author | SHA1 | Date |
|---|---|---|
|
|
f6ad1f4e60 | |
|
|
ab123991ee | |
|
|
f96091e310 | |
|
|
5ba5342d39 | |
|
|
9ba690fc0e |
|
|
@ -141,7 +141,7 @@ moon_zod/
|
|||
├── importers/ # Schema import tools
|
||||
│ └── from_json_schema.mbt # json_schema_to_moon_zod() — reverse JSON Schema → moon_zod code generation
|
||||
│
|
||||
├── tests/ # Test suite (466 tests)
|
||||
├── tests/ # Test suite (479 tests)
|
||||
│ ├── test_string.mbt # string() validator tests (trim, to_lower, to_upper, nonempty)
|
||||
│ ├── test_number.mbt # number() validator tests
|
||||
│ ├── test_boolean_null.mbt # boolean/null tests
|
||||
|
|
@ -189,7 +189,7 @@ moon_zod/
|
|||
|
||||
```bash
|
||||
# Testing & Building
|
||||
moon test # Run all tests (466 total, 0 warnings)
|
||||
moon test # Run all tests (479 total, 0 warnings)
|
||||
moon build # Build the library
|
||||
moon check # Type check (0 errors, 0 warnings)
|
||||
moon info && moon fmt # Update interface + format
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ moon_zod/
|
|||
├── importers/ # Schema 导入工具
|
||||
│ └── from_json_schema.mbt # json_schema_to_moon_zod() —— 反向 JSON Schema → moon_zod 代码生成
|
||||
│
|
||||
├── tests/ # 测试套件(466 个测试)
|
||||
├── tests/ # 测试套件(479 个测试)
|
||||
│ ├── test_string.mbt # string() 校验器测试(trim、to_lower、to_upper、nonempty)
|
||||
│ ├── test_number.mbt # number() 校验器测试
|
||||
│ ├── test_boolean_null.mbt # boolean/null 测试
|
||||
|
|
@ -189,7 +189,7 @@ moon_zod/
|
|||
|
||||
```bash
|
||||
# 测试与构建
|
||||
moon test # 运行所有测试(共 466 个,0 警告)
|
||||
moon test # 运行所有测试(共 479 个,0 警告)
|
||||
moon build # 构建库
|
||||
moon check # 类型检查(0 错误,0 警告)
|
||||
moon info && moon fmt # 更新接口 + 格式化
|
||||
|
|
|
|||
|
|
@ -24,34 +24,42 @@ pub fn Schema::parse_array(
|
|||
element_schema : Schema,
|
||||
json : Json,
|
||||
path_stack : Array[String],
|
||||
) -> SchemaResult {
|
||||
) -> RawSchemaResult {
|
||||
match json {
|
||||
Array(elements) => {
|
||||
let errors : Array[ValidationError] = []
|
||||
let raw : Array[RawIssue] = []
|
||||
let mut i = 0
|
||||
for element in elements {
|
||||
path_stack.push("[\{i}]")
|
||||
let result = parse_inner(element_schema, element, path_stack)
|
||||
match result {
|
||||
Err(item_errors) =>
|
||||
for e in item_errors {
|
||||
errors.push(e)
|
||||
Err(item_raw) =>
|
||||
for e in item_raw {
|
||||
raw.push(e)
|
||||
}
|
||||
_ => ()
|
||||
}
|
||||
let _ = path_stack.pop()
|
||||
i = i + 1
|
||||
}
|
||||
collect_errors(errors, path_stack, json, self.rules)
|
||||
if errors.is_empty() {
|
||||
collect_raw_errors(raw, path_stack, json, self.rules)
|
||||
if raw.is_empty() {
|
||||
Ok(json)
|
||||
} else {
|
||||
Err(errors)
|
||||
Err(raw)
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let path = format_path(path_stack)
|
||||
Err([ValidationError::{ path, message: type_error_msg(self), got: json }])
|
||||
let message = type_msg(self)
|
||||
Err([
|
||||
RawIssue::{
|
||||
code: IssueCode::InvalidType(type_origin(self.schema_type)),
|
||||
path,
|
||||
message,
|
||||
input: json,
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,11 +43,31 @@ pub fn extract_constraints(rules : Array[Rule]) -> ConstraintInfo {
|
|||
let mut multiple_of_val = 0.0
|
||||
let custom_messages_val : Array[String] = []
|
||||
|
||||
// First pass: extract from JSON annotations
|
||||
// First pass: extract from IssueCode (structured, fast path)
|
||||
for rule in rules {
|
||||
match rule.code {
|
||||
TooSmall(_, val, _) => min_value = val
|
||||
TooBig(_, val, _) => max_value = val
|
||||
InvalidFormat(fmt) =>
|
||||
if fmt == "integer" {
|
||||
is_int_val = true
|
||||
} else {
|
||||
format_val = fmt
|
||||
}
|
||||
NotMultipleOf(div) => multiple_of_val = div
|
||||
_ => ()
|
||||
}
|
||||
match rule.code {
|
||||
TooSmall("number", 0.0, false) => is_positive_val = true
|
||||
TooBig("number", 0.0, false) => is_negative_val = true
|
||||
_ => ()
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: extract from JSON annotations (fallback for Custom rules)
|
||||
for rule in rules {
|
||||
match rule.annotation {
|
||||
Object(map) => {
|
||||
// Length/Value constraints
|
||||
if map.contains("minLength") {
|
||||
match map.get("minLength") {
|
||||
Some(Number(v, ..)) => min_value = v
|
||||
|
|
@ -84,8 +104,6 @@ pub fn extract_constraints(rules : Array[Rule]) -> ConstraintInfo {
|
|||
_ => ()
|
||||
}
|
||||
}
|
||||
|
||||
// Format and pattern
|
||||
if map.contains("format") {
|
||||
match map.get("format") {
|
||||
Some(String(s)) => format_val = s
|
||||
|
|
@ -98,16 +116,12 @@ pub fn extract_constraints(rules : Array[Rule]) -> ConstraintInfo {
|
|||
_ => ()
|
||||
}
|
||||
}
|
||||
|
||||
// Type-specific flags
|
||||
if map.contains("type") {
|
||||
match map.get("type") {
|
||||
Some(String(s)) => if s == "integer" { is_int_val = true }
|
||||
_ => ()
|
||||
}
|
||||
}
|
||||
|
||||
// Numeric constraints
|
||||
if map.contains("exclusiveMinimum") {
|
||||
match map.get("exclusiveMinimum") {
|
||||
Some(Number(v, ..)) => if v == 0.0 { is_positive_val = true }
|
||||
|
|
@ -131,11 +145,10 @@ pub fn extract_constraints(rules : Array[Rule]) -> ConstraintInfo {
|
|||
}
|
||||
}
|
||||
|
||||
// Second pass: collect custom error messages
|
||||
// Third pass: collect custom error messages
|
||||
for rule in rules {
|
||||
match rule.annotation {
|
||||
Null =>
|
||||
// Skip certain standard messages
|
||||
if rule.message != "String must not be empty" {
|
||||
custom_messages_val.push(rule.message)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ pub fn Schema::parse_default(
|
|||
default_val : Json,
|
||||
json : Json,
|
||||
path_stack : Array[String],
|
||||
) -> SchemaResult {
|
||||
) -> RawSchemaResult {
|
||||
match json {
|
||||
Null => Ok(default_val)
|
||||
_ => parse_inner(inner, json, path_stack)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ pub fn Schema::parse_enum(
|
|||
values : Array[String],
|
||||
json : Json,
|
||||
path_stack : Array[String],
|
||||
) -> SchemaResult {
|
||||
) -> RawSchemaResult {
|
||||
let path = format_path(path_stack)
|
||||
match json {
|
||||
String(s) =>
|
||||
|
|
@ -30,15 +30,21 @@ pub fn Schema::parse_enum(
|
|||
Ok(json)
|
||||
} else {
|
||||
Err([
|
||||
ValidationError::{ path, message: "Invalid enum value", got: json },
|
||||
RawIssue::{
|
||||
code: IssueCode::InvalidValue(values.map(fn(v) { Json::string(v) })),
|
||||
path,
|
||||
message: "Invalid enum value",
|
||||
input: json,
|
||||
},
|
||||
])
|
||||
}
|
||||
_ =>
|
||||
Err([
|
||||
ValidationError::{
|
||||
RawIssue::{
|
||||
code: IssueCode::InvalidType("string"),
|
||||
path,
|
||||
message: "Expected string for enum",
|
||||
got: json,
|
||||
input: json,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
///|
|
||||
/// Machine-readable issue code for structured error classification.
|
||||
pub(all) enum IssueCode {
|
||||
InvalidType(String)
|
||||
TooBig(String, Double, Bool)
|
||||
TooSmall(String, Double, Bool)
|
||||
InvalidFormat(String)
|
||||
NotMultipleOf(Double)
|
||||
UnrecognizedKeys(Array[String])
|
||||
InvalidUnion(Array[String])
|
||||
MissingRequired(String)
|
||||
InvalidKey(String)
|
||||
InvalidElement(String, Int)
|
||||
InvalidValue(Array[Json])
|
||||
Custom
|
||||
} derive(Debug, Eq)
|
||||
|
||||
///|
|
||||
/// Error map function: contextual override for error messages.
|
||||
/// Returns `Some(msg)` to override, `None` to fall through to the pre-resolved message.
|
||||
pub type ErrorMap = (IssueCode, String, Json) -> String?
|
||||
|
||||
///|
|
||||
/// Parameters for `Schema::safe_parse`.
|
||||
pub(all) struct ParseParams {
|
||||
path : String
|
||||
error_map : ErrorMap?
|
||||
}
|
||||
|
||||
///|
|
||||
pub fn ParseParams::default() -> ParseParams {
|
||||
ParseParams::{ path: "", error_map: None }
|
||||
}
|
||||
|
||||
///|
|
||||
/// A raw validation issue with the message pre-resolved at the generation site.
|
||||
/// No Schema reference needed — messages are resolved immediately from
|
||||
/// rule.message / schema.invalid_type_error / schema.required_error / hardcoded default.
|
||||
pub(all) struct RawIssue {
|
||||
code : IssueCode
|
||||
path : String
|
||||
message : String
|
||||
input : Json
|
||||
}
|
||||
|
||||
///|
|
||||
/// Resolve an error message using the error map priority chain.
|
||||
/// Priority: error_map (contextual) → pre-resolved message.
|
||||
pub fn finalize_issue(raw : RawIssue, params : ParseParams) -> ValidationError {
|
||||
let message = match params.error_map {
|
||||
Some(map) =>
|
||||
match map(raw.code, raw.path, raw.input) {
|
||||
Some(m) => if m.is_empty() { raw.message } else { m }
|
||||
None => raw.message
|
||||
}
|
||||
None => raw.message
|
||||
}
|
||||
ValidationError::{ code: raw.code, path: raw.path, message, got: raw.input }
|
||||
}
|
||||
|
||||
///|
|
||||
/// Convert raw issues to validation errors using finalize_issue.
|
||||
pub fn finalize_issues(
|
||||
raw_issues : Array[RawIssue],
|
||||
params : ParseParams,
|
||||
) -> Array[ValidationError] {
|
||||
raw_issues.map(fn(raw) { finalize_issue(raw, params) })
|
||||
}
|
||||
|
||||
///|
|
||||
/// Collect raw issues from rule failures.
|
||||
pub fn collect_raw_errors(
|
||||
out : Array[RawIssue],
|
||||
path_stack : Array[String],
|
||||
json : Json,
|
||||
rules : Array[Rule],
|
||||
) -> Unit {
|
||||
let path = format_path(path_stack)
|
||||
for rule in rules {
|
||||
if !(rule.check)(json) {
|
||||
out.push(RawIssue::{
|
||||
code: rule.code,
|
||||
path,
|
||||
message: rule.message,
|
||||
input: json,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
/// Map SchemaType to its origin string for IssueCode payloads.
|
||||
pub fn type_origin(t : SchemaType) -> String {
|
||||
match t {
|
||||
StringType => "string"
|
||||
NumberType => "number"
|
||||
BooleanType => "boolean"
|
||||
NullType => "null"
|
||||
AnyType => "any"
|
||||
UnknownType => "unknown"
|
||||
ObjectType(_, _) => "object"
|
||||
ArrayType(_) => "array"
|
||||
TupleType(_) => "tuple"
|
||||
EnumType(_) => "enum"
|
||||
UnionType(_) => "union"
|
||||
IntersectionType(_) => "intersection"
|
||||
LiteralType(_) => "literal"
|
||||
TransformType(_, _) => "transform"
|
||||
PreprocessType(_, _) => "preprocess"
|
||||
OptionalType(_) => "optional"
|
||||
DefaultType(_, _) => "default"
|
||||
}
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ pub fn Schema::parse_intersection(
|
|||
schemas : Array[Schema],
|
||||
json : Json,
|
||||
path_stack : Array[String],
|
||||
) -> SchemaResult {
|
||||
) -> RawSchemaResult {
|
||||
fn merge_json(a : Json, b : Json) -> Json {
|
||||
match (a, b) {
|
||||
(Object(map_a), Object(map_b)) => {
|
||||
|
|
@ -42,20 +42,20 @@ pub fn Schema::parse_intersection(
|
|||
_ => a
|
||||
}
|
||||
}
|
||||
let errors : Array[ValidationError] = []
|
||||
let raw : Array[RawIssue] = []
|
||||
let mut merged = json
|
||||
for s in schemas {
|
||||
match parse_inner(s, json, path_stack) {
|
||||
Ok(result) => merged = merge_json(merged, result)
|
||||
Err(errs) =>
|
||||
for e in errs {
|
||||
errors.push(e)
|
||||
raw.push(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
if errors.is_empty() {
|
||||
if raw.is_empty() {
|
||||
Ok(merged)
|
||||
} else {
|
||||
Err(errors)
|
||||
Err(raw)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ pub fn Schema::parse_literal(
|
|||
expected : Json,
|
||||
json : Json,
|
||||
path_stack : Array[String],
|
||||
) -> SchemaResult {
|
||||
) -> RawSchemaResult {
|
||||
let path = format_path(path_stack)
|
||||
if json == expected {
|
||||
Ok(json)
|
||||
|
|
@ -41,7 +41,14 @@ pub fn Schema::parse_literal(
|
|||
} else {
|
||||
_self.invalid_type_error
|
||||
}
|
||||
Err([ValidationError::{ path, message, got: json }])
|
||||
Err([
|
||||
RawIssue::{
|
||||
code: IssueCode::InvalidValue([expected]),
|
||||
path,
|
||||
message,
|
||||
input: json,
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -59,7 +66,7 @@ fn json_to_literal_string(json : Json) -> String {
|
|||
False => "false"
|
||||
Null => "null"
|
||||
Array(arr) => {
|
||||
let parts = arr.map(fn(v) { json_to_literal_string(v) })
|
||||
let parts : Array[String] = arr.map(fn(v) { json_to_literal_string(v) })
|
||||
"[" + parts.join(", ") + "]"
|
||||
}
|
||||
Object(map) => {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ pub fn Schema::int(self : Schema, msg? : String = "") -> Schema {
|
|||
}
|
||||
},
|
||||
message,
|
||||
IssueCode::InvalidFormat("integer"),
|
||||
Json::object({ "type": Json::string("integer") }),
|
||||
)
|
||||
}
|
||||
|
|
@ -53,6 +54,7 @@ pub fn Schema::positive(self : Schema, msg? : String = "") -> Schema {
|
|||
}
|
||||
},
|
||||
message,
|
||||
IssueCode::TooSmall("number", 0.0, false),
|
||||
Json::object({ "exclusiveMinimum": Json::number(0.0) }),
|
||||
)
|
||||
}
|
||||
|
|
@ -74,6 +76,7 @@ pub fn Schema::negative(self : Schema, msg? : String = "") -> Schema {
|
|||
}
|
||||
},
|
||||
message,
|
||||
IssueCode::TooBig("number", 0.0, false),
|
||||
Json::object({ "exclusiveMaximum": Json::number(0.0) }),
|
||||
)
|
||||
}
|
||||
|
|
@ -100,6 +103,7 @@ pub fn Schema::multipleOf(self : Schema, n : Int, msg? : String = "") -> Schema
|
|||
}
|
||||
},
|
||||
message,
|
||||
IssueCode::NotMultipleOf(n.to_double()),
|
||||
Json::object({ "multipleOf": Json::number(n.to_double()) }),
|
||||
)
|
||||
}
|
||||
|
|
@ -125,6 +129,7 @@ pub fn Schema::finite(self : Schema, msg? : String = "") -> Schema {
|
|||
}
|
||||
},
|
||||
message,
|
||||
IssueCode::Custom,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -152,5 +157,6 @@ pub fn Schema::safe(self : Schema, msg? : String = "") -> Schema {
|
|||
}
|
||||
},
|
||||
message,
|
||||
IssueCode::Custom,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -230,10 +230,10 @@ pub fn Schema::parse_object(
|
|||
mode : ObjectMode,
|
||||
json : Json,
|
||||
path_stack : Array[String],
|
||||
) -> SchemaResult {
|
||||
) -> RawSchemaResult {
|
||||
match json {
|
||||
Object(input_map) => {
|
||||
let errors : Array[ValidationError] = []
|
||||
let raw : Array[RawIssue] = []
|
||||
let parsed_fields : Map[String, Json] = Map([])
|
||||
for field_name in spec.keys() {
|
||||
match spec.get(field_name) {
|
||||
|
|
@ -248,18 +248,19 @@ pub fn Schema::parse_object(
|
|||
} else {
|
||||
"Required"
|
||||
}
|
||||
errors.push(ValidationError::{
|
||||
raw.push(RawIssue::{
|
||||
code: IssueCode::MissingRequired(field_name.to_string()),
|
||||
path,
|
||||
message: req_msg,
|
||||
got: Json::null(),
|
||||
input: Json::null(),
|
||||
})
|
||||
}
|
||||
Some(field_json) => {
|
||||
let result = parse_inner(field_schema, field_json, path_stack)
|
||||
match result {
|
||||
Err(field_errors) =>
|
||||
for e in field_errors {
|
||||
errors.push(e)
|
||||
Err(field_raw) =>
|
||||
for e in field_raw {
|
||||
raw.push(e)
|
||||
}
|
||||
Ok(parsed) => parsed_fields.set(field_name, parsed)
|
||||
}
|
||||
|
|
@ -279,10 +280,11 @@ pub fn Schema::parse_object(
|
|||
path_stack.push(key)
|
||||
let path = format_path(path_stack)
|
||||
let _ = path_stack.pop()
|
||||
errors.push(ValidationError::{
|
||||
raw.push(RawIssue::{
|
||||
code: IssueCode::UnrecognizedKeys([key]),
|
||||
path,
|
||||
message: "Unexpected field",
|
||||
got: value,
|
||||
input: value,
|
||||
})
|
||||
}
|
||||
None => ()
|
||||
|
|
@ -290,19 +292,27 @@ pub fn Schema::parse_object(
|
|||
}
|
||||
_ => ()
|
||||
}
|
||||
collect_errors(errors, path_stack, json, self.rules)
|
||||
if errors.is_empty() {
|
||||
collect_raw_errors(raw, path_stack, json, self.rules)
|
||||
if raw.is_empty() {
|
||||
match mode {
|
||||
Strip => Ok(Json::object(parsed_fields))
|
||||
_ => Ok(json)
|
||||
}
|
||||
} else {
|
||||
Err(errors)
|
||||
Err(raw)
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let path = format_path(path_stack)
|
||||
Err([ValidationError::{ path, message: type_error_msg(self), got: json }])
|
||||
let message = type_msg(self)
|
||||
Err([
|
||||
RawIssue::{
|
||||
code: IssueCode::InvalidType(type_origin(self.schema_type)),
|
||||
path,
|
||||
message,
|
||||
input: json,
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ pub fn Schema::parse_optional(
|
|||
inner : Schema,
|
||||
json : Json,
|
||||
path_stack : Array[String],
|
||||
) -> SchemaResult {
|
||||
) -> RawSchemaResult {
|
||||
match json {
|
||||
Null => Ok(json)
|
||||
_ => parse_inner(inner, json, path_stack)
|
||||
|
|
|
|||
|
|
@ -27,22 +27,24 @@ pub fn Schema::parse_preprocess(
|
|||
inner : Schema,
|
||||
json : Json,
|
||||
path_stack : Array[String],
|
||||
) -> SchemaResult {
|
||||
) -> RawSchemaResult {
|
||||
match (closure.f)(json) {
|
||||
Err(msg) => {
|
||||
let path = format_path(path_stack)
|
||||
Err([ValidationError::{ path, message: msg, got: json }])
|
||||
Err([
|
||||
RawIssue::{ code: IssueCode::Custom, path, message: msg, input: json },
|
||||
])
|
||||
}
|
||||
Ok(preprocessed) =>
|
||||
match parse_inner(inner, preprocessed, path_stack) {
|
||||
Err(e) => Err(e)
|
||||
Ok(parsed) => {
|
||||
let errors : Array[ValidationError] = []
|
||||
collect_errors(errors, path_stack, parsed, self.rules)
|
||||
if errors.is_empty() {
|
||||
let raw : Array[RawIssue] = []
|
||||
collect_raw_errors(raw, path_stack, parsed, self.rules)
|
||||
if raw.is_empty() {
|
||||
Ok(parsed)
|
||||
} else {
|
||||
Err(errors)
|
||||
Err(raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,5 +8,5 @@ pub fn Schema::refine(
|
|||
check : (Json) -> Bool,
|
||||
message : String,
|
||||
) -> Schema {
|
||||
append_rule(self, check, message)
|
||||
append_rule(self, check, message, IssueCode::Custom)
|
||||
}
|
||||
|
|
|
|||
109
core/schema.mbt
109
core/schema.mbt
|
|
@ -39,6 +39,7 @@ pub(all) struct TransformClosure {
|
|||
pub(all) struct Rule {
|
||||
check : (Json) -> Bool
|
||||
message : String
|
||||
code : IssueCode
|
||||
annotation : Json
|
||||
} derive(Debug)
|
||||
|
||||
|
|
@ -176,8 +177,9 @@ pub fn append_rule(
|
|||
schema : Schema,
|
||||
check : (Json) -> Bool,
|
||||
message : String,
|
||||
code : IssueCode,
|
||||
) -> Schema {
|
||||
append_rule_with_annotation(schema, check, message, Json::null())
|
||||
append_rule_with_annotation(schema, check, message, code, Json::null())
|
||||
}
|
||||
|
||||
///|
|
||||
|
|
@ -188,12 +190,13 @@ pub fn append_rule_with_annotation(
|
|||
schema : Schema,
|
||||
check : (Json) -> Bool,
|
||||
message : String,
|
||||
code : IssueCode,
|
||||
annotation : Json,
|
||||
) -> Schema {
|
||||
match schema.schema_type {
|
||||
OptionalType(inner) => {
|
||||
let new_inner = append_rule_with_annotation(
|
||||
inner, check, message, annotation,
|
||||
inner, check, message, code, annotation,
|
||||
)
|
||||
{
|
||||
schema_type: OptionalType(new_inner),
|
||||
|
|
@ -207,7 +210,7 @@ pub fn append_rule_with_annotation(
|
|||
}
|
||||
DefaultType(inner, default_val) => {
|
||||
let new_inner = append_rule_with_annotation(
|
||||
inner, check, message, annotation,
|
||||
inner, check, message, code, annotation,
|
||||
)
|
||||
{
|
||||
schema_type: DefaultType(new_inner, default_val),
|
||||
|
|
@ -219,53 +222,24 @@ pub fn append_rule_with_annotation(
|
|||
brand: schema.brand,
|
||||
}
|
||||
}
|
||||
_ => { ..schema, rules: schema.rules + [{ check, message, annotation }] }
|
||||
_ =>
|
||||
{ ..schema, rules: schema.rules + [{ check, message, code, annotation }] }
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
fn type_error_msg(schema : Schema) -> String {
|
||||
|
||||
///|
|
||||
fn type_msg(schema : Schema) -> String {
|
||||
let default = "Expected " + type_origin(schema.schema_type)
|
||||
if !schema.invalid_type_error.is_empty() {
|
||||
schema.invalid_type_error
|
||||
} else {
|
||||
expected_msg(schema.schema_type)
|
||||
default
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
fn expected_msg(schema_type : SchemaType) -> String {
|
||||
match schema_type {
|
||||
StringType => "Expected string"
|
||||
NumberType => "Expected number"
|
||||
BooleanType => "Expected boolean"
|
||||
NullType => "Expected null"
|
||||
AnyType => "Expected any"
|
||||
UnknownType => "Expected unknown"
|
||||
ObjectType(_, _) => "Expected object"
|
||||
ArrayType(_) => "Expected array"
|
||||
TupleType(_) => "Expected tuple"
|
||||
EnumType(_) => "Invalid enum value"
|
||||
IntersectionType(_) => "Expected intersection match"
|
||||
TransformType(_, _) => "Validation failed"
|
||||
LiteralType(_) => "Expected literal value"
|
||||
_ => "Validation failed"
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
fn collect_errors(
|
||||
errors : Array[ValidationError],
|
||||
path_stack : Array[String],
|
||||
json : Json,
|
||||
rules : Array[Rule],
|
||||
) -> Unit {
|
||||
let path = format_path(path_stack)
|
||||
for rule in rules {
|
||||
if !(rule.check)(json) {
|
||||
errors.push(ValidationError::{ path, message: rule.message, got: json })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
pub fn format_path(stack : Array[String]) -> String {
|
||||
|
|
@ -317,21 +291,19 @@ fn parse_inner(
|
|||
schema : Schema,
|
||||
json : Json,
|
||||
path_stack : Array[String],
|
||||
) -> SchemaResult {
|
||||
// All parse helpers are called through this function so the same
|
||||
// mutable path_stack is shared across the entire call tree.
|
||||
) -> RawSchemaResult {
|
||||
match schema.schema_type {
|
||||
ObjectType(spec, mode) => schema.parse_object(spec, mode, json, path_stack)
|
||||
ArrayType(element_schema) =>
|
||||
schema.parse_array(element_schema, json, path_stack)
|
||||
TupleType(items) => schema.parse_tuple(items, json, path_stack)
|
||||
AnyType | UnknownType => {
|
||||
let errors : Array[ValidationError] = []
|
||||
collect_errors(errors, path_stack, json, schema.rules)
|
||||
if errors.is_empty() {
|
||||
let raw : Array[RawIssue] = []
|
||||
collect_raw_errors(raw, path_stack, json, schema.rules)
|
||||
if raw.is_empty() {
|
||||
Ok(json)
|
||||
} else {
|
||||
Err(errors)
|
||||
Err(raw)
|
||||
}
|
||||
}
|
||||
OptionalType(inner) => schema.parse_optional(inner, json, path_stack)
|
||||
|
|
@ -356,16 +328,22 @@ fn parse_inner(
|
|||
}
|
||||
if !valid {
|
||||
let path = format_path(path_stack)
|
||||
let message = type_msg(schema)
|
||||
return Err([
|
||||
ValidationError::{ path, message: type_error_msg(schema), got: json },
|
||||
RawIssue::{
|
||||
code: IssueCode::InvalidType(type_origin(schema.schema_type)),
|
||||
path,
|
||||
message,
|
||||
input: json,
|
||||
},
|
||||
])
|
||||
}
|
||||
let errors : Array[ValidationError] = []
|
||||
collect_errors(errors, path_stack, json, schema.rules)
|
||||
if errors.is_empty() {
|
||||
let raw : Array[RawIssue] = []
|
||||
collect_raw_errors(raw, path_stack, json, schema.rules)
|
||||
if raw.is_empty() {
|
||||
Ok(json)
|
||||
} else {
|
||||
Err(errors)
|
||||
Err(raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -381,8 +359,33 @@ pub fn Schema::parse(
|
|||
json : Json,
|
||||
path? : String = "",
|
||||
) -> SchemaResult {
|
||||
let path_stack : Array[String] = if path.is_empty() { [] } else { [path] }
|
||||
parse_inner(self, json, path_stack)
|
||||
Schema::safe_parse(self, json, ParseParams::{ path, error_map: None })
|
||||
}
|
||||
|
||||
///|
|
||||
/// Validate `json` against this schema with extended parameters.
|
||||
/// Supports contextual error map override.
|
||||
///
|
||||
/// # Example
|
||||
/// ```mbt nocheck
|
||||
/// schema.safe_parse(json, { error_map: fn(code, path, input) {
|
||||
/// match code { InvalidType(expected) => Some("必须是: " + expected) _ => None }
|
||||
/// }})
|
||||
/// ```
|
||||
pub fn Schema::safe_parse(
|
||||
self : Schema,
|
||||
json : Json,
|
||||
params : ParseParams,
|
||||
) -> SchemaResult {
|
||||
let path_stack : Array[String] = if params.path.is_empty() {
|
||||
[]
|
||||
} else {
|
||||
[params.path]
|
||||
}
|
||||
match parse_inner(self, json, path_stack) {
|
||||
Ok(v) => Ok(v)
|
||||
Err(raw) => Err(finalize_issues(raw, params))
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
|
|
|
|||
|
|
@ -64,7 +64,19 @@ pub fn Schema::min(self : Schema, n : Int, msg? : String = "") -> Schema {
|
|||
ArrayType(_) => Json::object({ "minItems": Json::number(n.to_double()) })
|
||||
_ => Json::null()
|
||||
}
|
||||
append_rule_with_annotation(self, check, message, annotation)
|
||||
let origin = match inner_type(self.schema_type) {
|
||||
StringType => "string"
|
||||
NumberType => "number"
|
||||
ArrayType(_) => "array"
|
||||
_ => "value"
|
||||
}
|
||||
append_rule_with_annotation(
|
||||
self,
|
||||
check,
|
||||
message,
|
||||
IssueCode::TooSmall(origin, n.to_double(), true),
|
||||
annotation,
|
||||
)
|
||||
}
|
||||
|
||||
///|
|
||||
|
|
@ -116,7 +128,19 @@ pub fn Schema::max(self : Schema, n : Int, msg? : String = "") -> Schema {
|
|||
ArrayType(_) => Json::object({ "maxItems": Json::number(n.to_double()) })
|
||||
_ => Json::null()
|
||||
}
|
||||
append_rule_with_annotation(self, check, message, annotation)
|
||||
let origin = match inner_type(self.schema_type) {
|
||||
StringType => "string"
|
||||
NumberType => "number"
|
||||
ArrayType(_) => "array"
|
||||
_ => "value"
|
||||
}
|
||||
append_rule_with_annotation(
|
||||
self,
|
||||
check,
|
||||
message,
|
||||
IssueCode::TooBig(origin, n.to_double(), true),
|
||||
annotation,
|
||||
)
|
||||
}
|
||||
|
||||
///|
|
||||
|
|
@ -256,6 +280,7 @@ pub fn Schema::email(self : Schema, msg? : String = "") -> Schema {
|
|||
}
|
||||
},
|
||||
message,
|
||||
IssueCode::InvalidFormat("email"),
|
||||
Json::object({ "format": Json::string("email") }),
|
||||
)
|
||||
}
|
||||
|
|
@ -409,6 +434,7 @@ pub fn Schema::url(self : Schema, msg? : String = "") -> Schema {
|
|||
}
|
||||
},
|
||||
message,
|
||||
IssueCode::InvalidFormat("url"),
|
||||
Json::object({ "format": Json::string("uri") }),
|
||||
)
|
||||
}
|
||||
|
|
@ -441,6 +467,7 @@ pub fn Schema::regex(
|
|||
}
|
||||
},
|
||||
message,
|
||||
IssueCode::InvalidFormat("regex"),
|
||||
Json::object({ "pattern": Json::string(pattern) }),
|
||||
)
|
||||
}
|
||||
|
|
@ -470,6 +497,7 @@ pub fn Schema::startsWith(
|
|||
}
|
||||
},
|
||||
message,
|
||||
IssueCode::Custom,
|
||||
Json::object({ "pattern": Json::string("^" + prefix) }),
|
||||
)
|
||||
}
|
||||
|
|
@ -499,6 +527,7 @@ pub fn Schema::endsWith(
|
|||
}
|
||||
},
|
||||
message,
|
||||
IssueCode::Custom,
|
||||
Json::object({ "pattern": Json::string(suffix + "$") }),
|
||||
)
|
||||
}
|
||||
|
|
@ -528,6 +557,7 @@ pub fn Schema::includes(
|
|||
}
|
||||
},
|
||||
message,
|
||||
IssueCode::Custom,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -578,7 +608,6 @@ pub fn Schema::uuid(self : Schema, msg? : String = "") -> Schema {
|
|||
chars[19] != 'B' {
|
||||
return false
|
||||
}
|
||||
// All other chars must be hex digits
|
||||
let positions = [
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 15, 16, 17, 20, 21, 22, 24, 25,
|
||||
26, 27, 28, 29, 30, 31, 32, 33, 34, 35,
|
||||
|
|
@ -594,6 +623,7 @@ pub fn Schema::uuid(self : Schema, msg? : String = "") -> Schema {
|
|||
}
|
||||
},
|
||||
message,
|
||||
IssueCode::InvalidFormat("uuid"),
|
||||
Json::object({ "format": Json::string("uuid") }),
|
||||
)
|
||||
}
|
||||
|
|
@ -630,6 +660,7 @@ pub fn Schema::cuid(self : Schema, msg? : String = "") -> Schema {
|
|||
}
|
||||
},
|
||||
message,
|
||||
IssueCode::InvalidFormat("cuid"),
|
||||
Json::object({ "format": Json::string("cuid") }),
|
||||
)
|
||||
}
|
||||
|
|
@ -749,6 +780,7 @@ pub fn Schema::datetime(self : Schema, msg? : String = "") -> Schema {
|
|||
}
|
||||
},
|
||||
message,
|
||||
IssueCode::InvalidFormat("datetime"),
|
||||
Json::object({ "format": Json::string("date-time") }),
|
||||
)
|
||||
}
|
||||
|
|
@ -894,6 +926,7 @@ pub fn Schema::ipv4(self : Schema, msg? : String = "") -> Schema {
|
|||
}
|
||||
},
|
||||
message,
|
||||
IssueCode::InvalidFormat("ipv4"),
|
||||
Json::object({ "format": Json::string("ipv4") }),
|
||||
)
|
||||
}
|
||||
|
|
@ -919,6 +952,7 @@ pub fn Schema::ipv6(self : Schema, msg? : String = "") -> Schema {
|
|||
}
|
||||
},
|
||||
message,
|
||||
IssueCode::InvalidFormat("ipv6"),
|
||||
Json::object({ "format": Json::string("ipv6") }),
|
||||
)
|
||||
}
|
||||
|
|
@ -944,6 +978,7 @@ pub fn Schema::ip(self : Schema, msg? : String = "") -> Schema {
|
|||
}
|
||||
},
|
||||
message,
|
||||
IssueCode::InvalidFormat("ip"),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -991,6 +1026,12 @@ pub fn Schema::nonempty(self : Schema, msg? : String = "") -> Schema {
|
|||
_ => "Array must not be empty"
|
||||
}
|
||||
let message = if msg.is_empty() { default_msg } else { msg }
|
||||
let origin = match inner_type(self.schema_type) {
|
||||
StringType => "string"
|
||||
ArrayType(_) => "array"
|
||||
TupleType(_) => "tuple"
|
||||
_ => "value"
|
||||
}
|
||||
append_rule(
|
||||
self,
|
||||
fn(json) {
|
||||
|
|
@ -1001,6 +1042,7 @@ pub fn Schema::nonempty(self : Schema, msg? : String = "") -> Schema {
|
|||
}
|
||||
},
|
||||
message,
|
||||
IssueCode::TooSmall(origin, 1.0, true),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1055,7 +1097,7 @@ pub fn Schema::length(self : Schema, n : Int, msg? : String = "") -> Schema {
|
|||
let check = schema_length_check(self, n)
|
||||
let message = if msg.is_empty() { schema_length_msg(self, n) } else { msg }
|
||||
// No annotation — avoids conflict with minLength/maxLength
|
||||
append_rule(self, check, message)
|
||||
append_rule(self, check, message, IssueCode::Custom)
|
||||
}
|
||||
|
||||
///|
|
||||
|
|
@ -1098,6 +1140,7 @@ pub fn Schema::ulid(self : Schema, msg? : String = "") -> Schema {
|
|||
}
|
||||
},
|
||||
message,
|
||||
IssueCode::InvalidFormat("ulid"),
|
||||
Json::object({ "format": Json::string("ulid") }),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,23 +38,30 @@ pub fn Schema::parse_transform(
|
|||
closure : TransformClosure,
|
||||
json : Json,
|
||||
path_stack : Array[String],
|
||||
) -> SchemaResult {
|
||||
) -> RawSchemaResult {
|
||||
match parse_inner(inner, json, path_stack) {
|
||||
Err(e) => Err(e)
|
||||
Ok(validated) =>
|
||||
match (closure.f)(validated) {
|
||||
Ok(transformed) => {
|
||||
let errors : Array[ValidationError] = []
|
||||
collect_errors(errors, path_stack, transformed, _self.rules)
|
||||
if errors.is_empty() {
|
||||
let raw : Array[RawIssue] = []
|
||||
collect_raw_errors(raw, path_stack, transformed, _self.rules)
|
||||
if raw.is_empty() {
|
||||
Ok(transformed)
|
||||
} else {
|
||||
Err(errors)
|
||||
Err(raw)
|
||||
}
|
||||
}
|
||||
Err(msg) => {
|
||||
let path = format_path(path_stack)
|
||||
Err([ValidationError::{ path, message: msg, got: json }])
|
||||
Err([
|
||||
RawIssue::{
|
||||
code: IssueCode::Custom,
|
||||
path,
|
||||
message: msg,
|
||||
input: json,
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,40 +24,61 @@ pub fn Schema::parse_tuple(
|
|||
items : Array[Schema],
|
||||
json : Json,
|
||||
path_stack : Array[String],
|
||||
) -> SchemaResult {
|
||||
) -> RawSchemaResult {
|
||||
match json {
|
||||
Array(elements) => {
|
||||
let errors : Array[ValidationError] = []
|
||||
if elements.length() != items.length() {
|
||||
let raw : Array[RawIssue] = []
|
||||
let actual_len = elements.length()
|
||||
let expected_len = items.length()
|
||||
if actual_len < expected_len {
|
||||
let path = format_path(path_stack)
|
||||
errors.push(ValidationError::{
|
||||
let msg = "Expected tuple of length \{expected_len}"
|
||||
raw.push(RawIssue::{
|
||||
code: IssueCode::TooSmall("tuple", expected_len.to_double(), true),
|
||||
path,
|
||||
message: "Expected tuple of length \{items.length()}",
|
||||
got: json,
|
||||
message: msg,
|
||||
input: json,
|
||||
})
|
||||
} else if actual_len > expected_len {
|
||||
let path = format_path(path_stack)
|
||||
let msg = "Expected tuple of length \{expected_len}"
|
||||
raw.push(RawIssue::{
|
||||
code: IssueCode::TooBig("tuple", expected_len.to_double(), true),
|
||||
path,
|
||||
message: msg,
|
||||
input: json,
|
||||
})
|
||||
} else {
|
||||
for i in 0..<items.length() {
|
||||
path_stack.push("[\{i}]")
|
||||
match parse_inner(items[i], elements[i], path_stack) {
|
||||
Err(item_errors) =>
|
||||
for e in item_errors {
|
||||
errors.push(e)
|
||||
Err(item_raw) =>
|
||||
for e in item_raw {
|
||||
raw.push(e)
|
||||
}
|
||||
_ => ()
|
||||
}
|
||||
let _ = path_stack.pop()
|
||||
}
|
||||
}
|
||||
collect_errors(errors, path_stack, json, self.rules)
|
||||
if errors.is_empty() {
|
||||
collect_raw_errors(raw, path_stack, json, self.rules)
|
||||
if raw.is_empty() {
|
||||
Ok(json)
|
||||
} else {
|
||||
Err(errors)
|
||||
Err(raw)
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let path = format_path(path_stack)
|
||||
Err([ValidationError::{ path, message: type_error_msg(self), got: json }])
|
||||
let message = type_msg(self)
|
||||
Err([
|
||||
RawIssue::{
|
||||
code: IssueCode::InvalidType(type_origin(self.schema_type)),
|
||||
path,
|
||||
message,
|
||||
input: json,
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
///|
|
||||
/// Represents a single validation failure.
|
||||
///
|
||||
/// `code` is the structured issue code for machine-readable error classification.
|
||||
/// `path` is the field path (e.g. `"name"`, `"address.city"`).
|
||||
/// `message` describes what went wrong.
|
||||
/// `got` is the actual JSON value that failed validation.
|
||||
pub(all) struct ValidationError {
|
||||
code : IssueCode
|
||||
path : String
|
||||
message : String
|
||||
got : Json
|
||||
}
|
||||
} derive(Debug)
|
||||
|
||||
///|
|
||||
/// Format a validation error as a human-readable string.
|
||||
|
|
@ -18,7 +20,9 @@ pub fn ValidationError::to_string(self : ValidationError) -> String {
|
|||
self.path +
|
||||
"] " +
|
||||
self.message +
|
||||
" (got: " +
|
||||
" (code: " +
|
||||
@debug.to_string(self.code) +
|
||||
", got: " +
|
||||
@debug.to_string(self.got) +
|
||||
")"
|
||||
}
|
||||
|
|
@ -27,3 +31,7 @@ pub fn ValidationError::to_string(self : ValidationError) -> String {
|
|||
/// Result type returned by `Schema::parse`.
|
||||
/// `Ok(json)` on success, `Err(errors)` with all collected errors on failure.
|
||||
pub type SchemaResult = Result[Json, Array[ValidationError]]
|
||||
|
||||
///|
|
||||
/// Internal result type used during parsing before error message finalization.
|
||||
pub type RawSchemaResult = Result[Json, Array[RawIssue]]
|
||||
|
|
|
|||
|
|
@ -23,15 +23,12 @@ pub fn Schema::parse_union(
|
|||
schemas : Array[Schema],
|
||||
json : Json,
|
||||
path_stack : Array[String],
|
||||
) -> SchemaResult {
|
||||
) -> RawSchemaResult {
|
||||
let all_branch_errors : Array[String] = []
|
||||
for s in schemas {
|
||||
match parse_inner(s, json, path_stack) {
|
||||
Ok(v) => return Ok(v)
|
||||
Err(errors) =>
|
||||
if errors.length() > 0 {
|
||||
all_branch_errors.push(errors[0].message)
|
||||
}
|
||||
Err(raw) => if raw.length() > 0 { all_branch_errors.push(raw[0].message) }
|
||||
}
|
||||
}
|
||||
let path = format_path(path_stack)
|
||||
|
|
@ -39,5 +36,12 @@ pub fn Schema::parse_union(
|
|||
let message = "Expected union type, but all branches failed. Branches: [" +
|
||||
branches +
|
||||
"]"
|
||||
Err([ValidationError::{ path, message, got: json }])
|
||||
Err([
|
||||
RawIssue::{
|
||||
code: IssueCode::InvalidUnion(all_branch_errors),
|
||||
path,
|
||||
message,
|
||||
input: json,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ moon_zod/
|
|||
├── importers/ # Schema import tools
|
||||
│ └── from_json_schema.mbt # json_schema_to_moon_zod() — reverse JSON Schema → moon_zod code generation
|
||||
│
|
||||
├── tests/ # Test suite (466 tests)
|
||||
├── tests/ # Test suite (479 tests)
|
||||
│ ├── test_string.mbt # string() validator tests (trim, to_lower, to_upper, nonempty)
|
||||
│ ├── test_number.mbt # number() validator tests
|
||||
│ ├── test_boolean_null.mbt # boolean/null tests
|
||||
|
|
@ -104,7 +104,7 @@ moon_zod/
|
|||
|
||||
```bash
|
||||
# Testing & Building
|
||||
moon test # Run all tests (466 total, 0 warnings)
|
||||
moon test # Run all tests (479 total, 0 warnings)
|
||||
moon build # Build the library
|
||||
moon check # Type check (0 errors, 0 warnings)
|
||||
moon info && moon fmt # Update interface + format
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ moon_zod/
|
|||
├── importers/ # Schema 导入工具
|
||||
│ └── from_json_schema.mbt # json_schema_to_moon_zod() —— 反向 JSON Schema → moon_zod 代码生成
|
||||
│
|
||||
├── tests/ # 测试套件(466 个测试)
|
||||
├── tests/ # 测试套件(479 个测试)
|
||||
│ ├── test_string.mbt # string() 校验器测试(trim、to_lower、to_upper、nonempty)
|
||||
│ ├── test_number.mbt # number() 校验器测试
|
||||
│ ├── test_boolean_null.mbt # boolean/null 测试
|
||||
|
|
@ -104,7 +104,7 @@ moon_zod/
|
|||
|
||||
```bash
|
||||
# 测试与构建
|
||||
moon test # 运行所有测试(共 466 个,0 警告)
|
||||
moon test # 运行所有测试(共 479 个,0 警告)
|
||||
moon build # 构建库
|
||||
moon check # 类型检查(0 错误,0 警告)
|
||||
moon info && moon fmt # 更新接口 + 格式化
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ fn json_to_schema_impl(
|
|||
}
|
||||
},
|
||||
"Value must be one of the allowed numbers",
|
||||
@core.IssueCode::Custom,
|
||||
)
|
||||
return result_schema
|
||||
}
|
||||
|
|
@ -320,6 +321,7 @@ fn apply_json_schema_constraints(
|
|||
}
|
||||
},
|
||||
"Value must be greater than \{v}",
|
||||
@core.IssueCode::Custom,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -344,6 +346,7 @@ fn apply_json_schema_constraints(
|
|||
}
|
||||
},
|
||||
"Value must be less than \{v}",
|
||||
@core.IssueCode::Custom,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,14 @@ pub using @core {
|
|||
type ValidationError,
|
||||
type SchemaResult,
|
||||
type ConstraintInfo,
|
||||
type IssueCode,
|
||||
type RawIssue,
|
||||
type ParseParams,
|
||||
type ErrorMap,
|
||||
type RawSchemaResult,
|
||||
finalize_issue,
|
||||
finalize_issues,
|
||||
collect_raw_errors,
|
||||
string,
|
||||
number,
|
||||
boolean,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,251 @@
|
|||
///|
|
||||
test "safe_parse accepts valid input" {
|
||||
let s = string().min(3)
|
||||
let params = ParseParams::default()
|
||||
match s.safe_parse(Json::string("abc"), params) {
|
||||
Ok(v) => @debug.assert_eq(v, Json::string("abc"))
|
||||
Err(_) => fail("expected Ok")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
test "safe_parse returns errors for invalid input" {
|
||||
let s = string().min(3)
|
||||
let params = ParseParams::default()
|
||||
guard s.safe_parse(Json::string("ab"), params) is Err(_) else {
|
||||
fail("expected Err")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
test "error_map overrides type error message" {
|
||||
let s = string()
|
||||
let params = ParseParams::{
|
||||
path: "",
|
||||
error_map: Some(fn(code, _path, _input) {
|
||||
match code {
|
||||
IssueCode::InvalidType(_) => Some("必须是字符串")
|
||||
_ => None
|
||||
}
|
||||
}),
|
||||
}
|
||||
match s.safe_parse(Json::number(42.0), params) {
|
||||
Err(errors) => @debug.assert_eq(errors[0].message, "必须是字符串")
|
||||
Ok(_) => fail("expected Err")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
test "error_map overrides missing required message" {
|
||||
let s = object({ "name": string() })
|
||||
let params = ParseParams::{
|
||||
path: "",
|
||||
error_map: Some(fn(code, _path, _input) {
|
||||
match code {
|
||||
IssueCode::MissingRequired(_) => Some("缺少姓名")
|
||||
_ => None
|
||||
}
|
||||
}),
|
||||
}
|
||||
match s.safe_parse(parse_json("{}"), params) {
|
||||
Err(errors) => @debug.assert_eq(errors[0].message, "缺少姓名")
|
||||
Ok(_) => fail("expected Err")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
test "error_map overrides union error message" {
|
||||
let s = union([string(), number()])
|
||||
let params = ParseParams::{
|
||||
path: "",
|
||||
error_map: Some(fn(code, _path, _input) {
|
||||
match code {
|
||||
IssueCode::InvalidUnion(_) => Some("必须是字符串或数字")
|
||||
_ => None
|
||||
}
|
||||
}),
|
||||
}
|
||||
match s.safe_parse(Json::boolean(true), params) {
|
||||
Err(errors) =>
|
||||
@debug.assert_eq(errors[0].message, "必须是字符串或数字")
|
||||
Ok(_) => fail("expected Err")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
test "error_map does not affect other parses" {
|
||||
let s = string()
|
||||
let params = ParseParams::{
|
||||
path: "",
|
||||
error_map: Some(fn(code, _path, _input) {
|
||||
match code {
|
||||
IssueCode::InvalidType(_) => Some("custom type error")
|
||||
_ => None
|
||||
}
|
||||
}),
|
||||
}
|
||||
match s.safe_parse(Json::number(42.0), params) {
|
||||
Err(errors) => @debug.assert_eq(errors[0].message, "custom type error")
|
||||
Ok(_) => fail("expected Err")
|
||||
}
|
||||
let default_params = ParseParams::default()
|
||||
match s.safe_parse(Json::number(42.0), default_params) {
|
||||
Err(errors) => @debug.assert_eq(errors[0].message, "Expected string")
|
||||
Ok(_) => fail("expected Err")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
test "error_map none uses default message" {
|
||||
let s = string()
|
||||
let params = ParseParams::default()
|
||||
match s.safe_parse(Json::number(42.0), params) {
|
||||
Err(errors) => @debug.assert_eq(errors[0].message, "Expected string")
|
||||
Ok(_) => fail("expected Err")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
test "error_map empty string falls back to inline or schema default" {
|
||||
let s = string(invalid_type_error="custom type error")
|
||||
let params = ParseParams::{
|
||||
path: "",
|
||||
error_map: Some(fn(code, _path, _input) {
|
||||
match code {
|
||||
IssueCode::InvalidType(_) => Some("")
|
||||
_ => None
|
||||
}
|
||||
}),
|
||||
}
|
||||
match s.safe_parse(Json::number(42.0), params) {
|
||||
Err(errors) => @debug.assert_eq(errors[0].message, "custom type error")
|
||||
Ok(_) => fail("expected Err")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
test "error_map empty string falls back to hardcoded default" {
|
||||
let s = string()
|
||||
let params = ParseParams::{
|
||||
path: "",
|
||||
error_map: Some(fn(code, _path, _input) {
|
||||
match code {
|
||||
IssueCode::InvalidType(_) => Some("")
|
||||
_ => None
|
||||
}
|
||||
}),
|
||||
}
|
||||
match s.safe_parse(Json::number(42.0), params) {
|
||||
Err(errors) => @debug.assert_eq(errors[0].message, "Expected string")
|
||||
Ok(_) => fail("expected Err")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
test "error_map multiple errors in one parse" {
|
||||
let s = object({ "name": string(), "age": number().int() })
|
||||
let params = ParseParams::{
|
||||
path: "",
|
||||
error_map: Some(fn(code, _path, _input) {
|
||||
match code {
|
||||
IssueCode::MissingRequired(_) => Some("缺少必填字段")
|
||||
IssueCode::InvalidType(_) => Some("类型错误")
|
||||
_ => None
|
||||
}
|
||||
}),
|
||||
}
|
||||
let input = parse_json("{\"name\": true}")
|
||||
match s.safe_parse(input, params) {
|
||||
Err(errors) => {
|
||||
@debug.assert_eq(errors.length(), 2)
|
||||
let messages = errors.map(fn(e) { e.message })
|
||||
@debug.assert_eq(messages.contains("缺少必填字段"), true)
|
||||
@debug.assert_eq(messages.contains("类型错误"), true)
|
||||
}
|
||||
Ok(_) => fail("expected Err")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
test "error_map priority over invalid_type_error" {
|
||||
let s = string(invalid_type_error="schema type error")
|
||||
let params = ParseParams::{
|
||||
path: "",
|
||||
error_map: Some(fn(code, _path, _input) {
|
||||
match code {
|
||||
IssueCode::InvalidType(_) => Some("map type error")
|
||||
_ => None
|
||||
}
|
||||
}),
|
||||
}
|
||||
match s.safe_parse(Json::number(42.0), params) {
|
||||
Err(errors) => @debug.assert_eq(errors[0].message, "map type error")
|
||||
Ok(_) => fail("expected Err")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
test "error_map nested schema" {
|
||||
let s = object({ "user": object({ "age": number().int() }) })
|
||||
let params = ParseParams::{
|
||||
path: "",
|
||||
error_map: Some(fn(code, _path, _input) {
|
||||
match code {
|
||||
IssueCode::InvalidType(expected) => Some("类型错误: " + expected)
|
||||
_ => None
|
||||
}
|
||||
}),
|
||||
}
|
||||
let input = parse_json("{\"user\": {\"age\": \"x\"}}")
|
||||
match s.safe_parse(input, params) {
|
||||
Err(errors) => @debug.assert_eq(errors[0].message, "类型错误: number")
|
||||
Ok(_) => fail("expected Err")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
test "error_map array elements" {
|
||||
let s = array(number().int())
|
||||
let params = ParseParams::{
|
||||
path: "",
|
||||
error_map: Some(fn(code, _path, _input) {
|
||||
match code {
|
||||
IssueCode::InvalidType(expected) => Some("需要: " + expected)
|
||||
_ => None
|
||||
}
|
||||
}),
|
||||
}
|
||||
let input = parse_json("[1, \"two\"]")
|
||||
match s.safe_parse(input, params) {
|
||||
Err(errors) => @debug.assert_eq(errors[0].message, "需要: number")
|
||||
Ok(_) => fail("expected Err")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
test "error_map priority over type error inline" {
|
||||
let s = string(invalid_type_error="custom type error")
|
||||
let params = ParseParams::{
|
||||
path: "",
|
||||
error_map: Some(fn(code, _path, _input) {
|
||||
match code {
|
||||
IssueCode::InvalidType(_) => Some("map type error")
|
||||
_ => None
|
||||
}
|
||||
}),
|
||||
}
|
||||
match s.safe_parse(Json::number(42.0), params) {
|
||||
Err(errors) => @debug.assert_eq(errors[0].message, "map type error")
|
||||
Ok(_) => fail("expected Err")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
test "safe_parse with path prefix" {
|
||||
let s = string().min(3)
|
||||
let params = ParseParams::{ path: "user.name", error_map: None }
|
||||
match s.safe_parse(Json::string("ab"), params) {
|
||||
Err(errors) => @debug.assert_eq(errors[0].path, "user.name")
|
||||
Ok(_) => fail("expected Err")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
///|
|
||||
test "validation_error has code field" {
|
||||
let s = string()
|
||||
guard s.parse(Json::number(42.0)) is Err(errors) else { fail("expected Err") }
|
||||
@debug.assert_eq(errors[0].code, IssueCode::InvalidType("string"))
|
||||
}
|
||||
|
||||
///|
|
||||
test "issue_code invalid_type string" {
|
||||
let s = string()
|
||||
guard s.parse(Json::number(42.0)) is Err(errors) else { fail("expected Err") }
|
||||
@debug.assert_eq(errors[0].code, IssueCode::InvalidType("string"))
|
||||
}
|
||||
|
||||
///|
|
||||
test "issue_code invalid_type number" {
|
||||
let s = number()
|
||||
guard s.parse(Json::string("abc")) is Err(errors) else {
|
||||
fail("expected Err")
|
||||
}
|
||||
@debug.assert_eq(errors[0].code, IssueCode::InvalidType("number"))
|
||||
}
|
||||
|
||||
///|
|
||||
test "issue_code invalid_type boolean" {
|
||||
let s = boolean()
|
||||
guard s.parse(Json::string("true")) is Err(errors) else {
|
||||
fail("expected Err")
|
||||
}
|
||||
@debug.assert_eq(errors[0].code, IssueCode::InvalidType("boolean"))
|
||||
}
|
||||
|
||||
///|
|
||||
test "issue_code invalid_type object" {
|
||||
let s = object(Map([]))
|
||||
guard s.parse(Json::string("hello")) is Err(errors) else {
|
||||
fail("expected Err")
|
||||
}
|
||||
@debug.assert_eq(errors[0].code, IssueCode::InvalidType("object"))
|
||||
}
|
||||
|
||||
///|
|
||||
test "issue_code invalid_type array" {
|
||||
let s = array(string())
|
||||
guard s.parse(Json::string("hello")) is Err(errors) else {
|
||||
fail("expected Err")
|
||||
}
|
||||
@debug.assert_eq(errors[0].code, IssueCode::InvalidType("array"))
|
||||
}
|
||||
|
||||
///|
|
||||
test "issue_code rule_error_too_small" {
|
||||
let s = string().min(3)
|
||||
guard s.parse(Json::string("ab")) is Err(errors) else { fail("expected Err") }
|
||||
@debug.assert_eq(errors[0].code, IssueCode::TooSmall("string", 3.0, true))
|
||||
}
|
||||
|
||||
///|
|
||||
test "issue_code email_rule_invalid_format" {
|
||||
let s = string().email()
|
||||
guard s.parse(Json::string("not-an-email")) is Err(errors) else {
|
||||
fail("expected Err")
|
||||
}
|
||||
@debug.assert_eq(errors[0].code, IssueCode::InvalidFormat("email"))
|
||||
}
|
||||
|
||||
///|
|
||||
test "issue_code refine is custom" {
|
||||
let s = string().refine(
|
||||
fn(s) {
|
||||
match s {
|
||||
Json::String(str) => str.length() > 3
|
||||
_ => false
|
||||
}
|
||||
},
|
||||
"too short",
|
||||
)
|
||||
guard s.parse(Json::string("ab")) is Err(errors) else { fail("expected Err") }
|
||||
@debug.assert_eq(errors[0].code, IssueCode::Custom)
|
||||
@debug.assert_eq(errors[0].message, "too short")
|
||||
}
|
||||
|
||||
///|
|
||||
test "issue_code missing_required" {
|
||||
let s = object({ "name": string() })
|
||||
let input = parse_json("{}")
|
||||
guard s.parse(input) is Err(errors) else { fail("expected Err") }
|
||||
@debug.assert_eq(errors[0].code, IssueCode::MissingRequired("name"))
|
||||
}
|
||||
|
||||
///|
|
||||
test "issue_code missing_required custom message" {
|
||||
let s = object({ "name": string(required_error="姓名必填") })
|
||||
let input = parse_json("{}")
|
||||
guard s.parse(input) is Err(errors) else { fail("expected Err") }
|
||||
@debug.assert_eq(errors[0].code, IssueCode::MissingRequired("name"))
|
||||
@debug.assert_eq(errors[0].message, "姓名必填")
|
||||
}
|
||||
|
||||
///|
|
||||
test "issue_code invalid_union" {
|
||||
let s = union([string(), number()])
|
||||
guard s.parse(Json::boolean(true)) is Err(errors) else {
|
||||
fail("expected Err")
|
||||
}
|
||||
match errors[0].code {
|
||||
IssueCode::InvalidUnion(_) => ()
|
||||
_ => fail("expected InvalidUnion")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
test "issue_code unrecognized_keys strict" {
|
||||
let s = object({ "name": string() }).strict()
|
||||
let input = parse_json("{\"name\": \"Alice\", \"extra\": 1}")
|
||||
guard s.parse(input) is Err(errors) else { fail("expected Err") }
|
||||
@debug.assert_eq(errors[0].code, IssueCode::UnrecognizedKeys(["extra"]))
|
||||
}
|
||||
|
||||
///|
|
||||
test "issue_code invalid_value enum" {
|
||||
let s = enum_values(["red", "green", "blue"])
|
||||
guard s.parse(Json::string("yellow")) is Err(errors) else {
|
||||
fail("expected Err")
|
||||
}
|
||||
match errors[0].code {
|
||||
IssueCode::InvalidValue(_) => ()
|
||||
_ => fail("expected InvalidValue")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
test "issue_code invalid_value literal" {
|
||||
let s = literal(Json::string("expected"))
|
||||
guard s.parse(Json::string("actual")) is Err(errors) else {
|
||||
fail("expected Err")
|
||||
}
|
||||
match errors[0].code {
|
||||
IssueCode::InvalidValue(_) => ()
|
||||
_ => fail("expected InvalidValue")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
test "issue_code nested object path" {
|
||||
let s = object({ "user": object({ "age": number().int() }) })
|
||||
let input = parse_json("{\"user\": {\"age\": \"x\"}}")
|
||||
guard s.parse(input) is Err(errors) else { fail("expected Err") }
|
||||
@debug.assert_eq(errors[0].path, "user.age")
|
||||
@debug.assert_eq(errors[0].code, IssueCode::InvalidType("number"))
|
||||
}
|
||||
|
||||
///|
|
||||
test "issue_code array_index_path" {
|
||||
let s = array(number().int())
|
||||
let input = parse_json("[1, \"two\", 3]")
|
||||
guard s.parse(input) is Err(errors) else { fail("expected Err") }
|
||||
@debug.assert_eq(errors[0].path, "[1]")
|
||||
@debug.assert_eq(errors[0].code, IssueCode::InvalidType("number"))
|
||||
}
|
||||
|
||||
///|
|
||||
test "issue_code optional_missing_no_error" {
|
||||
let s = object({ "name": string().optional() })
|
||||
guard s.parse(parse_json("{}")) is Ok(_) else { fail("expected Ok") }
|
||||
}
|
||||
|
||||
///|
|
||||
test "issue_code default_uses_default_no_error" {
|
||||
let s = object({ "name": string().default("anon") })
|
||||
guard s.parse(parse_json("{}")) is Ok(_) else { fail("expected Ok") }
|
||||
}
|
||||
|
|
@ -226,10 +226,9 @@ test "schema_to_prompt refine custom message" {
|
|||
}
|
||||
|
||||
///|
|
||||
test "schema_to_prompt nonempty is filtered" {
|
||||
// nonempty has no annotation and its message matches the built-in
|
||||
test "schema_to_prompt nonempty shows min constraint" {
|
||||
let s = string().nonempty()
|
||||
@debug.assert_eq(schema_to_prompt(s), "string")
|
||||
@debug.assert_eq(schema_to_prompt(s), "string // [min: 1]")
|
||||
}
|
||||
|
||||
///|
|
||||
|
|
|
|||
Loading…
Reference in New Issue