// Package validate implements deterministic contract validation. // // ArchitectureBlueprint.md section 5.4 requires each revision to have an // identifiable contract the gateway can enforce, and FluidAPIStandards.md // section 36 makes "a deterministic API contract" the first minimal-conformance // requirement. Validation must therefore be a pure function of the request and // the contract: no inference, no model, no defaults invented at runtime. // // The JSON Schema support here is a documented subset rather than a complete // implementation. The subset covers what an interface contract actually // constrains — types, required members, enums, bounds and nesting — and // anything outside it is reported as unsupported rather than silently passed. // A validator that quietly ignores a keyword it does not understand is worse // than no validator, because it reports success. package validate import ( "errors" "fmt" "math" "sort" "strings" ) // Schema is the supported JSON Schema subset. type Schema struct { Type any `json:"type,omitempty" yaml:"type,omitempty"` Properties map[string]*Schema `json:"properties,omitempty" yaml:"properties,omitempty"` Required []string `json:"required,omitempty" yaml:"required,omitempty"` Items *Schema `json:"items,omitempty" yaml:"items,omitempty"` Enum []any `json:"enum,omitempty" yaml:"enum,omitempty"` Format string `json:"format,omitempty" yaml:"format,omitempty"` AdditionalProperties *bool `json:"additionalProperties,omitempty" yaml:"additionalProperties,omitempty"` Minimum *float64 `json:"minimum,omitempty" yaml:"minimum,omitempty"` Maximum *float64 `json:"maximum,omitempty" yaml:"maximum,omitempty"` MinLength *int `json:"minLength,omitempty" yaml:"minLength,omitempty"` MaxLength *int `json:"maxLength,omitempty" yaml:"maxLength,omitempty"` MinItems *int `json:"minItems,omitempty" yaml:"minItems,omitempty"` MaxItems *int `json:"maxItems,omitempty" yaml:"maxItems,omitempty"` Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` // Nullable follows OpenAPI 3.0; 3.1 uses a type union instead. Both are // accepted because contracts in the wild use both. Nullable bool `json:"nullable,omitempty" yaml:"nullable,omitempty"` } // Violation is one contract breach, named precisely enough to fix. type Violation struct { // Path locates the offending value, such as "body.entry.title". Path string // Message says what is wrong in terms the consumer can act on. Message string } func (v Violation) String() string { if v.Path == "" { return v.Message } return v.Path + ": " + v.Message } // Result collects violations from one validation. type Result struct { Violations []Violation } // OK reports whether validation passed. func (r *Result) OK() bool { return len(r.Violations) == 0 } // Error renders every violation, most specific path first. func (r *Result) Error() string { if r.OK() { return "" } sorted := make([]Violation, len(r.Violations)) copy(sorted, r.Violations) sort.Slice(sorted, func(i, j int) bool { if sorted[i].Path != sorted[j].Path { return sorted[i].Path < sorted[j].Path } return sorted[i].Message < sorted[j].Message }) parts := make([]string, len(sorted)) for i, v := range sorted { parts[i] = v.String() } return strings.Join(parts, "; ") } // FirstPath returns the path of the first violation, for error reporting. func (r *Result) FirstPath() string { if r.OK() { return "" } best := r.Violations[0].Path for _, v := range r.Violations[1:] { if v.Path < best { best = v.Path } } return best } func (r *Result) add(path, format string, args ...any) { r.Violations = append(r.Violations, Violation{Path: path, Message: fmt.Sprintf(format, args...)}) } // ErrUnsupported reports a schema keyword outside the supported subset. var ErrUnsupported = errors.New("unsupported schema construct") // Resolver resolves $ref pointers within a document. type Resolver interface { Resolve(ref string) (*Schema, error) } // Validate checks a decoded JSON value against a schema. func Validate(value any, schema *Schema, resolver Resolver) *Result { r := &Result{} validateValue(value, schema, "", r, resolver, 0) return r } // maxDepth bounds recursion so a cyclic $ref cannot hang the request path. const maxDepth = 64 func validateValue(value any, schema *Schema, path string, r *Result, resolver Resolver, depth int) { if schema == nil { return } if depth > maxDepth { r.add(path, "schema nesting exceeds %d levels; refusing to recurse further", maxDepth) return } if schema.Ref != "" { if resolver == nil { r.add(path, "schema uses $ref %q but no resolver is configured", schema.Ref) return } resolved, err := resolver.Resolve(schema.Ref) if err != nil { r.add(path, "cannot resolve $ref %q: %v", schema.Ref, err) return } validateValue(value, resolved, path, r, resolver, depth+1) return } if value == nil { if schema.Nullable || typeAllows(schema, "null") { return } if schema.Type != nil { r.add(path, "must not be null") } return } if schema.Type != nil && !matchesType(value, schema) { r.add(path, "expected %s, got %s", describeType(schema.Type), goTypeName(value)) return } if len(schema.Enum) > 0 && !inEnum(value, schema.Enum) { r.add(path, "value %v is not one of %v", value, schema.Enum) } switch v := value.(type) { case map[string]any: validateObject(v, schema, path, r, resolver, depth) case []any: validateArray(v, schema, path, r, resolver, depth) case string: validateString(v, schema, path, r) case float64: validateNumber(v, schema, path, r) } } func validateObject(obj map[string]any, schema *Schema, path string, r *Result, resolver Resolver, depth int) { for _, req := range schema.Required { if _, ok := obj[req]; !ok { r.add(join(path, req), "is required") } } if schema.AdditionalProperties != nil && !*schema.AdditionalProperties && schema.Properties != nil { keys := make([]string, 0, len(obj)) for k := range obj { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { if _, declared := schema.Properties[k]; !declared { // An unknown field is a discoverability signal as much as an // error: it is usually a consumer guessing at a capability. r.add(join(path, k), "is not a field of this contract") } } } if schema.Properties == nil { return } names := make([]string, 0, len(schema.Properties)) for name := range schema.Properties { names = append(names, name) } sort.Strings(names) for _, name := range names { if v, ok := obj[name]; ok { validateValue(v, schema.Properties[name], join(path, name), r, resolver, depth+1) } } } func validateArray(arr []any, schema *Schema, path string, r *Result, resolver Resolver, depth int) { if schema.MinItems != nil && len(arr) < *schema.MinItems { r.add(path, "needs at least %d items, has %d", *schema.MinItems, len(arr)) } if schema.MaxItems != nil && len(arr) > *schema.MaxItems { r.add(path, "allows at most %d items, has %d", *schema.MaxItems, len(arr)) } if schema.Items == nil { return } for i, item := range arr { validateValue(item, schema.Items, fmt.Sprintf("%s[%d]", path, i), r, resolver, depth+1) } } func validateString(s string, schema *Schema, path string, r *Result) { // Length is counted in runes, not bytes. A 4096-character limit that // rejected a 3000-character entry because of accents would be wrong in // exactly the case this framework was built to publish. length := len([]rune(s)) if schema.MinLength != nil && length < *schema.MinLength { r.add(path, "needs at least %d characters, has %d", *schema.MinLength, length) } if schema.MaxLength != nil && length > *schema.MaxLength { r.add(path, "allows at most %d characters, has %d", *schema.MaxLength, length) } } func validateNumber(n float64, schema *Schema, path string, r *Result) { if schema.Minimum != nil && n < *schema.Minimum { r.add(path, "must be at least %v", *schema.Minimum) } if schema.Maximum != nil && n > *schema.Maximum { r.add(path, "must be at most %v", *schema.Maximum) } } // typeAllows reports whether a schema's type declaration includes want. func typeAllows(schema *Schema, want string) bool { switch t := schema.Type.(type) { case string: return t == want case []any: for _, v := range t { if s, ok := v.(string); ok && s == want { return true } } } return false } func matchesType(value any, schema *Schema) bool { names := typeNames(schema.Type) if len(names) == 0 { return true } for _, name := range names { if matchesSingleType(value, name) { return true } } return false } func matchesSingleType(value any, name string) bool { switch name { case "object": _, ok := value.(map[string]any) return ok case "array": _, ok := value.([]any) return ok case "string": _, ok := value.(string) return ok case "boolean": _, ok := value.(bool) return ok case "number": _, ok := value.(float64) return ok case "integer": // JSON has one number type; an integer is a number with no fraction. f, ok := value.(float64) return ok && f == math.Trunc(f) case "null": return value == nil } return true } func typeNames(t any) []string { switch v := t.(type) { case string: return []string{v} case []any: out := make([]string, 0, len(v)) for _, item := range v { if s, ok := item.(string); ok { out = append(out, s) } } return out } return nil } func describeType(t any) string { names := typeNames(t) if len(names) == 0 { return "any" } return strings.Join(names, " or ") } func goTypeName(v any) string { switch t := v.(type) { case map[string]any: return "object" case []any: return "array" case string: return "string" case bool: return "boolean" case float64: if t == math.Trunc(t) { return "integer" } return "number" case nil: return "null" } return fmt.Sprintf("%T", v) } func inEnum(value any, enum []any) bool { for _, candidate := range enum { if equalJSON(value, candidate) { return true } } return false } func equalJSON(a, b any) bool { switch av := a.(type) { case string: bv, ok := b.(string) return ok && av == bv case float64: switch bv := b.(type) { case float64: return av == bv case int: return av == float64(bv) } case bool: bv, ok := b.(bool) return ok && av == bv case nil: return b == nil } return fmt.Sprint(a) == fmt.Sprint(b) } func join(path, name string) string { if path == "" { return name } return path + "." + name }