Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion example/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,24 @@ require (
cloud.google.com/go/iam v1.5.3 // indirect
cloud.google.com/go/monitoring v1.24.3 // indirect
cloud.google.com/go/storage v1.60.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.11.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/CrisisTextLine/modular/modules/auth v0.4.0 // indirect
github.com/CrisisTextLine/modular/modules/cache v0.4.0 // indirect
github.com/CrisisTextLine/modular/modules/chimux v1.4.0 // indirect
github.com/CrisisTextLine/modular/modules/eventbus/v2 v2.0.0 // indirect
github.com/CrisisTextLine/modular/modules/httpclient v0.5.0 // indirect
github.com/CrisisTextLine/modular/modules/httpserver v0.4.0 // indirect
github.com/CrisisTextLine/modular/modules/jsonschema v1.4.0 // indirect
github.com/CrisisTextLine/modular/modules/letsencrypt v0.4.0 // indirect
github.com/CrisisTextLine/modular/modules/logmasker v0.3.0 // indirect
github.com/CrisisTextLine/modular/modules/reverseproxy/v2 v2.2.0 // indirect
github.com/CrisisTextLine/modular/modules/scheduler v0.4.0 // indirect
github.com/DataDog/datadog-go/v5 v5.4.0 // indirect
Expand Down Expand Up @@ -82,6 +95,8 @@ require (
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-acme/lego/v4 v4.26.0 // indirect
github.com/go-chi/chi/v5 v5.2.2 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
Expand Down Expand Up @@ -117,9 +132,11 @@ require (
github.com/jcmturner/gofork v1.7.6 // indirect
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/miekg/dns v1.1.68 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
Expand All @@ -133,6 +150,7 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/prometheus/client_golang v1.19.1 // indirect
Expand All @@ -144,6 +162,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
Expand All @@ -162,12 +181,14 @@ require (
go.uber.org/zap v1.27.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.42.0 // indirect
google.golang.org/api v0.265.0 // indirect
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect
Expand Down
70 changes: 60 additions & 10 deletions example/go.sum

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/CrisisTextLine/modular/modules/auth v0.4.0
github.com/CrisisTextLine/modular/modules/cache v0.4.0
github.com/CrisisTextLine/modular/modules/eventbus/v2 v2.0.0
github.com/CrisisTextLine/modular/modules/jsonschema v1.4.0
github.com/CrisisTextLine/modular/modules/reverseproxy/v2 v2.2.0
github.com/CrisisTextLine/modular/modules/scheduler v0.4.0
github.com/GoCodeAlone/go-plugin v0.0.0-20260220090904-b4c35f0e4271
Expand Down Expand Up @@ -152,7 +153,7 @@ require (
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/launchdarkly/ccache v1.1.0 // indirect
github.com/launchdarkly/eventsource v1.10.0 // indirect
Expand Down Expand Up @@ -189,6 +190,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
Expand Down
10 changes: 8 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ github.com/CrisisTextLine/modular/modules/eventbus v1.7.0 h1:SSeu7rjuECDgFa+iNyn
github.com/CrisisTextLine/modular/modules/eventbus v1.7.0/go.mod h1:I1tGf3DmadwyMP2NE2m6XHYl9ebXB9wBc/KZLywTR4c=
github.com/CrisisTextLine/modular/modules/eventbus/v2 v2.0.0 h1:bDNWBparvVzXnhLxjFPJ9MDg7N4NUnNOjfn56G/CwGU=
github.com/CrisisTextLine/modular/modules/eventbus/v2 v2.0.0/go.mod h1:5DmacIYrhhiN18i2OHyAVBiNKbN2jHuEv2UJoRToMg0=
github.com/CrisisTextLine/modular/modules/jsonschema v1.4.0 h1:NIhTrDgjhGwMi2D0ukGsd3n/M1W807u6Rhlqm89Sj8Q=
github.com/CrisisTextLine/modular/modules/jsonschema v1.4.0/go.mod h1:TeM3mt/+1X5VmlWF4nZpgp4qCGPmAahQs5jAzuWLbOo=
github.com/CrisisTextLine/modular/modules/reverseproxy/v2 v2.2.0 h1:SUJEPA61IbjdUwKdSembQTbX9rKz5v4vmyr/cbvb4tY=
github.com/CrisisTextLine/modular/modules/reverseproxy/v2 v2.2.0/go.mod h1:/jVQz+0c/OSm0KcLElNAQueI5BoLd48l1KHV4Np+RO8=
github.com/CrisisTextLine/modular/modules/scheduler v0.4.0 h1:PDYAD+hL7E6mM7YJey9ag1dnTTcJwsepoylxfZY8trw=
Expand Down Expand Up @@ -170,6 +172,8 @@ github.com/digitalocean/godo v1.175.0 h1:tpfwJFkBzpePxvvFazOn69TXctdxuFlOs7DMVXs
github.com/digitalocean/godo v1.175.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
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/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
Expand Down Expand Up @@ -321,8 +325,8 @@ github.com/jhump/protoreflect v1.16.0 h1:54fZg+49widqXYQ0b+usAFHbMkBGR4PpXrsHc8+
github.com/jhump/protoreflect v1.16.0/go.mod h1:oYPd7nPvcBw/5wlDfm/AVmU9zH9BgqGCI469pGxfj/8=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU=
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0=
github.com/karlseguin/expect v1.0.2-0.20190806010014-778a5f0c6003 h1:vJ0Snvo+SLMY72r5J4sEfkuE7AFbixEP2qRbEcum/wA=
github.com/karlseguin/expect v1.0.2-0.20190806010014-778a5f0c6003/go.mod h1:zNBxMY8P21owkeogJELCLeHIt+voOSduHYTFUbwRAV8=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
Expand Down Expand Up @@ -440,6 +444,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
Expand Down
258 changes: 258 additions & 0 deletions plugins/modularcompat/jsonschema_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
package modularcompat

import (
"log/slog"
"os"
"strings"
"testing"

"github.com/CrisisTextLine/modular"
"github.com/CrisisTextLine/modular/modules/jsonschema"
)

// userSchema is a sample JSON Schema used across tests to validate user payloads.
const userSchema = `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": { "type": "string", "minLength": 1 },
"age": { "type": "integer", "minimum": 0 },
"email": { "type": "string" }
},
"required": ["name", "age"],
"additionalProperties": false
}`

// writeSchemaFile creates a temp file containing schema JSON and returns its path.
// The caller is responsible for removing the file.
func writeSchemaFile(t *testing.T, content string) string {
t.Helper()
f, err := os.CreateTemp("", "wf-schema-*.json")
if err != nil {
t.Fatalf("create temp schema file: %v", err)
}
if _, err := f.WriteString(content); err != nil {
t.Fatalf("write schema file: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("close schema file: %v", err)
}
t.Cleanup(func() { _ = os.Remove(f.Name()) })
return f.Name()
}

// newTestApp initialises a modular application with the jsonschema module registered.
// It returns the started Application; the caller must call Stop.
func newTestApp(t *testing.T) modular.Application {
t.Helper()
app := modular.NewStdApplication(
modular.NewStdConfigProvider(nil),
slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError})),
)
app.RegisterModule(jsonschema.NewModule())
if err := app.Init(); err != nil {
t.Fatalf("app.Init: %v", err)
}
if err := app.Start(); err != nil {
t.Fatalf("app.Start: %v", err)
}
t.Cleanup(func() {
if err := app.Stop(); err != nil {
t.Logf("app.Stop: %v", err)
}
})
return app
}

// getJSONSchemaService retrieves the JSONSchemaService registered by the jsonschema module.
func getJSONSchemaService(t *testing.T, app modular.Application) jsonschema.JSONSchemaService {
t.Helper()
var svc jsonschema.JSONSchemaService
if err := app.GetService("jsonschema.service", &svc); err != nil {
t.Fatalf("GetService(jsonschema.service): %v", err)
}
return svc
}

// TestJSONSchemaModuleWiredIntoApp verifies that the jsonschema module registered
// via the modularcompat plugin factory initialises inside a modular application
// and exposes its service.
func TestJSONSchemaModuleWiredIntoApp(t *testing.T) {
p := New()
factories := p.ModuleFactories()
factory, ok := factories["jsonschema.modular"]
if !ok {
t.Fatal("jsonschema.modular factory not found in modularcompat plugin")
}

app := modular.NewStdApplication(
modular.NewStdConfigProvider(nil),
slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError})),
)
app.RegisterModule(factory("schema-module", nil))
if err := app.Init(); err != nil {
t.Fatalf("app.Init: %v", err)
}
t.Cleanup(func() { _ = app.Stop() })

var svc jsonschema.JSONSchemaService
if err := app.GetService("jsonschema.service", &svc); err != nil {
t.Fatalf("jsonschema.service not available after Init: %v", err)
}
}

