From b57ca1149f3b175dcb94a8829f6a686c1897965c Mon Sep 17 00:00:00 2001 From: Igor Ignatyev Date: Wed, 25 Jun 2025 12:45:47 +0300 Subject: [PATCH] cobra object support --- docs/actions.schema.md | 14 ++ .../platform/actions/build/action.yaml | 10 + pkg/action/action_test.go | 21 +- pkg/action/loader.go | 28 ++- pkg/action/yaml.def.go | 99 +++++++- pkg/action/yaml_const_test.go | 33 ++- plugins/actionscobra/cobra.go | 223 +++++++++++++++++- 7 files changed, 408 insertions(+), 20 deletions(-) diff --git a/docs/actions.schema.md b/docs/actions.schema.md index bafe4a8..9763adc 100644 --- a/docs/actions.schema.md +++ b/docs/actions.schema.md @@ -60,6 +60,7 @@ See [examples](#examples) of how required and default are used and more complex 3. `integer` 4. `number` - float64 values 5. `array` (currently array of 1 supported type) +6. `object` - parses string as JSON string into map[string]any See [JSON Schema Reference](https://json-schema.org/understanding-json-schema/reference) for better understanding of how to use types, format, enums and other useful features. @@ -161,6 +162,19 @@ action: description: | This is an optional option of type array. It may be omitted, default value is used. + + - name: optObj + title: Option object + type: object + properties: # Optional object properties. Adding them forces strict property validation for jsonSchema. + key: + type: string + description: key property of object + default: + key: value + description: | + This is an optional option of type object. + It may be omitted, default value is used. - name: optenum title: Option enum diff --git a/example/actions/platform/actions/build/action.yaml b/example/actions/platform/actions/build/action.yaml index 628991c..b7f5526 100644 --- a/example/actions/platform/actions/build/action.yaml +++ b/example/actions/platform/actions/build/action.yaml @@ -49,6 +49,16 @@ action: items: type: boolean description: This is an optional enum option with a default value + - name: optObj + title: Option Object + description: Option to do something + type: object + properties: + key: + type: string + description: Property of object + default: + key: value - name: optIP title: Option String IP type: string diff --git a/pkg/action/action_test.go b/pkg/action/action_test.go index 3fe7fa9..4cf6298 100644 --- a/pkg/action/action_test.go +++ b/pkg/action/action_test.go @@ -46,7 +46,8 @@ func Test_Action(t *testing.T) { "opt3": 1, "opt4": 1.45, "optarr": []any{"opt5.1val", "opt5.2val"}, - "opt6": "unexpectedOpt", + "optobj": map[string]any{"opt61": "opt6.1val", "opt62": "opt6.2val"}, + "opt7": "unexpectedOpt", } input := NewInput(act, inputArgs, inputOpts, nil) require.NotNil(input) @@ -55,9 +56,15 @@ func Test_Action(t *testing.T) { require.NoError(err) require.NotNil(act.input) + // Retrieve the object value to match later with command output. + opt6MapValue := inputOpts["optobj"] + assert.NotNil(opt6MapValue) + opt6Value := opt6MapValue.(map[string]any)["opt61"] + assert.NotNil(opt6Value) + // Option is not defined, but should be there // because [manager.ValidateInput] decides if the input correct or not. - _, okOpt := act.input.Opts()["opt6"] + _, okOpt := act.input.Opts()["opt7"] assert.True(okOpt) assert.Equal(inputArgs, act.input.Args()) assert.Equal(inputOpts, act.input.Opts()) @@ -70,7 +77,7 @@ func Test_Action(t *testing.T) { "-c", "ls -lah", fmt.Sprintf("%v %v %v %v", inputArgs["arg2"], inputArgs["arg1"], inputArgs["arg-1"], inputArgs["arg_12"]), - fmt.Sprintf("%v %v %v %v %v %v", inputOpts["opt3"], inputOpts["opt2"], inputOpts["opt1"], inputOpts["opt-1"], inputOpts["opt4"], inputOpts["optarr"]), + fmt.Sprintf("%v %v %v %v %v %v %v %v", inputOpts["opt3"], inputOpts["opt2"], inputOpts["opt1"], inputOpts["opt-1"], inputOpts["opt4"], inputOpts["optarr"], inputOpts["optobj"], opt6Value), fmt.Sprintf("%v", envVar1), fmt.Sprintf("%v ", envVar1), } @@ -348,6 +355,14 @@ func Test_ActionInputValidate(t *testing.T) { )}, {"valid array type integer", validOptArrayInt, nil, InputParams{"opt_array_int": []int{1, 2, 3}}, nil, nil}, {"valid array type integer - default used", validOptArrayIntDefault, nil, nil, nil, nil}, + {"valid object type", validOptObjectImplicit, nil, InputParams{"opt_object": make(map[string]any)}, nil, nil}, + {"valid object type with default", validOptObjectWithDefault, nil, nil, nil, nil}, + {"valid object type, invalid property passed", validOptObjectWithDefault, nil, InputParams{"opt_object": map[string]any{"key1": "value2", "key2": "value1"}}, nil, schemaErr( + newErrAddProps(opt("opt_object"), "key2"), + )}, + {"invalid object type - string given", validOptObjectImplicit, nil, InputParams{"opt_object": "test_string"}, nil, schemaErr( + newErrExpType(opt("opt_object"), "object", "string"), + )}, {"valid multiple args and opts", validMultipleArgsAndOpts, InputParams{"arg_int": 1, "arg_str": "mystr", "arg_str2": "mystr", "arg_bool": true}, InputParams{"opt_str_required": "mystr"}, nil, nil}, {"invalid multiple args and opts - multiple causes", validMultipleArgsAndOpts, InputParams{"arg_int": "str", "arg_str": 1}, InputParams{"opt_str": 1}, nil, schemaErr( newErrMissProp(arg(), "arg_str2", "arg_bool"), diff --git a/pkg/action/loader.go b/pkg/action/loader.go index 9c01a55..8a95897 100644 --- a/pkg/action/loader.go +++ b/pkg/action/loader.go @@ -132,7 +132,12 @@ Action definition is correct, but dashes are not allowed in templates, replace " matches := rgxTplVar.FindAllSubmatch(b, -1) for _, m := range matches { k := string(m[1]) - if _, ok := data[k]; !ok { + + // Check if the key exists in the data. + // Note: Keys containing dots are currently treated as nested key paths, not literal keys. + // To support literal dot-containing keys (e.g., "test.key"), additional logic would be needed. + keyParts := strings.Split(k, ".") + if !hasNestedKey(data, keyParts) { miss[k] = struct{}{} } } @@ -146,6 +151,27 @@ Action definition is correct, but dashes are not allowed in templates, replace " return res, nil } +func hasNestedKey(data map[string]any, keyPath []string) bool { + if len(keyPath) == 0 { + // nothing to check if path is empty + return true + } + + if len(keyPath) == 1 { + _, exists := data[keyPath[0]] + return exists + } + + // Check if the current key exists and is a map + if val, ok := data[keyPath[0]]; ok { + if nestedMap, isMap := val.(map[string]any); isMap { + return hasNestedKey(nestedMap, keyPath[1:]) + } + } + + return false +} + // ConvertInputToTplVars creates a map with input variables suitable for template engine. func ConvertInputToTplVars(input *Input, ac *DefAction) map[string]any { args := input.Args() diff --git a/pkg/action/yaml.def.go b/pkg/action/yaml.def.go index 02f7063..7fa61b8 100644 --- a/pkg/action/yaml.def.go +++ b/pkg/action/yaml.def.go @@ -363,12 +363,13 @@ func (l *ParametersList) UnmarshalYAML(nodeList *yaml.Node) (err error) { // DefParameter stores command argument or option declaration. type DefParameter struct { - Title string `yaml:"title"` - Description string `yaml:"description"` - Type jsonschema.Type `yaml:"type"` - Default any `yaml:"default"` - Enum []any `yaml:"enum"` - Items *DefArrayItems `yaml:"items"` + Title string `yaml:"title"` + Description string `yaml:"description"` + Type jsonschema.Type `yaml:"type"` + Default any `yaml:"default"` + Enum []any `yaml:"enum"` + Items *DefArrayItems `yaml:"items"` + Properties map[string]*DefObjectItem `yaml:"properties"` // Action specific behavior for parameters. // Name is an action unique parameter name used. @@ -443,13 +444,24 @@ func (p *DefParameter) UnmarshalYAML(n *yaml.Node) (err error) { _, okDef := p.raw["default"] if okDef { // Ensure default value respects the type. - dval, errDef := jsonschema.EnsureType(p.Type, p.Default) - if errDef != nil { - l, c := yamlNodeLineCol(n, "default") - return yamlTypeErrorLine(errDef.Error(), l, c) + if err = ensureDefaultValue(p, n); err != nil { + return err + } + } else { + // Ensure object default value is a map always + if p.Type == jsonschema.Object { + if err = ensureDefaultValue(p, n); err != nil { + return err + } + } + } + + // Set additional properties to false for objects. + if p.Type == jsonschema.Object { + if len(p.Properties) > 0 { + p.raw["additionalProperties"] = false + setAdditionalPropertiesRecursive(p.raw) } - p.Default = dval - p.raw["default"] = p.Default } // Not JSONSchema properties. @@ -461,6 +473,62 @@ func (p *DefParameter) UnmarshalYAML(n *yaml.Node) (err error) { return nil } +// setAdditionalPropertiesRecursive recursively sets additionalProperties to false +// for all nested object properties that have their own properties defined +func setAdditionalPropertiesRecursive(rawData map[string]any) { + properties, ok := rawData["properties"].(map[string]any) + if !ok { + return + } + + for _, prop := range properties { + propMap, ok := prop.(map[string]any) + if !ok { + continue + } + + if !isObjectWithProperties(propMap) { + continue + } + + propMap["additionalProperties"] = false + setAdditionalPropertiesRecursive(propMap) + } +} + +// isObjectWithProperties checks if a property is an object type with properties +func isObjectWithProperties(propMap map[string]any) bool { + propType, hasType := propMap["type"] + if !hasType { + return false + } + + if jsonschema.TypeFromString(propType.(string)) != jsonschema.Object { + return false + } + + nestedProps, hasProps := propMap["properties"].(map[string]any) + return hasProps && len(nestedProps) > 0 +} + +// ensureDefaultValue ensures that a parameter has a proper default value using jsonschema.EnsureType +func ensureDefaultValue(p *DefParameter, n *yaml.Node) error { + dval, err := jsonschema.EnsureType(p.Type, p.Default) + if err != nil { + // For when default is explicitly set but invalid + if p.Default != nil { + l, c := yamlNodeLineCol(n, "default") + return yamlTypeErrorLine(err.Error(), l, c) + } + // For when default needs to be generated (like for objects without an explicit default) + l, c := yamlNodeLineCol(n, "type") + return yamlTypeErrorLine(err.Error(), l, c) + } + p.Default = dval + p.raw["default"] = p.Default + return nil +} + func unmarshalParamListYaml(nl *yaml.Node) ([]*DefParameter, error) { if nl.Kind != yaml.SequenceNode { return nil, yamlTypeErrorLine(sErrFieldMustBeArr, nl.Line, nl.Column) @@ -494,6 +562,13 @@ type DefArrayItems struct { Type jsonschema.Type `yaml:"type"` } +// DefObjectItem stores object type related information. +type DefObjectItem struct { + Type jsonschema.Type `yaml:"type"` + Description string `yaml:"description"` + Properties map[string]*DefObjectItem `yaml:"properties"` +} + // DefValueProcessor stores information about processor and options that should be applied to processor. type DefValueProcessor struct { ID string `yaml:"processor"` diff --git a/pkg/action/yaml_const_test.go b/pkg/action/yaml_const_test.go index a1c18cc..8654c61 100644 --- a/pkg/action/yaml_const_test.go +++ b/pkg/action/yaml_const_test.go @@ -54,6 +54,12 @@ action: title: Option 5 Array description: Option 5 description type: array + - name: optobj + title: Option 6 Object + description: Option 6 description + type: object + default: + opt61: opt61_default runtime: type: container image: my/image:v1 @@ -77,7 +83,7 @@ runtime: - -c - ls -lah - "{{ .arg2 }} {{ .arg1 }} {{ .arg_1 }} {{ .arg_12 }}" - - "{{ .opt3 }} {{ .opt2 }} {{ .opt1 }} {{ .opt_1 }} {{ .opt4 }} {{ .optarr }}" + - "{{ .opt3 }} {{ .opt2 }} {{ .opt1 }} {{ .opt_1 }} {{ .opt4 }} {{ .optarr }} {{ .optobj }} {{ .optobj.opt61 }}" - ${TEST_ENV_1} ${TEST_ENV_UND} - "${TEST_ENV_1} ${TEST_ENV_UND}" ` @@ -541,6 +547,31 @@ action: default: [1, 2, 3] ` +const validOptObjectImplicit = ` +runtime: plugin +action: + title: Title + options: + - name: opt_object + type: object + required: true +` + +const validOptObjectWithDefault = ` +runtime: plugin +action: + title: Title + options: + - name: opt_object + type: object + required: true + properties: + key1: + type: string + default: + key1: value1 +` + const validMultipleArgsAndOpts = ` runtime: plugin action: diff --git a/plugins/actionscobra/cobra.go b/plugins/actionscobra/cobra.go index 68f2f22..987038f 100644 --- a/plugins/actionscobra/cobra.go +++ b/plugins/actionscobra/cobra.go @@ -1,6 +1,7 @@ package actionscobra import ( + "encoding/json" "fmt" "reflect" "strings" @@ -13,6 +14,189 @@ import ( "github.com/launchrctl/launchr/pkg/jsonschema" ) +type objectParseResult struct { + subFlagHandler *objectSubFlags + parsedJSON *jsonObjectValue +} + +// GetValue returns the merged result from both JSON and object sub flags +func (r *objectParseResult) GetValue() any { + // Get values from object sub-flags. + subFlagValues := r.subFlagHandler.BuildObject() + + // Get JSON values + var jsonValues map[string]any + if r.parsedJSON != nil && r.parsedJSON.Value != nil { + jsonValues = r.parsedJSON.Value + } + + // If subFlagValues exist, they take precedence + if len(subFlagValues) > 0 { + result := make(map[string]any) + + // First add JSON values + for k, v := range jsonValues { + result[k] = v + } + + // Then add/override with subFlag values + for k, v := range subFlagValues { + result[k] = v + } + + return result + } + + // If there is no subFlagValues, return JSON result + if len(jsonValues) > 0 { + return jsonValues + } + + // return an empty map in case of nothing + return make(map[string]any) +} + +type objectSubFlags struct { + flagPtrs map[string]any // Store flag pointers + flagTypes map[string]jsonschema.Type + flagNames map[string]string // Store actual flag names + mainFlagName string // Store the main flag name for change tracking +} + +func (h *objectSubFlags) RegisterNestedFlags(flags *pflag.FlagSet, param *action.DefParameter) error { + h.mainFlagName = param.Name // Store the main flag name + if param.Properties == nil { + return nil + } + + for propName, propDef := range param.Properties { + dotKey := fmt.Sprintf("%s.%s", param.Name, propName) + desc := fmt.Sprintf("%s.%s - %s", param.Name, propName, propDef.Description) + + // Store the mapping between the property name and full flag name + h.flagNames[propName] = dotKey + + // Get default value + var propDefaultValue any + if param.Default != nil { + if defaultMap, ok := param.Default.(map[string]any); ok { + propDefaultValue = defaultMap[propName] + } + } + + dval, err := jsonschema.EnsureType(propDef.Type, propDefaultValue) + if err != nil { + return err + } + + // Register flag and store pointer + var ptr any + switch propDef.Type { + case jsonschema.String: + ptr = flags.String(dotKey, dval.(string), desc) + case jsonschema.Integer: + ptr = flags.Int(dotKey, dval.(int), desc) + case jsonschema.Number: + ptr = flags.Float64(dotKey, dval.(float64), desc) + case jsonschema.Boolean: + ptr = flags.Bool(dotKey, dval.(bool), desc) + default: + // nested object and arrays aren't supported yet for inline flags. + continue + } + + h.flagPtrs[propName] = ptr + h.flagTypes[propName] = propDef.Type + } + + return nil +} + +// HasChangedFlags checks if any of the related flags have changed +func (h *objectSubFlags) HasChangedFlags(cmd *launchr.Command) bool { + for _, flagName := range h.flagNames { + if cmd.Flags().Changed(flagName) { + return true + } + } + return false +} + +func (h *objectSubFlags) BuildObject() map[string]any { + result := make(map[string]any) + + for propName, flagPtr := range h.flagPtrs { + flagType := h.flagTypes[propName] + + switch flagType { + case jsonschema.String: + if ptr, ok := flagPtr.(*string); ok && *ptr != "" { + result[propName] = *ptr + } + case jsonschema.Integer: + if ptr, ok := flagPtr.(*int); ok { + result[propName] = *ptr + } + case jsonschema.Number: + if ptr, ok := flagPtr.(*float64); ok { + result[propName] = *ptr + } + case jsonschema.Boolean: + if ptr, ok := flagPtr.(*bool); ok { + result[propName] = *ptr + } + + default: + panic(fmt.Sprintf("json schema object prop type %q is not implemented", flagType)) + } + } + + return result +} + +type jsonObjectValue struct { + Value map[string]any +} + +// jsonFlag handles JSON array/object types that aren't natively supported +type jsonFlag struct { + target *jsonObjectValue +} + +// String returns the string representation of the value +func (v *jsonFlag) String() string { + if v.target == nil || v.target.Value == nil { + return "" + } + jsonBytes, err := json.Marshal(v.target.Value) + if err != nil { + return "" + } + return string(jsonBytes) +} + +// Set parses and sets the value from a string +func (v *jsonFlag) Set(s string) error { + var parsed map[string]any + + // Try to parse as JSON + if err := json.Unmarshal([]byte(s), &parsed); err != nil { + return fmt.Errorf("invalid JSON: %v", err) + } + + if v.target == nil { + v.target = &jsonObjectValue{} + } + v.target.Value = parsed + + return nil +} + +// Type returns the type name for help text +func (v *jsonFlag) Type() string { + return "json" +} + // CobraImpl returns cobra command implementation for an action command. func CobraImpl(a *action.Action, streams launchr.Streams, manager action.Manager) (*launchr.Command, error) { def := a.ActionDef() @@ -101,9 +285,18 @@ func CobraImpl(a *action.Action, streams launchr.Streams, manager action.Manager func filterChangedFlags(cmd *launchr.Command, opts action.InputParams) action.InputParams { filtered := make(action.InputParams) for name, flag := range opts { - // Filter options not set. - if opts[name] != nil && cmd.Flags().Changed(name) { - filtered[name] = flag + if simpleObj, ok := flag.(*objectParseResult); ok { + // Check if the main flag OR any of the related sub flags changed + mainFlagChanged := cmd.Flags().Changed(name) + nestedFlagsChanged := simpleObj.subFlagHandler.HasChangedFlags(cmd) + if opts[name] != nil && (mainFlagChanged || nestedFlagsChanged) { + filtered[name] = flag + } + } else { + // Original logic for non-object flags + if opts[name] != nil && cmd.Flags().Changed(name) { + filtered[name] = flag + } } } return filtered @@ -163,6 +356,28 @@ func setFlag(flags *pflag.FlagSet, param *action.DefParameter) (any, error) { // @todo use flags.Var() and define a custom value, jsonschema accepts "any". return nil, fmt.Errorf("json schema array type %q is not implemented", param.Items.Type) } + case jsonschema.Object: + osf := &objectSubFlags{ + mainFlagName: param.Name, + flagPtrs: make(map[string]any), + flagTypes: make(map[string]jsonschema.Type), + flagNames: make(map[string]string), + } + // Register nested flags + // Currently cover only 1 level of object + if err = osf.RegisterNestedFlags(flags, param); err != nil { + return nil, fmt.Errorf("failed to register object nested flags: %w", err) + } + + jsonTarget := &jsonObjectValue{Value: dval.(map[string]any)} + // Register JSON flag + flags.VarP(&jsonFlag{target: jsonTarget}, param.Name, param.Shorthand, desc+" (JSON format)") + + // Return combined result + val = &objectParseResult{ + parsedJSON: jsonTarget, + subFlagHandler: osf, + } default: return nil, fmt.Errorf("json schema type %q is not implemented", param.Type) } @@ -198,6 +413,8 @@ func derefOpt(v any) any { return *v case *[]bool: return *v + case *objectParseResult: + return v.GetValue() default: if reflect.ValueOf(v).Kind() == reflect.Ptr { panic(fmt.Sprintf("error on a value dereferencing: unsupported %T", v))