diff --git a/go.mod b/go.mod index b1a218c..07c7441 100644 --- a/go.mod +++ b/go.mod @@ -44,7 +44,6 @@ require ( github.com/gofrs/flock v0.8.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.2 // indirect github.com/klauspost/compress v1.16.7 // indirect - go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.16.0 // indirect go.opentelemetry.io/proto/otlp v1.0.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20230731193218-e0aa005b6bdf // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230731193218-e0aa005b6bdf // indirect diff --git a/go.sum b/go.sum index 9b99388..0618acf 100644 --- a/go.sum +++ b/go.sum @@ -679,38 +679,22 @@ go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.42.0 h1:l7AmwSVqozWKKXeZHycpdmpycQECRpoGwJ1FW2sWfTo= -go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.42.0/go.mod h1:Ep4uoO2ijR0f49Pr7jAqyTjSCyS1SRL18wwttKfwqXA= go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.44.0 h1:vSuzwGXaJ3nm8a6JGeRc2V28qP1NB4iRTcobhU/z3Fs= go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.44.0/go.mod h1:+H7htXVkUjPfQ45PNlcbXUmMXUr16uXDvuR+7TAGfVQ= -go.opentelemetry.io/contrib/propagators/b3 v1.17.0 h1:ImOVvHnku8jijXqkwCSyYKRDt2YrnGXD4BbhcpfbfJo= +go.opentelemetry.io/contrib/propagators/b3 v1.19.0 h1:ulz44cpm6V5oAeg5Aw9HyqGFMS6XM7untlMEhD7YzzA= go.opentelemetry.io/otel v1.10.0/go.mod h1:NbvWjCthWHKBEUMpf0/v8ZRZlni86PpGFEMA9pnQSnQ= -go.opentelemetry.io/otel v1.17.0 h1:MW+phZ6WZ5/uk2nd93ANk/6yJ+dVrvNWUjGhnnFU5jM= -go.opentelemetry.io/otel v1.17.0/go.mod h1:I2vmBGtFaODIVMBSTPVDlJSzBDNf93k60E6Ft0nyjo0= go.opentelemetry.io/otel v1.19.0 h1:MuS/TNf4/j4IXsZuJegVzI1cwut7Qc00344rgH7p8bs= go.opentelemetry.io/otel v1.19.0/go.mod h1:i0QyjOq3UPoTzff0PJB2N66fb4S0+rSbSB15/oyH9fY= -go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.16.0 h1:t4ZwRPU+emrcvM2e9DHd0Fsf0JTPVcbfa/BhTDF03d0= -go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.16.0/go.mod h1:vLarbg68dH2Wa77g71zmKQqlQ8+8Rq3GRG31uc0WcWI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.16.0 h1:cbsD4cUcviQGXdw8+bo5x2wazq10SKz8hEbtCRPcU78= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.16.0/go.mod h1:JgXSGah17croqhJfhByOLVY719k1emAXC8MVhCIJlRs= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.16.0 h1:TVQp/bboR4mhZSav+MdgXB8FaRho1RC8UwVn3T0vjVc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.16.0/go.mod h1:I33vtIe0sR96wfrUcilIzLoA3mLHhRmz9S9Te0S3gDo= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0 h1:3d+S281UTjM+AbF31XSOYn1qXn3BgIdWl8HNEpx08Jk= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0/go.mod h1:0+KuTDyKL4gjKCF75pHOX4wuzYDUZYfAQdSu43o+Z2I= -go.opentelemetry.io/otel/metric v1.17.0 h1:iG6LGVz5Gh+IuO0jmgvpTB6YVrCGngi8QGm+pMd8Pdc= -go.opentelemetry.io/otel/metric v1.17.0/go.mod h1:h4skoxdZI17AxwITdmdZjjYJQH5nzijUUjm+wtPph5o= go.opentelemetry.io/otel/metric v1.19.0 h1:aTzpGtV0ar9wlV4Sna9sdJyII5jTVJEvKETPiOKwvpE= go.opentelemetry.io/otel/metric v1.19.0/go.mod h1:L5rUsV9kM1IxCj1MmSdS+JQAcVm319EUrDVLrt7jqt8= -go.opentelemetry.io/otel/sdk v1.17.0 h1:FLN2X66Ke/k5Sg3V623Q7h7nt3cHXaW1FOvKKrW0IpE= -go.opentelemetry.io/otel/sdk v1.17.0/go.mod h1:U87sE0f5vQB7hwUoW98pW5Rz4ZDuCFBZFNUBlSgmDFQ= go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o= go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A= go.opentelemetry.io/otel/sdk/metric v0.40.0 h1:qOM29YaGcxipWjL5FzpyZDpCYrDREvX0mVlmXdOjCHU= go.opentelemetry.io/otel/trace v1.10.0/go.mod h1:Sij3YYczqAdz+EhmGhE6TpTxUO5/F/AzrK+kxfGqySM= -go.opentelemetry.io/otel/trace v1.17.0 h1:/SWhSRHmDPOImIAetP1QAeMnZYiQXrTy4fMMYOdSKWQ= -go.opentelemetry.io/otel/trace v1.17.0/go.mod h1:I/4vKTgFclIsXRVucpH25X0mpFSczM7aHeaz0ZBLWjY= go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1Dzxpg= go.opentelemetry.io/otel/trace v1.19.0/go.mod h1:mfaSyvGyEJEI0nyV2I4qhNQnbBOUUmYZpYojqMnX2vo= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= @@ -764,8 +748,6 @@ golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= -golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= -golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -856,8 +838,6 @@ golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= -golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -984,8 +964,6 @@ golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= @@ -1002,8 +980,6 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= -golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1244,8 +1220,6 @@ google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ5 google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.57.0 h1:kfzNeI/klCGD2YPMUlaGNT3pxvYfga7smW3Vth8Zsiw= -google.golang.org/grpc v1.57.0/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo= google.golang.org/grpc v1.58.2 h1:SXUpjxeVF3FKrTYQI4f4KvbGD5u2xccdYdurwowix5I= google.golang.org/grpc v1.58.2/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= diff --git a/pkg/api/v1alpha1/extension_resource_definitions.go b/pkg/api/v1alpha1/extension_resource_definitions.go index 19a1dd5..7993fc1 100644 --- a/pkg/api/v1alpha1/extension_resource_definitions.go +++ b/pkg/api/v1alpha1/extension_resource_definitions.go @@ -15,10 +15,9 @@ import ( "github.com/metal-toolbox/governor-api/internal/dbtools" "github.com/metal-toolbox/governor-api/internal/models" events "github.com/metal-toolbox/governor-api/pkg/events/v1alpha1" + "github.com/metal-toolbox/governor-api/pkg/jsonschema" "github.com/volatiletech/sqlboiler/v4/boil" "github.com/volatiletech/sqlboiler/v4/queries/qm" - - jsonschema "github.com/santhosh-tekuri/jsonschema/v5" ) // ExtensionResourceDefinition is the extension resource definition response @@ -218,7 +217,17 @@ func (r *Router) createExtensionResourceDefinition(c *gin.Context) { schema = string(req.Schema) } - if _, err := jsonschema.CompileString("https://governor/s.json", schema); err != nil { + compiler := jsonschema.NewCompiler( + extensionID, req.SlugPlural, req.Version, + jsonschema.WithUniqueConstraint( + c.Request.Context(), + &models.ExtensionResourceDefinition{}, + nil, + nil, + ), + ) + + if _, err := compiler.Compile(schema); err != nil { sendError(c, http.StatusBadRequest, "ERD schema is not valid: "+err.Error()) return } diff --git a/pkg/jsonschema/compiler.go b/pkg/jsonschema/compiler.go new file mode 100644 index 0000000..c3883ce --- /dev/null +++ b/pkg/jsonschema/compiler.go @@ -0,0 +1,75 @@ +package jsonschema + +import ( + "context" + "fmt" + "strings" + + "github.com/metal-toolbox/governor-api/internal/models" + "github.com/santhosh-tekuri/jsonschema/v5" + "github.com/volatiletech/sqlboiler/v4/boil" +) + +// Compiler is a struct for a JSON schema compiler +type Compiler struct { + jsonschema.Compiler + + extensionID string + erdSlugPlural string + version string +} + +// Option is a functional configuration option for JSON schema compiler +type Option func(c *Compiler) + +// NewCompiler configures and creates a new JSON schema compiler +func NewCompiler( + extensionID, slugPlural, version string, + opts ...Option, +) *Compiler { + c := &Compiler{*jsonschema.NewCompiler(), extensionID, slugPlural, version} + + for _, opt := range opts { + opt(c) + } + + return c +} + +// WithUniqueConstraint enables the unique constraint extension for a JSON +// schema. An extra `unique` field can be added to the JSON schema, and the +// Validator will ensure that the combination of every properties in the +// array is unique within the given extension resource definition. +// Note that unique constraint validation will be skipped if db is nil. +func WithUniqueConstraint( + ctx context.Context, + extensionResourceDefinition *models.ExtensionResourceDefinition, + resourceID *string, + db boil.ContextExecutor, +) Option { + return func(c *Compiler) { + c.RegisterExtension( + "uniqueConstraint", + JSONSchemaUniqueConstraint, + &UniqueConstraintCompiler{extensionResourceDefinition, resourceID, ctx, db}, + ) + } +} + +func (c *Compiler) schemaURL() string { + return fmt.Sprintf( + "https://governor/extensions/%s/erds/%s/%s/schema.json", + c.extensionID, c.erdSlugPlural, c.version, + ) +} + +// Compile compiles the schema string +func (c *Compiler) Compile(schema string) (*jsonschema.Schema, error) { + url := c.schemaURL() + + if err := c.AddResource(url, strings.NewReader(schema)); err != nil { + return nil, err + } + + return c.Compiler.Compile(url) +} diff --git a/pkg/jsonschema/doc.go b/pkg/jsonschema/doc.go new file mode 100644 index 0000000..241cb99 --- /dev/null +++ b/pkg/jsonschema/doc.go @@ -0,0 +1,3 @@ +// Package jsonschema provides a JSON schema validator that is tailored for +// validations of governor's Extension Resources +package jsonschema diff --git a/pkg/jsonschema/errors.go b/pkg/jsonschema/errors.go new file mode 100644 index 0000000..4ebed06 --- /dev/null +++ b/pkg/jsonschema/errors.go @@ -0,0 +1,13 @@ +package jsonschema + +import "errors" + +var ( + // ErrInvalidUniqueProperty is returned when the schema's unique property + // is invalid + ErrInvalidUniqueProperty = errors.New(`property "unique" is invalid`) + + // ErrUniqueConstraintViolation is returned when an object violates the unique + // constrain + ErrUniqueConstraintViolation = errors.New("unique constraint violation") +) diff --git a/pkg/jsonschema/unique_constraint.go b/pkg/jsonschema/unique_constraint.go new file mode 100644 index 0000000..ba8f4bb --- /dev/null +++ b/pkg/jsonschema/unique_constraint.go @@ -0,0 +1,224 @@ +package jsonschema + +import ( + "context" + "fmt" + "reflect" + + "github.com/metal-toolbox/governor-api/internal/models" + "github.com/santhosh-tekuri/jsonschema/v5" + "github.com/volatiletech/sqlboiler/v4/boil" + "github.com/volatiletech/sqlboiler/v4/queries/qm" +) + +// JSONSchemaUniqueConstraint is a JSON schema extension that provides a +// "unique" property of type array +var JSONSchemaUniqueConstraint = jsonschema.MustCompileString( + "https://governor/json-schemas/unique.json", + `{ + "properties": { + "unique": { + "type": "array", + "items": { + "type": "string" + } + } + } + }`, +) + +// UniqueConstraintSchema is the schema struct for the unique constraint JSON schema extension +type UniqueConstraintSchema struct { + UniqueFieldTypesMap map[string]string + ERD *models.ExtensionResourceDefinition + ResourceID *string + ctx context.Context + db boil.ContextExecutor +} + +// UniqueConstraintSchema implements jsonschema.ExtSchema +var _ jsonschema.ExtSchema = (*UniqueConstraintSchema)(nil) + +// Validate checks the uniqueness of the provided value against a database +// to ensure the unique constraint is satisfied. +func (s *UniqueConstraintSchema) Validate(_ jsonschema.ValidationContext, v interface{}) error { + // Skip validation if no database is provided + if s.db == nil { + return nil + } + + // Skip validation if no constraint is provided + if len(s.UniqueFieldTypesMap) == 0 { + return nil + } + + // Try to assert the provided value as a map, skip validation otherwise + mappedValue, ok := v.(map[string]interface{}) + if !ok { + return nil + } + + qms := []qm.QueryMod{} + + if s.ResourceID != nil { + qms = append(qms, qm.Where("id != ?", *s.ResourceID)) + } + + for k, v := range mappedValue { + fieldType, exists := s.UniqueFieldTypesMap[k] + if !exists { + continue + } + + if fieldType == "string" { + if vStr, ok := v.(string); ok { + v = fmt.Sprintf(`"%s"`, vStr) + } + } + + qms = append(qms, qm.Where(`resource->? = ?`, k, v)) + } + + exists, err := s.ERD.SystemExtensionResources(qms...).Exists(s.ctx, s.db) + if err != nil { + return &jsonschema.ValidationError{ + Message: err.Error(), + } + } + + if exists { + return &jsonschema.ValidationError{ + InstanceLocation: s.ERD.Name, + KeywordLocation: "unique", + Message: ErrUniqueConstraintViolation.Error(), + } + } + + return nil +} + +// UniqueConstraintCompiler is the compiler struct for the unique constraint JSON schema extension +type UniqueConstraintCompiler struct { + ERD *models.ExtensionResourceDefinition + ResourceID *string + ctx context.Context + db boil.ContextExecutor +} + +// UniqueConstraintCompiler implements jsonschema.ExtCompiler +var _ jsonschema.ExtCompiler = (*UniqueConstraintCompiler)(nil) + +// Compile compiles the unique constraint JSON schema extension +func (uc *UniqueConstraintCompiler) Compile( + _ jsonschema.CompilerContext, m map[string]interface{}, +) (jsonschema.ExtSchema, error) { + unique, ok := m["unique"] + if !ok { + // If "unique" is not in the map, skip processing + return nil, nil + } + + uniqueFields, err := assertStringSlice(unique) + if err != nil { + return nil, err + } + + if len(uniqueFields) == 0 { + // unique property is not provided, skip + return nil, nil + } + + requiredFields, err := assertStringSlice(m["required"]) + if err != nil { + return nil, err + } + + requiredMap := make(map[string]bool, len(requiredFields)) + for _, f := range requiredFields { + requiredMap[f] = true + } + + propertiesMap, ok := m["properties"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf( + `%w: cannot apply unique constraint when "properties" is not provided or invalid`, + ErrInvalidUniqueProperty, + ) + } + + return uc.compileUniqueConstraint(uniqueFields, requiredMap, propertiesMap) +} + +func (uc *UniqueConstraintCompiler) compileUniqueConstraint( + uniqueFields []string, requiredMap map[string]bool, propertiesMap map[string]interface{}, +) (jsonschema.ExtSchema, error) { + // map fieldName => fieldType + resultUniqueFields := make(map[string]string) + + for _, fieldName := range uniqueFields { + if !requiredMap[fieldName] { + return nil, fmt.Errorf( + `%w: unique property needs to be a required property, "%s" is not in "required"`, + ErrInvalidUniqueProperty, + fieldName, + ) + } + + prop, ok := propertiesMap[fieldName] + if !ok { + return nil, fmt.Errorf( + `%w: missing property definition for unique field "%s"`, + ErrInvalidUniqueProperty, + fieldName, + ) + } + + fieldType, ok := prop.(map[string]interface{})["type"].(string) + if !ok || !isValidType(fieldType) { + return nil, fmt.Errorf( + `%w: invalid type "%s" for unique field "%s"`, + ErrInvalidUniqueProperty, + fieldType, + fieldName, + ) + } + + resultUniqueFields[fieldName] = fieldType + } + + return &UniqueConstraintSchema{resultUniqueFields, uc.ERD, uc.ResourceID, uc.ctx, uc.db}, nil +} + +// Checks if the provided field type is valid for unique constraints +func isValidType(fieldType string) bool { + return fieldType == "string" || fieldType == "number" || fieldType == "integer" || fieldType == "boolean" +} + +// helper function to assert string slice type +func assertStringSlice(value interface{}) ([]string, error) { + values, ok := value.([]interface{}) + if !ok { + return nil, fmt.Errorf( + `%w: unable to convert %v to string array`, + ErrInvalidUniqueProperty, + reflect.TypeOf(value), + ) + } + + strs := make([]string, len(values)) + + for i, v := range values { + str, ok := v.(string) + if !ok { + return nil, fmt.Errorf( + `%w: unable to convert %v to string`, + ErrInvalidUniqueProperty, + reflect.TypeOf(v), + ) + } + + strs[i] = str + } + + return strs, nil +} diff --git a/pkg/jsonschema/unique_constraint_test.go b/pkg/jsonschema/unique_constraint_test.go new file mode 100644 index 0000000..a6dcb79 --- /dev/null +++ b/pkg/jsonschema/unique_constraint_test.go @@ -0,0 +1,317 @@ +package jsonschema + +import ( + "context" + "database/sql" + "reflect" + "testing" + + "github.com/cockroachdb/cockroach-go/v2/testserver" + dbm "github.com/metal-toolbox/governor-api/db" + "github.com/metal-toolbox/governor-api/internal/models" + "github.com/pressly/goose/v3" + "github.com/santhosh-tekuri/jsonschema/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/volatiletech/sqlboiler/v4/boil" + "github.com/volatiletech/sqlboiler/v4/queries/qm" +) + +type UniqueConstrainTestSuite struct { + suite.Suite + + db *sql.DB +} + +func (s *UniqueConstrainTestSuite) seedTestDB() error { + testData := []string{ + `INSERT INTO extensions (id, name, description, enabled, slug, status) + VALUES ('00000001-0000-0000-0000-000000000001', 'Test Extension', 'some extension', true, 'test-extension', 'online');`, + ` + INSERT INTO extension_resource_definitions (id, name, description, enabled, slug_singular, slug_plural, version, scope, schema, extension_id) + VALUES ('00000001-0000-0000-0000-000000000002', 'Test Resource', 'some-description', true, 'test-resource', 'test-resources', 'v1', 'system', + '{"$id": "v1.person.test-ex-1","$schema": "https://json-schema.org/draft/2020-12/schema","title": "Person","type": "object","unique": ["firstName", "lastName"],"required": ["firstName", "lastName"],"properties": {"firstName": {"type": "string","description": "The person''s first name.","ui": {"hide": true}},"lastName": {"type": "string","description": "The person''s last name."},"age": {"description": "Age in years which must be equal to or greater than zero.","type": "integer","minimum": 0}}}'::jsonb, + '00000001-0000-0000-0000-000000000001'); + `, + `INSERT INTO system_extension_resources (id, resource, extension_resource_definition_id) + VALUES ('00000001-0000-0000-0000-000000000003', '{"age": 10, "firstName": "Hello", "lastName": "World"}'::jsonb, '00000001-0000-0000-0000-000000000002');`, + } + + for _, q := range testData { + _, err := s.db.Query(q) + if err != nil { + return err + } + } + + return nil +} + +func (s *UniqueConstrainTestSuite) SetupSuite() { + ts, err := testserver.NewTestServer() + if err != nil { + panic(err) + } + + s.db, err = sql.Open("postgres", ts.PGURL().String()) + if err != nil { + panic(err) + } + + goose.SetBaseFS(dbm.Migrations) + + if err := goose.Up(s.db, "migrations"); err != nil { + panic("migration failed - could not set up test db") + } + + if err := s.seedTestDB(); err != nil { + panic("db setup failed - could not seed test db: " + err.Error()) + } +} + +func (s *UniqueConstrainTestSuite) TestCompile() { + tests := []struct { + name string + inputMap map[string]interface{} + expectedErr string + }{ + { + name: "no unique key", + inputMap: map[string]interface{}{}, + expectedErr: "", + }, + { + name: "invalid unique field type", + inputMap: map[string]interface{}{ + "unique": 1234, + }, + expectedErr: "unable to convert", + }, + { + name: "unique exists but empty", + inputMap: map[string]interface{}{ + "unique": []interface{}{}, + }, + expectedErr: "", + }, + { + name: "unique exists but required invalid", + inputMap: map[string]interface{}{ + "unique": []interface{}{"a"}, + "required": 1234, + }, + expectedErr: "unable to convert", + }, + { + name: "missing properties", + inputMap: map[string]interface{}{ + "unique": []interface{}{"a"}, + "required": []interface{}{"a"}, + }, + expectedErr: "cannot apply unique constraint when \"properties\" is not provided or invalid", + }, + + { + name: "invalid properties type", + inputMap: map[string]interface{}{ + "unique": []interface{}{"a"}, + "required": []interface{}{"a"}, + "properties": "invalidType", + }, + expectedErr: "cannot apply unique constraint when \"properties\" is not provided or invalid", + }, + { + name: "valid unique, required, and properties", + inputMap: map[string]interface{}{ + "unique": []interface{}{"a"}, + "required": []interface{}{"a"}, + "properties": map[string]interface{}{ + "a": map[string]interface{}{ + "type": "string", + }, + }, + }, + expectedErr: "", + }, + { + name: "unique field not in properties", + inputMap: map[string]interface{}{ + "unique": []interface{}{"b"}, + "required": []interface{}{"b"}, + "properties": map[string]interface{}{ + "a": map[string]interface{}{ + "type": "string", + }, + }, + }, + expectedErr: "missing property definition for unique field", + }, + { + name: "unique property not in required", + inputMap: map[string]interface{}{ + "unique": []interface{}{"a"}, + "required": []interface{}{"b"}, + "properties": map[string]interface{}{ + "a": map[string]interface{}{"type": "string"}, + "b": map[string]interface{}{"type": "string"}, + }, + }, + expectedErr: "unique property needs to be a required property", + }, + } + + for _, tt := range tests { + s.Suite.T().Run(tt.name, func(t *testing.T) { + uc := &UniqueConstraintCompiler{ + ctx: context.Background(), + db: nil, + } + _, err := uc.Compile(jsonschema.CompilerContext{}, tt.inputMap) + if tt.expectedErr == "" { + assert.Nil(t, err) + } else { + assert.Contains(t, err.Error(), tt.expectedErr) + } + }) + } +} + +func (s *UniqueConstrainTestSuite) TestValidate() { + resourceID := "00000001-0000-0000-0000-000000000003" + + tests := []struct { + name string + db boil.ContextExecutor + resourceID *string + value interface{} + uniqueFields map[string]string + expectedErr string + existsReturn bool + existsErr error + }{ + { + name: "no DB provided", + db: nil, + value: map[string]interface{}{"firstName": "test1", "lastName": "test11"}, + uniqueFields: map[string]string{"firstName": "string", "lastName": "string"}, + expectedErr: "", + }, + { + name: "value not a map", + db: s.db, + value: "not-a-map", + uniqueFields: map[string]string{"firstName": "string", "lastName": "string"}, + expectedErr: "", + }, + { + name: "value matches uniqueFields (string)", + db: s.db, + value: map[string]interface{}{"firstName": "Hello", "lastName": "World"}, + uniqueFields: map[string]string{"firstName": "string", "lastName": "string"}, + expectedErr: "unique constraint violation", + }, + { + name: "allow self updates (string)", + db: s.db, + resourceID: &resourceID, + value: map[string]interface{}{"firstName": "Hello", "lastName": "World"}, + uniqueFields: map[string]string{"firstName": "string", "lastName": "string"}, + }, + { + name: "empty unique fields", + db: s.db, + value: map[string]interface{}{"firstName": "Hello", "lastName": "World", "age": 10}, + uniqueFields: map[string]string{}, + }, + { + name: "value matches uniqueFields (int)", + db: s.db, + value: map[string]interface{}{"firstName": "Hello", "age": 10}, + uniqueFields: map[string]string{"firstName": "string", "age": "int"}, + expectedErr: "unique constraint violation", + }, + { + name: "allow self updates (int)", + db: s.db, + resourceID: &resourceID, + value: map[string]interface{}{"firstName": "Hello", "age": 10}, + uniqueFields: map[string]string{"firstName": "string", "age": "int"}, + }, + } + + erd, err := models. + ExtensionResourceDefinitions(qm.Where("id = ?", "00000001-0000-0000-0000-000000000002")). + One(context.Background(), s.db) + if err != nil { + panic(err) + } + + for _, tt := range tests { + s.T().Run(tt.name, func(t *testing.T) { + schema := &UniqueConstraintSchema{ + UniqueFieldTypesMap: tt.uniqueFields, + ERD: erd, + ctx: context.Background(), + db: tt.db, + ResourceID: tt.resourceID, + } + + err := schema.Validate(jsonschema.ValidationContext{}, tt.value) + if tt.expectedErr == "" { + assert.Nil(t, err) + } else { + assert.NotNil(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + } + }) + } +} + +func TestAssertStringSlice(t *testing.T) { + tests := []struct { + name string + input interface{} + expected []string + expectedErr string + }{ + { + name: "valid string slice", + input: []interface{}{"foo", "bar", "baz"}, + expected: []string{"foo", "bar", "baz"}, + expectedErr: "", + }, + { + name: "invalid type", + input: "not a slice", + expected: nil, + expectedErr: "to string array", + }, + { + name: "invalid element type", + input: []interface{}{"foo", 42, "baz"}, + expected: nil, + expectedErr: "to string", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual, err := assertStringSlice(tt.input) + + if tt.expectedErr == "" { + assert.Nil(t, err) + if !reflect.DeepEqual(actual, tt.expected) { + t.Errorf("assertStringSlice() = %v, expected %v", actual, tt.expected) + } + } else { + assert.NotNil(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + return + } + }) + } +} + +func TestUniqueConstraintSuite(t *testing.T) { + suite.Run(t, new(UniqueConstrainTestSuite)) +}