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) }