// TestJSONSchemaValidateBytes tests validating raw JSON bytes against a compiled schema.
func TestJSONSchemaValidateBytes(t *testing.T) {
app := newTestApp(t)
svc := getJSONSchemaService(t, app)

schemaPath := writeSchemaFile(t, userSchema)
schema, err := svc.CompileSchema(schemaPath)
if err != nil {
t.Fatalf("CompileSchema: %v", err)
}

cases := []struct {
name string
payload string
wantErr bool
}{
{"valid payload", `{"name":"Alice","age":30}`, false},
{"valid with optional email", `{"name":"Bob","age":25,"email":"bob@example.com"}`, false},
{"missing required field age", `{"name":"Charlie"}`, true},
{"missing required field name", `{"age":40}`, true},
{"extra property rejected", `{"name":"Dave","age":20,"phone":"555"}`, true},
{"wrong type for age", `{"name":"Eve","age":"old"}`, true},
{"empty object", `{}`, true},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := svc.ValidateBytes(schema, []byte(tc.payload))
if tc.wantErr && err == nil {
t.Errorf("expected validation error, got nil")
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected validation error: %v", err)
}
})
}
}

// TestJSONSchemaValidateReader tests validating JSON from an io.Reader.
func TestJSONSchemaValidateReader(t *testing.T) {
app := newTestApp(t)
svc := getJSONSchemaService(t, app)

schemaPath := writeSchemaFile(t, userSchema)
schema, err := svc.CompileSchema(schemaPath)
if err != nil {
t.Fatalf("CompileSchema: %v", err)
}

t.Run("valid", func(t *testing.T) {
if err := svc.ValidateReader(schema, strings.NewReader(`{"name":"Alice","age":30}`)); err != nil {
t.Errorf("unexpected error: %v", err)
}
})

t.Run("invalid", func(t *testing.T) {
if err := svc.ValidateReader(schema, strings.NewReader(`{"name":"Alice"}`)); err == nil {
t.Error("expected validation error, got nil")
}
})
}

