From eb7ac84c51228b8225205d0aeecf1604441a9dfa Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 4 May 2026 20:57:21 -0500 Subject: [PATCH] Implement basic, JSON, and JSON Schema validation --- go.mod | 7 +- go.sum | 7 + internal/validate/standard_validator.go | 127 +++++++++++++++ internal/validate/standard_validator_test.go | 160 +++++++++++++++++++ 4 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 internal/validate/standard_validator.go create mode 100644 internal/validate/standard_validator_test.go diff --git a/go.mod b/go.mod index aa9fdf7..8c941f4 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,9 @@ module gitea.maximumdirect.net/eric/scriptorium go 1.25.5 -require gopkg.in/yaml.v3 v3.0.1 // indirect +require ( + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 + gopkg.in/yaml.v3 v3.0.1 +) + +require golang.org/x/text v0.14.0 // indirect diff --git a/go.sum b/go.sum index 4bc0337..7e38d45 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,10 @@ +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/validate/standard_validator.go b/internal/validate/standard_validator.go new file mode 100644 index 0000000..8489450 --- /dev/null +++ b/internal/validate/standard_validator.go @@ -0,0 +1,127 @@ +package validate + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "gitea.maximumdirect.net/eric/scriptorium/internal/domain" + "github.com/santhosh-tekuri/jsonschema/v6" +) + +// StandardValidator provides basic, JSON, and JSON Schema output validation. +type StandardValidator struct { + schemaBaseDir string +} + +func NewStandardValidator(schemaBaseDir string) Validator { + return &StandardValidator{schemaBaseDir: schemaBaseDir} +} + +func (v *StandardValidator) Validate(ctx context.Context, artifact *domain.Artifact, contract domain.OutputContract) (domain.ValidationResult, error) { + select { + case <-ctx.Done(): + return domain.ValidationResult{}, ctx.Err() + default: + } + + res := domain.ValidationResult{ + Mode: contract.ValidationMode, + SchemaPath: contract.SchemaPath, + RepairAttempts: contract.RepairAttempts, + } + + if artifact == nil { + return domain.ValidationResult{}, errors.New("artifact is required for validation") + } + + switch contract.ValidationMode { + case domain.ValidationNone: + res.Status = domain.ValidationSkipped + res.IsValid = true + return res, nil + case domain.ValidationBasic: + if strings.TrimSpace(string(artifact.Body)) == "" { + res.Status = domain.ValidationFailed + res.IsValid = false + res.Errors = []string{"output is empty"} + return res, nil + } + res.Status = domain.ValidationPassed + res.IsValid = true + return res, nil + case domain.ValidationJSON: + _, jsonErr := parseJSON(artifact.Body) + if jsonErr != nil { + res.Status = domain.ValidationFailed + res.IsValid = false + res.Errors = []string{fmt.Sprintf("invalid JSON: %v", jsonErr)} + return res, nil + } + res.Status = domain.ValidationPassed + res.IsValid = true + return res, nil + case domain.ValidationJSONSchema: + instance, jsonErr := parseJSON(artifact.Body) + if jsonErr != nil { + res.Status = domain.ValidationFailed + res.IsValid = false + res.Errors = []string{fmt.Sprintf("invalid JSON: %v", jsonErr)} + return res, nil + } + + schemaPath, err := v.resolveSchemaPath(contract.SchemaPath) + if err != nil { + return domain.ValidationResult{}, err + } + + compiler := jsonschema.NewCompiler() + schema, err := compiler.Compile(schemaPath) + if err != nil { + return domain.ValidationResult{}, fmt.Errorf("failed to compile JSON schema %q: %w", schemaPath, err) + } + + if err := schema.Validate(instance); err != nil { + res.Status = domain.ValidationFailed + res.IsValid = false + res.Errors = []string{fmt.Sprintf("json schema validation failed: %v", err)} + return res, nil + } + + res.Status = domain.ValidationPassed + res.IsValid = true + return res, nil + default: + return domain.ValidationResult{}, fmt.Errorf("unsupported validation mode: %q", contract.ValidationMode) + } +} + +func parseJSON(body []byte) (any, error) { + var v any + if err := json.Unmarshal(body, &v); err != nil { + return nil, err + } + return v, nil +} + +func (v *StandardValidator) resolveSchemaPath(schemaPath string) (string, error) { + if strings.TrimSpace(schemaPath) == "" { + return "", errors.New("schema path is required for json_schema validation") + } + + resolved := schemaPath + if !filepath.IsAbs(schemaPath) { + resolved = filepath.Join(v.schemaBaseDir, schemaPath) + } + + resolved = filepath.Clean(resolved) + if _, err := os.Stat(resolved); err != nil { + return "", fmt.Errorf("failed to access schema file %q: %w", resolved, err) + } + + return resolved, nil +} diff --git a/internal/validate/standard_validator_test.go b/internal/validate/standard_validator_test.go new file mode 100644 index 0000000..7a95070 --- /dev/null +++ b/internal/validate/standard_validator_test.go @@ -0,0 +1,160 @@ +package validate + +import ( + "context" + "os" + "path/filepath" + "testing" + + "gitea.maximumdirect.net/eric/scriptorium/internal/domain" +) + +func TestStandardValidatorNoneSkipped(t *testing.T) { + v := NewStandardValidator("") + + res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte("ignored")}, domain.OutputContract{ + ValidationMode: domain.ValidationNone, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if res.Status != domain.ValidationSkipped { + t.Fatalf("expected skipped, got %q", res.Status) + } + if !res.IsValid { + t.Fatal("expected valid=true for skipped") + } +} + +func TestStandardValidatorBasicSuccess(t *testing.T) { + v := NewStandardValidator("") + + res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte("hello")}, domain.OutputContract{ + ValidationMode: domain.ValidationBasic, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if res.Status != domain.ValidationPassed || !res.IsValid { + t.Fatalf("expected passed/valid, got status=%q valid=%v", res.Status, res.IsValid) + } +} + +func TestStandardValidatorBasicFailureEmpty(t *testing.T) { + v := NewStandardValidator("") + + res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(" \n\t ")}, domain.OutputContract{ + ValidationMode: domain.ValidationBasic, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if res.Status != domain.ValidationFailed || res.IsValid { + t.Fatalf("expected failed/invalid, got status=%q valid=%v", res.Status, res.IsValid) + } + if len(res.Errors) == 0 { + t.Fatal("expected validation errors") + } +} + +func TestStandardValidatorJSONSuccess(t *testing.T) { + v := NewStandardValidator("") + + res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"ok":true}`)}, domain.OutputContract{ + ValidationMode: domain.ValidationJSON, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if res.Status != domain.ValidationPassed || !res.IsValid { + t.Fatalf("expected passed/valid, got status=%q valid=%v", res.Status, res.IsValid) + } +} + +func TestStandardValidatorJSONFailure(t *testing.T) { + v := NewStandardValidator("") + + res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"ok":`)}, domain.OutputContract{ + ValidationMode: domain.ValidationJSON, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if res.Status != domain.ValidationFailed || res.IsValid { + t.Fatalf("expected failed/invalid, got status=%q valid=%v", res.Status, res.IsValid) + } + if len(res.Errors) == 0 { + t.Fatal("expected parse errors") + } +} + +func TestStandardValidatorJSONSchemaSuccess(t *testing.T) { + tmp := t.TempDir() + schemaPath := filepath.Join(tmp, "schema.json") + if err := os.WriteFile(schemaPath, []byte(`{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"} + } +}`), 0644); err != nil { + t.Fatal(err) + } + + v := NewStandardValidator(tmp) + + res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"name":"eris"}`)}, domain.OutputContract{ + ValidationMode: domain.ValidationJSONSchema, + SchemaPath: "schema.json", + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if res.Status != domain.ValidationPassed || !res.IsValid { + t.Fatalf("expected passed/valid, got status=%q valid=%v", res.Status, res.IsValid) + } +} + +func TestStandardValidatorJSONSchemaFailure(t *testing.T) { + tmp := t.TempDir() + schemaPath := filepath.Join(tmp, "schema.json") + if err := os.WriteFile(schemaPath, []byte(`{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"} + } +}`), 0644); err != nil { + t.Fatal(err) + } + + v := NewStandardValidator(tmp) + + res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"count":1}`)}, domain.OutputContract{ + ValidationMode: domain.ValidationJSONSchema, + SchemaPath: "schema.json", + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if res.Status != domain.ValidationFailed || res.IsValid { + t.Fatalf("expected failed/invalid, got status=%q valid=%v", res.Status, res.IsValid) + } + if len(res.Errors) == 0 { + t.Fatal("expected schema errors") + } +} + +func TestStandardValidatorJSONSchemaSchemaLoadError(t *testing.T) { + v := NewStandardValidator(t.TempDir()) + + _, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"name":"eris"}`)}, domain.OutputContract{ + ValidationMode: domain.ValidationJSONSchema, + SchemaPath: "missing.json", + }) + if err == nil { + t.Fatal("expected schema load error") + } +}