Skip to content

Commit

Permalink
Override not editable fields during updates (kubeflow#200)
Browse files Browse the repository at this point in the history
* Override not editable fields during updates

* Setup test for not editable fields override
  • Loading branch information
lampajr authored Nov 27, 2023
1 parent 06ea855 commit 2fac150
Show file tree
Hide file tree
Showing 8 changed files with 457 additions and 32 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
model-registry
metadata.sqlite.db
vendor
coverage.txt

# Robot Framework files
log.html
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ bin/golangci-lint:
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(PROJECT_BIN) v1.54.2

bin/goverter:
GOBIN=$(PROJECT_PATH)/bin go install github.com/jmattheis/goverter/cmd/goverter@v1.0.0
GOBIN=$(PROJECT_PATH)/bin go install github.com/jmattheis/goverter/cmd/goverter@v1.1.1

OPENAPI_GENERATOR ?= ${PROJECT_BIN}/openapi-generator-cli
NPM ?= "$(shell which npm)"
Expand Down
125 changes: 124 additions & 1 deletion internal/converter/generated/openapi_converter.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 41 additions & 0 deletions internal/converter/opeanpi_converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ package converter

import "github.com/opendatahub-io/model-registry/pkg/openapi"

// NOTE: methods must follow these patterns, otherwise tests could not find possible issues:
// Converters createEntity to entity: Convert<ENTITY>Create
// Converters updateEntity to entity: Convert<ENTITY>Update
// Converters override fields entity: OverrideNotEditableFor<ENTITY>

// goverter:converter
// goverter:output:file ./generated/openapi_converter.gen.go
// goverter:wrapErrors
Expand Down Expand Up @@ -43,4 +48,40 @@ type OpenAPIConverter interface {

// goverter:ignore Id CreateTimeSinceEpoch LastUpdateTimeSinceEpoch Name ModelVersionId
ConvertServeModelUpdate(source *openapi.ServeModelUpdate) (*openapi.ServeModel, error)

// Ignore all fields that ARE editable
// goverter:default InitRegisteredModelWithUpdate
// goverter:autoMap Existing
// goverter:ignore Id CreateTimeSinceEpoch LastUpdateTimeSinceEpoch Description ExternalID CustomProperties State
OverrideNotEditableForRegisteredModel(source OpenapiUpdateWrapper[openapi.RegisteredModel]) (openapi.RegisteredModel, error)

// Ignore all fields that ARE editable
// goverter:default InitModelVersionWithUpdate
// goverter:autoMap Existing
// goverter:ignore Id CreateTimeSinceEpoch LastUpdateTimeSinceEpoch Description ExternalID CustomProperties State
OverrideNotEditableForModelVersion(source OpenapiUpdateWrapper[openapi.ModelVersion]) (openapi.ModelVersion, error)

// Ignore all fields that ARE editable
// goverter:default InitModelArtifactWithUpdate
// goverter:autoMap Existing
// goverter:ignore Id CreateTimeSinceEpoch LastUpdateTimeSinceEpoch Description ExternalID CustomProperties Uri State ServiceAccountName ModelFormatName ModelFormatVersion StorageKey StoragePath
OverrideNotEditableForModelArtifact(source OpenapiUpdateWrapper[openapi.ModelArtifact]) (openapi.ModelArtifact, error)

// Ignore all fields that ARE editable
// goverter:default InitServingEnvironmentWithUpdate
// goverter:autoMap Existing
// goverter:ignore Id CreateTimeSinceEpoch LastUpdateTimeSinceEpoch Description ExternalID CustomProperties
OverrideNotEditableForServingEnvironment(source OpenapiUpdateWrapper[openapi.ServingEnvironment]) (openapi.ServingEnvironment, error)

// Ignore all fields that ARE editable
// goverter:default InitInferenceServiceWithUpdate
// goverter:autoMap Existing
// goverter:ignore Id CreateTimeSinceEpoch LastUpdateTimeSinceEpoch Description ExternalID CustomProperties ModelVersionId Runtime State
OverrideNotEditableForInferenceService(source OpenapiUpdateWrapper[openapi.InferenceService]) (openapi.InferenceService, error)

// Ignore all fields that ARE editable
// goverter:default InitServeModelWithUpdate
// goverter:autoMap Existing
// goverter:ignore Id CreateTimeSinceEpoch LastUpdateTimeSinceEpoch Description ExternalID CustomProperties LastKnownState
OverrideNotEditableForServeModel(source OpenapiUpdateWrapper[openapi.ServeModel]) (openapi.ServeModel, error)
}
160 changes: 160 additions & 0 deletions internal/converter/openapi_converter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package converter

import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"reflect"
"regexp"
"strings"
"testing"

"github.com/opendatahub-io/model-registry/pkg/openapi"
)