// TestJSONSchemaValidateInterface tests validating a Go map (unmarshaled JSON) directly.
func TestJSONSchemaValidateInterface(t *testing.T) {
app := newTestApp(t)
svc := getJSONSchemaService(t, app)

schemaPath := writeSchemaFile(t, userSchema)
schema, err := svc.CompileSchema(schemaPath)
if err != nil {
t.Fatalf("CompileSchema: %v", err)
}

valid := map[string]any{"name": "Alice", "age": float64(30)}
if err := svc.ValidateInterface(schema, valid); err != nil {
t.Errorf("unexpected error for valid interface: %v", err)
}

invalid := map[string]any{"name": "Alice"} // missing "age"
if err := svc.ValidateInterface(schema, invalid); err == nil {
t.Error("expected validation error for invalid interface, got nil")
}
}

// TestJSONSchemaRegistryWorkflow simulates the schema-registry + validator use case
// described in the PR review: compile multiple schemas once into an in-memory
// "registry" map, then validate incoming payloads using the right schema by name.
func TestJSONSchemaRegistryWorkflow(t *testing.T) {
app := newTestApp(t)
svc := getJSONSchemaService(t, app)

// --- schema definitions ---
schemas := map[string]string{
"user": `{
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer", "minimum": 0}
},
"required": ["name", "age"],
"additionalProperties": false
}`,
"product": `{
"type": "object",
"properties": {
"id": {"type": "string"},
"price": {"type": "number", "minimum": 0}
},
"required": ["id", "price"],
"additionalProperties": false
}`,
}

// Compile all schemas once and store in a registry map.
schemaRegistry := make(map[string]jsonschema.Schema, len(schemas))
for name, def := range schemas {
path := writeSchemaFile(t, def)
compiled, err := svc.CompileSchema(path)
if err != nil {
t.Fatalf("CompileSchema(%s): %v", name, err)
}
schemaRegistry[name] = compiled
}

// --- validation table ---
type testCase struct {
schemaName string
payload string
wantErr bool
}
cases := []testCase{
{"user", `{"name":"Alice","age":30}`, false},
{"user", `{"name":"Bob"}`, true}, // missing age
{"user", `{"name":"Carol","age":25,"x":"y"}`, true}, // extra field
{"product", `{"id":"sku-1","price":9.99}`, false},
{"product", `{"id":"sku-2"}`, true}, // missing price
{"product", `{"id":"sku-3","price":-1}`, true}, // price below minimum
}

for _, tc := range cases {
t.Run(tc.schemaName+"/"+tc.payload, func(t *testing.T) {
sc, ok := schemaRegistry[tc.schemaName]
if !ok {
t.Fatalf("schema %q not in registry", tc.schemaName)
}
err := svc.ValidateBytes(sc, []byte(tc.payload))
if tc.wantErr && err == nil {
t.Errorf("expected validation error, got nil")
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected validation error: %v", err)
}
})
}
}
Loading
Loading