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
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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue