From 5d04f606d2cce76bfc103a5619b910766f90af1f Mon Sep 17 00:00:00 2001 From: Michael Lacore Date: Fri, 6 Sep 2024 10:35:19 -0700 Subject: [PATCH] Add schematobicep (#8) * working with basic generation, still needs decorators * declarations schema to bicep (#7) * update test files, allowed to enum * declare description * reformatting, adding min/max value support * moving creating param line to helper function * trying to handle defaults * fix defaults (strings), still fixing min/max lengths * making test easier to see * fixing default int, prefixing description with sys to avoid collisions * removing notes about defaults * moving comment about description decorator * min/max len string and secure in * min/max len array, default array * moving default array complexity to new helper * default object (almost) need to fix map order * default object working * cleaning up var names * allowed objects working * splitting out functionality to helper for allowed object * refactor pass 1, everything but array/object/allowed optimized * renaming functions, moving them around, fixed arrays * renamed more functions * allowed arrays/objects passing, but need to refactor * remove unneeded test, sloppily solved default objects/arrays * New tests, removing some comments * simplified parseArray & parseObject, just need to handle nest levels * Fixing nest levels after refactoring - Updated renderBicep, parseArray, and parseObject to pass the prefix level back and forth to maintain nesting - Updated test JSON for multi-level nested objects and arrays - Updated test bicep for multi-level nested objects and arrays * Fixed spacing for all but last closing bracket * Fixed deep nesting for objects/arrays * adding error handling, restructured order of functions * Fixes based on suggestions * errors.New -> fmt.Errorf * removing excess error calling * Merging a function, handling some errors --------- Co-authored-by: chrisghill --- pkg/bicep/schematobicep.go | 213 ++++++++++++++++++ pkg/bicep/schematobicep_test.go | 42 ++++ pkg/bicep/testdata/simple.bicep | 127 +++++++++++ pkg/bicep/testdata/simple.json | 345 ++++++++++++++++++++++++++++++ pkg/bicep/testdata/template.bicep | 13 +- 5 files changed, 734 insertions(+), 6 deletions(-) create mode 100644 pkg/bicep/schematobicep.go create mode 100644 pkg/bicep/schematobicep_test.go create mode 100644 pkg/bicep/testdata/simple.bicep create mode 100644 pkg/bicep/testdata/simple.json diff --git a/pkg/bicep/schematobicep.go b/pkg/bicep/schematobicep.go new file mode 100644 index 0000000..b555470 --- /dev/null +++ b/pkg/bicep/schematobicep.go @@ -0,0 +1,213 @@ +package bicep + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "reflect" + + "github.com/massdriver-cloud/airlock/pkg/schema" +) + +var indent string = " " + +func SchemaToBicep(in io.Reader) ([]byte, error) { + inBytes, err := io.ReadAll(in) + if err != nil { + return nil, err + } + + root := schema.Schema{} + err = json.Unmarshal(inBytes, &root) + if err != nil { + return nil, err + } + + content := bytes.NewBuffer(nil) + + flattenedProperties := schema.ExpandProperties(&root) + for prop := flattenedProperties.Oldest(); prop != nil; prop = prop.Next() { + err = createBicepParameter(prop.Key, prop.Value, content) + if err != nil { + return nil, err + } + } + + return content.Bytes(), nil +} + +func createBicepParameter(name string, sch *schema.Schema, buf *bytes.Buffer) error { + bicepType, err := getBicepTypeFromSchema(sch.Type) + if err != nil { + return err + } + + writeDescription(sch, buf) + if allowParamErr := writeAllowedParams(sch, buf); allowParamErr != nil { + return allowParamErr + } + writeMinValue(sch, buf, bicepType) + writeMaxValue(sch, buf, bicepType) + writeMinLength(sch, buf, bicepType) + writeMaxLength(sch, buf, bicepType) + writeSecure(sch, buf, bicepType) + return writeBicepParam(name, sch, buf, bicepType) +} + +func writeBicepParam(name string, sch *schema.Schema, buf *bytes.Buffer, bicepType string) error { + var defVal string + + if sch.Default != nil { + renderedVal, err := renderBicep(sch.Default, "") + if err != nil { + return err + } + + defVal = fmt.Sprintf(" = %s", renderedVal) + } + + buf.WriteString(fmt.Sprintf("param %s %s%s\n", name, bicepType, defVal)) + return nil +} + +func renderBicep(val interface{}, prefix string) (string, error) { + switch reflect.TypeOf(val).Kind() { + case reflect.String: + return fmt.Sprintf("'%s'", val), nil + case reflect.Float64: + return fmt.Sprintf("%v", val), nil + case reflect.Bool: + return fmt.Sprintf("%v", val), nil + case reflect.Slice: + assertedVal, asserArrErr := val.([]interface{}) + if asserArrErr != true { + return "", fmt.Errorf("unable to convert value into array: %v", val) + } + + return parseArray(assertedVal, prefix) + case reflect.Map: + assertedVal, asserObjErr := val.(map[string]interface{}) + if asserObjErr != true { + return "", fmt.Errorf("unable to convert value into object: %v", val) + } + + return parseObject(assertedVal, prefix) + default: + return "", errors.New("unknown type: " + reflect.TypeOf(val).Kind().String()) + } +} + +func getBicepTypeFromSchema(schemaType string) (string, error) { + switch schemaType { + case "string": + return "string", nil + case "integer", "number": + return "int", nil + case "boolean": + return "bool", nil + case "object", "": + return "object", nil + case "array": + return "array", nil + default: + return "", errors.New("unknown type: " + schemaType) + } +} + +func writeDescription(sch *schema.Schema, buf *bytes.Buffer) { + if sch.Description != "" { + // decorators are in sys namespace. to avoid potential collision with other parameters named "description", we use "sys.description" instead of just "description" https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/parameters#decorators + buf.WriteString(fmt.Sprintf("@sys.description('%s')\n", sch.Description)) + } +} + +func writeAllowedParams(sch *schema.Schema, buf *bytes.Buffer) error { + if sch.Enum != nil && len(sch.Enum) > 0 { + renderedVal, err := renderBicep(sch.Enum, "") + if err != nil { + return err + } + + buf.WriteString(fmt.Sprintf("@allowed(%s)\n", renderedVal)) + } + return nil +} + +func writeMinValue(sch *schema.Schema, buf *bytes.Buffer, bicepType string) { + if bicepType == "int" && sch.Minimum != "" { + // set this to %v because sch.Minimum uses json.Number type + buf.WriteString(fmt.Sprintf("@minValue(%v)\n", sch.Minimum)) + } +} + +func writeMaxValue(sch *schema.Schema, buf *bytes.Buffer, bicepType string) { + if bicepType == "int" && sch.Maximum != "" { + buf.WriteString(fmt.Sprintf("@maxValue(%v)\n", sch.Maximum)) + } +} + +func writeMinLength(sch *schema.Schema, buf *bytes.Buffer, bicepType string) { + switch bicepType { + case "array": + if sch.MinItems != nil { + buf.WriteString(fmt.Sprintf("@minLength(%d)\n", *sch.MinItems)) + } + case "string": + if sch.MinLength != nil { + buf.WriteString(fmt.Sprintf("@minLength(%d)\n", *sch.MinLength)) + } + } +} + +func writeMaxLength(sch *schema.Schema, buf *bytes.Buffer, bicepType string) { + switch bicepType { + case "array": + if sch.MaxItems != nil { + buf.WriteString(fmt.Sprintf("@maxLength(%d)\n", *sch.MaxItems)) + } + case "string": + if sch.MaxLength != nil { + buf.WriteString(fmt.Sprintf("@maxLength(%d)\n", *sch.MaxLength)) + } + } +} + +func writeSecure(sch *schema.Schema, buf *bytes.Buffer, bicepType string) { + if bicepType == "string" && sch.Format == "password" { + buf.WriteString("@secure()\n") + } +} + +func parseArray(arr []interface{}, prefix string) (string, error) { + parsedArr := "[\n" + + for _, v := range arr { + renderedVal, err := renderBicep(v, prefix+indent) + if err != nil { + return "", err + } + + parsedArr += fmt.Sprintf("%s%s", prefix+indent, renderedVal) + "\n" + } + + parsedArr += fmt.Sprintf("%s]", prefix) + return parsedArr, nil +} + +func parseObject(obj map[string]interface{}, prefix string) (string, error) { + parsedObj := "{\n" + + for k, v := range obj { + renderedVal, err := renderBicep(v, prefix+indent) + if err != nil { + return "", err + } + + parsedObj += fmt.Sprintf("%s%s: %s", prefix+indent, k, renderedVal) + "\n" + } + + parsedObj += fmt.Sprintf("%s}", prefix) + return parsedObj, nil +} diff --git a/pkg/bicep/schematobicep_test.go b/pkg/bicep/schematobicep_test.go new file mode 100644 index 0000000..7d51253 --- /dev/null +++ b/pkg/bicep/schematobicep_test.go @@ -0,0 +1,42 @@ +package bicep_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/massdriver-cloud/airlock/pkg/bicep" +) + +func TestSchemaToBicep(t *testing.T) { + type testData struct { + name string + } + tests := []testData{ + { + name: "simple", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + want, err := os.ReadFile(filepath.Join("testdata", tc.name+".bicep")) + if err != nil { + t.Fatalf("%d, unexpected error", err) + } + + schemaFile, err := os.Open(filepath.Join("testdata", tc.name+".json")) + if err != nil { + t.Fatalf("%d, unexpected error", err) + } + + got, err := bicep.SchemaToBicep(schemaFile) + if err != nil { + t.Fatalf("%d, unexpected error", err) + } + + if string(got) != string(want) { + t.Fatalf("\ngot: %q\n want: %q", string(got), string(want)) + } + }) + } +} diff --git a/pkg/bicep/testdata/simple.bicep b/pkg/bicep/testdata/simple.bicep new file mode 100644 index 0000000..b2adf53 --- /dev/null +++ b/pkg/bicep/testdata/simple.bicep @@ -0,0 +1,127 @@ +param stringtest string +param integertest int +param numbertest int +param booltest bool +param arraytest array +param objecttest object +param nestedtest object +@allowed([ + 'foo' + 'bar' +]) +param enumtest string +@allowed([ + 1 + 2 +]) +param enumtestints int +@allowed([ + true + false +]) +param enumtestbools bool +@allowed([ + [ + 'foo' + 'bar' + ] + [ + 'baz' + 'qux' + ] +]) +param enumtestarrays array +@allowed([ + { + foo: 'bar' + } + { + baz: 'qux' + } +]) +param enumobjecttest object +@sys.description('This is a description') +param descriptiontest string +@sys.description('This is a new description') +@allowed([ + 'foo' + 'bar' + 'baz' +]) +param descriptionenumtest string +@minValue(5) +param minvaluetest int +@maxValue(10) +param maxvaluetest int +@minValue(5) +@maxValue(10) +param minmaxvaluetest int +@minLength(5) +param minlengthstringtest string +@maxLength(10) +param maxlengthstringtest string +@minLength(5) +@maxLength(10) +param minmaxlengthstringtest string +@minLength(2) +param minlengtharraytest array +@maxLength(5) +param maxlengtharraytest array +@minLength(2) +@maxLength(5) +param minmaxlengtharraytest array +param defaultstringtest string = 'foo' +param defaultintegertest int = 5 +param defaultbooltest bool = true +param defaultarraytest array = [ + 'foo' + 'bar' +] +param defaultobjecttest object = { + bar: 'baz' + foo: 5 +} +param defaultspaceobjecttest object = { + foo: 'bar baz' + lorem: 'ipsum' +} +param defaultarrayobjecttest array = [ + { + bar: 'baz' + foo: 5 + } + { + bar: 'qux' + foo: 10 + } +] +param defaultnestedarraytest array = [ + [ + [ + 'foo' + ] + [ + 'bar' + ] + ] + [ + [ + 'baz' + ] + [ + 'qux' + ] + ] +] +param defaultnestedobjecttest object = { + foo: { + bar: { + baz: 'qux' + } + } + quid: { + pro: 'quo' + } +} +@secure() +param securestringtest string diff --git a/pkg/bicep/testdata/simple.json b/pkg/bicep/testdata/simple.json new file mode 100644 index 0000000..830ead6 --- /dev/null +++ b/pkg/bicep/testdata/simple.json @@ -0,0 +1,345 @@ +{ + "required": [ + "stringtest", + "integertest", + "numbertest", + "booltest", + "arraytest", + "objecttest", + "nestedtest", + "enumtest" + ], + "properties": { + "stringtest": { + "type": "string" + }, + "integertest": { + "type": "integer" + }, + "numbertest": { + "type": "number" + }, + "booltest": { + "type": "boolean" + }, + "arraytest": { + "type": "array", + "items": { + "type": "string" + } + }, + "objecttest": { + "type": "object", + "required": [ + "foo" + ], + "properties": { + "foo": { + "type": "string" + }, + "bar": { + "type": "integer" + } + } + }, + "nestedtest": { + "type": "object", + "required": [ + "top" + ], + "properties": { + "top": { + "type": "object", + "required": [ + "nested" + ], + "properties": { + "nested": { + "type": "string" + } + } + } + } + }, + "enumtest": { + "type": "string", + "enum": [ + "foo", + "bar" + ] + }, + "enumtestints": { + "type": "integer", + "enum": [ + 1, + 2 + ] + }, + "enumtestbools": { + "type": "boolean", + "enum": [ + true, + false + ] + }, + "enumtestarrays": { + "type": "array", + "items": { + "type": "string" + }, + "enum": [ + [ + "foo", + "bar" + ], + [ + "baz", + "qux" + ] + ] + }, + "enumobjecttest": { + "type": "object", + "enum": [ + { + "foo": "bar" + }, + { + "baz": "qux" + } + ] + }, + "descriptiontest": { + "type": "string", + "description": "This is a description" + }, + "descriptionenumtest": { + "type": "string", + "description": "This is a new description", + "enum": [ + "foo", + "bar", + "baz" + ] + }, + "minvaluetest": { + "type": "integer", + "minimum": 5 + }, + "maxvaluetest": { + "type": "integer", + "maximum": 10 + }, + "minmaxvaluetest": { + "type": "integer", + "minimum": 5, + "maximum": 10 + }, + "minlengthstringtest": { + "type": "string", + "minLength": 5 + }, + "maxlengthstringtest": { + "type": "string", + "maxLength": 10 + }, + "minmaxlengthstringtest": { + "type": "string", + "minLength": 5, + "maxLength": 10 + }, + "minlengtharraytest": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 2 + }, + "maxlengtharraytest": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 5 + }, + "minmaxlengtharraytest": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 2, + "maxItems": 5 + }, + "defaultstringtest": { + "type": "string", + "default": "foo" + }, + "defaultintegertest": { + "type": "integer", + "default": 5 + }, + "defaultbooltest": { + "type": "boolean", + "default": true + }, + "defaultarraytest": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "foo", + "bar" + ] + }, + "defaultobjecttest": { + "type": "object", + "required": [ + "bar", + "foo" + ], + "properties": { + "bar": { + "type": "string" + }, + "foo": { + "type": "integer" + } + }, + "default": { + "bar": "baz", + "foo": 5 + } + }, + "defaultspaceobjecttest": { + "type": "object", + "required": [ + "foo", + "lorem" + ], + "properties": { + "foo": { + "type": "string" + }, + "lorem": { + "type": "string" + } + }, + "default": { + "foo": "bar baz", + "lorem": "ipsum" + } + }, + "defaultarrayobjecttest": { + "type": "array", + "items": { + "type": "object", + "required": [ + "bar", + "foo" + ], + "properties": { + "bar": { + "type": "string" + }, + "foo": { + "type": "integer" + } + } + }, + "default": [ + { + "bar": "baz", + "foo": 5 + }, + { + "bar": "qux", + "foo": 10 + } + ] + }, + "defaultnestedarraytest": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "default": [ + [ + [ + "foo" + ], + [ + "bar" + ] + ], + [ + [ + "baz" + ], + [ + "qux" + ] + ] + ] + }, + "defaultnestedobjecttest": { + "type": "object", + "required": [ + "foo", + "quid" + ], + "properties": { + "foo": { + "type": "object", + "required": [ + "bar" + ], + "properties": { + "bar": { + "type": "object", + "required": [ + "baz" + ], + "properties": { + "baz": { + "type": "string" + } + } + } + } + }, + "quid": { + "type": "object", + "required": [ + "pro" + ], + "properties": { + "pro": { + "type": "string" + } + } + } + }, + "default": { + "foo": { + "bar": { + "baz": "qux" + } + }, + "quid": { + "pro": "quo" + } + } + }, + "securestringtest": { + "type": "string", + "format": "password" + } + } +} diff --git a/pkg/bicep/testdata/template.bicep b/pkg/bicep/testdata/template.bicep index 6aeb6cc..d113aea 100644 --- a/pkg/bicep/testdata/template.bicep +++ b/pkg/bicep/testdata/template.bicep @@ -2,7 +2,7 @@ @minLength(2) @maxLength(20) @allowed(['foo','bar']) -param testString string = "foo" +param testString string = 'foo' @minValue(0) @maxValue(10) @@ -31,11 +31,11 @@ param testObject object = { param testArrayObject array = [ { - foo: 'bar', + foo: 'bar' num: 10 - }, + } { - foo: 'baz', + foo: 'baz' num: 2 } ] @@ -49,5 +49,6 @@ param testSecureString string @secure() param testSecureObject object -resource whatever 'foobar' = { -} \ No newline at end of file +resource whatever 'Microsoft.Network/virtualNetworks@2023-11-01' = { + name: 'myVnet' +}