feat(tool): Top5#2 完整 JSON-Schema 校验 + 类型强转 + 错误路径 + defaults(对齐 OpenClaw validation)
- 引入 github.com/santhosh-tekuri/jsonschema/v5(纯 Go,OpenClaw/MCP SDK 同思路)。 - validate.go 重写:validateArgs 用 jsonschema Compile+Validate 替换手写子集(required单层), 支持 required/properties/嵌套 object/array items/additionalProperties/allOf/anyOf/oneOf/ 类型联合/enum/min-max/pattern/defaults;编译器缓存 sync.Map;Draft7。 - coerce.go:对齐 OpenClaw coerceWithJsonSchema 类型强转(string→number/integer/boolean/ array/object,number/bool→string;union 处理;object/array 递归)。 - defaults.go:applyDefaults 缺失属性按 default 填(含嵌套),先填默认再强转再校验。 - 错误路径:ValidationError.InstanceLocation → 带路径报告(/count: expected integer...)。 - 测试:tool_test.go 更新 required 语义 + 补强转/default/错误路径用例。 go build/vet/test 全绿 + windows/darwin 交叉编译 + gofmt。
This commit is contained in:
1 parent
24d5924cee
commit
464ed2cad3
6 files changed
+718
-156
No files matched your search
@@ -0,0 +1,407 @@
|
||||
package tool
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// maxJSONCoerceLen caps how long a string we will attempt to JSON-parse when
|
||||
// coercing a model-emitted string into an array or object. It mirrors the cap
|
||||
// OpenClaw uses so a huge string argument fails fast instead of being parsed.
|
||||
const maxJSONCoerceLen = 64 * 1024
|
||||
|
||||
// jsonNumberRe matches a JSON number token; used to decide whether a model
|
||||
// string such as "3.5" may be safely coerced to a number.
|
||||
var jsonNumberRe = regexp.MustCompile(`^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$`)
|
||||
|
||||
// coerceWithSchema mirrors OpenClaw's coerceWithJsonSchema: it walks the
|
||||
// schema's allOf/anyOf/oneOf, coerces a value whose type does not yet match,
|
||||
// then recurses into object properties and array items. It returns the value it
|
||||
// was given (mutating maps/slices in place) so validation sees coerced types.
|
||||
func coerceWithSchema(value any, schema map[string]any) any {
|
||||
next := value
|
||||
|
||||
if arr, ok := schema["allOf"].([]any); ok {
|
||||
for _, raw := range arr {
|
||||
if s, ok := raw.(map[string]any); ok {
|
||||
next = coerceWithSchema(next, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if arr, ok := schema["anyOf"].([]any); ok {
|
||||
next = coerceWithUnion(next, arr)
|
||||
}
|
||||
if arr, ok := schema["oneOf"].([]any); ok {
|
||||
next = coerceWithUnion(next, arr)
|
||||
}
|
||||
|
||||
types := schemaTypes(schema)
|
||||
if len(types) > 0 && !matchesAnyType(next, types) {
|
||||
for _, t := range types {
|
||||
if coerced, ok := coercePrimitiveByType(next, t); ok {
|
||||
next = coerced
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isObject(next) {
|
||||
applyObjectCoercion(next.(map[string]any), schema)
|
||||
}
|
||||
if isArray(next) {
|
||||
applyArrayCoercion(next.([]any), schema)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// coerceWithUnion tries each union member in turn, cloning the value and
|
||||
// coercing against that member, returning the first candidate that validates.
|
||||
// A null value is returned as-is when some member accepts null so it is not
|
||||
// mangled into the empty string by a string branch.
|
||||
func coerceWithUnion(value any, schemas []any) any {
|
||||
if value == nil {
|
||||
for _, raw := range schemas {
|
||||
s, ok := raw.(map[string]any)
|
||||
if !ok || !contains(schemaTypes(s), "null") {
|
||||
continue
|
||||
}
|
||||
if compiled, err := compileSchema(s); err == nil && compiled.Validate(value) == nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, raw := range schemas {
|
||||
s, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
candidate := coerceWithSchema(deepCopy(value), s)
|
||||
if compiled, err := compileSchema(s); err == nil && compiled.Validate(candidate) == nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// applyObjectCoercion recurses into declared properties and, when
|
||||
// additionalProperties is itself a schema, into undeclared keys.
|
||||
func applyObjectCoercion(value map[string]any, schema map[string]any) {
|
||||
props, _ := schema["properties"].(map[string]any)
|
||||
for name, raw := range props {
|
||||
ps, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if val, present := value[name]; present {
|
||||
value[name] = coerceWithSchema(val, ps)
|
||||
}
|
||||
}
|
||||
if ap, ok := schema["additionalProperties"].(map[string]any); ok {
|
||||
for name, val := range value {
|
||||
if _, defined := props[name]; !defined {
|
||||
value[name] = coerceWithSchema(val, ap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyArrayCoercion recurses into array items, handling the schematic,
|
||||
// tuple-in-items and 2020-12 prefixItems forms.
|
||||
func applyArrayCoercion(value []any, schema map[string]any) {
|
||||
switch items := schema["items"].(type) {
|
||||
case map[string]any:
|
||||
for i := range value {
|
||||
value[i] = coerceWithSchema(value[i], items)
|
||||
}
|
||||
case []any:
|
||||
for i, item := range items {
|
||||
if i >= len(value) {
|
||||
break
|
||||
}
|
||||
if is, ok := item.(map[string]any); ok {
|
||||
value[i] = coerceWithSchema(value[i], is)
|
||||
}
|
||||
}
|
||||
}
|
||||
if prefix, ok := schema["prefixItems"].([]any); ok {
|
||||
for i, item := range prefix {
|
||||
if i >= len(value) {
|
||||
break
|
||||
}
|
||||
if is, ok := item.(map[string]any); ok {
|
||||
value[i] = coerceWithSchema(value[i], is)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// coercePrimitiveByType maps a requested JSON type to its coercion helper; when
|
||||
// no helper can apply it returns the value unchanged.
|
||||
func coercePrimitiveByType(value any, typ string) (any, bool) {
|
||||
switch typ {
|
||||
case "number":
|
||||
return coerceNumber(value)
|
||||
case "integer":
|
||||
return coerceInteger(value)
|
||||
case "boolean":
|
||||
return coerceBoolean(value)
|
||||
case "string":
|
||||
return coerceString(value)
|
||||
case "array":
|
||||
return coerceArray(value)
|
||||
case "object":
|
||||
return coerceObject(value)
|
||||
case "null":
|
||||
return coerceNull(value)
|
||||
default:
|
||||
return value, false
|
||||
}
|
||||
}
|
||||
|
||||
func coerceNumber(value any) (any, bool) {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return 0, true
|
||||
case string:
|
||||
if trimmed := strings.TrimSpace(v); trimmed != "" {
|
||||
if n, ok := parseNumber(trimmed); ok {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
case bool:
|
||||
if v {
|
||||
return 1, true
|
||||
}
|
||||
return 0, true
|
||||
}
|
||||
return value, false
|
||||
}
|
||||
|
||||
func coerceInteger(value any) (any, bool) {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return 0, true
|
||||
case string:
|
||||
if trimmed := strings.TrimSpace(v); trimmed != "" {
|
||||
if n, ok := parseNumber(trimmed); ok && math.Trunc(n) == n {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
case bool:
|
||||
if v {
|
||||
return 1, true
|
||||
}
|
||||
return 0, true
|
||||
}
|
||||
return value, false
|
||||
}
|
||||
|
||||
func coerceBoolean(value any) (any, bool) {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return false, true
|
||||
case string:
|
||||
if v == "true" {
|
||||
return true, true
|
||||
}
|
||||
if v == "false" {
|
||||
return false, true
|
||||
}
|
||||
case float64:
|
||||
if v == 1 {
|
||||
return true, true
|
||||
}
|
||||
if v == 0 {
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
return value, false
|
||||
}
|
||||
|
||||
func coerceString(value any) (any, bool) {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return "", true
|
||||
case bool:
|
||||
return strconv.FormatBool(v), true
|
||||
case float64:
|
||||
return strconv.FormatFloat(v, 'f', -1, 64), true
|
||||
case float32:
|
||||
return strconv.FormatFloat(float64(v), 'f', -1, 32), true
|
||||
case int:
|
||||
return strconv.Itoa(v), true
|
||||
case int64:
|
||||
return strconv.FormatInt(v, 10), true
|
||||
case json.Number:
|
||||
return v.String(), true
|
||||
}
|
||||
return value, false
|
||||
}
|
||||
|
||||
func coerceArray(value any) (any, bool) {
|
||||
s, ok := value.(string)
|
||||
if !ok || len(s) > maxJSONCoerceLen || strings.TrimSpace(s) == "" {
|
||||
return value, false
|
||||
}
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(s), &parsed); err != nil {
|
||||
return value, false
|
||||
}
|
||||
if arr, ok := parsed.([]any); ok {
|
||||
return arr, true
|
||||
}
|
||||
return value, false
|
||||
}
|
||||
|
||||
func coerceObject(value any) (any, bool) {
|
||||
s, ok := value.(string)
|
||||
if !ok || len(s) > maxJSONCoerceLen || strings.TrimSpace(s) == "" {
|
||||
return value, false
|
||||
}
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(s), &parsed); err != nil {
|
||||
return value, false
|
||||
}
|
||||
if obj, ok := parsed.(map[string]any); ok {
|
||||
return obj, true
|
||||
}
|
||||
return value, false
|
||||
}
|
||||
|
||||
func coerceNull(value any) (any, bool) {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return nil, true
|
||||
}
|
||||
case float64:
|
||||
if v == 0 {
|
||||
return nil, true
|
||||
}
|
||||
case bool:
|
||||
if !v {
|
||||
return nil, true
|
||||
}
|
||||
}
|
||||
return value, false
|
||||
}
|
||||
|
||||
// parseNumber parses a strictly JSON-shaped numeric token (no hex, Inf, NaN).
|
||||
func parseNumber(s string) (float64, bool) {
|
||||
if !jsonNumberRe.MatchString(s) {
|
||||
return 0, false
|
||||
}
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil || math.IsInf(f, 0) || math.IsNaN(f) {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
}
|
||||
|
||||
// schemaTypes returns the type names declared by a schema (string or union).
|
||||
func schemaTypes(schema map[string]any) []string {
|
||||
switch t := schema["type"].(type) {
|
||||
case string:
|
||||
return []string{t}
|
||||
case []any:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, item := range t {
|
||||
if s, ok := item.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// matchesAnyType reports whether value already satisfies one of the type names.
|
||||
func matchesAnyType(value any, types []string) bool {
|
||||
for _, t := range types {
|
||||
if matchesJSONType(value, t) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesJSONType reports whether value satisfies a single JSON type name.
|
||||
func matchesJSONType(value any, typ string) bool {
|
||||
switch typ {
|
||||
case "number":
|
||||
return isNumber(value)
|
||||
case "integer":
|
||||
return isInteger(value)
|
||||
case "boolean":
|
||||
_, ok := value.(bool)
|
||||
return ok
|
||||
case "string":
|
||||
_, ok := value.(string)
|
||||
return ok
|
||||
case "null":
|
||||
return value == nil
|
||||
case "array":
|
||||
return isArray(value)
|
||||
case "object":
|
||||
return isObject(value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// isObject reports whether v is a JSON object (map[string]any).
|
||||
func isObject(v any) bool {
|
||||
_, ok := v.(map[string]any)
|
||||
return ok
|
||||
}
|
||||
|
||||
// isArray reports whether v is a JSON array ([]any).
|
||||
func isArray(v any) bool {
|
||||
_, ok := v.([]any)
|
||||
return ok
|
||||
}
|
||||
|
||||
// contains reports whether slice has s.
|
||||
func contains(slice []string, s string) bool {
|
||||
for _, item := range slice {
|
||||
if item == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isNumber reports whether v is any integer or floating-point numeric type.
|
||||
func isNumber(v any) bool {
|
||||
switch v.(type) {
|
||||
case json.Number, float32, float64,
|
||||
int, int8, int16, int32, int64,
|
||||
uint, uint8, uint16, uint32, uint64:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// isInteger reports whether v is an integer type or a floating-point / json
|
||||
// Number that holds an integral value.
|
||||
func isInteger(v any) bool {
|
||||
switch x := v.(type) {
|
||||
case int, int8, int16, int32, int64,
|
||||
uint, uint8, uint16, uint32, uint64:
|
||||
return true
|
||||
case float32:
|
||||
return math.Trunc(float64(x)) == float64(x)
|
||||
case float64:
|
||||
return math.Trunc(x) == x
|
||||
case json.Number:
|
||||
_, err := strconv.ParseInt(x.String(), 10, 64)
|
||||
return err == nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user