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
This commit is contained in:
tegwick 2026-09-04 08:14:19 +02:00
parent 3407b9cf85
commit 61d8d8cabe
4 changed files with 1088 additions and 2 deletions

View file

@ -0,0 +1,393 @@
// 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
}

View file

@ -0,0 +1,405 @@
package validate
import (
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"gopkg.in/yaml.v3"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/runtime"
)
// Document is the supported subset of an OpenAPI description.
type Document struct {
OpenAPI string `json:"openapi" yaml:"openapi"`
Paths map[string]*PathItem `json:"paths" yaml:"paths"`
Components *Components `json:"components,omitempty" yaml:"components,omitempty"`
}
// Components holds reusable schemas.
type Components struct {
Schemas map[string]*Schema `json:"schemas,omitempty" yaml:"schemas,omitempty"`
}
// PathItem holds the operations available at one path.
type PathItem struct {
Get *Operation `json:"get,omitempty" yaml:"get,omitempty"`
Put *Operation `json:"put,omitempty" yaml:"put,omitempty"`
Post *Operation `json:"post,omitempty" yaml:"post,omitempty"`
Delete *Operation `json:"delete,omitempty" yaml:"delete,omitempty"`
Patch *Operation `json:"patch,omitempty" yaml:"patch,omitempty"`
Head *Operation `json:"head,omitempty" yaml:"head,omitempty"`
}
// operations returns the declared operations by method.
func (p *PathItem) operations() map[string]*Operation {
out := map[string]*Operation{}
for method, op := range map[string]*Operation{
http.MethodGet: p.Get, http.MethodPut: p.Put, http.MethodPost: p.Post,
http.MethodDelete: p.Delete, http.MethodPatch: p.Patch, http.MethodHead: p.Head,
} {
if op != nil {
out[method] = op
}
}
return out
}
// Operation is one method on one path.
type Operation struct {
OperationID string `json:"operationId,omitempty" yaml:"operationId,omitempty"`
Parameters []*Parameter `json:"parameters,omitempty" yaml:"parameters,omitempty"`
RequestBody *RequestBody `json:"requestBody,omitempty" yaml:"requestBody,omitempty"`
}
// Parameter is a path, query or header parameter.
type Parameter struct {
Name string `json:"name" yaml:"name"`
In string `json:"in" yaml:"in"`
Required bool `json:"required,omitempty" yaml:"required,omitempty"`
Schema *Schema `json:"schema,omitempty" yaml:"schema,omitempty"`
}
// RequestBody describes an operation's body.
type RequestBody struct {
Required bool `json:"required,omitempty" yaml:"required,omitempty"`
Content map[string]*MediaType `json:"content,omitempty" yaml:"content,omitempty"`
}
// MediaType binds a content type to a schema.
type MediaType struct {
Schema *Schema `json:"schema,omitempty" yaml:"schema,omitempty"`
}
// Contract is a parsed, validated OpenAPI description ready to enforce.
type Contract struct {
doc *Document
routes []route
}
// route is one compiled path template.
type route struct {
template string
segments []segment
item *PathItem
}
type segment struct {
literal string
variable string
}
// ParseOpenAPI compiles an OpenAPI document.
//
// Parsing happens once, at revision publication, not per request. Compiling a
// contract on the hot path would put an unbounded amount of work between a
// consumer and their response for no benefit, since the contract cannot change
// without a new revision.
func ParseOpenAPI(raw []byte) (*Contract, error) {
var doc Document
if err := yaml.Unmarshal(raw, &doc); err != nil {
return nil, fmt.Errorf("parse contract: %w", err)
}
if len(doc.Paths) == 0 {
return nil, fmt.Errorf("contract declares no paths")
}
templates := make([]string, 0, len(doc.Paths))
for t := range doc.Paths {
templates = append(templates, t)
}
// Sorting makes route order deterministic. Two templates can match the same
// request, and which one wins must not depend on map iteration.
sort.Strings(templates)
c := &Contract{doc: &doc}
for _, t := range templates {
c.routes = append(c.routes, route{
template: t,
segments: compile(t),
item: doc.Paths[t],
})
}
// Literal routes are matched before templated ones, so /entries/latest
// wins over /entries/{id} regardless of alphabetical order.
sort.SliceStable(c.routes, func(i, j int) bool {
return variableCount(c.routes[i].segments) < variableCount(c.routes[j].segments)
})
return c, nil
}
func compile(template string) []segment {
parts := strings.Split(strings.Trim(template, "/"), "/")
out := make([]segment, 0, len(parts))
for _, p := range parts {
if strings.HasPrefix(p, "{") && strings.HasSuffix(p, "}") {
out = append(out, segment{variable: strings.Trim(p, "{}")})
continue
}
out = append(out, segment{literal: p})
}
return out
}
func variableCount(segs []segment) int {
n := 0
for _, s := range segs {
if s.variable != "" {
n++
}
}
return n
}
// match finds the route serving a path, with its extracted variables.
func (c *Contract) match(path string) (route, map[string]string, bool) {
parts := strings.Split(strings.Trim(path, "/"), "/")
for _, r := range c.routes {
if len(r.segments) != len(parts) {
continue
}
vars := map[string]string{}
ok := true
for i, seg := range r.segments {
if seg.variable != "" {
if parts[i] == "" {
ok = false
break
}
vars[seg.variable] = parts[i]
continue
}
if seg.literal != parts[i] {
ok = false
break
}
}
if ok {
return r, vars, true
}
}
return route{}, nil, false
}
// Resolve implements Resolver for local component references.
func (c *Contract) Resolve(ref string) (*Schema, error) {
const prefix = "#/components/schemas/"
if !strings.HasPrefix(ref, prefix) {
// Remote references would make validation depend on a network fetch,
// which the request path must never do.
return nil, fmt.Errorf("%w: only local %s references are supported", ErrUnsupported, prefix)
}
if c.doc.Components == nil {
return nil, fmt.Errorf("contract declares no components")
}
s, ok := c.doc.Components.Schemas[strings.TrimPrefix(ref, prefix)]
if !ok {
return nil, fmt.Errorf("no such component schema")
}
return s, nil
}
// Operations lists the operations the contract declares, for complexity
// measurement and for reporting surface area.
func (c *Contract) Operations() []string {
var out []string
for _, r := range c.routes {
for method := range r.item.operations() {
out = append(out, method+" "+r.template)
}
}
sort.Strings(out)
return out
}
// OpenAPIValidator enforces a compiled contract at the gateway.
//
// It implements runtime.ContractValidator. Contracts are compiled per revision
// and cached by contract digest: two revisions sharing a contract share the
// compiled form, and a changed contract is a different digest and so a
// different entry.
type OpenAPIValidator struct {
contracts map[contract.Digest]*Contract
}
// NewOpenAPIValidator returns a validator holding no contracts.
func NewOpenAPIValidator() *OpenAPIValidator {
return &OpenAPIValidator{contracts: map[contract.Digest]*Contract{}}
}
// Register compiles and stores the contract for a digest.
func (v *OpenAPIValidator) Register(digest contract.Digest, raw []byte) error {
c, err := ParseOpenAPI(raw)
if err != nil {
return err
}
v.contracts[digest] = c
return nil
}
// Contract returns a registered contract.
func (v *OpenAPIValidator) Contract(digest contract.Digest) (*Contract, bool) {
c, ok := v.contracts[digest]
return c, ok
}
// Validate checks a request against its revision's contract.
//
// An unregistered contract is a refusal, not a pass. A gateway that served
// traffic for a revision whose contract it could not find would be serving
// undeclared semantics, which is the thing minimal conformance forbids.
func (v *OpenAPIValidator) Validate(rev contract.Revision, r *http.Request, body []byte) error {
c, ok := v.contracts[rev.Contract.Digest]
if !ok {
return &runtime.ValidationError{
Message: fmt.Sprintf("no contract registered for revision %s", rev.ID),
}
}
matched, pathVars, found := c.match(r.URL.Path)
if !found {
return &runtime.ValidationError{
Message: fmt.Sprintf("%s is not a path in this contract", r.URL.Path),
}
}
op, ok := matched.item.operations()[r.Method]
if !ok {
allowed := make([]string, 0)
for method := range matched.item.operations() {
allowed = append(allowed, method)
}
sort.Strings(allowed)
return &runtime.ValidationError{
Message: fmt.Sprintf("%s is not allowed on %s; the contract declares %s",
r.Method, matched.template, strings.Join(allowed, ", ")),
}
}
result := &Result{}
validateParameters(op, r, pathVars, result, c)
validateBody(op, r, body, result, c)
if !result.OK() {
return &runtime.ValidationError{
Field: result.FirstPath(),
Message: result.Error(),
}
}
return nil
}
func validateParameters(op *Operation, r *http.Request, pathVars map[string]string, result *Result, resolver Resolver) {
query := r.URL.Query()
for _, p := range op.Parameters {
if p == nil {
continue
}
var (
raw string
present bool
)
switch p.In {
case "path":
raw, present = pathVars[p.Name]
case "query":
present = query.Has(p.Name)
raw = query.Get(p.Name)
case "header":
raw = r.Header.Get(p.Name)
present = raw != ""
default:
// Cookie parameters and anything else are reported rather than
// ignored: silently skipping a constraint is how a validator
// reports success it did not earn.
result.add(p.In+"."+p.Name, "%v: parameter location %q", ErrUnsupported, p.In)
continue
}
if !present {
if p.Required {
result.add(p.In+"."+p.Name, "is required")
}
continue
}
if p.Schema != nil {
validateValue(coerce(raw, p.Schema), p.Schema, p.In+"."+p.Name, result, resolver, 0)
}
}
}
// coerce turns a string parameter into the type its schema declares.
//
// Query and path parameters arrive as text; comparing them against a numeric
// schema without conversion would fail every well-formed request.
func coerce(raw string, schema *Schema) any {
names := typeNames(schema.Type)
if len(names) == 0 {
return raw
}
switch names[0] {
case "integer", "number":
var f float64
if _, err := fmt.Sscanf(raw, "%g", &f); err == nil {
return f
}
// Left as a string so the type mismatch is reported honestly rather
// than becoming a confusing zero.
return raw
case "boolean":
switch raw {
case "true":
return true
case "false":
return false
}
return raw
}
return raw
}
func validateBody(op *Operation, r *http.Request, body []byte, result *Result, resolver Resolver) {
if op.RequestBody == nil {
return
}
if len(body) == 0 {
if op.RequestBody.Required {
result.add("body", "is required")
}
return
}
mediaType := "application/json"
if ct := r.Header.Get("Content-Type"); ct != "" {
mediaType = strings.TrimSpace(strings.Split(ct, ";")[0])
}
media, ok := op.RequestBody.Content[mediaType]
if !ok {
declared := make([]string, 0, len(op.RequestBody.Content))
for m := range op.RequestBody.Content {
declared = append(declared, m)
}
sort.Strings(declared)
result.add("body", "content type %q is not declared; the contract accepts %s",
mediaType, strings.Join(declared, ", "))
return
}
if media == nil || media.Schema == nil {
return
}
var decoded any
if err := json.Unmarshal(body, &decoded); err != nil {
result.add("body", "is not valid JSON: %v", err)
return
}
validateValue(decoded, media.Schema, "body", result, resolver, 0)
}

