From d9ab99e27e687353d316c7ddc03cef4d18a2668f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lovro=20Ma=C5=BEgon?= Date: Fri, 16 Feb 2024 18:45:03 +0100 Subject: [PATCH] Paramgen (#19) * move paramgen from connector SDK to commons * fix source code path * fix example in readme --- paramgen/README.md | 170 +++++ paramgen/internal/paramgen.go | 648 ++++++++++++++++++ paramgen/internal/paramgen_test.go | 191 ++++++ paramgen/internal/template.go | 139 ++++ paramgen/internal/template_test.go | 145 ++++ paramgen/internal/testdata/basic/go.mod | 3 + paramgen/internal/testdata/basic/specs.go | 63 ++ paramgen/internal/testdata/complex/global.go | 25 + paramgen/internal/testdata/complex/go.mod | 3 + .../testdata/complex/internal/global.go | 23 + paramgen/internal/testdata/complex/specs.go | 40 ++ .../internal/testdata/complex/specs_test.go | 23 + paramgen/internal/testdata/invalid1/go.mod | 3 + paramgen/internal/testdata/invalid1/specs.go | 22 + paramgen/internal/testdata/invalid2/go.mod | 3 + paramgen/internal/testdata/invalid2/specs.go | 19 + paramgen/internal/testdata/tags/go.mod | 3 + paramgen/internal/testdata/tags/specs.go | 29 + paramgen/main.go | 90 +++ 19 files changed, 1642 insertions(+) create mode 100644 paramgen/README.md create mode 100644 paramgen/internal/paramgen.go create mode 100644 paramgen/internal/paramgen_test.go create mode 100644 paramgen/internal/template.go create mode 100644 paramgen/internal/template_test.go create mode 100644 paramgen/internal/testdata/basic/go.mod create mode 100644 paramgen/internal/testdata/basic/specs.go create mode 100644 paramgen/internal/testdata/complex/global.go create mode 100644 paramgen/internal/testdata/complex/go.mod create mode 100644 paramgen/internal/testdata/complex/internal/global.go create mode 100644 paramgen/internal/testdata/complex/specs.go create mode 100644 paramgen/internal/testdata/complex/specs_test.go create mode 100644 paramgen/internal/testdata/invalid1/go.mod create mode 100644 paramgen/internal/testdata/invalid1/specs.go create mode 100644 paramgen/internal/testdata/invalid2/go.mod create mode 100644 paramgen/internal/testdata/invalid2/specs.go create mode 100644 paramgen/internal/testdata/tags/go.mod create mode 100644 paramgen/internal/testdata/tags/specs.go create mode 100644 paramgen/main.go diff --git a/paramgen/README.md b/paramgen/README.md new file mode 100644 index 0000000..bf3dab4 --- /dev/null +++ b/paramgen/README.md @@ -0,0 +1,170 @@ +# ParamGen + +ParamGen is a conduit tool that generates the code to return the parameters map from a certain Go struct. + +## Installation + +Once you have installed Go, install the paramgen tool. + +**Note:** If you have not done so already be sure to add `$GOPATH/bin` to your `PATH`. + +``` +go install github.com/conduitio/conduit-commons/paramgen@latest +``` + +## Usage + +ParamGen has one required argument, which is the struct name, and two optional flags for the path and the output file name. + +``` +paramgen [-path] [-output] structName +``` + +Example: + +``` +paramgen -path=./source -output=source_params.go SourceConfig +``` + +This example will search for a struct called `SourceConfig` in the path `./source`, it will create a parameter map of +only the exported fields, and generate the code to return this map in the file `source_params.go` under the same folder. + +### Parameter Tags + +In order to give your parameter a name, a default value, or add some validations to it, tags are the way to go. +We have three tags that can be parsed: + +1. `json`: this tag is used to rename the parameter. + + ```go + Name string `json:"first-name"` + ``` + +2. `default`: sets the default value for the parameter. + + ```go + Name string `default:"conduit"` + ``` + +3. `validate`: adds builtin validations to the parameter, these validations will be executed by conduit once a connector + is configured. Validations are separated by a comma, and have 6 main types: + * `required`: a boolean tag to indicate if a field is required or not. If it is added to the validate tag without a + value, then we assume the field is required. + + ```go + NameRequired string `validate:"required"` + NameRequired2 string `validate:"required=true"` + NameNotRequired string `validate:"required=false"` + ``` + + * `lt` or `less-than`: takes an int or a float value, indicated that the parameter should be less than the value provided. + * `gt` or `greater-than`: takes an int or a float value, indicated that the parameter should be greater than the value provided. + + ```go + Age int `validate:"gt=0,lt=200"` + Age2 float `validate:"greater-than=0,less-than=200.2"` + ``` + + * `inclusion`: validates that the parameter value is included in a specified list, this list values are separated + using a pipe character `|`. + + ```go + Gender string `validate:"inclusion=male|female|other"` + ``` + + * `exclusion`: validates that the parameter value is NOT included in a specified list, this list values are separated + using a pipe character `|`. + + ```go + Color string `validate:"exclusion=red|green"` + ``` + + * `regex`: validates that the parameter value matches the regex pattern. + + ```go + Email string `validate:"regex=^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$"` + ``` + +## Example + +Assume we have this configuration struct: + +```go +package source +type SourceConfig struct { + InnerConfig + // Param1 my param1 description + Param1 int `validate:"required,gt=0,lt=100" default:"10"` + // comment about param2 + Param2 bool `validate:"inclusion=true|t,exclusion=false|f" default:"t"` + Param3 string `validate:"regex=.*" default:"yes"` + + // this will be ignored because it's not exported + privateParam string +} + +type InnerConfig struct { + Name string `validate:"required" json:"my-name"` +} +``` + +And you call ParamGen: + +``` +paramgen -path ./source SourceConfig +``` + +A file called `paramgen.go` will be created under `./source`: + +```go +// Code generated by ParamGen. DO NOT EDIT. +// Source: github.com/conduitio/conduit-commons/tree/main/paramgen + +package source + +import ( + "regexp" + + "github.com/conduitio/conduit-commons/config" +) + +func (SourceConfig) Parameters() map[string]config.Parameter { + return map[string]config.Parameter{ + "my-name": { + Default: "", + Description: "", + Type: config.ParameterTypeString, + Validations: []config.Validation{ + config.ValidationRequired{}, + }, + }, + "param1": { + Default: "10", + Description: "param1 my param1 description", + Type: config.ParameterTypeInt, + Validations: []config.Validation{ + config.ValidationRequired{}, + config.ValidationGreaterThan{V: 0}, + config.ValidationLessThan{V: 100}, + }, + }, + "param2": { + Default: "t", + Description: "comment about param2", + Type: config.ParameterTypeBool, + Validations: []config.Validation{ + config.ValidationInclusion{List: []string{"true", "t"}}, + config.ValidationExclusion{List: []string{"false", "f"}}, + }, + }, + "param3": { + Default: "yes", + Description: "", + Type: config.ParameterTypeString, + Validations: []config.Validation{ + config.ValidationRegex{Regex: regexp.MustCompile(".*")}, + }, + }, + } +} +``` diff --git a/paramgen/internal/paramgen.go b/paramgen/internal/paramgen.go new file mode 100644 index 0000000..134e087 --- /dev/null +++ b/paramgen/internal/paramgen.go @@ -0,0 +1,648 @@ +// Copyright © 2023 Meroxa, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//nolint:goerr113,wrapcheck // we don't care about wrapping errors here +package internal + +import ( + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os/exec" + "reflect" + "regexp" + "strconv" + "strings" + "unicode" + + "github.com/conduitio/conduit-commons/config" +) + +const ( + tagParamName = "json" + tagParamDefault = "default" + tagParamValidate = "validate" + + validationRequired = "required" + validationLT = "lt" + validationLessThan = "less-than" + validationGT = "gt" + validationGreaterThan = "greater-than" + validationInclusion = "inclusion" + validationExclusion = "exclusion" + validationRegex = "regex" + + tagSeparator = "," + validateSeparator = "=" + listSeparator = "|" + fieldSeparator = "." +) + +// ParseParameters parses the struct into a map of parameter, requires the +// folder path that has the struct, and the struct name. +func ParseParameters(path string, name string) (map[string]config.Parameter, string, error) { + mod, err := parseModule(path) + if err != nil { + return nil, "", fmt.Errorf("error parsing module: %w", err) + } + pkg, err := parsePackage(path) + if err != nil { + return nil, "", fmt.Errorf("error parsing package: %w", err) + } + myStruct, file, err := findStruct(pkg, name) + if err != nil { + return nil, "", err + } + + return (¶meterParser{ + pkg: pkg, + mod: mod, + file: file, + imports: map[string]*ast.Package{}, + }).Parse(myStruct) +} + +type module struct { + Path string `json:"Path"` // module path + Dir string `json:"Dir"` // directory holding files for this module, if any + Error *moduleError `json:"Error"` // error loading module +} + +type moduleError struct { + Err string `json:"Err"` // the error itself +} + +func parseModule(path string) (module, error) { + cmd := exec.Command("go", "list", "-m", "-json") + cmd.Dir = path + stdout, err := cmd.StdoutPipe() + if err != nil { + return module{}, fmt.Errorf("error piping stdout of go list command: %w", err) + } + if err := cmd.Start(); err != nil { + return module{}, fmt.Errorf("error starting go list command: %w", err) + } + var mod module + if err := json.NewDecoder(stdout).Decode(&mod); err != nil { + return module{}, fmt.Errorf("error decoding go list output: %w", err) + } + if err := cmd.Wait(); err != nil { + return module{}, fmt.Errorf("error running command %q: %w", cmd.String(), err) + } + if mod.Error != nil { + return module{}, fmt.Errorf("error loading module: %s", mod.Error.Err) + } + return mod, nil +} + +func parsePackage(path string) (*ast.Package, error) { + fset := token.NewFileSet() + filterTests := func(info fs.FileInfo) bool { + return !strings.HasSuffix(info.Name(), "_test.go") + } + pkgs, err := parser.ParseDir(fset, path, filterTests, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("couldn't parse directory %s: %w", path, err) + } + // Make sure they are all in one package. + if len(pkgs) == 0 { + return nil, fmt.Errorf("no source-code package in directory %s", path) + } + if len(pkgs) > 1 { + return nil, fmt.Errorf("multiple packages in directory %s", path) + } + for _, v := range pkgs { + return v, nil // return first package + } + panic("unreachable") +} + +func findStruct(pkg *ast.Package, name string) (*ast.StructType, *ast.File, error) { + var structType *ast.StructType + var file *ast.File + for _, f := range pkg.Files { + ast.Inspect(f, func(n ast.Node) bool { + // Check if the node is a struct declaration + if typeSpec, ok := n.(*ast.TypeSpec); ok && typeSpec.Name.String() == name { + structType, ok = typeSpec.Type.(*ast.StructType) + if !ok { + // Node is not a struct declaration + return true + } + file = f + // stop iterating + return false + } + // Return true to continue iterating over the ast.File + return true + }) + } + if file == nil { + return nil, nil, fmt.Errorf("struct %q was not found in the package %q", name, pkg.Name) + } + return structType, file, nil +} + +type parameterParser struct { + // pkg holds the current package we are working with + pkg *ast.Package + // file holds the current file we are working with + file *ast.File + + mod module + + imports map[string]*ast.Package +} + +func (p *parameterParser) Parse(structType *ast.StructType) (map[string]config.Parameter, string, error) { + pkgName := p.pkg.Name + + parameters, err := p.parseStructType(structType, nil) + if err != nil { + return nil, "", err + } + + return parameters, pkgName, nil +} + +func (p *parameterParser) parseIdent(ident *ast.Ident, field *ast.Field) (params map[string]config.Parameter, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("[parseIdent] %w", err) + } + }() + + if p.isBuiltinType(ident.Name) { + // builtin type, that's a parameter + t := p.getParamType(ident) + name, param, err := p.parseSingleParameter(field, t) + if err != nil { + return nil, err + } + return map[string]config.Parameter{name: param}, nil + } + + if ident.Obj == nil { + // need to find the identifier in another file + ts, file, err := p.findType(p.pkg, ident.Name) + if err != nil { + return nil, err + } + + // change the type for simplicity + ident.Obj = &ast.Object{ + Name: ident.Name, + Decl: ts, + } + + // back up current file and replace it because we are now working with + // another file, we want to revert this once we are done parsing this type + backupFile := p.file + p.file = file + defer func() { + p.file = backupFile + }() + } + + switch v := ident.Obj.Decl.(type) { + case *ast.TypeSpec: + return p.parseTypeSpec(v, field) + default: + return nil, fmt.Errorf("unexpected type: %T", ident.Obj.Decl) + } +} + +func (p *parameterParser) parseTypeSpec(ts *ast.TypeSpec, f *ast.Field) (params map[string]config.Parameter, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("[parseTypeSpec] %w", err) + } + }() + + switch v := ts.Type.(type) { + case *ast.StructType: + return p.parseStructType(v, f) + case *ast.SelectorExpr: + return p.parseSelectorExpr(v, f) + case *ast.Ident: + return p.parseIdent(v, f) + default: + return nil, fmt.Errorf("unexpected type: %T", ts.Type) + } +} + +func (p *parameterParser) parseStructType(st *ast.StructType, f *ast.Field) (params map[string]config.Parameter, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("[parseStructType] %w", err) + } + }() + + for _, f := range st.Fields.List { + fieldParams, err := p.parseField(f) + if err != nil { + return nil, fmt.Errorf("error parsing field %q: %w", f.Names[0].Name, err) + } + if params == nil { + params = fieldParams + continue + } + for k, v := range fieldParams { + if _, ok := params[k]; ok { + return nil, fmt.Errorf("parameter %q is defined twice", k) + } + params[k] = v + } + } + if f != nil { + // attach prefix of field in which this struct type is declared + params = p.attachPrefix(f, params) + } + return params, nil +} + +func (p *parameterParser) parseField(f *ast.Field) (params map[string]config.Parameter, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("[parseField] %w", err) + } + }() + + if len(f.Names) == 1 && !f.Names[0].IsExported() { + return nil, nil //nolint:nilnil // ignore unexported fields + } + + switch v := f.Type.(type) { + case *ast.Ident: + // identifier (builtin type or type in same package) + return p.parseIdent(v, f) + case *ast.StructType: + // nested type + return p.parseStructType(v, f) + case *ast.SelectorExpr: + return p.parseSelectorExpr(v, f) + case *ast.ArrayType: + strType := fmt.Sprintf("%s", v.Elt) + if !p.isBuiltinType(strType) && !strings.Contains(strType, "time Duration") { + return nil, fmt.Errorf("unsupported slice type: %s", strType) + } + + name, param, err := p.parseSingleParameter(f, config.ParameterTypeString) + if err != nil { + return nil, err + } + return map[string]config.Parameter{name: param}, nil + default: + return nil, fmt.Errorf("unknown type: %T", f.Type) + } +} + +func (p *parameterParser) parseSelectorExpr(se *ast.SelectorExpr, f *ast.Field) (params map[string]config.Parameter, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("[parseSelectorExpr] %w", err) + } + }() + + imp, err := p.findImportSpec(se) + if err != nil { + return nil, err + } + + if impPath := strings.Trim(imp.Path.Value, `"`); impPath == "time" && se.Sel.Name == "Duration" { + // we allow the duration type + name, param, err := p.parseSingleParameter(f, config.ParameterTypeDuration) + if err != nil { + return nil, err + } + return map[string]config.Parameter{name: param}, nil + } + + // first find package + pkg, err := p.findPackage(imp.Path.Value) + if err != nil { + return nil, err + } + + // now find requested type in that package + ts, file, err := p.findType(pkg, se.Sel.Name) + if err != nil { + return nil, err + } + + // back up current file and replace it because we are now working with + // another file, we want to revert this once we are done parsing this type + backupFile := p.file + backupPkg := p.pkg + p.file = file + p.pkg = pkg + defer func() { + p.file = backupFile + p.pkg = backupPkg + }() + + return p.parseTypeSpec(ts, f) +} + +func (p *parameterParser) findPackage(importPath string) (*ast.Package, error) { + // first cleanup string + importPath = strings.Trim(importPath, `"`) + + if !strings.HasPrefix(importPath, p.mod.Path) { + // we only allow types declared in the same module + return nil, fmt.Errorf("we do not support parameters from package %v (please use builtin types or time.Duration)", importPath) + } + + if pkg, ok := p.imports[importPath]; ok { + // it's cached already + return pkg, nil + } + + pkgDir := p.mod.Dir + strings.TrimPrefix(importPath, p.mod.Path) + pkg, err := parsePackage(pkgDir) + if err != nil { + return nil, fmt.Errorf("could not parse package dir %q: %w", pkgDir, err) + } + + // cache it for future use + p.imports[importPath] = pkg + return pkg, nil +} + +func (p *parameterParser) findType(pkg *ast.Package, typeName string) (*ast.TypeSpec, *ast.File, error) { + var file *ast.File + var found *ast.TypeSpec + for _, f := range pkg.Files { + ast.Inspect(f, func(node ast.Node) bool { + ts, ok := node.(*ast.TypeSpec) + if !ok { + return true + } + if ts.Name.Name != typeName { + return true + } + + // found our type, store the file and type + file = f + found = ts + return false + }) + if found != nil { + // already found the type + break + } + } + if found == nil { + return nil, nil, fmt.Errorf("could not find type %v in package %v", typeName, pkg.Name) + } + return found, file, nil +} + +func (p *parameterParser) findImportSpec(se *ast.SelectorExpr) (*ast.ImportSpec, error) { + var impName string + switch x := se.X.(type) { + case *ast.Ident: + impName = x.Name + default: + return nil, fmt.Errorf("unexpected type: %T", x) + } + + for _, i := range p.file.Imports { + if (i.Name != nil && i.Name.Name == impName) || + strings.HasSuffix(strings.Trim(i.Path.Value, `"`), impName) { + return i, nil + } + } + return nil, fmt.Errorf("could not find import %q", impName) +} + +func (p *parameterParser) attachPrefix(f *ast.Field, params map[string]config.Parameter) map[string]config.Parameter { + // attach prefix if a tag is present or if the field is named + prefix := p.getTag(f.Tag, tagParamName) + if prefix == "" && len(f.Names) > 0 { + prefix = p.formatFieldName(f.Names[0].Name) + } + if prefix == "" { + // no prefix to attach + return params + } + + prefixedParams := make(map[string]config.Parameter) + for k, v := range params { + prefixedParams[prefix+fieldSeparator+k] = v + } + return prefixedParams +} + +func (p *parameterParser) isBuiltinType(name string) bool { + switch name { + case "string", "bool", "int", "uint", "int8", "uint8", "int16", "uint16", "int32", "uint32", "int64", "uint64", + "byte", "rune", "float32", "float64": + return true + default: + return false + } +} + +func (p *parameterParser) parseSingleParameter(f *ast.Field, t config.ParameterType) (paramName string, param config.Parameter, err error) { + fieldName, err := p.getFieldName(f) + if err != nil { + return "", config.Parameter{}, err + } + + paramName = p.getTag(f.Tag, tagParamName) + if paramName == "" { + // if there's no tag use the formatted field paramName + paramName = p.formatFieldName(fieldName) + } + + var validations []config.Validation + validate := p.getTag(f.Tag, tagParamValidate) + if validate != "" { + validations, err = p.parseValidateTag(validate) + if err != nil { + return "", config.Parameter{}, err + } + } + + return paramName, config.Parameter{ + Default: p.getTag(f.Tag, tagParamDefault), + Description: p.formatFieldComment(f, fieldName, paramName), + Validations: validations, + Type: t, + }, nil +} + +func (p *parameterParser) getFieldName(f *ast.Field) (string, error) { + if len(f.Names) == 1 { + return f.Names[0].Name, nil + } + + switch v := f.Type.(type) { + case *ast.Ident: + return v.Name, nil + case *ast.SelectorExpr: + return v.Sel.Name, nil + default: + return "", fmt.Errorf("unexpected type: %T", f.Type) + } +} + +func (p *parameterParser) getParamType(i *ast.Ident) config.ParameterType { + switch i.Name { + case "int8", "uint8", "int16", "uint16", "int32", "rune", "uint32", "int64", "uint64", "int", "uint": + return config.ParameterTypeInt + case "float32", "float64": + return config.ParameterTypeFloat + case "bool": + return config.ParameterTypeBool + default: + return config.ParameterTypeString + } +} + +// formatFieldName formats the name to a camel case string that starts with a +// lowercase letter. If the string starts with multiple uppercase letters, all +// but the last character in the sequence will be converted into lowercase +// letters (e.g. HTTPRequest -> httpRequest). +func (p *parameterParser) formatFieldName(name string) string { + if name == "" { + return "" + } + nameRunes := []rune(name) + foundLowercase := false + i := 0 + newName := strings.Map(func(r rune) rune { + if foundLowercase { + return r + } + if unicode.IsLower(r) { + // short circuit + foundLowercase = true + return r + } + if i == 0 || + (len(nameRunes) > i+1 && unicode.IsUpper(nameRunes[i+1])) { + r = unicode.ToLower(r) + } + i++ + return r + }, name) + return newName +} + +func (p *parameterParser) formatFieldComment(f *ast.Field, fieldName, paramName string) string { + doc := f.Doc + if doc == nil { + // fallback to line comment + doc = f.Comment + } + c := strings.ReplaceAll(doc.Text(), fieldName, paramName) + if len(c) == 0 { + return c + } + + whitespacePrefix := "" + for _, r := range c { + if !unicode.IsSpace(r) { + break + } + whitespacePrefix += string(r) + } + + // get rid of whitespace in first line + c = strings.TrimPrefix(c, whitespacePrefix) + // get rid of whitespace in front of all other lines + c = strings.ReplaceAll(c, "\n"+whitespacePrefix, "\n") + // get rid of new lines and use a space instead + c = strings.ReplaceAll(c, "\n", " ") + // trim space (get rid of any eventual new lines at the end) + c = strings.Trim(c, " ") + return c +} + +func (p *parameterParser) getTag(lit *ast.BasicLit, tag string) string { + if lit == nil { + return "" + } + + st := reflect.StructTag(strings.Trim(lit.Value, "`")) + return st.Get(tag) +} + +func (p *parameterParser) parseValidateTag(tag string) ([]config.Validation, error) { + validations := make([]config.Validation, 0) + split := strings.Split(tag, tagSeparator) + + for i, s := range split { + s = strings.TrimSpace(s) + split[i] = s + v, err := p.parseValidation(split[i]) + if err != nil { + return nil, err + } + if v != nil { + validations = append(validations, v) + } + } + return validations, nil +} + +func (p *parameterParser) parseValidation(str string) (config.Validation, error) { + if str == validationRequired { + return config.ValidationRequired{}, nil + } + split := strings.Split(str, validateSeparator) + if len(split) != 2 { + return nil, fmt.Errorf("invalid tag format") + } + + switch split[0] { + case validationRequired: + req, err := strconv.ParseBool(split[1]) + if err != nil { + return nil, err + } + // if required=false then do not add a validation + if !req { + return nil, nil + } + return config.ValidationRequired{}, nil + case validationLT, validationLessThan: + val, err := strconv.ParseFloat(split[1], 64) + if err != nil { + return nil, err + } + return config.ValidationLessThan{V: val}, nil + case validationGT, validationGreaterThan: + val, err := strconv.ParseFloat(split[1], 64) + if err != nil { + return nil, err + } + return config.ValidationGreaterThan{V: val}, nil + case validationInclusion: + list := strings.Split(split[1], listSeparator) + return config.ValidationInclusion{List: list}, nil + case validationExclusion: + list := strings.Split(split[1], listSeparator) + return config.ValidationExclusion{List: list}, nil + case validationRegex: + return config.ValidationRegex{Regex: regexp.MustCompile(split[1])}, nil + default: + return nil, fmt.Errorf("invalid value for tag validate: %s", str) + } +} diff --git a/paramgen/internal/paramgen_test.go b/paramgen/internal/paramgen_test.go new file mode 100644 index 0000000..3bb69e4 --- /dev/null +++ b/paramgen/internal/paramgen_test.go @@ -0,0 +1,191 @@ +// Copyright © 2023 Meroxa, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package internal + +import ( + "errors" + "regexp" + "testing" + + "github.com/conduitio/conduit-commons/config" + "github.com/matryer/is" +) + +func TestParseSpecificationSuccess(t *testing.T) { + testCases := []struct { + path string + name string + pkg string + want map[string]config.Parameter + }{ + { + path: "./testdata/basic", + name: "SourceConfig", + pkg: "example", + want: map[string]config.Parameter{ + "foo": { + Default: "bar", + Description: "foo is a required field in the global config with the name \"foo\" and default value \"bar\".", + Type: config.ParameterTypeString, + Validations: []config.Validation{ + config.ValidationRequired{}, + }, + }, + "myString": { + Description: "myString my string description", + Type: config.ParameterTypeString, + }, + "myBool": {Type: config.ParameterTypeBool}, + "myInt": { + Type: config.ParameterTypeInt, + Validations: []config.Validation{ + config.ValidationLessThan{ + V: 100, + }, + config.ValidationGreaterThan{ + V: 0, + }, + }, + }, + "myUint": {Type: config.ParameterTypeInt}, + "myInt8": {Type: config.ParameterTypeInt}, + "myUint8": {Type: config.ParameterTypeInt}, + "myInt16": {Type: config.ParameterTypeInt}, + "myUint16": {Type: config.ParameterTypeInt}, + "myInt32": {Type: config.ParameterTypeInt}, + "myUint32": {Type: config.ParameterTypeInt}, + "myInt64": {Type: config.ParameterTypeInt}, + "myUint64": {Type: config.ParameterTypeInt}, + "myByte": {Type: config.ParameterTypeString}, + "myRune": {Type: config.ParameterTypeInt}, + "myFloat32": {Type: config.ParameterTypeFloat}, + "myFloat64": {Type: config.ParameterTypeFloat}, + "myDuration": {Type: config.ParameterTypeDuration}, + "myIntSlice": {Type: config.ParameterTypeString}, + "myFloatSlice": {Type: config.ParameterTypeString}, + "myDurSlice": {Type: config.ParameterTypeString}, + }, + }, + { + path: "./testdata/complex", + name: "SourceConfig", + pkg: "example", + want: map[string]config.Parameter{ + "global.duration": { + Default: "1s", + Description: "duration does not have a name so the type name is used.", + Type: config.ParameterTypeDuration, + }, + "nestMeHere.anotherNested": { + Type: config.ParameterTypeInt, + Description: "nestMeHere.anotherNested is also nested under nestMeHere. This is a block comment.", + }, + "nestMeHere.formatThisName": { + Type: config.ParameterTypeFloat, + Default: "this is not a float", + Description: "formatThisName should become \"formatThisName\". Default is not a float but that's not a problem, paramgen does not validate correctness.", + }, + "customType": { + Type: config.ParameterTypeDuration, + Description: "customType uses a custom type that is convertible to a supported type. Line comments are allowed.", + }, + }, + }, + { + path: "./testdata/tags", + name: "Config", + pkg: "tags", + want: map[string]config.Parameter{ + "my-name": { + Type: config.ParameterTypeString, + Validations: []config.Validation{config.ValidationRequired{}}, + }, + "my-param": { + Type: config.ParameterTypeInt, + Description: "my-param i am a parameter comment", + Default: "3", + Validations: []config.Validation{ + config.ValidationRequired{}, + config.ValidationGreaterThan{V: 0}, + config.ValidationLessThan{V: 100}, + }, + }, + "param2": { + Type: config.ParameterTypeBool, + Default: "t", + Validations: []config.Validation{ + config.ValidationInclusion{List: []string{"true", "t"}}, + config.ValidationExclusion{List: []string{"false", "f"}}, + }, + }, + "param3": { + Type: config.ParameterTypeString, + Default: "yes", + Validations: []config.Validation{ + config.ValidationRequired{}, + config.ValidationRegex{Regex: regexp.MustCompile(".*")}, + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.path, func(t *testing.T) { + is := is.New(t) + got, pkg, err := ParseParameters(tc.path, tc.name) + is.NoErr(err) + is.Equal(pkg, tc.pkg) + is.Equal(got, tc.want) + }) + } +} + +func TestParseSpecificationFail(t *testing.T) { + testCases := []struct { + path string + name string + wantErr error + }{{ + path: "./testdata/invalid1", + name: "SourceConfig", + wantErr: errors.New("we do not support parameters from package net/http (please use builtin types or time.Duration)"), + }, { + path: "./testdata/invalid2", + name: "SourceConfig", + wantErr: errors.New("invalid value for tag validate: invalidValidation=hi"), + }, { + path: "./testdata/basic", + name: "SomeConfig", + wantErr: errors.New("struct \"SomeConfig\" was not found in the package \"example\""), + }} + + for _, tc := range testCases { + t.Run(tc.path, func(t *testing.T) { + is := is.New(t) + _, pkg, err := ParseParameters(tc.path, tc.name) + is.Equal(pkg, "") + is.True(err != nil) + for { + unwrapped := errors.Unwrap(err) + if unwrapped == nil { + break + } + err = unwrapped + } + is.Equal(err, tc.wantErr) + }) + } +} diff --git a/paramgen/internal/template.go b/paramgen/internal/template.go new file mode 100644 index 0000000..be04fff --- /dev/null +++ b/paramgen/internal/template.go @@ -0,0 +1,139 @@ +// Copyright © 2023 Meroxa, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package internal + +import ( + "bytes" + "fmt" + "go/format" + "log" + "reflect" + "strconv" + "text/template" + + "github.com/conduitio/conduit-commons/config" +) + +const ( + tmpl = `// Code generated by paramgen. DO NOT EDIT. +// Source: github.com/ConduitIO/conduit-commons/tree/main/paramgen + +package {{ $.Package }} + +import ( + {{- if $.HasRegex }} + "regexp" + {{ end }} + "github.com/conduitio/conduit-commons/config" +) + +func ({{ $.Struct }}) Parameters() map[string]config.Parameter { + return map[string]config.Parameter{ + {{- range $name, $parameter := .Parameters }} + {{ $.Quote $name }}: { + Default: {{ $.Quote .Default }}, + Description: {{ $.Quote .Description }}, + Type: config.{{ .GetTypeConstant }}, + Validations: []config.Validation{ + {{- range $index, $validation := .Validations }} + {{ $parameter.GetValidation $index }}, + {{- end }} + }, + }, + {{- end }} + } +} +` +) + +type templateData struct { + Parameters map[string]parameter + Package string + Struct string +} + +func (templateData) Quote(s string) string { + return strconv.Quote(s) +} + +var parameterTypeConstantMapping = map[config.ParameterType]string{ + config.ParameterTypeString: "ParameterTypeString", + config.ParameterTypeInt: "ParameterTypeInt", + config.ParameterTypeFloat: "ParameterTypeFloat", + config.ParameterTypeBool: "ParameterTypeBool", + config.ParameterTypeFile: "ParameterTypeFile", + config.ParameterTypeDuration: "ParameterTypeDuration", +} + +type parameter config.Parameter + +func (p parameter) GetTypeConstant() string { + return parameterTypeConstantMapping[p.Type] +} + +func (p parameter) GetValidation(index int) string { + validation := p.Validations[index] + + regexValidation, ok := validation.(config.ValidationRegex) + if !ok { + // default behavior + return fmt.Sprintf("%#v", p.Validations[index]) + } + + validationType := reflect.TypeOf(validation).String() + validationParameters := fmt.Sprintf("Regex: regexp.MustCompile(%q)", regexValidation.Regex) + return fmt.Sprintf("%s{%s}", validationType, validationParameters) +} + +func (t templateData) HasRegex() bool { + for _, p := range t.Parameters { + for _, v := range p.Validations { + if _, ok := v.(config.ValidationRegex); ok { + return true + } + } + } + return false +} + +func GenerateCode(parameters map[string]config.Parameter, packageName string, structName string) string { + // create the go template + t := template.Must(template.New("").Parse(tmpl)) + + internalParams := make(map[string]parameter, len(parameters)) + for k, v := range parameters { + internalParams[k] = parameter(v) + } + + data := templateData{ + Package: packageName, + Struct: structName, + Parameters: internalParams, + } + var processed bytes.Buffer + // execute the template + err := t.Execute(&processed, data) + if err != nil { + log.Fatalf("error: failed to execute template: %v", err) + } + + // format the output as Go code in the “gofmt” style + formatted, err := format.Source(processed.Bytes()) + if err != nil { + log.Fatalf("error: could not format processed template: %v", err) + } + + return string(formatted) +} diff --git a/paramgen/internal/template_test.go b/paramgen/internal/template_test.go new file mode 100644 index 0000000..63ac45b --- /dev/null +++ b/paramgen/internal/template_test.go @@ -0,0 +1,145 @@ +// Copyright © 2023 Meroxa, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package internal + +import ( + "regexp" + "testing" + + "github.com/conduitio/conduit-commons/config" + "github.com/matryer/is" +) + +func TestGenerateCodeWithRegex(t *testing.T) { + is := is.New(t) + got := GenerateCode(map[string]config.Parameter{ + "int.param": { + Default: "1", + Description: "my int param with \"quotes\"", + Type: config.ParameterTypeInt, + Validations: []config.Validation{ + config.ValidationRequired{}, + config.ValidationExclusion{List: []string{"0", "-1"}}, + config.ValidationGreaterThan{V: -3}, + config.ValidationLessThan{V: 3}, + }, + }, + "bool.param": { + Default: "true", + Description: "my bool param", + Type: config.ParameterTypeBool, + Validations: []config.Validation{ + config.ValidationRegex{Regex: regexp.MustCompile(".*")}, + }, + }, + "string.param": { + Description: "simple string param", + Type: config.ParameterTypeString, + }, + }, "s3", "SourceConfig") + + want := `// Code generated by paramgen. DO NOT EDIT. +// Source: github.com/ConduitIO/conduit-commons/tree/main/paramgen + +package s3 + +import ( + "regexp" + + "github.com/conduitio/conduit-commons/config" +) + +func (SourceConfig) Parameters() map[string]config.Parameter { + return map[string]config.Parameter{ + "bool.param": { + Default: "true", + Description: "my bool param", + Type: config.ParameterTypeBool, + Validations: []config.Validation{ + config.ValidationRegex{Regex: regexp.MustCompile(".*")}, + }, + }, + "int.param": { + Default: "1", + Description: "my int param with \"quotes\"", + Type: config.ParameterTypeInt, + Validations: []config.Validation{ + config.ValidationRequired{}, + config.ValidationExclusion{List: []string{"0", "-1"}}, + config.ValidationGreaterThan{V: -3}, + config.ValidationLessThan{V: 3}, + }, + }, + "string.param": { + Default: "", + Description: "simple string param", + Type: config.ParameterTypeString, + Validations: []config.Validation{}, + }, + } +} +` + is.Equal(got, want) +} + +func TestGenerateCodeWithoutRegex(t *testing.T) { + is := is.New(t) + got := GenerateCode(map[string]config.Parameter{ + "int.param": { + Default: "1", + Description: "my int param", + Type: config.ParameterTypeInt, + Validations: []config.Validation{}, + }, + "duration.param": { + Default: "1s", + Description: "my duration param", + Type: config.ParameterTypeDuration, + Validations: []config.Validation{ + config.ValidationInclusion{List: []string{"1s", "2s", "3s"}}, + }, + }, + }, "file", "Config") + + want := `// Code generated by paramgen. DO NOT EDIT. +// Source: github.com/ConduitIO/conduit-commons/tree/main/paramgen + +package file + +import ( + "github.com/conduitio/conduit-commons/config" +) + +func (Config) Parameters() map[string]config.Parameter { + return map[string]config.Parameter{ + "duration.param": { + Default: "1s", + Description: "my duration param", + Type: config.ParameterTypeDuration, + Validations: []config.Validation{ + config.ValidationInclusion{List: []string{"1s", "2s", "3s"}}, + }, + }, + "int.param": { + Default: "1", + Description: "my int param", + Type: config.ParameterTypeInt, + Validations: []config.Validation{}, + }, + } +} +` + is.Equal(got, want) +} diff --git a/paramgen/internal/testdata/basic/go.mod b/paramgen/internal/testdata/basic/go.mod new file mode 100644 index 0000000..04fcff9 --- /dev/null +++ b/paramgen/internal/testdata/basic/go.mod @@ -0,0 +1,3 @@ +module example.com/test + +go 1.18 diff --git a/paramgen/internal/testdata/basic/specs.go b/paramgen/internal/testdata/basic/specs.go new file mode 100644 index 0000000..6032931 --- /dev/null +++ b/paramgen/internal/testdata/basic/specs.go @@ -0,0 +1,63 @@ +// Copyright © 2023 Meroxa, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package example + +import ( + "net/http" + "time" +) + +// GlobalConfig is a reusable config struct used in the source and destination +// config. +type GlobalConfig struct { + // MyGlobalString is a required field in the global config with the name + // "foo" and default value "bar". + MyGlobalString string `json:"foo" default:"bar" validate:"required"` +} + +// SourceConfig this comment will be ignored. +type SourceConfig struct { + GlobalConfig + + // MyString my string description + MyString string + MyBool bool + + MyInt int `validate:"lt=100, gt=0"` + MyUint uint + MyInt8 int8 + MyUint8 uint8 + MyInt16 int16 + MyUint16 uint16 + MyInt32 int32 + MyUint32 uint32 + MyInt64 int64 + MyUint64 uint64 + + MyByte byte + MyRune rune + + MyFloat32 float32 + MyFloat64 float64 + + MyDuration time.Duration + + MyIntSlice []int + MyFloatSlice []float32 + MyDurSlice []time.Duration + + // this field is ignored because it is not exported + ignoreThis http.Client +} diff --git a/paramgen/internal/testdata/complex/global.go b/paramgen/internal/testdata/complex/global.go new file mode 100644 index 0000000..e0c6b9e --- /dev/null +++ b/paramgen/internal/testdata/complex/global.go @@ -0,0 +1,25 @@ +// Copyright © 2023 Meroxa, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// go:build ignoreBuildTags + +package example + +import ( + "example.com/test/internal" +) + +// GlobalConfig is defined in another file. It is defined with an underlying +// type that is in another package (should still work). +type GlobalConfig internal.GlobalConfig diff --git a/paramgen/internal/testdata/complex/go.mod b/paramgen/internal/testdata/complex/go.mod new file mode 100644 index 0000000..04fcff9 --- /dev/null +++ b/paramgen/internal/testdata/complex/go.mod @@ -0,0 +1,3 @@ +module example.com/test + +go 1.18 diff --git a/paramgen/internal/testdata/complex/internal/global.go b/paramgen/internal/testdata/complex/internal/global.go new file mode 100644 index 0000000..81efeaa --- /dev/null +++ b/paramgen/internal/testdata/complex/internal/global.go @@ -0,0 +1,23 @@ +// Copyright © 2023 Meroxa, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package internal + +import "time" + +// GlobalConfig is an internal struct that paramgen still parses. +type GlobalConfig struct { + // Duration does not have a name so the type name is used. + time.Duration `default:"1s"` // line comments on fields with doc comments are ignored +} diff --git a/paramgen/internal/testdata/complex/specs.go b/paramgen/internal/testdata/complex/specs.go new file mode 100644 index 0000000..87fb85e --- /dev/null +++ b/paramgen/internal/testdata/complex/specs.go @@ -0,0 +1,40 @@ +// Copyright © 2023 Meroxa, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package example + +import "time" + +type SourceConfig struct { + // GlobalConfig parameters should be nested under "global". This comment + // should be ignored. + Global GlobalConfig `json:"global"` + // Nested structs can be used to create namespaces + Nested struct { + // FORMATThisName should become "formatThisName". Default is not a float + // but that's not a problem, paramgen does not validate correctness. + FORMATThisName float32 `default:"this is not a float"` + // unexported fields should be ignored. + unexportedField string + } `json:"nestMeHere"` + /* + AnotherNested is also nested under nestMeHere. + This is a block comment. + */ + AnotherNested int `json:"nestMeHere.anotherNested"` + CustomType CustomDuration // CustomType uses a custom type that is convertible to a supported type. Line comments are allowed. +} + +type CustomDuration CustomDuration2 +type CustomDuration2 time.Duration diff --git a/paramgen/internal/testdata/complex/specs_test.go b/paramgen/internal/testdata/complex/specs_test.go new file mode 100644 index 0000000..9bdabb5 --- /dev/null +++ b/paramgen/internal/testdata/complex/specs_test.go @@ -0,0 +1,23 @@ +// Copyright © 2023 Meroxa, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package example_test + +import ( + "testing" +) + +func TestConfig(t *testing.T) { + tests are not parsed at all by paramgen so this file can contain code that doesn't compile +} diff --git a/paramgen/internal/testdata/invalid1/go.mod b/paramgen/internal/testdata/invalid1/go.mod new file mode 100644 index 0000000..04fcff9 --- /dev/null +++ b/paramgen/internal/testdata/invalid1/go.mod @@ -0,0 +1,3 @@ +module example.com/test + +go 1.18 diff --git a/paramgen/internal/testdata/invalid1/specs.go b/paramgen/internal/testdata/invalid1/specs.go new file mode 100644 index 0000000..f580535 --- /dev/null +++ b/paramgen/internal/testdata/invalid1/specs.go @@ -0,0 +1,22 @@ +// Copyright © 2023 Meroxa, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package example + +import "net/http" + +type SourceConfig struct { + // We don't support types imported from packages outside this module. + InvalidType http.Client +} diff --git a/paramgen/internal/testdata/invalid2/go.mod b/paramgen/internal/testdata/invalid2/go.mod new file mode 100644 index 0000000..04fcff9 --- /dev/null +++ b/paramgen/internal/testdata/invalid2/go.mod @@ -0,0 +1,3 @@ +module example.com/test + +go 1.18 diff --git a/paramgen/internal/testdata/invalid2/specs.go b/paramgen/internal/testdata/invalid2/specs.go new file mode 100644 index 0000000..c9e0f28 --- /dev/null +++ b/paramgen/internal/testdata/invalid2/specs.go @@ -0,0 +1,19 @@ +// Copyright © 2023 Meroxa, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package example + +type SourceConfig struct { + MyInt int `validate:"invalidValidation=hi"` +} diff --git a/paramgen/internal/testdata/tags/go.mod b/paramgen/internal/testdata/tags/go.mod new file mode 100644 index 0000000..04fcff9 --- /dev/null +++ b/paramgen/internal/testdata/tags/go.mod @@ -0,0 +1,3 @@ +module example.com/test + +go 1.18 diff --git a/paramgen/internal/testdata/tags/specs.go b/paramgen/internal/testdata/tags/specs.go new file mode 100644 index 0000000..9368969 --- /dev/null +++ b/paramgen/internal/testdata/tags/specs.go @@ -0,0 +1,29 @@ +// Copyright © 2023 Meroxa, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tags + +// Config is a reusable config struct used in the source and destination +type Config struct { + InnerConfig + // Param1 i am a parameter comment + Param1 int `validate:"required, gt=0, lt=100" default:"3" json:"my-param"` + + Param2 bool `validate:"inclusion=true|t, exclusion=false|f, required=false" default:"t"` + Param3 string `validate:"required=true, regex=.*" default:"yes"` +} + +type InnerConfig struct { + Name string `validate:"required" json:"my-name"` +} diff --git a/paramgen/main.go b/paramgen/main.go new file mode 100644 index 0000000..b0053a9 --- /dev/null +++ b/paramgen/main.go @@ -0,0 +1,90 @@ +// Copyright © 2023 Meroxa, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "flag" + "fmt" + "log" + "os" + "strings" + + "github.com/conduitio/conduit-commons/paramgen/internal" +) + +func main() { + log.SetFlags(0) + log.SetPrefix("paramgen: ") + + // parse the command arguments + args := parseFlags() + + // parse the sdk parameters + params, pkg, err := internal.ParseParameters(args.path, args.structName) + if err != nil { + log.Fatalf("error: failed to parse parameters: %v", err) + } + + code := internal.GenerateCode(params, pkg, args.structName) + + path := strings.TrimSuffix(args.path, "/") + "/" + args.output + err = os.WriteFile(path, []byte(code), 0o600) + if err != nil { + log.Fatalf("error: failed to output file: %v", err) + } +} + +type Args struct { + output string + path string + structName string +} + +func parseFlags() Args { + flags := flag.NewFlagSet(os.Args[0], flag.ExitOnError) + var ( + output = flags.String("output", "paramgen.go", "name of the output file") + path = flags.String("path", ".", "directory path to the package that has the configuration struct") + ) + + // flags is set up to exit on error, we can safely ignore the error + _ = flags.Parse(os.Args[1:]) + + if len(flags.Args()) == 0 { + log.Println("error: struct name argument missing") + fmt.Println() + flags.Usage() + os.Exit(1) + } + + var args Args + args.output = stringPtrToVal(output) + args.path = stringPtrToVal(path) + args.structName = flags.Args()[0] + + // add .go suffix if it is not in the name + if !strings.HasSuffix(args.output, ".go") { + args.output += ".go" + } + + return args +} + +func stringPtrToVal(s *string) string { + if s == nil { + return "" + } + return *s +}