diff --git a/README.md b/README.md index 86e4ee2..0ae9e89 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,10 @@ running Terraform. - `--stdout`: Print schema to stdout and prevent all other logging unless an error occurs. Does not create a file. Overrides `--debug` and `--output`. +- `--export-variables`: Export the variables in JSON format directly and do not create a JSON Schema. This provides similar functionality to applications such as terraform-docs, where the input variables can be output to a machine-readable format such as JSON. The `type` field is converted to a type constraint based on the type definition, and the `default` field is translated to its literal value. `condition` inside the `validation` block is left as a string, because it is difficult to represent arbitrary (ie unevaluated) hcl Expressions in JSON. + +- `--escape-json`: Escape special characters in the JSON (`<`,`>` and `&`) so that the schema can be used in a web context. By default, this behaviour is disabled so the JSON file can be read more easily, though it does not effect external programs such as `jq`. + # Design ### Parsing Terraform Configuration Files @@ -134,7 +138,24 @@ Here is an example schema generate from a module with only the variable listed a }, "required": [] // only variables without a default are required, unless `--require-all` is set } +``` +Alternatively, if the program is run with the `--export-variables` flag, the returned JSON will be in the form: + +```JSON +{ + "age": { + "description": "Your age", + "default": 10, + "sensitive": false, + "nullable": false, + "validation": { + "condition": "var.age >= 0", + "error_message": "Age must not be negative" + }, + "type": "number" + } +} ``` ### Translating Types to JSON Schema diff --git a/cmd/cmd.go b/cmd/cmd.go index ff95f41..6a9681e 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -2,6 +2,7 @@ package cmd import ( + "bytes" "encoding/json" "fmt" "os" @@ -10,6 +11,7 @@ import ( "github.com/spf13/cobra" + tsjson "github.com/HewlettPackard/terraschema/pkg/json" "github.com/HewlettPackard/terraschema/pkg/jsonschema" ) @@ -23,6 +25,8 @@ var ( inputPath string outputPath string debugOut bool + exportVariables bool + escapeJSON bool ) // rootCmd is the base command for terraschema @@ -93,6 +97,15 @@ func init() { "make all variables nullable unless nullable set to false explicitly, to make behavior consistent with Terraform", ) + rootCmd.Flags().BoolVar(&exportVariables, "export-variables", false, + "export variables to a JSON file or stdout instead of creating a schema", + ) + + rootCmd.Flags().BoolVar(&escapeJSON, "escape-json", false, + "escape JSON special characters in the output, so that the Schema can be used in a\n"+ + "web context", + ) + rootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { _ = rootCmd.Usage() @@ -158,23 +171,46 @@ func outputFileChecks() error { } func runCommand(cmd *cobra.Command, args []string) error { - // TODO: suppress other printing while outputting to stdout (probably with slog) - outputMap, err := jsonschema.CreateSchema(inputPath, jsonschema.CreateSchemaOptions{ - RequireAll: requireAll, - AllowAdditionalProperties: !disallowAdditionalProperties, - AllowEmpty: allowEmpty, - DebugOut: debugOut && !outputStdOut, - SuppressLogging: outputStdOut, - NullableAll: nullableAll, - }) - if err != nil { - return fmt.Errorf("error creating schema: %w", err) + var outputMap any + var err error + + jsonIndent := "\t" + + if exportVariables { + outputMap, err = tsjson.ExportVariables(inputPath, tsjson.ExportVariablesOptions{ + AllowEmpty: allowEmpty, + SuppressLogging: outputStdOut, + DebugOut: debugOut && !outputStdOut, + EscapeJSON: escapeJSON, + Indent: jsonIndent, + }) + if err != nil { + return fmt.Errorf("error exporting variables: %w", err) + } + } else { + outputMap, err = jsonschema.CreateSchema(inputPath, jsonschema.CreateSchemaOptions{ + RequireAll: requireAll, + AllowAdditionalProperties: !disallowAdditionalProperties, + AllowEmpty: allowEmpty, + DebugOut: debugOut && !outputStdOut, + SuppressLogging: outputStdOut, + NullableAll: nullableAll, + }) + if err != nil { + return fmt.Errorf("error creating schema: %w", err) + } } - jsonOutput, err := json.MarshalIndent(outputMap, "", "\t") + buffer := &bytes.Buffer{} + encoder := json.NewEncoder(buffer) + encoder.SetEscapeHTML(escapeJSON) + encoder.SetIndent("", jsonIndent) + + err = encoder.Encode(outputMap) if err != nil { return fmt.Errorf("error marshalling schema: %w", err) } + jsonOutput := buffer.Bytes() if outputStdOut { fmt.Println(string(jsonOutput)) @@ -194,7 +230,7 @@ func runCommand(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("error writing schema to %q: %w", outputPath, err) } - fmt.Printf("Info: Schema written to %q\n", outputPath) + fmt.Printf("Info: schema written to %q\n", outputPath) return nil } diff --git a/pkg/json/json.go b/pkg/json/json.go new file mode 100644 index 0000000..e819709 --- /dev/null +++ b/pkg/json/json.go @@ -0,0 +1,106 @@ +package json + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + + "github.com/HewlettPackard/terraschema/pkg/model" + "github.com/HewlettPackard/terraschema/pkg/reader" +) + +type ExportVariablesOptions struct { + AllowEmpty bool + DebugOut bool + SuppressLogging bool + // this option is used to escape HTML characters in the output JSON. Since these schema files + // aren't intended to be used directly in a web context, this is set to false by default. + EscapeJSON bool + Indent string +} + +type MarshallableVariableBlock struct { + model.TranslatedVariable + EscapeHTML bool + Indent string +} + +var _ json.Marshaler = MarshallableVariableBlock{} + +type JSONVariableBlock struct { + Default *any `json:"default,omitempty"` + Description *string `json:"description,omitempty"` + Nullable *bool `json:"nullable,omitempty"` + Sensitive *bool `json:"sensitive,omitempty"` + Validation *JSONValidationBlock `json:"validation,omitempty"` + Type *any `json:"type,omitempty"` +} + +type JSONValidationBlock struct { + Condition string `json:"condition"` + ErrorMessage string `json:"error_message"` +} + +func ExportVariables(path string, options ExportVariablesOptions) (map[string]MarshallableVariableBlock, error) { + jsonMap := make(map[string]MarshallableVariableBlock) + varMap, err := reader.GetVarMap(path, options.DebugOut) + if err != nil { + if options.AllowEmpty && (errors.Is(err, reader.ErrFilesNotFound) || errors.Is(err, reader.ErrNoVariablesFound)) { + if !options.SuppressLogging { + fmt.Printf("Warning: directory %q: %v, creating empty variables file\n", path, err) + } + + return jsonMap, nil + } else { + return jsonMap, fmt.Errorf("error reading tf files at %q: %w", path, err) + } + } + + for k, v := range varMap { + jsonMap[k] = MarshallableVariableBlock{v, options.EscapeJSON, options.Indent} + } + + return jsonMap, nil +} + +func (j MarshallableVariableBlock) MarshalJSON() ([]byte, error) { + translatedBlock := JSONVariableBlock{ + Description: j.Variable.Description, + Nullable: j.Variable.Nullable, + Sensitive: j.Variable.Sensitive, + } + + translatedType, err := reader.GetTypeConstraint(j.Variable.Type) + if err != nil { + return nil, fmt.Errorf("error marshalling type constraint: %w", err) + } + translatedBlock.Type = &translatedType + + translatedDefault, err := reader.ExpressionToJSONObject(j.Variable.Default) + if err != nil { + return nil, fmt.Errorf("error marshalling default expression: %w", err) + } + translatedBlock.Default = &translatedDefault + + if j.Variable.Validation != nil { + if j.ConditionAsString == nil { + return nil, errors.New("validation block present with no condition") + } + translatedBlock.Validation = &JSONValidationBlock{ + Condition: *j.ConditionAsString, + ErrorMessage: j.Variable.Validation.ErrorMessage, + } + } + + buffer := &bytes.Buffer{} + encoder := json.NewEncoder(buffer) + encoder.SetEscapeHTML(j.EscapeHTML) + encoder.SetIndent("", j.Indent) + err = encoder.Encode(translatedBlock) + if err != nil { + return nil, fmt.Errorf("error marshalling variable block: %w", err) + } + + return buffer.Bytes(), nil +} diff --git a/pkg/jsonschema/json-schema.go b/pkg/jsonschema/json-schema.go index 77ba5c6..6eb27bd 100644 --- a/pkg/jsonschema/json-schema.go +++ b/pkg/jsonschema/json-schema.go @@ -83,7 +83,7 @@ func createNode(name string, v model.TranslatedVariable, options CreateSchemaOpt } if v.Variable.Default != nil { - def, err := expressionToJSONObject(v.Variable.Default) + def, err := reader.ExpressionToJSONObject(v.Variable.Default) if err != nil { return nil, fmt.Errorf("error converting default value to JSON object: %w", err) } diff --git a/pkg/jsonschema/validation.go b/pkg/jsonschema/validation.go index 586427e..8d8ac92 100644 --- a/pkg/jsonschema/validation.go +++ b/pkg/jsonschema/validation.go @@ -5,6 +5,8 @@ import ( "fmt" "github.com/hashicorp/hcl/v2" + + "github.com/HewlettPackard/terraschema/pkg/reader" ) type conditionMutator func(hcl.Expression, string, string) (map[string]any, error) @@ -79,7 +81,7 @@ func contains(ex hcl.Expression, name string, _ string) (map[string]any, error) newEnum := []any{} for _, val := range l { - simple, err := expressionToJSONObject(val) + simple, err := reader.ExpressionToJSONObject(val) if err != nil { return nil, fmt.Errorf("value in list could not be converted to JSON") } @@ -127,7 +129,7 @@ func canRegex(ex hcl.Expression, name string, t string) (map[string]any, error) return nil, fmt.Errorf("second argument is not a direct reference to the input variable") } - patternJSON, err := expressionToJSONObject(regexArgs[0]) + patternJSON, err := reader.ExpressionToJSONObject(regexArgs[0]) if err != nil { return nil, fmt.Errorf("pattern could not be converted to JSON: %w", err) } diff --git a/pkg/jsonschema/validation_util.go b/pkg/jsonschema/validation_util.go index cb3c2ef..1770ce1 100644 --- a/pkg/jsonschema/validation_util.go +++ b/pkg/jsonschema/validation_util.go @@ -7,6 +7,8 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/zclconf/go-cty/cty/gocty" + + "github.com/HewlettPackard/terraschema/pkg/reader" ) func isExpressionVarName(ex hcl.Expression, name string) bool { @@ -262,7 +264,7 @@ func parseEqualityExpression(ex *hclsyntax.BinaryOpExpr, name string, enum *[]an } if isExpressionVarName(ex.LHS, name) { - object, err := expressionToJSONObject(ex.RHS) + object, err := reader.ExpressionToJSONObject(ex.RHS) if err != nil { return fmt.Errorf("value could not be converted to JSON: %w", err) } diff --git a/pkg/jsonschema/value.go b/pkg/reader/value.go similarity index 80% rename from pkg/jsonschema/value.go rename to pkg/reader/value.go index 201fc37..a391359 100644 --- a/pkg/jsonschema/value.go +++ b/pkg/reader/value.go @@ -1,5 +1,5 @@ // (C) Copyright 2024 Hewlett Packard Enterprise Development LP -package jsonschema +package reader import ( "encoding/json" @@ -8,8 +8,12 @@ import ( ctyjson "github.com/zclconf/go-cty/cty/json" ) -// expressionToJSONObject converts an HCL expression to an `any` type so that can be marshaled to JSON later. -func expressionToJSONObject(in hcl.Expression) (any, error) { +// ExpressionToJSONObject converts an HCL expression to an `any` type so that can be marshaled to JSON later. +func ExpressionToJSONObject(in hcl.Expression) (any, error) { + if in == nil { + return nil, nil //nolint:nilnil + } + v, d := in.Value(&hcl.EvalContext{}) if d.HasErrors() { return nil, d diff --git a/pkg/jsonschema/value_test.go b/pkg/reader/value_test.go similarity index 84% rename from pkg/jsonschema/value_test.go rename to pkg/reader/value_test.go index 21ea6e0..a880da6 100644 --- a/pkg/jsonschema/value_test.go +++ b/pkg/reader/value_test.go @@ -1,5 +1,5 @@ // (C) Copyright 2024 Hewlett Packard Enterprise Development LP -package jsonschema +package reader import ( "encoding/json" @@ -10,8 +10,6 @@ import ( "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/require" - - "github.com/HewlettPackard/terraschema/pkg/reader" ) func TestExpressionToJSONObject_Default(t *testing.T) { @@ -37,8 +35,8 @@ func TestExpressionToJSONObject_Default(t *testing.T) { defaults := make(map[string]any) - varMap, err := reader.GetVarMap(filepath.Join(tfPath, name), true) - if err != nil && !errors.Is(err, reader.ErrFilesNotFound) { + varMap, err := GetVarMap(filepath.Join(tfPath, name), true) + if err != nil && !errors.Is(err, ErrFilesNotFound) { t.Errorf("error reading tf files: %v", err) } @@ -47,7 +45,7 @@ func TestExpressionToJSONObject_Default(t *testing.T) { continue } - defaults[key], err = expressionToJSONObject(val.Variable.Default) + defaults[key], err = ExpressionToJSONObject(val.Variable.Default) require.NoError(t, err) }