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
405 lines
11 KiB
Go
405 lines
11 KiB
Go
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)
|
|
}
|