View file

@ -0,0 +1,288 @@
package validate
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/runtime"
)
const hallContract = `
openapi: "3.1.0"
paths:
/v1/hall-entries:
post:
operationId: publishEntry
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [id, title, body]
additionalProperties: false
properties:
id: {type: string, minLength: 1}
title: {type: string, maxLength: 120}
body: {type: string, maxLength: 4096}
format: {type: string, enum: [teaser, full]}
get:
operationId: listEntries
parameters:
- name: limit
in: query
schema: {type: integer, minimum: 1, maximum: 100}
/v1/hall-entries/latest:
get:
operationId: latestEntry
/v1/hall-entries/{id}:
get:
operationId: getEntry
parameters:
- name: id
in: path
required: true
schema: {type: string, minLength: 1}
`
func testRevision() contract.Revision {
return contract.Revision{
ID: "R-1",
Contract: contract.RevisionContract{Digest: contract.Digest("sha256:" + strings.Repeat("1", 64))},
}
}
func newValidator(t *testing.T) *OpenAPIValidator {
t.Helper()
v := NewOpenAPIValidator()
if err := v.Register(testRevision().Contract.Digest, []byte(hallContract)); err != nil {
t.Fatal(err)
}
return v
}
func request(method, target, body string) *http.Request {
var r *http.Request
if body == "" {
r = httptest.NewRequest(method, target, nil)
} else {
r = httptest.NewRequest(method, target, strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
}
return r
}
func TestValidRequestPasses(t *testing.T) {
v := newValidator(t)
body := `{"id":"e-1","title":"the river reached the forge","body":"...","format":"teaser"}`
if err := v.Validate(testRevision(), request(http.MethodPost, "/v1/hall-entries", body), []byte(body)); err != nil {
t.Fatalf("a conforming request was rejected: %v", err)
}
}
func TestRequiredFieldsEnforced(t *testing.T) {
v := newValidator(t)
body := `{"id":"e-1"}`
err := v.Validate(testRevision(), request(http.MethodPost, "/v1/hall-entries", body), []byte(body))
if err == nil {
t.Fatal("a request missing required fields was accepted")
}
for _, want := range []string{"title", "body"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error does not name missing %q: %v", want, err)
}
}
}
// TestLengthLimitIsCountedInRunes is the case this framework exists to publish:
// a hall entry with accented characters must not be rejected for a byte count
// it never exceeded.
func TestLengthLimitIsCountedInRunes(t *testing.T) {
v := newValidator(t)
// 3000 multi-byte runes: well under the 4096 rune limit, well over it in bytes.
body := `{"id":"e-1","title":"t","body":"` + strings.Repeat("é", 3000) + `"}`
if err := v.Validate(testRevision(), request(http.MethodPost, "/v1/hall-entries", body), []byte(body)); err != nil {
t.Fatalf("a 3000-character entry was rejected against a 4096-character limit: %v", err)
}
over := `{"id":"e-1","title":"t","body":"` + strings.Repeat("a", 4097) + `"}`
if err := v.Validate(testRevision(), request(http.MethodPost, "/v1/hall-entries", over), []byte(over)); err == nil {
t.Error("a 4097-character entry passed a 4096-character limit")
}
}
func TestUnknownFieldRejectedWhenClosed(t *testing.T) {
v := newValidator(t)
body := `{"id":"e-1","title":"t","body":"b","urgency":"high"}`
err := v.Validate(testRevision(), request(http.MethodPost, "/v1/hall-entries", body), []byte(body))
if err == nil {
t.Fatal("an undeclared field was accepted")
}
if !strings.Contains(err.Error(), "urgency") {
t.Errorf("error does not name the unknown field: %v", err)
}
}
func TestEnumEnforced(t *testing.T) {
v := newValidator(t)
body := `{"id":"e-1","title":"t","body":"b","format":"interpretive-dance"}`
if err := v.Validate(testRevision(), request(http.MethodPost, "/v1/hall-entries", body), []byte(body)); err == nil {
t.Error("a value outside the enum was accepted")
}
}
// TestLiteralRoutesBeatTemplates: /latest must not be swallowed by /{id}.
func TestLiteralRoutesBeatTemplates(t *testing.T) {
c, err := ParseOpenAPI([]byte(hallContract))
if err != nil {
t.Fatal(err)
}
matched, _, ok := c.match("/v1/hall-entries/latest")
if !ok {
t.Fatal("no route matched /latest")
}
if matched.template != "/v1/hall-entries/latest" {
t.Errorf("matched %q; the literal route must win over the template", matched.template)
}
matched, vars, ok := c.match("/v1/hall-entries/e-7")
if !ok {
t.Fatal("no route matched an id")
}
if matched.template != "/v1/hall-entries/{id}" || vars["id"] != "e-7" {
t.Errorf("template match wrong: %q vars %v", matched.template, vars)
}
}
func TestUnknownPathAndMethodRejected(t *testing.T) {
v := newValidator(t)
err := v.Validate(testRevision(), request(http.MethodGet, "/v1/nope", ""), nil)
if err == nil {
t.Error("an undeclared path was accepted")
}
err = v.Validate(testRevision(), request(http.MethodDelete, "/v1/hall-entries", ""), nil)
if err == nil {
t.Fatal("an undeclared method was accepted")
}
// The error should say what is allowed; a bare rejection teaches nothing.
if !strings.Contains(err.Error(), "GET") || !strings.Contains(err.Error(), "POST") {
t.Errorf("error does not name the allowed methods: %v", err)
}
}
func TestQueryParametersAreCoercedAndBounded(t *testing.T) {
v := newValidator(t)
if err := v.Validate(testRevision(), request(http.MethodGet, "/v1/hall-entries?limit=10", ""), nil); err != nil {
t.Errorf("a valid numeric query parameter was rejected: %v", err)
}
if err := v.Validate(testRevision(), request(http.MethodGet, "/v1/hall-entries?limit=500", ""), nil); err == nil {
t.Error("a query parameter over its maximum was accepted")
}
if err := v.Validate(testRevision(), request(http.MethodGet, "/v1/hall-entries?limit=lots", ""), nil); err == nil {
t.Error("a non-numeric value for an integer parameter was accepted")
}
// An absent optional parameter is fine.
if err := v.Validate(testRevision(), request(http.MethodGet, "/v1/hall-entries", ""), nil); err != nil {
t.Errorf("an absent optional parameter was rejected: %v", err)
}
}
// TestUnregisteredContractIsRefused: serving a revision whose contract cannot
// be found would mean serving undeclared semantics.
func TestUnregisteredContractIsRefused(t *testing.T) {
v := NewOpenAPIValidator()
err := v.Validate(testRevision(), request(http.MethodGet, "/v1/hall-entries", ""), nil)
if err == nil {
t.Fatal("a revision with no registered contract was served")
}
var ve *runtime.ValidationError
if !errors.As(err, &ve) {
t.Errorf("got %T, want *runtime.ValidationError", err)
}
}
func TestMalformedBodyReportedClearly(t *testing.T) {
v := newValidator(t)
body := `{"id": "e-1",`
err := v.Validate(testRevision(), request(http.MethodPost, "/v1/hall-entries", body), []byte(body))
if err == nil {
t.Fatal("malformed JSON was accepted")
}
if !strings.Contains(err.Error(), "valid JSON") {
t.Errorf("error is not clear about the cause: %v", err)
}
}
func TestRemoteRefsAreRefused(t *testing.T) {
// Resolving a remote reference would make validation depend on a network
// fetch from the request path.
c, err := ParseOpenAPI([]byte(hallContract))
if err != nil {
t.Fatal(err)
}
if _, err := c.Resolve("https://example.com/schema.json"); !errors.Is(err, ErrUnsupported) {
t.Errorf("a remote $ref was accepted: %v", err)
}
}
func TestLocalRefsResolve(t *testing.T) {
doc := `
openapi: "3.1.0"
components:
schemas:
Entry:
type: object
required: [id]
properties:
id: {type: string}
paths:
/v1/entries:
post:
requestBody:
required: true
content:
application/json:
schema: {$ref: "#/components/schemas/Entry"}
`
v := NewOpenAPIValidator()
digest := contract.Digest("sha256:" + strings.Repeat("2", 64))
if err := v.Register(digest, []byte(doc)); err != nil {
t.Fatal(err)
}
rev := testRevision()
rev.Contract.Digest = digest
if err := v.Validate(rev, request(http.MethodPost, "/v1/entries", `{"id":"e-1"}`), []byte(`{"id":"e-1"}`)); err != nil {
t.Errorf("a valid referenced body was rejected: %v", err)
}
if err := v.Validate(rev, request(http.MethodPost, "/v1/entries", `{}`), []byte(`{}`)); err == nil {
t.Error("a body missing a required referenced field was accepted")
}
}
func TestOperationsListedForComplexityMeasurement(t *testing.T) {
c, err := ParseOpenAPI([]byte(hallContract))
if err != nil {
t.Fatal(err)
}
ops := c.Operations()
if len(ops) != 4 {
t.Errorf("operations = %v, want 4", ops)
}
}
func TestEmptyContractRefused(t *testing.T) {
if _, err := ParseOpenAPI([]byte(`openapi: "3.1.0"`)); err == nil {
t.Error("a contract declaring no paths was accepted")
}
}

View file

@ -4,7 +4,7 @@ type: workplan
title: "Deterministic data plane (Blueprint Phase A)"
domain: infotech
repo: fluid-core
status: active
status: done
owner: worsch
topic_slug: fluid-core
created: "2026-09-04"
@ -64,7 +64,7 @@ read from routing policy the router does not author.
```task
id: FLUID-WP-0003-T04
status: todo
status: done
priority: high
state_hub_task_id: "70bae578-d3e3-5a00-b5cd-4c363bc5b473"
```