Add generic raw output validators

This commit is contained in:
2026-07-07 21:32:56 +00:00
parent 5ef027b6f0
commit 3e67be6ac3
16 changed files with 550 additions and 12 deletions

View File

@@ -0,0 +1,81 @@
package validjsonschema
import (
"bytes"
"context"
"fmt"
"github.com/santhosh-tekuri/jsonschema/v6"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
const Key = "generic/valid_json_schema"
const ReasonCodeInvalidJSON = "invalid_json"
const ReasonCodeSchemaInvalid = "json_schema_invalid"
var _ contracts.Validator = (*Validator)(nil)
type Validator struct{}
func New() *Validator {
return &Validator{}
}
func (v *Validator) Name() string {
return Key
}
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassDeterministic
}
func (v *Validator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) {
if len(req.Schema.JSONSchema) == 0 {
return contracts.ValidationResult{}, fmt.Errorf("response schema content is not available")
}
instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(req.Payload.Content))
if err != nil {
return contracts.ValidationResult{
Approved: false,
ReasonCode: ReasonCodeInvalidJSON,
Message: "payload is not valid JSON",
}, nil
}
schemaDocument, err := jsonschema.UnmarshalJSON(bytes.NewReader(req.Schema.JSONSchema))
if err != nil {
return contracts.ValidationResult{}, fmt.Errorf("parse response schema: %w", err)
}
compiler := jsonschema.NewCompiler()
if err := compiler.AddResource("schema.json", schemaDocument); err != nil {
return contracts.ValidationResult{}, fmt.Errorf("load response schema: %w", err)
}
schema, err := compiler.Compile("schema.json")
if err != nil {
return contracts.ValidationResult{}, fmt.Errorf("compile response schema: %w", err)
}
if err := schema.Validate(instance); err != nil {
return contracts.ValidationResult{
Approved: false,
ReasonCode: ReasonCodeSchemaInvalid,
Message: "payload does not conform to response schema",
}, nil
}
return contracts.ValidationResult{Approved: true}, nil
}
func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{
Key: Key,
ExecutionClass: contracts.ExecutionClassDeterministic,
}
}
func Register(registry *pipeline.ValidatorRegistry) error {
return registry.RegisterWithSpec(Spec(), func() (contracts.Validator, error) {
return New(), nil
})
}

View File

@@ -0,0 +1,111 @@
package validjsonschema
import (
"context"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestValidatorAcceptsSchemaConformantJSON(t *testing.T) {
result, err := New().Validate(context.Background(), requestWithSchema(`{"name":"Aria"}`, objectSchema()))
if err != nil {
t.Fatalf("Validate() error = %v, want nil", err)
}
if !result.Approved {
t.Fatalf("Validate() = %#v, want approved", result)
}
}
func TestValidatorRejectsInvalidPayloadJSON(t *testing.T) {
result, err := New().Validate(context.Background(), requestWithSchema(`{"name":`, objectSchema()))
if err != nil {
t.Fatalf("Validate() error = %v, want nil", err)
}
if result.Approved {
t.Fatalf("Approved = true, want false")
}
if result.ReasonCode != ReasonCodeInvalidJSON {
t.Fatalf("ReasonCode = %q, want %q", result.ReasonCode, ReasonCodeInvalidJSON)
}
}
func TestValidatorRejectsSchemaNonConformance(t *testing.T) {
result, err := New().Validate(context.Background(), requestWithSchema(`{"name":3}`, objectSchema()))
if err != nil {
t.Fatalf("Validate() error = %v, want nil", err)
}
if result.Approved {
t.Fatalf("Approved = true, want false")
}
if result.ReasonCode != ReasonCodeSchemaInvalid {
t.Fatalf("ReasonCode = %q, want %q", result.ReasonCode, ReasonCodeSchemaInvalid)
}
}
func TestValidatorErrorsWhenSchemaContentMissing(t *testing.T) {
_, err := New().Validate(context.Background(), requestWithSchema(`{"name":"Aria"}`, nil))
if err == nil {
t.Fatal("Validate() error = nil, want missing schema content error")
}
if !strings.Contains(err.Error(), "schema content") {
t.Fatalf("Validate() error = %q, want schema content context", err.Error())
}
}
func TestValidatorErrorsWhenSchemaContentIsMalformed(t *testing.T) {
_, err := New().Validate(context.Background(), requestWithSchema(`{"name":"Aria"}`, []byte(`{"type":`)))
if err == nil {
t.Fatal("Validate() error = nil, want malformed schema error")
}
if !strings.Contains(err.Error(), "parse response schema") {
t.Fatalf("Validate() error = %q, want parse schema context", err.Error())
}
}
func TestSpecAndRegister(t *testing.T) {
if Spec().Key != Key || Spec().ExecutionClass != contracts.ExecutionClassDeterministic {
t.Fatalf("Spec() = %#v, want key and deterministic execution", Spec())
}
registry := pipeline.NewValidatorRegistry()
if err := Register(registry); err != nil {
t.Fatalf("Register() error = %v, want nil", err)
}
validator, err := registry.Build(Key)
if err != nil {
t.Fatalf("Build(%q) error = %v, want nil", Key, err)
}
if validator.Name() != Key {
t.Fatalf("Name() = %q, want %q", validator.Name(), Key)
}
}
func requestWithSchema(payload string, schema []byte) contracts.ValidationRequest {
return contracts.ValidationRequest{
Schema: contracts.ResponseSchema{
ID: "test.schema",
Name: "test_schema",
Version: "v1",
JSONSchema: append([]byte(nil), schema...),
},
Payload: contracts.RawPayload{
Content: []byte(payload),
MediaType: "application/json",
},
}
}
func objectSchema() []byte {
return []byte(`{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["name"],
"properties": {
"name": {"type": "string"}
},
"additionalProperties": false
}`)
}