Skip to content

cobra object support #117

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/actions.schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -161,6 +162,19 @@ action:
description: |
This is an optional option of type array<string>.
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
Expand Down
10 changes: 10 additions & 0 deletions example/actions/platform/actions/build/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ action:
items:
type: boolean
description: This is an optional enum<boolean> 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
Expand Down
21 changes: 18 additions & 3 deletions pkg/action/action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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())
Expand All @@ -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),
}
Expand Down Expand Up @@ -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"),
Expand Down
28 changes: 27 additions & 1 deletion pkg/action/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}{}
}
}
Expand All @@ -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()
Expand Down
99 changes: 87 additions & 12 deletions pkg/action/yaml.def.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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"`
Expand Down
33 changes: 32 additions & 1 deletion pkg/action/yaml_const_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}"
`
Expand Down Expand Up @@ -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:
Expand Down
Loading