mirror of
https://github.com/kubevela/kubevela.git
synced 2026-08-27 16:17:34 +00:00
Implement API for generating OpenAPI schema for definition
Fetched cue parameters from workloaddefinition/traitdefinition, generated OpenAPI v3 schema, fixed problems of tainted `description` filed, added filed "title" required by form-render component, added it in API server router To fix #680
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -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"}
|
||||
@@ -0,0 +1,7 @@
|
||||
project: {
|
||||
name: string
|
||||
}
|
||||
|
||||
parameter: {
|
||||
min: int
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
project: {
|
||||
name: string
|
||||
}
|
||||
|
||||
noParameter: {
|
||||
min: int
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+10
@@ -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
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#parameter:
|
||||
// +usage=Which image would you like to use for your service
|
||||
// +short=i
|
||||
image: string
|
||||
}
|
||||
Reference in New Issue
Block a user