fluid-core/internal/validate/jsonschema.go
tegwick 61d8d8cabe Add the OpenAPI contract validator, closing FLUID-WP-0003
The gateway can now enforce a revision's declared contract, which makes
"a deterministic API contract" -- the first minimal-conformance
requirement -- something the framework actually checks rather than
assumes.

The JSON Schema support is a documented subset. Keywords outside it are
reported as unsupported rather than skipped, because a validator that
silently ignores a constraint it does not understand is worse than none:
it reports success it did not earn. The same reasoning refuses remote
$refs, which would make request-path validation depend on a network
fetch, and refuses to serve a revision whose contract is not registered.

String lengths are counted in runes. A 4096-character limit that
rejected a 3000-character hall entry because of its accents would be
wrong in exactly the case this framework was built to publish.

Literal routes are matched before templated ones, so /entries/latest is
not swallowed by /entries/{id} -- which matters, since a latest-entry
convenience route is the Blueprint's own worked example.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1116572@bnt-lap001
Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
2026-09-04 08:14:19 +02:00

393 lines
10 KiB
Go

// 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
}