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:
parent
3407b9cf85
commit
61d8d8cabe
4 changed files with 1088 additions and 2 deletions
393
internal/validate/jsonschema.go
Normal file
393
internal/validate/jsonschema.go
Normal 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
|
||||
}
|
||||
405
internal/validate/openapi.go
Normal file
405
internal/validate/openapi.go
Normal 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)
|
||||
}
|
||||
288
internal/validate/openapi_test.go
Normal file
288
internal/validate/openapi_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue