Skip to content

Commit

Permalink
add validator
Browse files Browse the repository at this point in the history
  • Loading branch information
bailinhe committed Oct 13, 2023
1 parent c8c0419 commit 04ae799
Show file tree
Hide file tree
Showing 6 changed files with 603 additions and 3 deletions.
15 changes: 12 additions & 3 deletions pkg/api/v1alpha1/extension_resource_definitions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
75 changes: 75 additions & 0 deletions pkg/jsonschema/compiler.go
Original file line number Diff line number Diff line change
@@ -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)
}
3 changes: 3 additions & 0 deletions pkg/jsonschema/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Package jsonschema provides a JSON schema validator that is tailored for
// validations of governor's Extension Resources
package jsonschema
13 changes: 13 additions & 0 deletions pkg/jsonschema/errors.go
Original file line number Diff line number Diff line change
@@ -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")
)
229 changes: 229 additions & 0 deletions pkg/jsonschema/unique_constraint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
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, value := range mappedValue {
// Convert the value to string
v := fmt.Sprint(value)

fieldType, exists := s.UniqueFieldTypesMap[k]
if !exists {
continue
}

if fieldType == "string" {
v = fmt.Sprintf(`"%s"`, v)
}

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 {
_, ok := map[string]bool{
"string": true, "number": true, "integer": true, "boolean": true,
}[fieldType]

return ok
}

// 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
}
Loading

0 comments on commit 04ae799

Please sign in to comment.