Skip to content

Commit

Permalink
Add --export-variables and --escape-json (#19)
Browse files Browse the repository at this point in the history
Add --export-variables and --escape-json

Also move some code to the reader package, since it is needed by the new json package.

This returns a JSON blob of the variables with behaviour described in the README file. I also disabled the default behaviour of Go where it escapes certain characters for HTML reasons because I don't think it will be needed here in the majority of cases, and it makes the condition statements harder to read if you're not using a special app like jq.
  • Loading branch information
AislingHPE authored Aug 30, 2024
1 parent a66fdfd commit 8eb198f
Show file tree
Hide file tree
Showing 8 changed files with 195 additions and 26 deletions.
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
62 changes: 49 additions & 13 deletions cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
package cmd

import (
"bytes"
"encoding/json"
"fmt"
"os"
Expand All @@ -10,6 +11,7 @@ import (

"github.com/spf13/cobra"

tsjson "github.com/HewlettPackard/terraschema/pkg/json"
"github.com/HewlettPackard/terraschema/pkg/jsonschema"
)

Expand All @@ -23,6 +25,8 @@ var (
inputPath string
outputPath string
debugOut bool
exportVariables bool
escapeJSON bool
)

// rootCmd is the base command for terraschema
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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))
Expand All @@ -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
}
106 changes: 106 additions & 0 deletions pkg/json/json.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion pkg/jsonschema/json-schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
6 changes: 4 additions & 2 deletions pkg/jsonschema/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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)
}
Expand Down
4 changes: 3 additions & 1 deletion pkg/jsonschema/validation_util.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
10 changes: 7 additions & 3 deletions pkg/jsonschema/value.go → pkg/reader/value.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// (C) Copyright 2024 Hewlett Packard Enterprise Development LP
package jsonschema
package reader

import (
"encoding/json"
Expand All @@ -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
Expand Down
10 changes: 4 additions & 6 deletions pkg/jsonschema/value_test.go → pkg/reader/value_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// (C) Copyright 2024 Hewlett Packard Enterprise Development LP
package jsonschema
package reader

import (
"encoding/json"
Expand All @@ -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) {
Expand All @@ -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)
}

Expand All @@ -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)
}

Expand Down

0 comments on commit 8eb198f

Please sign in to comment.