// visitor
type visitor struct {
t *testing.T
entities map[string]*oapiEntity
}

func newVisitor(t *testing.T, f *ast.File) visitor {
return visitor{
t: t,
entities: map[string]*oapiEntity{
"RegisteredModel": {
obj: openapi.RegisteredModel{},
},
"ModelVersion": {
obj: openapi.ModelVersion{},
},
"ModelArtifact": {
obj: openapi.ModelArtifact{},
},
"ServingEnvironment": {
obj: openapi.ServingEnvironment{},
},
"InferenceService": {
obj: openapi.InferenceService{},
},
"ServeModel": {
obj: openapi.ServeModel{},
},
},
}
}

func (v *visitor) extractGroup(regex *regexp.Regexp, s string) string {
extracted := regex.FindStringSubmatch(s)
if len(extracted) != 2 {
v.t.Errorf("unable to extract groups from %s for %s", regex.String(), s)
}
// the first one is the wole matched string, the second one is the group
return extracted[1]
}

func (v *visitor) getEntity(name string) *oapiEntity {
val, ok := v.entities[name]
if !ok {
v.t.Errorf("openapi entity not found in the entities map: %s", name)
}
return val
}

func (v visitor) Visit(n ast.Node) ast.Visitor {
if n == nil {
return nil
}

switch d := n.(type) {
case *ast.InterfaceType:
for _, m := range d.Methods.List {
methodName := m.Names[0].Name

if converterMethodPattern.MatchString(methodName) {
entityName := v.extractGroup(converterMethodPattern, methodName)
entity := v.getEntity(entityName)
// there should be just one doc comment matching ignoreDirectivePattern
for _, c := range m.Doc.List {
if ignoreDirectivePattern.MatchString(c.Text) {
entity.notEditableFields = v.extractGroup(ignoreDirectivePattern, c.Text)
}
}
} else if overrideNotEditableMethodPattern.MatchString(methodName) {
entityName := v.extractGroup(overrideNotEditableMethodPattern, methodName)
entity := v.getEntity(entityName)
// there should be just one doc comment matching ignoreDirectivePattern
for _, c := range m.Doc.List {
if ignoreDirectivePattern.MatchString(c.Text) {
entity.ignoredFields = v.extractGroup(ignoreDirectivePattern, c.Text)
}
}
}
}
v.checkEntities()
}
return v
}

// checkEntities check if all editable fields are listed in the goverter ignore directive of OverrideNotEditableFor
func (v *visitor) checkEntities() {
errorMsgs := map[string][]string{}
for k, v := range v.entities {
msgs := checkEntity(v)
if len(msgs) > 0 {
errorMsgs[k] = msgs
}
}

if len(errorMsgs) > 0 {
missingFieldsMsg := ""
for k, fields := range errorMsgs {
missingFieldsMsg += fmt.Sprintf("%s: %v\n", k, fields)
}
v.t.Errorf("missing fields to be ignored for OverrideNotEditableFor* goverter methods:\n%v", missingFieldsMsg)
}
}

// checkEntity check if there are missing fields to be ignored in the override method
func checkEntity(entity *oapiEntity) []string {
res := []string{}
objType := reflect.TypeOf(entity.obj)
for i := 0; i < objType.NumField(); i++ {
field := objType.Field(i)
if !strings.Contains(entity.notEditableFields, field.Name) && !strings.Contains(entity.ignoredFields, field.Name) {
// check if the not editable field (first check) is not present in the ignored fields (second check)
// if this condition is true, we missed that field in the Override method ignore list
res = append(res, field.Name)
}
}
return res
}

// test

var converterMethodPattern *regexp.Regexp = regexp.MustCompile(`Convert(?P<entity>\w+)Update`)
var overrideNotEditableMethodPattern *regexp.Regexp = regexp.MustCompile(`OverrideNotEditableFor(?P<entity>\w+)`)
var ignoreDirectivePattern *regexp.Regexp = regexp.MustCompile(`// goverter:ignore (?P<fields>.+)`)

func TestOverrideNotEditableFields(t *testing.T) {
_ = setup(t)

fset := token.NewFileSet() // positions are relative to fset
wd, err := os.Getwd()
if err != nil {
t.Errorf("error getting current working directory")
}
filePath := fmt.Sprintf("%s/opeanpi_converter.go", wd)
f, _ := parser.ParseFile(fset, filePath, nil, parser.ParseComments)

v := newVisitor(t, f)
ast.Walk(v, f)
}

type oapiEntity struct {
obj any
notEditableFields string
ignoredFields string
}
Loading

0 comments on commit 2fac150

Please sign in to comment.