Repository: compose-spec/compose-go
Pull request #789 (2025-05-14 11:10:54)

adopt xeipuuv/gojsonschema
github.com/xeipuuv/gojsonschema is inactive (last commit is 5 years old)
switch to github.com/xeipuuv/gojsonschema with best effort to preserve error messages

closes https://github.com/compose-spec/compose-go/issues/749

tested with compose by https://github.com/docker/compose/pull/12831


Patch:
diff --git a/go.mod b/go.mod
index 2f5c3fd..05ae650 100644
--- a/go.mod
+++ b/go.mod
@@ -10,9 +10,9 @@ require (
 	github.com/google/go-cmp v0.5.9
 	github.com/mattn/go-shellwords v1.0.12
 	github.com/opencontainers/go-digest v1.0.0
+	github.com/santhosh-tekuri/jsonschema/v6 v6.0.1
 	github.com/sirupsen/logrus v1.9.0
 	github.com/stretchr/testify v1.8.4
-	github.com/xeipuuv/gojsonschema v1.2.0
 	github.com/xhit/go-str2duration/v2 v2.1.0
 	golang.org/x/sync v0.3.0
 	gopkg.in/yaml.v3 v3.0.1
@@ -22,7 +22,6 @@ require (
 require (
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
-	github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
-	github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
-	golang.org/x/sys v0.1.0 // indirect
+	golang.org/x/sys v0.5.0 // indirect
+	golang.org/x/text v0.14.0 // indirect
 )
diff --git a/go.sum b/go.sum
index e390ddf..6844e22 100644
--- a/go.sum
+++ b/go.sum
@@ -3,6 +3,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
 github.com/distribution/reference v0.5.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/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
 github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
 github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
@@ -18,19 +20,16 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/santhosh-tekuri/jsonschema v1.2.4 h1:hNhW8e7t+H1vgY+1QeEQpveR6D4+OwKPXCfD2aieJis=
+github.com/santhosh-tekuri/jsonschema v1.2.4/go.mod h1:TEAUOeZSmIxTTuHatJzrvARHiuO9LYd+cIxzgEHCQI4=
+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.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
 github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
-github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
-github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
-github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
-github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
-github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
 github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc=
 github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -50,10 +49,12 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
-golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
diff --git a/schema/schema.go b/schema/schema.go
index bfbaa93..8aa7ccf 100644
--- a/schema/schema.go
+++ b/schema/schema.go
@@ -19,35 +19,25 @@ package schema
 import (
 	// Enable support for embedded static resources
 	_ "embed"
+	"errors"
 	"fmt"
+	"slices"
 	"strings"
 	"time"
 
-	"github.com/xeipuuv/gojsonschema"
+	"github.com/santhosh-tekuri/jsonschema/v6"
+	"github.com/santhosh-tekuri/jsonschema/v6/kind"
+	"golang.org/x/text/language"
+	"golang.org/x/text/message"
 )
 
-type portsFormatChecker struct{}
-
-func (checker portsFormatChecker) IsFormat(_ interface{}) bool {
-	// TODO: implement this
-	return true
-}
-
-type durationFormatChecker struct{}
-
-func (checker durationFormatChecker) IsFormat(input interface{}) bool {
+func durationFormatChecker(input any) error {
 	value, ok := input.(string)
 	if !ok {
-		return false
+		return fmt.Errorf("expected string")
 	}
 	_, err := time.ParseDuration(value)
-	return err == nil
-}
-
-func init() {
-	gojsonschema.FormatCheckers.Add("expose", portsFormatChecker{})
-	gojsonschema.FormatCheckers.Add("ports", portsFormatChecker{})
-	gojsonschema.FormatCheckers.Add("duration", durationFormatChecker{})
+	return err
 }
 
 // Schema is the compose-spec JSON schema
@@ -57,108 +47,88 @@ var Schema string
 
 // Validate uses the jsonschema to validate the configuration
 func Validate(config map[string]interface{}) error {
-	schemaLoader := gojsonschema.NewStringLoader(Schema)
-	dataLoader := gojsonschema.NewGoLoader(config)
-
-	result, err := gojsonschema.Validate(schemaLoader, dataLoader)
+	compiler := jsonschema.NewCompiler()
+	json, err := jsonschema.UnmarshalJSON(strings.NewReader(Schema))
 	if err != nil {
 		return err
 	}
-
-	if !result.Valid() {
-		return toError(result)
+	err = compiler.AddResource("compose-spec.json", json)
+	if err != nil {
+		return err
+	}
+	compiler.RegisterFormat(&jsonschema.Format{
+		Name:     "duration",
+		Validate: durationFormatChecker,
+	})
+	schema := compiler.MustCompile("compose-spec.json")
+	err = schema.Validate(config)
+	var verr *jsonschema.ValidationError
+	if ok := errors.As(err, &verr); ok {
+		return validationError{getMostSpecificError(verr)}
 	}
-
-	return nil
-}
-
-func toError(result *gojsonschema.Result) error {
-	err := getMostSpecificError(result.Errors())
 	return err
 }
 
-const (
-	jsonschemaOneOf = "number_one_of"
-	jsonschemaAnyOf = "number_any_of"
-)
+type validationError struct {
+	err *jsonschema.ValidationError
+}
 
-func getDescription(err validationError) string {
-	switch err.parent.Type() {
-	case "invalid_type":
-		if expectedType, ok := err.parent.Details()["expected"].(string); ok {
-			return fmt.Sprintf("must be a %s", humanReadableType(expectedType))
-		}
-	case jsonschemaOneOf, jsonschemaAnyOf:
-		if err.child == nil {
-			return err.parent.Description()
-		}
-		return err.child.Description()
+func (e validationError) Error() string {
+	path := strings.Join(e.err.InstanceLocation, ".")
+	p := message.NewPrinter(language.English)
+	switch k := e.err.ErrorKind.(type) {
+	case *kind.Type:
+		return fmt.Sprintf("%s must be a %s", path, humanReadableType(k.Want...))
+	case *kind.Minimum:
+		return fmt.Sprintf("%s must be greater than or equal to %s", path, k.Want.Num())
+	case *kind.Maximum:
+		return fmt.Sprintf("%s must be less than or equal to %s", path, k.Want.Num())
 	}
-	return err.parent.Description()
+	return fmt.Sprintf("%s %s", path, e.err.ErrorKind.LocalizedString(p))
 }
 
-func humanReadableType(definition string) string {
-	if definition[0:1] == "[" {
-		allTypes := strings.Split(definition[1:len(definition)-1], ",")
-		for i, t := range allTypes {
-			allTypes[i] = humanReadableType(t)
+func humanReadableType(want ...string) string {
+	if len(want) == 1 {
+		switch want[0] {
+		case "object":
+			return "mapping"
+		default:
+			return want[0]
 		}
-		return fmt.Sprintf(
-			"%s or %s",
-			strings.Join(allTypes[0:len(allTypes)-1], ", "),
-			allTypes[len(allTypes)-1],
-		)
-	}
-	if definition == "object" {
-		return "mapping"
 	}
-	if definition == "array" {
-		return "list"
-	}
-	return definition
-}
 
-type validationError struct {
-	parent gojsonschema.ResultError
-	child  gojsonschema.ResultError
-}
+	for i, s := range want {
+		want[i] = humanReadableType(s)
+	}
 
-func (err validationError) Error() string {
-	description := getDescription(err)
-	return fmt.Sprintf("%s %s", err.parent.Field(), description)
+	slices.Sort(want)
+	return fmt.Sprintf(
+		"%s or %s",
+		strings.Join(want[0:len(want)-1], ", "),
+		want[len(want)-1],
+	)
 }
 
-func getMostSpecificError(errors []gojsonschema.ResultError) validationError {
-	mostSpecificError := 0
-	for i, err := range errors {
-		if specificity(err) > specificity(errors[mostSpecificError]) {
-			mostSpecificError = i
-			continue
-		}
-
-		if specificity(err) == specificity(errors[mostSpecificError]) {
-			// Invalid type errors win in a tie-breaker for most specific field name
-			if err.Type() == "invalid_type" && errors[mostSpecificError].Type() != "invalid_type" {
-				mostSpecificError = i
-			}
-		}
-	}
-
-	if mostSpecificError+1 == len(errors) {
-		return validationError{parent: errors[mostSpecificError]}
+func getMostSpecificError(err *jsonschema.ValidationError) *jsonschema.ValidationError {
+	var mostSpecificError *jsonschema.ValidationError
+	if len(err.Causes) == 0 {
+		return err
 	}
-
-	switch errors[mostSpecificError].Type() {
-	case "number_one_of", "number_any_of":
-		return validationError{
-			parent: errors[mostSpecificError],
-			child:  errors[mostSpecificError+1],
+	for _, cause := range err.Causes {
+		cause = getMostSpecificError(cause)
+		if specificity(cause) > specificity(mostSpecificError) {
+			mostSpecificError = cause
 		}
-	default:
-		return validationError{parent: errors[mostSpecificError]}
 	}
+	return mostSpecificError
 }
 
-func specificity(err gojsonschema.ResultError) int {
-	return len(strings.Split(err.Field(), "."))
+func specificity(err *jsonschema.ValidationError) int {
+	if err == nil {
+		return -1
+	}
+	if _, ok := err.ErrorKind.(*kind.AdditionalProperties); ok {
+		return len(err.InstanceLocation) + 1
+	}
+	return len(err.InstanceLocation)
 }


Test patch:
diff --git a/loader/loader_test.go b/loader/loader_test.go
index 42819c6..881279c 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -640,7 +640,7 @@ services:
     environment:
       FOO: ["1"]
 `)
-	assert.ErrorContains(t, err, "services.dict-env.environment.FOO must be a string, number, boolean or null")
+	assert.ErrorContains(t, err, "services.dict-env.environment.FOO must be a boolean, null, number or string")
 }
 
 func TestInvalidEnvironmentObject(t *testing.T) {
@@ -1054,7 +1054,7 @@ func TestInvalidResource(t *testing.T) {
                 impossible:
                   x: 1
 `)
-	assert.ErrorContains(t, err, "Additional property impossible is not allowed")
+	assert.ErrorContains(t, err, "additional properties 'impossible' not allowed")
 }
 
 func TestInvalidExternalAndDriverCombination(t *testing.T) {
@@ -1196,7 +1196,7 @@ services:
         foo:
           bar: zot
 `)
-	assert.ErrorContains(t, err, "services.tmpfs.volumes.0 Additional property foo is not allowed")
+	assert.ErrorContains(t, err, "services.tmpfs.volumes.0 additional properties 'foo' not allowed")
 }
 
 func TestLoadBindMountSourceMustNotBeEmpty(t *testing.T) {
@@ -1350,7 +1350,7 @@ services:
         tmpfs:
           size: -1
 `)
-	assert.ErrorContains(t, err, "services.tmpfs.volumes.0.tmpfs.size Must be greater than or equal to 0")
+	assert.ErrorContains(t, err, "services.tmpfs.volumes.0.tmpfs.size must be greater than or equal to 0")
 }
 
 func TestLoadTmpfsVolumeSizeMustBeInteger(t *testing.T) {
@@ -2264,7 +2264,7 @@ services:
             - driver: nvidia
               count: 2
 `)
-	assert.ErrorContains(t, err, `capabilities is required`)
+	assert.ErrorContains(t, err, "missing property 'capabilities'")
 }
 
 func TestServiceGpus(t *testing.T) {
@@ -3132,7 +3132,7 @@ services:
 `, nil), func(options *Options) {
 		options.ResolvePaths = false
 	})
-	assert.ErrorContains(t, err, "validating filename0.yml: services.frontend.develop.watch.0 action is required")
+	assert.ErrorContains(t, err, "services.frontend.develop.watch.0 missing property 'action'")
 }
 
 func TestBadServiceConfig(t *testing.T) {
@@ -3803,7 +3803,7 @@ services:
         numbers: 12
         booleans: true
 `)
-	assert.Check(t, strings.Contains(err.Error(), "services.test.provider type is required"))
+	assert.ErrorContains(t, err, "services.test.provider missing property 'type'")
 }
 
 func TestImageVolume(t *testing.T) {
diff --git a/schema/schema_test.go b/schema/schema_test.go
index df35d4e..9287ad1 100644
--- a/schema/schema_test.go
+++ b/schema/schema_test.go
@@ -18,20 +18,18 @@ package schema
 
 import (
 	"os"
-	"path/filepath"
+	"strings"
 	"testing"
 
-	"github.com/xeipuuv/gojsonschema"
+	"github.com/santhosh-tekuri/jsonschema/v6"
 	"gopkg.in/yaml.v3"
 	"gotest.tools/v3/assert"
 )
 
-type dict map[string]interface{}
-
 func TestValidate(t *testing.T) {
-	config := dict{
-		"services": dict{
-			"foo": dict{
+	config := map[string]any{
+		"services": map[string]any{
+			"foo": map[string]any{
 				"image": "busybox",
 			},
 		},
@@ -41,21 +39,21 @@ func TestValidate(t *testing.T) {
 }
 
 func TestValidateUndefinedTopLevelOption(t *testing.T) {
-	config := dict{
-		"helicopters": dict{
-			"foo": dict{
+	config := map[string]any{
+		"helicopters": map[string]any{
+			"foo": map[string]any{
 				"image": "busybox",
 			},
 		},
 	}
 
 	err := Validate(config)
-	assert.ErrorContains(t, err, "Additional property helicopters is not allowed")
+	assert.ErrorContains(t, err, "additional properties 'helicopters' not allowed")
 }
 
 func TestValidateAllowsXTopLevelFields(t *testing.T) {
-	config := dict{
-		"x-extra-stuff": dict{},
+	config := map[string]any{
+		"x-extra-stuff": map[string]any{},
 	}
 
 	assert.NilError(t, Validate(config))
@@ -63,37 +61,37 @@ func TestValidateAllowsXTopLevelFields(t *testing.T) {
 }
 
 func TestValidateAllowsXFields(t *testing.T) {
-	config := dict{
-		"services": dict{
-			"bar": dict{
-				"x-extra-stuff": dict{},
+	config := map[string]any{
+		"services": map[string]any{
+			"bar": map[string]any{
+				"x-extra-stuff": map[string]any{},
 			},
-			"foo": dict{
-				"depends_on": dict{
-					"x-dependency": dict{
+			"foo": map[string]any{
+				"depends_on": map[string]any{
+					"x-dependency": map[string]any{
 						"condition": "service_started",
 					},
 				},
 			},
 		},
-		"volumes": dict{
-			"bar": dict{
-				"x-extra-stuff": dict{},
+		"volumes": map[string]any{
+			"bar": map[string]any{
+				"x-extra-stuff": map[string]any{},
 			},
 		},
-		"networks": dict{
-			"bar": dict{
-				"x-extra-stuff": dict{},
+		"networks": map[string]any{
+			"bar": map[string]any{
+				"x-extra-stuff": map[string]any{},
 			},
 		},
-		"configs": dict{
-			"bar": dict{
-				"x-extra-stuff": dict{},
+		"configs": map[string]any{
+			"bar": map[string]any{
+				"x-extra-stuff": map[string]any{},
 			},
 		},
-		"secrets": dict{
-			"bar": dict{
-				"x-extra-stuff": dict{},
+		"secrets": map[string]any{
+			"bar": map[string]any{
+				"x-extra-stuff": map[string]any{},
 			},
 		},
 	}
@@ -102,14 +100,14 @@ func TestValidateAllowsXFields(t *testing.T) {
 }
 
 func TestValidateSecretConfigNames(t *testing.T) {
-	config := dict{
-		"configs": dict{
-			"bar": dict{
+	config := map[string]any{
+		"configs": map[string]any{
+			"bar": map[string]any{
 				"name": "foobar",
 			},
 		},
-		"secrets": dict{
-			"baz": dict{
+		"secrets": map[string]any{
+			"baz": map[string]any{
 				"name": "foobaz",
 			},
 		},
@@ -119,17 +117,15 @@ func TestValidateSecretConfigNames(t *testing.T) {
 	assert.NilError(t, Validate(config))
 }
 
-type array []interface{}
-
 func TestValidatePlacement(t *testing.T) {
-	config := dict{
-		"services": dict{
-			"foo": dict{
+	config := map[string]any{
+		"services": map[string]any{
+			"foo": map[string]any{
 				"image": "busybox",
-				"deploy": dict{
-					"placement": dict{
-						"preferences": array{
-							dict{
+				"deploy": map[string]any{
+					"placement": map[string]any{
+						"preferences": []any{
+							map[string]any{
 								"spread": "node.labels.az",
 							},
 						},
@@ -144,9 +140,9 @@ func TestValidatePlacement(t *testing.T) {
 }
 
 func TestValidateIsolation(t *testing.T) {
-	config := dict{
-		"services": dict{
-			"foo": dict{
+	config := map[string]any{
+		"services": map[string]any{
+			"foo": map[string]any{
 				"image":     "busybox",
 				"isolation": "some-isolation-value",
 			},
@@ -157,12 +153,12 @@ func TestValidateIsolation(t *testing.T) {
 }
 
 func TestValidateRollbackConfig(t *testing.T) {
-	config := dict{
-		"services": dict{
-			"foo": dict{
+	config := map[string]any{
+		"services": map[string]any{
+			"foo": map[string]any{
 				"image": "busybox",
-				"deploy": dict{
-					"rollback_config": dict{
+				"deploy": map[string]any{
+					"rollback_config": map[string]any{
 						"parallelism": 1,
 					},
 				},
@@ -175,12 +171,12 @@ func TestValidateRollbackConfig(t *testing.T) {
 }
 
 func TestValidateRollbackConfigWithOrder(t *testing.T) {
-	config := dict{
-		"services": dict{
-			"foo": dict{
+	config := map[string]any{
+		"services": map[string]any{
+			"foo": map[string]any{
 				"image": "busybox",
-				"deploy": dict{
-					"rollback_config": dict{
+				"deploy": map[string]any{
+					"rollback_config": map[string]any{
 						"parallelism": 1,
 						"order":       "start-first",
 					},
@@ -194,16 +190,16 @@ func TestValidateRollbackConfigWithOrder(t *testing.T) {
 }
 
 func TestValidateRollbackConfigWithUpdateConfig(t *testing.T) {
-	config := dict{
-		"services": dict{
-			"foo": dict{
+	config := map[string]any{
+		"services": map[string]any{
+			"foo": map[string]any{
 				"image": "busybox",
-				"deploy": dict{
-					"update_config": dict{
+				"deploy": map[string]any{
+					"update_config": map[string]any{
 						"parallelism": 1,
 						"order":       "start-first",
 					},
-					"rollback_config": dict{
+					"rollback_config": map[string]any{
 						"parallelism": 1,
 						"order":       "start-first",
 					},
@@ -217,19 +213,19 @@ func TestValidateRollbackConfigWithUpdateConfig(t *testing.T) {
 }
 
 func TestValidateRollbackConfigWithUpdateConfigFull(t *testing.T) {
-	config := dict{
-		"services": dict{
-			"foo": dict{
+	config := map[string]any{
+		"services": map[string]any{
+			"foo": map[string]any{
 				"image": "busybox",
-				"deploy": dict{
-					"update_config": dict{
+				"deploy": map[string]any{
+					"update_config": map[string]any{
 						"parallelism":    1,
 						"order":          "start-first",
 						"delay":          "10s",
 						"failure_action": "pause",
 						"monitor":        "10s",
 					},
-					"rollback_config": dict{
+					"rollback_config": map[string]any{
 						"parallelism":    1,
 						"order":          "start-first",
 						"delay":          "10s",
@@ -248,19 +244,19 @@ func TestValidateRollbackConfigWithUpdateConfigFull(t *testing.T) {
 func TestValidateVariables(t *testing.T) {
 	bytes, err := os.ReadFile("using-variables.yaml")
 	assert.NilError(t, err)
-	var config dict
+	var config map[string]any
 	err = yaml.Unmarshal(bytes, &config)
 	assert.NilError(t, err)
 	assert.NilError(t, Validate(config))
 }
 
 func TestSchema(t *testing.T) {
-	abs, err := filepath.Abs("compose-spec.json")
+	compiler := jsonschema.NewCompiler()
+	json, err := jsonschema.UnmarshalJSON(strings.NewReader(Schema))
+	assert.NilError(t, err)
+	err = compiler.AddResource("compose-spec.json", json)
 	assert.NilError(t, err)
-	schema := gojsonschema.NewReferenceLoader("file:///" + abs)
-	sl := gojsonschema.NewSchemaLoader()
-	sl.Draft = gojsonschema.Draft7
-	sl.Validate = true
-	_, err = sl.Compile(schema)
+	compiler.DefaultDraft(jsonschema.Draft7)
+	_, err = compiler.Compile("compose-spec.json")
 	assert.NilError(t, err)
 }