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
393 lines
10 KiB
Go
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
|
|
}
|