diff --git a/go.mod b/go.mod index 741d361f6..64125881d 100644 --- a/go.mod +++ b/go.mod @@ -15,11 +15,11 @@ require ( github.com/deckarep/golang-set v1.7.1 github.com/fatih/color v1.9.0 github.com/gertd/go-pluralize v0.1.7 + github.com/getkin/kin-openapi v0.34.0 github.com/ghodss/yaml v1.0.0 github.com/gin-contrib/static v0.0.0-20200815103939-31fb0c56a3d1 github.com/gin-gonic/gin v1.6.3 github.com/go-logr/logr v0.1.0 - github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/jsonreference v0.19.5 // indirect github.com/go-openapi/spec v0.19.8 // indirect github.com/go-openapi/swag v0.19.11 // indirect diff --git a/go.sum b/go.sum index dd0c4d7d9..ed6a7c96f 100644 --- a/go.sum +++ b/go.sum @@ -543,6 +543,8 @@ github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7 h1:LofdAjjjqCSXMwL github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY= github.com/gertd/go-pluralize v0.1.7 h1:RgvJTJ5W7olOoAks97BOwOlekBFsLEyh00W48Z6ZEZY= github.com/gertd/go-pluralize v0.1.7/go.mod h1:O4eNeeIf91MHh1GJ2I47DNtaesm66NYvjYgAahcqSDQ= +github.com/getkin/kin-openapi v0.34.0 h1:B/YcH4BXcEzQfpCv7WvdPubb3EnMuwEcI3O+l6GpiXc= +github.com/getkin/kin-openapi v0.34.0/go.mod h1:ZJSfy1PxJv2QQvH9EdBj3nupRTVvV42mkW6zKUlRBwk= github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghodss/yaml v0.0.0-20180820084758-c7ce16629ff4/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= @@ -1219,6 +1221,7 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v0.0.0-20160406211939-eadb3ce320cb/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kyoh86/exportloopref v0.1.7/go.mod h1:h1rDl2Kdj97+Kwh4gdz3ujE7XHmH51Q0lUiZ1z4NLj8= github.com/kyokomi/emoji v2.2.4+incompatible h1:np0woGKwx9LiHAQmwZx79Oc0rHpNw3o+3evou4BEPv4= @@ -1884,6 +1887,7 @@ go.etcd.io/bbolt v1.3.1-etcd.7/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XK go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/etcd v0.0.0-20181031231232-83304cfc808c/go.mod h1:weASp41xM3dk0YHg1s/W8ecdGP5G4teSTMBPpYAaUgA= +go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738 h1:VcrIfasaLFkyjk6KNlXQSzO+B0fZcnECiDrKJsfxka0= go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg= go.mongodb.org/mongo-driver v1.0.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= go.mongodb.org/mongo-driver v1.1.1/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= diff --git a/hack/references/generate.go b/hack/references/generate.go index bcc1900e6..fe4f6d5c0 100644 --- a/hack/references/generate.go +++ b/hack/references/generate.go @@ -1,7 +1,6 @@ package main import ( - "errors" "fmt" "io/ioutil" "os" @@ -13,6 +12,7 @@ import ( "github.com/oam-dev/kubevela/apis/types" mycue "github.com/oam-dev/kubevela/pkg/cue" "github.com/oam-dev/kubevela/pkg/plugins" + "github.com/oam-dev/kubevela/pkg/utils/common" ) const BaseRefPath = "docs/en/developers/references" @@ -70,7 +70,7 @@ func main() { CapabilityType: capabilityType, } - cueValue, err := getCUEParameterValue(c.CueTemplate) + cueValue, err := common.GetCUEParameterValue(c.CueTemplate) if err != nil { fmt.Printf("failed to retrieve `parameters` value from %s with err: %s", c.Name, err) os.Exit(1) @@ -114,35 +114,6 @@ func (ref *ReferenceMarkdown) prepareParameterTable(tableName string, parameterL return refContent } -// getCUEParameterValue converts definitions to cue format -func getCUEParameterValue(cueStr string) (cue.Value, error) { - r := cue.Runtime{} - template, err := r.Compile("", cueStr+mycue.BaseTemplate) - if err != nil { - return cue.Value{}, err - } - tempStruct, err := template.Value().Struct() - if err != nil { - return cue.Value{}, err - } - // find the parameter definition - var paraDef cue.FieldInfo - var found bool - for i := 0; i < tempStruct.Len(); i++ { - paraDef = tempStruct.Field(i) - if paraDef.Name == "parameter" { - found = true - break - } - } - if !found { - return cue.Value{}, errors.New("arguments not exist") - } - arguments := paraDef.Value - - return arguments, nil -} - // parseParameters parses every parameter func (ref *ReferenceMarkdown) parseParameters(paraValue cue.Value, paramKey string, depth int) error { var params []Parameter diff --git a/pkg/appfile/service.go b/pkg/appfile/service.go index f878e5e57..470ce2b1f 100644 --- a/pkg/appfile/service.go +++ b/pkg/appfile/service.go @@ -17,7 +17,7 @@ import ( "github.com/oam-dev/kubevela/pkg/oam" ) -// Service defines the service spec for AppFile, it will contain all information a service realted including OAM component, traits, source to image, etc... +// Service defines the service spec for AppFile, it will contain all information a service related including OAM component, traits, source to image, etc... type Service map[string]interface{} // DefaultWorkloadType defines the default service type if no type specified in Appfile diff --git a/pkg/appfile/service_test.go b/pkg/appfile/service_test.go index c1c55bbb4..434b1db47 100644 --- a/pkg/appfile/service_test.go +++ b/pkg/appfile/service_test.go @@ -1 +1,22 @@ package appfile + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetType(t *testing.T) { + svc1 := Service{} + got := svc1.GetType() + assert.Equal(t, DefaultWorkloadType, got) + + var workload2 = "W2" + map2 := map[string]interface{}{ + "type": workload2, + "cpu": "0.5", + } + svc2 := Service(map2) + got = svc2.GetType() + assert.Equal(t, workload2, got) +} diff --git a/pkg/server/definitionHandler.go b/pkg/server/definitionHandler.go new file mode 100644 index 000000000..56069bad8 --- /dev/null +++ b/pkg/server/definitionHandler.go @@ -0,0 +1,19 @@ +package server + +import ( + "github.com/gin-gonic/gin" + + "github.com/oam-dev/kubevela/pkg/server/util" + "github.com/oam-dev/kubevela/pkg/serverlib" +) + +// GetDefinition gets OpenAPI schema from Cue section of a WorkloadDefinition/TraitDefinition +func (s *APIServer) GetDefinition(c *gin.Context) { + definitionName := c.Param("name") + parameter, err := serverlib.GetDefinition(definitionName) + if err != nil { + util.HandleError(c, util.StatusInternalServerError, err) + return + } + util.AssembleResponse(c, string(parameter), nil) +} diff --git a/pkg/server/route.go b/pkg/server/route.go index baddc5f1b..012c589e8 100644 --- a/pkg/server/route.go +++ b/pkg/server/route.go @@ -149,6 +149,12 @@ func (s *APIServer) setupRoute(staticPath string) http.Handler { caps.GET("", s.ListCapabilities) } + // Definition related api + defs := api.Group(util.Definition) + { + defs.GET("/:name", s.GetDefinition) + } + // version api.GET(util.VersionPath, s.GetVersion) diff --git a/pkg/server/util/middleware.go b/pkg/server/util/middleware.go index 566c2e005..f2b0d35d2 100644 --- a/pkg/server/util/middleware.go +++ b/pkg/server/util/middleware.go @@ -53,6 +53,7 @@ const ( CapabilityPath = "/capabilities" CapabilityCenterPath = "/capability-centers" VersionPath = "/version" + Definition = "/definitions" ) // NoRoute is a handler which is invoked when there is no route matches. diff --git a/pkg/serverlib/definition.go b/pkg/serverlib/definition.go new file mode 100644 index 000000000..ddb3b6c79 --- /dev/null +++ b/pkg/serverlib/definition.go @@ -0,0 +1,213 @@ +package serverlib + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + + mycue "github.com/oam-dev/kubevela/pkg/cue" + "github.com/oam-dev/kubevela/pkg/utils/common" + "github.com/oam-dev/kubevela/pkg/utils/system" +) + +const ( + // OpenAPISchemaDir is the folder name under ~/.vela/capabilities + OpenAPISchemaDir = "openapi" + // UsageTag is usage comment annotation + UsageTag = "+usage=" + // ShortTag is the short alias annotation + ShortTag = "\\n+short" +) + +// OpenAPISchema is the struct for OpenAPI Schema generated by Cue OpenAPI +type OpenAPISchema struct { + OpenAPI string `json:"openapi"` + Components Components `json:"components"` +} + +// Components is the struct filed of OpenAPISchema +type Components struct { + Schemas Schemas `json:"schemas"` +} + +// Schemas is the struct filed of Components +type Schemas struct { + Parameter map[string]interface{} `json:"parameter"` +} + +// GetDefinition is the main function for GetDefinition API +func GetDefinition(name string) ([]byte, error) { + openAPISchema, err := generateOpenAPISchemaFromCapabilityParameter(name) + if err != nil { + return nil, err + } + swagger, err := openapi3.NewSwaggerLoader().LoadSwaggerFromData(openAPISchema) + if err != nil { + return nil, err + } + schemaRef := swagger.Components.Schemas["parameter"] + schema := schemaRef.Value + fixOpenAPISchema("", schema) + + parameter, err := schema.MarshalJSON() + if err != nil { + return nil, err + } + return parameter, nil +} + +// generateOpenAPISchemaFromCapabilityParameter returns the parameter of a definition in cue.Value format +func generateOpenAPISchemaFromCapabilityParameter(name string) ([]byte, error) { + dir, err := system.GetCapabilityDir() + if err != nil { + return nil, err + } + + definitionCueName := fmt.Sprintf("%s.cue", name) + schemaDir := filepath.Join(dir, OpenAPISchemaDir) + if err = prepareParameterCue(dir, definitionCueName, schemaDir); err != nil { + return nil, err + } + + if err = appendCueReference(filepath.Join(dir, OpenAPISchemaDir, definitionCueName)); err != nil { + return nil, err + } + + filename := filepath.FromSlash(definitionCueName) + return common.GenOpenAPIFromFile(filepath.Join(dir, OpenAPISchemaDir), filename) +} + +// prepareParameterCue cuts `parameter` section form definition .cue file +func prepareParameterCue(fileDir, fileName string, targetSchemaDir string) error { + if _, err := os.Stat(targetSchemaDir); err != nil && os.IsNotExist(err) { + if err := os.Mkdir(targetSchemaDir, 0750); err != nil { + return err + } + } + + cueFile := filepath.Join(fileDir, fileName) + f, err := os.Open(filepath.Clean(cueFile)) + if err != nil { + return err + } + //nolint + defer f.Close() + schemaFile := filepath.Join(targetSchemaDir, fileName) + _, err = os.Stat(schemaFile) + if err == nil { + if err = os.Truncate(schemaFile, 0); err != nil { + return err + } + } + targetFile, err := os.OpenFile(filepath.Clean(schemaFile), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) + if err != nil { + return err + } + //nolint + defer targetFile.Close() + + scanner := bufio.NewScanner(f) + var withParameterFlag bool + r := regexp.MustCompile("[[:space:]]*parameter:[[:space:]]*{.*") + + for scanner.Scan() { + text := scanner.Text() + if r.MatchString(text) { + // a variable has to be refined as a definition which starts with "#" + text = fmt.Sprintf("parameter: #parameter\n#%s", text) + withParameterFlag = true + } + if _, err := targetFile.WriteString(fmt.Sprintf("%s\n", text)); err != nil { + return err + } + } + + if !withParameterFlag { + return fmt.Errorf("cue file %s doesn't contain section `parmeter`", cueFile) + } + return nil +} + +// appendCueReference appends `context` filed to parameter .cue file +func appendCueReference(cueFile string) error { + f, err := os.OpenFile(filepath.Clean(cueFile), os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + return err + } + //nolint + defer f.Close() + + if _, err := f.WriteString(mycue.BaseTemplate); err != nil { + return err + } + return nil +} + +// fixOpenAPISchema fixes tainted `description` filed, missing of title `field`. +func fixOpenAPISchema(name string, schema *openapi3.Schema) { + t := schema.Type + switch t { + case "object": + for k, v := range schema.Properties { + s := v.Value + fixOpenAPISchema(k, s) + } + case "array": + if name != "" { + schema.Title = name + } + fixOpenAPISchema("", schema.Items.Value) + default: + if name != "" { + schema.Title = name + } + } + line := schema.Description + if strings.Contains(line, UsageTag) { + newDescription := strings.ReplaceAll(line, UsageTag, "") + if strings.Contains(newDescription, ShortTag) { + newDescription = strings.ReplaceAll(newDescription, ShortTag, "") + } + schema.Description = newDescription + } +} + +// addTitleField adds title field +func addTitleField(line, propertyName string) string { + blanks := strings.Split(line, "\"")[0] + title := fmt.Sprintf("%s\"title\": \"%s\"", blanks, propertyName) + return title + ",\n" +} + +// getParameterItemName gets the name of a parameter item +func getParameterItemName(previousLine string) (string, error) { + // parse property name, like from `"cmd": {\n` + tempPropertyName := strings.Split(previousLine, "\"") + if len(tempPropertyName) <= 1 { + return "", fmt.Errorf("could not get property name from: %s", previousLine) + } + propertyName := strings.Split(tempPropertyName[1], "\"")[0] + return propertyName, nil +} + +// getParameterFromOpenAPISchema retrieves needs section from OpenAPI schema for front-end requirements +func getParameterFromOpenAPISchema(openAPISchema []byte) ([]byte, error) { + var schema OpenAPISchema + var parameterJSON []byte + var err error + if err = json.Unmarshal(openAPISchema, &schema); err != nil { + return nil, err + } + + parameter := schema.Components.Schemas.Parameter + if parameterJSON, err = json.Marshal(parameter); err != nil { + return nil, err + } + return parameterJSON, nil +} diff --git a/pkg/serverlib/definition_test.go b/pkg/serverlib/definition_test.go new file mode 100644 index 000000000..7d9bd744c --- /dev/null +++ b/pkg/serverlib/definition_test.go @@ -0,0 +1,207 @@ +package serverlib + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/crossplane/crossplane-runtime/pkg/test" + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + + "github.com/oam-dev/kubevela/pkg/utils/system" +) + +const TestDir = "testdata/definition" + +func TestGenerateOpenAPISchemaFromCapabilityParameter(t *testing.T) { + + var invalidWorkloadName = "IAmAnInvalidWorkloadDefinition" + capabilityDir, _ := system.GetCapabilityDir() + if _, err := os.Stat(capabilityDir); err != nil && os.IsNotExist(err) { + os.Mkdir(capabilityDir, 0755) + } + invalidWorkloadPath := filepath.Join(capabilityDir, fmt.Sprintf("%s.cue", invalidWorkloadName)) + + type want struct { + data []byte + err error + } + + cases := map[string]struct { + reason string + name string + want want + }{ + "GenerateOpenAPISchemaFromInvalidCapability": { + reason: "generate OpenAPI schema for an invalid Workload/Trait", + name: invalidWorkloadName, + want: want{data: nil, err: &os.PathError{Op: "open", Path: invalidWorkloadPath, Err: fmt.Errorf("no such file or directory")}}, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := generateOpenAPISchemaFromCapabilityParameter(tc.name) + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\ngetDefinition(...): -want error, +got error:\n%s", tc.reason, diff) + } + if diff := cmp.Diff(tc.want.data, got); diff != "" { + t.Errorf("\n%s\ngetDefinition(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestPrepareParameterCue(t *testing.T) { + targetSchemaDir := filepath.Join(TestDir, "openapi") + var noParameterCueName = "workloadNoParameter.cue" + var err = fmt.Errorf("cue file %s doesn't contain section `parmeter`", filepath.Join(TestDir, noParameterCueName)) + + var invalidDir = "IAmAnInvalidDirectory/openapi" + var invalidDirErr = &os.PathError{Op: "mkdir", Path: invalidDir, Err: fmt.Errorf("no such file or directory")} + + var invalidSourceFile = "IAmAnInvalidFile" + invalidSourceFilePath := filepath.Join(TestDir, invalidSourceFile) + var invalidSourceFileErr = &os.PathError{Op: "open", Path: invalidSourceFilePath, Err: fmt.Errorf("no such file or directory")} // + // fmt.Errorf("open %s: no such file or directory", ) + cases := map[string]struct { + reason string + fileDir string + fileName string + targetSchemaDir string + want error + }{ + "PrepareANormalParameterCueFile": { + reason: "Prepare a normal parameter cue file", + fileDir: TestDir, + fileName: "workload1.cue", + targetSchemaDir: targetSchemaDir, + want: nil, + }, + "CueFileNotContainParameter": { + reason: "Prepare a cue file which doesn't contain `parameter` section", + fileDir: TestDir, + fileName: noParameterCueName, + targetSchemaDir: targetSchemaDir, + want: err, + }, + "InvalidTargetSchemaDir": { + reason: "target schema directory is invalid", + fileDir: "", + fileName: "", + targetSchemaDir: invalidDir, + want: invalidDirErr, + }, + "InvalidSourceCueFile": { + reason: "source cue file is invalid", + fileDir: TestDir, + fileName: invalidSourceFile, + targetSchemaDir: TestDir, + want: invalidSourceFileErr, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + err := prepareParameterCue(tc.fileDir, tc.fileName, tc.targetSchemaDir) + if diff := cmp.Diff(tc.want, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\nprepareParameterCue(...): -want error, +got error:\n%s", tc.reason, diff) + } + + }) + } + os.RemoveAll(targetSchemaDir) +} + +func TestAppendCueReference(t *testing.T) { + var cueStr = ` +#parameter: { + min: int +} +` + temporaryDir := filepath.Join(TestDir, "temp") + os.Mkdir(temporaryDir, 0750) + var cueFile = filepath.Join(temporaryDir, "workloadPureParameter.cue") + ioutil.WriteFile(cueFile, []byte(cueStr), 0750) + cases := map[string]struct { + reason string + cueFile string + want error + }{ + "AppendCueReference": { + reason: "Append Cue Reference", + cueFile: cueFile, + want: nil, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + err := appendCueReference(tc.cueFile) + if (err == nil && tc.want != nil) || (err != nil && tc.want == nil) { + t.Errorf("%s\nappendCueReference(...): -want %s, +got %s", tc.reason, tc.want, err) + } + data, err := ioutil.ReadFile(tc.cueFile) + if err != nil { + t.Errorf("%s\nappendCueReference(...): target file %s could not be read: %s", tc.reason, tc.cueFile, err) + } + if strings.HasSuffix(string(data), "context: {") { + t.Errorf("%s\nappendCueReference(...): target file %s doesn't contain `parameter` section", tc.reason, tc.cueFile) + } + }) + } + os.RemoveAll(temporaryDir) +} + +func TestAddTitleField(t *testing.T) { + got := addTitleField(" \"description\": \"cpu core\"", "cpu") + title := " \"title\": \"cpu\",\n" + assert.Equal(t, title, got) +} + +func TestGetParameterItemName(t *testing.T) { + got, err := getParameterItemName(" \"cmd\": {\n") + name := "cmd" + assert.NoError(t, err) + assert.Equal(t, name, got) +} + +func TestGetParameterFromOpenAPISchema(t *testing.T) { + cases := map[string]struct { + reason string + fileDir string + fileName string + targetFile string + want error + }{ + "GetParameterFromOpenAPISchema": { + reason: "get parameter from OpenAPI schema", + fileDir: TestDir, + fileName: "normalOpenAPISchemaFixed.json", + targetFile: "normalOpenAPISchemaParameter.json", + want: nil, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + rawFile := filepath.Join(tc.fileDir, tc.fileName) + raw, err := ioutil.ReadFile(rawFile) + got, err := getParameterFromOpenAPISchema(raw) + if (err == nil && tc.want != nil) || (err != nil && tc.want == nil) { + t.Errorf("%s\ngetParameterFromOpenAPISchema(...): -want %s, +got %s", tc.reason, tc.want, err) + } + targetFile := filepath.Join(tc.fileDir, tc.targetFile) + expect, err := ioutil.ReadFile(targetFile) + if err != nil { + t.Errorf("%s\ngetParameterFromOpenAPISchema(...): target file %s could not be read: %s", tc.reason, targetFile, err) + } + if strings.Compare(string(got), string(expect)) != 0 { + t.Errorf("%s\ngetParameterFromOpenAPISchema(...): pure parameter schema %s isn't retrieved", tc.reason, targetFile) + } + }) + } +} diff --git a/pkg/serverlib/testdata/definition/normalOpenAPISchema.json b/pkg/serverlib/testdata/definition/normalOpenAPISchema.json new file mode 100644 index 000000000..d744c7cf9 --- /dev/null +++ b/pkg/serverlib/testdata/definition/normalOpenAPISchema.json @@ -0,0 +1,34 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Generated by cue.", + "version": "no version" + }, + "paths": {}, + "components": { + "schemas": { + "parameter": { + "type": "object", + "required": [ + "image" + ], + "properties": { + "cmd": { + "description": "+usage=Commands to run in the container", + "type": "array", + "items": { + "type": "string" + } + }, + "image": { + "description": "+usage=Which image would you like to use for your service\n+short=i", + "type": "string" + }, + "cpu": { + "type": "string" + } + } + } + } + } +} diff --git a/pkg/serverlib/testdata/definition/normalOpenAPISchemaFixed.json b/pkg/serverlib/testdata/definition/normalOpenAPISchemaFixed.json new file mode 100644 index 000000000..a87136ad9 --- /dev/null +++ b/pkg/serverlib/testdata/definition/normalOpenAPISchemaFixed.json @@ -0,0 +1,38 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Generated by cue.", + "version": "no version" + }, + "paths": {}, + "components": { + "schemas": { + "parameter": { + "type": "object", + "required": [ + "image" + ], + "properties": { + "cmd": { + "description": "Commands to run in the container", + "title": "cmd", + "type": "array", + "items": { + "type": "string" + } + }, + "image": { + "description": "Which image would you like to use for your service", + "title": "image", + "type": "string" + }, + "cpu": { + "title": "cpu", + "type": "string" + } + } + } + } + } +} + diff --git a/pkg/serverlib/testdata/definition/normalOpenAPISchemaParameter.json b/pkg/serverlib/testdata/definition/normalOpenAPISchemaParameter.json new file mode 100644 index 000000000..b686cc335 --- /dev/null +++ b/pkg/serverlib/testdata/definition/normalOpenAPISchemaParameter.json @@ -0,0 +1 @@ +{"properties":{"cmd":{"description":"Commands to run in the container","items":{"type":"string"},"title":"cmd","type":"array"},"cpu":{"title":"cpu","type":"string"},"image":{"description":"Which image would you like to use for your service","title":"image","type":"string"}},"required":["image"],"type":"object"} \ No newline at end of file diff --git a/pkg/serverlib/testdata/definition/webservice.json b/pkg/serverlib/testdata/definition/webservice.json new file mode 100644 index 000000000..03848897a --- /dev/null +++ b/pkg/serverlib/testdata/definition/webservice.json @@ -0,0 +1,88 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Generated by cue.", + "version": "no version" + }, + "paths": {}, + "components": { + "schemas": { + "parameter": { + "type": "object", + "required": [ + "image", + "port" + ], + "properties": { + "cmd": { + "description": "+usage=Commands to run in the container", + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "description": "+usage=Define arguments by using environment variables", + "type": "array", + "items": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "description": "+usage=Environment variable name", + "type": "string" + }, + "value": { + "description": "+usage=The value of the environment variable", + "type": "string" + }, + "valueFrom": { + "description": "+usage=Specifies a source the value of this var should come from", + "type": "object", + "required": [ + "secretKeyRef" + ], + "properties": { + "secretKeyRef": { + "description": "+usage=Selects a key of a secret in the pod's namespace", + "type": "object", + "required": [ + "name", + "key" + ], + "properties": { + "name": { + "description": "+usage=The name of the secret in the pod's namespace to select from", + "type": "string" + }, + "key": { + "description": "+usage=The key of the secret to select from. Must be a valid secret key", + "type": "string" + } + } + } + } + } + } + } + }, + "image": { + "description": "+usage=Which image would you like to use for your service\n+short=i", + "type": "string" + }, + "port": { + "description": "+usage=Which port do you want customer traffic sent to\n+short=p", + "type": "integer", + "default": 80 + }, + "cpu": { + "description": "+usage=Number of CPU units for the service, like `0.5` (0.5 CPU core), `1` (1 CPU core)", + "type": "string" + } + } + } + } + } +} \ No newline at end of file diff --git a/pkg/serverlib/testdata/definition/webserviceFixed.json b/pkg/serverlib/testdata/definition/webserviceFixed.json new file mode 100755 index 000000000..d7a2255b5 --- /dev/null +++ b/pkg/serverlib/testdata/definition/webserviceFixed.json @@ -0,0 +1 @@ +{"properties":{"cmd":{"description":"Commands to run in the container","items":{"type":"string"},"title":"cmd","type":"array"},"cpu":{"description":"Number of CPU units for the service, like `0.5` (0.5 CPU core), `1` (1 CPU core)","title":"cpu","type":"string"},"env":{"description":"Define arguments by using environment variables","items":{"properties":{"name":{"description":"Environment variable name","title":"name","type":"string"},"value":{"description":"The value of the environment variable","title":"value","type":"string"},"valueFrom":{"description":"Specifies a source the value of this var should come from","properties":{"secretKeyRef":{"description":"Selects a key of a secret in the pod's namespace","properties":{"key":{"description":"The key of the secret to select from. Must be a valid secret key","title":"key","type":"string"},"name":{"description":"The name of the secret in the pod's namespace to select from","title":"name","type":"string"}},"required":["name","key"],"title":"secretKeyRef","type":"object"}},"required":["secretKeyRef"],"title":"valueFrom","type":"object"}},"required":["name"],"type":"object"},"title":"env","type":"array"},"image":{"description":"Which image would you like to use for your service","title":"image","type":"string"},"port":{"default":80,"description":"Which port do you want customer traffic sent to","title":"port","type":"integer"}},"required":["image","port"],"type":"object"} \ No newline at end of file diff --git a/pkg/serverlib/testdata/definition/workload1.cue b/pkg/serverlib/testdata/definition/workload1.cue new file mode 100644 index 000000000..79d2ca229 --- /dev/null +++ b/pkg/serverlib/testdata/definition/workload1.cue @@ -0,0 +1,7 @@ +project: { + name: string +} + +parameter: { + min: int +} diff --git a/pkg/serverlib/testdata/definition/workloadNoParameter.cue b/pkg/serverlib/testdata/definition/workloadNoParameter.cue new file mode 100644 index 000000000..61c565f6a --- /dev/null +++ b/pkg/serverlib/testdata/definition/workloadNoParameter.cue @@ -0,0 +1,7 @@ +project: { + name: string +} + +noParameter: { + min: int +} diff --git a/pkg/utils/common/common.go b/pkg/utils/common/common.go index 7996a1acb..9c0292858 100644 --- a/pkg/utils/common/common.go +++ b/pkg/utils/common/common.go @@ -1,21 +1,28 @@ package common import ( + "bytes" "context" + "encoding/json" + "errors" "fmt" "io/ioutil" "net/http" "os" + "path/filepath" + "cuelang.org/go/cue" + "cuelang.org/go/cue/load" + "cuelang.org/go/encoding/openapi" certmanager "github.com/wonderflow/cert-manager-api/pkg/apis/certmanager/v1" k8sruntime "k8s.io/apimachinery/pkg/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/controller-runtime/pkg/client/config" core "github.com/oam-dev/kubevela/apis/core.oam.dev" - "github.com/oam-dev/kubevela/apis/standard.oam.dev/v1alpha1" "github.com/oam-dev/kubevela/apis/types" + mycue "github.com/oam-dev/kubevela/pkg/cue" ) var ( @@ -60,3 +67,59 @@ func HTTPGet(ctx context.Context, url string) ([]byte, error) { defer resp.Body.Close() return ioutil.ReadAll(resp.Body) } + +// GetCUEParameterValue converts definitions to cue format +func GetCUEParameterValue(cueStr string) (cue.Value, error) { + r := cue.Runtime{} + template, err := r.Compile("", cueStr+mycue.BaseTemplate) + if err != nil { + return cue.Value{}, err + } + tempStruct, err := template.Value().Struct() + if err != nil { + return cue.Value{}, err + } + // find the parameter definition + var paraDef cue.FieldInfo + var found bool + for i := 0; i < tempStruct.Len(); i++ { + paraDef = tempStruct.Field(i) + if paraDef.Name == "parameter" { + found = true + break + } + } + if !found { + return cue.Value{}, errors.New("parameter not exist") + } + arguments := paraDef.Value + + return arguments, nil +} + +// GenOpenAPI generates OpenAPI json schema from cue.Instance +func GenOpenAPI(inst *cue.Instance) ([]byte, error) { + if inst.Err != nil { + return nil, inst.Err + } + defaultConfig := &openapi.Config{} + b, err := openapi.Gen(inst, defaultConfig) + if err != nil { + return nil, err + } + var out = &bytes.Buffer{} + _ = json.Indent(out, b, "", " ") + return out.Bytes(), nil +} + +// GenOpenAPIFromFile generates OpenAPI json schema from cue file +func GenOpenAPIFromFile(filePath string, fileName string) ([]byte, error) { + filename := filepath.FromSlash(fileName) + inst := cue.Build(load.Instances([]string{filename}, &load.Config{ + Dir: filePath, + }))[0] + if inst.Err != nil { + return nil, inst.Err + } + return GenOpenAPI(inst) +} diff --git a/pkg/utils/common/common_test.go b/pkg/utils/common/common_test.go new file mode 100644 index 000000000..4c5b4bcc5 --- /dev/null +++ b/pkg/utils/common/common_test.go @@ -0,0 +1,208 @@ +package common + +import ( + "errors" + "fmt" + "io/ioutil" + "path/filepath" + "testing" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/load" + + "github.com/crossplane/crossplane-runtime/pkg/test" + "github.com/google/go-cmp/cmp" +) + +func TestGetCUEParameterValue(t *testing.T) { + type want struct { + err error + } + var validCueStr = ` +parameter: { + min: int +} +` + + var CueStrNotContainParameter = ` +output: { + min: int +} +` + cases := map[string]struct { + reason string + cueStr string + want want + }{ + "GetCUEParameterValue": { + reason: "cue string is valid", + cueStr: validCueStr, + want: want{ + err: nil, + }, + }, + "CUEStringNotContainParameter": { + reason: "cue string doesn't contain Parameter", + cueStr: CueStrNotContainParameter, + want: want{ + err: fmt.Errorf("parameter not exist"), + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + _, err := GetCUEParameterValue(tc.cueStr) + if tc.want.err != nil { + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\nGenOpenAPIFromFile(...): -want error, +got error:\n%s", tc.reason, diff) + } + } + + }) + } +} + +func TestGetCUEParameterValue4RareCases(t *testing.T) { + type want struct { + errMsg string + } + + var invalidCueStr = ` +name +` + cases := map[string]struct { + reason string + cueStr string + want want + }{ + "CUEStringInvalid": { + reason: "cue string is invalid", + cueStr: invalidCueStr, + want: want{ + errMsg: "reference \"name\" not found", + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + _, err := GetCUEParameterValue(tc.cueStr) + if diff := cmp.Diff(tc.want.errMsg, err.Error(), test.EquateConditions()); diff != "" { + t.Errorf("\n%s\nGenOpenAPIFromFile(...): -want error, +got error:\n%s", tc.reason, diff) + } + + }) + } +} + +func TestGenOpenAPIFromFile(t *testing.T) { + type want struct { + data []byte + err error + } + var dir = "testdata" + var validCueFile = "workload1.cue" + var validTargetSchema = "workload1.json" + targetFile := filepath.Join(dir, validTargetSchema) + expect, _ := ioutil.ReadFile(targetFile) + + normalWant := want{ + data: expect, + err: nil, + } + + cases := map[string]struct { + reason string + fileDir string + fileName string + targetSchema string + want want + }{ + "GenOpenAPIFromFile": { + reason: "generate OpenAPI schema from a cue file", + fileDir: dir, + fileName: validCueFile, + targetSchema: validTargetSchema, + want: normalWant, + }, + "CueFileIsNotValid": { + reason: "generate OpenAPI schema from a cue file which is invalid", + fileDir: dir, + fileName: "workload2Invalid.cue", + want: want{ + data: nil, + err: errors.New("expected 'EOF', found '}'"), + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := GenOpenAPIFromFile(tc.fileDir, tc.fileName) + if tc.want.err != nil { + if diff := cmp.Diff(tc.want.err, errors.New(err.Error()), test.EquateErrors()); diff != "" { + t.Errorf("\n%s\nGenOpenAPIFromFile(...): -want error, +got error:\n%s", tc.reason, diff) + } + } + + if diff := cmp.Diff(tc.want.data, got); diff != "" { + t.Errorf("\n%s\nGenOpenAPIFromFile(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestGenOpenAPI(t *testing.T) { + type want struct { + data []byte + err error + } + var dir = "testdata" + var validCueFile = "workload1.cue" + var validTargetSchema = "workload1.json" + targetFile := filepath.Join(dir, validTargetSchema) + expect, _ := ioutil.ReadFile(targetFile) + + normalWant := want{ + data: expect, + err: nil, + } + + f := filepath.FromSlash(validCueFile) + + inst := cue.Build(load.Instances([]string{f}, &load.Config{ + Dir: dir, + }))[0] + + cases := map[string]struct { + reason string + fileDir string + fileName string + targetSchema string + want want + }{ + "GenOpenAPI": { + reason: "generate OpenAPI schema", + fileDir: dir, + fileName: validCueFile, + targetSchema: validTargetSchema, + want: normalWant, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := GenOpenAPI(inst) + if tc.want.err != nil { + if diff := cmp.Diff(tc.want.err, errors.New(err.Error()), test.EquateErrors()); diff != "" { + t.Errorf("\n%s\nGenOpenAPIFromFile(...): -want error, +got error:\n%s", tc.reason, diff) + } + } + + if diff := cmp.Diff(tc.want.data, got); diff != "" { + t.Errorf("\n%s\nGenOpenAPIFromFile(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/pkg/utils/common/testdata/workload1.cue b/pkg/utils/common/testdata/workload1.cue new file mode 100644 index 000000000..d5a4bca78 --- /dev/null +++ b/pkg/utils/common/testdata/workload1.cue @@ -0,0 +1,10 @@ +#parameter: { + // +usage=Which image would you like to use for your service + // +short=i + image: string + + // +usage=Commands to run in the container + cmd?: [...string] + + cpu?: string +} diff --git a/pkg/utils/common/testdata/workload1.json b/pkg/utils/common/testdata/workload1.json new file mode 100644 index 000000000..c7f8c88a6 --- /dev/null +++ b/pkg/utils/common/testdata/workload1.json @@ -0,0 +1,34 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Generated by cue.", + "version": "no version" + }, + "paths": {}, + "components": { + "schemas": { + "parameter": { + "type": "object", + "required": [ + "image" + ], + "properties": { + "cmd": { + "description": "+usage=Commands to run in the container", + "type": "array", + "items": { + "type": "string" + } + }, + "image": { + "description": "+usage=Which image would you like to use for your service\n+short=i", + "type": "string" + }, + "cpu": { + "type": "string" + } + } + } + } + } +} \ No newline at end of file diff --git a/pkg/utils/common/testdata/workload2Invalid.cue b/pkg/utils/common/testdata/workload2Invalid.cue new file mode 100644 index 000000000..9cb24766d --- /dev/null +++ b/pkg/utils/common/testdata/workload2Invalid.cue @@ -0,0 +1,5 @@ +#parameter: + // +usage=Which image would you like to use for your service + // +short=i + image: string +}