diff --git a/internal/validate/jsonschema.go b/internal/validate/jsonschema.go new file mode 100644 index 0000000..3200b26 --- /dev/null +++ b/internal/validate/jsonschema.go @@ -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 +} diff --git a/internal/validate/openapi.go b/internal/validate/openapi.go new file mode 100644 index 0000000..fd07fed --- /dev/null +++ b/internal/validate/openapi.go @@ -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) +} diff --git a/internal/validate/openapi_test.go b/internal/validate/openapi_test.go new file mode 100644 index 0000000..b0b51f0 --- /dev/null +++ b/internal/validate/openapi_test.go @@ -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") + } +} diff --git a/workplans/FLUID-WP-0003-deterministic-data-plane.md b/workplans/FLUID-WP-0003-deterministic-data-plane.md index 8938a50..2230879 100644 --- a/workplans/FLUID-WP-0003-deterministic-data-plane.md +++ b/workplans/FLUID-WP-0003-deterministic-data-plane.md @@ -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" ```