Add documentation to `UnmarshalJSON` implementations (#179)
diff --git a/config.go b/config.go index e8ea638..6f08d7d 100644 --- a/config.go +++ b/config.go
@@ -32,6 +32,10 @@ return nil } +// UnmarshalJSON implements json.Unmarshaler for Config. +// +// As per established convention this method should only ever +// be invoked *indirectly* via [encoding/json] library. func (c *Config) UnmarshalJSON(b []byte) error { type rawConfig Config var config rawConfig
diff --git a/expression.go b/expression.go index 5ecb15c..a9da729 100644 --- a/expression.go +++ b/expression.go
@@ -47,6 +47,9 @@ } // UnmarshalJSON implements json.Unmarshaler for Expression. +// +// As per established convention this method should only ever +// be invoked *indirectly* via [encoding/json] library. func (e *Expression) UnmarshalJSON(b []byte) error { result := new(ExpressionData)
diff --git a/metadata.go b/metadata.go index 8ac111a..9509cf6 100644 --- a/metadata.go +++ b/metadata.go
@@ -57,6 +57,10 @@ return nil } +// UnmarshalJSON implements json.Unmarshaler for MetadataFunctions. +// +// As per established convention this method should only ever +// be invoked *indirectly* via [encoding/json] library. func (f *MetadataFunctions) UnmarshalJSON(b []byte) error { type rawFunctions MetadataFunctions var functions rawFunctions
diff --git a/parse_test.go b/parse_test.go index 725e281..1bc22de 100644 --- a/parse_test.go +++ b/parse_test.go
@@ -18,6 +18,7 @@ const testGoldenPlanFileName = "plan.json" const testGoldenStateFileName = "state.json" const testGoldenSchemasFileName = "schemas.json" +const testInvalidDir = "invalid" func testParse(t *testing.T, filename string, typ reflect.Type) { entries, err := os.ReadDir(testFixtureDir) @@ -31,6 +32,10 @@ } t.Run(e.Name(), func(t *testing.T) { + if e.Name() == testInvalidDir { + t.Skip("Skipping known invalid test fixture") + } + expected, err := os.ReadFile(filepath.Join(testFixtureDir, e.Name(), filename)) if err != nil { if os.IsNotExist(err) {
diff --git a/plan.go b/plan.go index 579e8d7..54c37ba 100644 --- a/plan.go +++ b/plan.go
@@ -142,6 +142,10 @@ return nil } +// UnmarshalJSON implements json.Unmarshaler for Plan. +// +// As per established convention this method should only ever +// be invoked *indirectly* via [encoding/json] library. func (p *Plan) UnmarshalJSON(b []byte) error { type rawPlan Plan var plan rawPlan
diff --git a/plan_test.go b/plan_test.go index 9da2ef6..46c9c35 100644 --- a/plan_test.go +++ b/plan_test.go
@@ -5,6 +5,7 @@ import ( "encoding/json" + "io" "os" "reflect" "testing" @@ -187,7 +188,7 @@ t.Fatal(err) } - testCases := map[string]struct { + numericsTestCases := map[string]struct { useJSONNumber bool expected any }{ @@ -200,9 +201,7 @@ }, } - for name, testCase := range testCases { - name, testCase := name, testCase - + for name, testCase := range numericsTestCases { t.Run(name, func(t *testing.T) { t.Parallel() @@ -210,7 +209,7 @@ plan.UseJSONNumber(testCase.useJSONNumber) - err = plan.UnmarshalJSON(b) + err = json.Unmarshal(b, &plan) if err != nil { t.Fatal(err) } @@ -232,4 +231,43 @@ } }) } + + jsonValidationTestCases := map[string]struct { + filePath string + expectError bool + }{ + "invalid plan JSON": { + filePath: "testdata/invalid/plan.json", + expectError: true, + }, + "valid plan JSON": { + filePath: "testdata/basic/plan.json", + }, + } + + for tn, tc := range jsonValidationTestCases { + t.Run(tn, func(t *testing.T) { + f, err := os.Open(tc.filePath) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + b, err := io.ReadAll(f) + if err != nil { + t.Fatal(err) + } + + var plan Plan + err = json.Unmarshal(b, &plan) + + if tc.expectError && err == nil { + t.Fatalf("expected error; got none") + } + + if !tc.expectError && err != nil { + t.Errorf("expected no error, got %q", err.Error()) + } + }) + } }
diff --git a/schemas.go b/schemas.go index b65c13b..581f0c4 100644 --- a/schemas.go +++ b/schemas.go
@@ -60,6 +60,10 @@ return nil } +// UnmarshalJSON implements json.Unmarshaler for ProviderSchemas. +// +// As per established convention this method should only ever +// be invoked *indirectly* via [encoding/json] library. func (p *ProviderSchemas) UnmarshalJSON(b []byte) error { type rawSchemas ProviderSchemas var schemas rawSchemas
diff --git a/state.go b/state.go index ff17aef..8347ad5 100644 --- a/state.go +++ b/state.go
@@ -78,6 +78,10 @@ return nil } +// UnmarshalJSON implements json.Unmarshaler for State. +// +// As per established convention this method should only ever +// be invoked *indirectly* via [encoding/json] library. func (s *State) UnmarshalJSON(b []byte) error { type rawState State var state rawState
diff --git a/state_test.go b/state_test.go index a817a21..cc9a056 100644 --- a/state_test.go +++ b/state_test.go
@@ -42,22 +42,44 @@ } } -func TestStateUnmarshal_valid(t *testing.T) { - f, err := os.Open("testdata/no_changes/state.json") - if err != nil { - t.Fatal(err) - } - defer f.Close() - - b, err := io.ReadAll(f) - if err != nil { - t.Fatal(err) +func TestStateUnmarshal(t *testing.T) { + testCases := map[string]struct { + filePath string + expectError bool + }{ + "valid state JSON": { + filePath: "testdata/no_changes/state.json", + }, + "invalid state JSON": { + filePath: "testdata/invalid/state.json", + expectError: true, + }, } - var state State - err = json.Unmarshal(b, &state) - if err != nil { - t.Fatal(err) + for tn, tc := range testCases { + t.Run(tn, func(t *testing.T) { + f, err := os.Open(tc.filePath) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + b, err := io.ReadAll(f) + if err != nil { + t.Fatal(err) + } + + var state State + err = json.Unmarshal(b, &state) + + if tc.expectError && err == nil { + t.Fatalf("expected error; got none") + } + + if !tc.expectError && err != nil { + t.Errorf("expected no error, got %q", err.Error()) + } + }) } }
diff --git a/testdata/invalid/plan.json b/testdata/invalid/plan.json new file mode 100644 index 0000000..b0b1697 --- /dev/null +++ b/testdata/invalid/plan.json
@@ -0,0 +1,700 @@ +{ + "format_version": "1.1", + "terraform_version": "1.2.0-rc1", + "variables": { + "foo": { + "value": "bar" + }, + "map": { + "value": { + "foo": "bar", + "number": 42 + } + }, + "number": { + "value": 42 + } + }, + "planned_values": { + "outputs": { + "foo": { + "sensitive": true, + "value": "bar", + "type": "string" + }, + "interpolated": { + "sensitive": false + }, + "interpolated_deep": { + "sensitive": false + }, + "list": { + "sensitive": false, + "value": [ + "foo", + "bar" + ], + "type": [ + "tuple", + [ + "string", + "string" + ] + ] + }, + "map": { + "sensitive": false, + "value": { + "foo": "bar", + "number": 42 + }, + "type": [ + "object", + { + "foo": "string", + "number": "number" + } + ] + }, + "referenced": { + "sensitive": false + }, + "referenced_deep": { + "sensitive": false + }, + "string": { + "sensitive": false, + "value": "foo", + "type": "string" + } + }, + "root_module": { + "resources": [ + { + "address": "null_resource.bar", + "mode": "managed", + "type": "null_resource", + "name": "bar", + "provider_name": "registry.terraform.io/hashicorp/null", + "schema_version": 0, + "sensitive_values": { + "triggers": {} + } + }, + { + "address": "null_resource.baz[0]", + "mode": "managed", + "type": "null_resource", + "name": "baz", + "index": 0, + "provider_name": "registry.terraform.io/hashicorp/null", + "schema_version": 0, + "sensitive_values": { + "triggers": {} + } + }, + { + "address": "null_resource.baz[1]", + "mode": "managed", + "type": "null_resource", + "name": "baz", + "index": 1, + "provider_name": "registry.terraform.io/hashicorp/null", + "schema_version": 0, + "sensitive_values": { + "triggers": {} + } + }, + { + "address": "null_resource.baz[2]", + "mode": "managed", + "type": "null_resource", + "name": "baz", + "index": 2, + "provider_name": "registry.terraform.io/hashicorp/null", + "schema_version": 0, + "sensitive_values": { + "triggers": {} + } + }, + { + "address": "null_resource.foo", + "mode": "managed", + "type": "null_resource", + "name": "foo", + "provider_name": "registry.terraform.io/hashicorp/null", + "schema_version": 0, + "values": { + "triggers": { + "foo": "bar" + } + }, + "sensitive_values": { + "triggers": {} + } + } + ], + "child_modules": [ + { + "resources": [ + { + "address": "module.foo.null_resource.aliased", + "mode": "managed", + "type": "null_resource", + "name": "aliased", + "provider_name": "registry.terraform.io/hashicorp/null", + "schema_version": 0, + "values": { + "triggers": null + }, + "sensitive_values": {} + }, + { + "address": "module.foo.null_resource.foo", + "mode": "managed", + "type": "null_resource", + "name": "foo", + "provider_name": "registry.terraform.io/hashicorp/null", + "schema_version": 0, + "values": { + "triggers": { + "foo": "bar" + } + }, + "sensitive_values": { + "triggers": {} + } + } + ], + "address": "module.foo" + } + ] + } + }, + "resource_changes": [ + { + "address": "module.foo.null_resource.aliased", + "module_address": "module.foo", + "mode": "managed", + "type": "null_resource", + "name": "aliased", + "provider_name": "registry.terraform.io/hashicorp/null", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "triggers": null + }, + "after_unknown": { + "id": true + }, + "before_sensitive": false, + "after_sensitive": {} + } + }, + { + "address": "module.foo.null_resource.foo", + "module_address": "module.foo", + "mode": "managed", + "type": "null_resource", + "name": "foo", + "provider_name": "registry.terraform.io/hashicorp/null", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "triggers": { + "foo": "bar" + } + }, + "after_unknown": { + "id": true, + "triggers": {} + }, + "before_sensitive": false, + "after_sensitive": { + "triggers": {} + } + } + }, + { + "address": "null_resource.bar", + "mode": "managed", + "type": "null_resource", + "name": "bar", + "provider_name": "registry.terraform.io/hashicorp/null", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": {}, + "after_unknown": { + "id": true, + "triggers": true + }, + "before_sensitive": false, + "after_sensitive": { + "triggers": {} + } + } + }, + { + "address": "null_resource.baz[0]", + "mode": "managed", + "type": "null_resource", + "name": "baz", + "index": 0, + "provider_name": "registry.terraform.io/hashicorp/null", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": {}, + "after_unknown": { + "id": true, + "triggers": true + }, + "before_sensitive": false, + "after_sensitive": { + "triggers": {} + } + } + }, + { + "address": "null_resource.baz[1]", + "mode": "managed", + "type": "null_resource", + "name": "baz", + "index": 1, + "provider_name": "registry.terraform.io/hashicorp/null", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": {}, + "after_unknown": { + "id": true, + "triggers": true + }, + "before_sensitive": false, + "after_sensitive": { + "triggers": {} + } + } + }, + { + "address": "null_resource.baz[2]", + "mode": "managed", + "type": "null_resource", + "name": "baz", + "index": 2, + "provider_name": "registry.terraform.io/hashicorp/null", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": {}, + "after_unknown": { + "id": true, + "triggers": true + }, + "before_sensitive": false, + "after_sensitive": { + "triggers": {} + } + } + }, + { + "address": "null_resource.foo", + "mode": "managed", + "type": "null_resource", + "name": "foo", + "provider_name": "registry.terraform.io/hashicorp/null", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "triggers": { + "foo": "bar" + } + }, + "after_unknown": { + "id": true, + "triggers": {} + }, + "before_sensitive": false, + "after_sensitive": { + "triggers": {} + } + } + } + ], + "output_changes": { + "foo": { + "actions": [ + "create" + ], + "before": null, + "after": "bar", + "after_unknown": false, + "before_sensitive": true, + "after_sensitive": true + }, + "interpolated": { + "actions": [ + "create" + ], + "before": null, + "after_unknown": true, + "before_sensitive": false, + "after_sensitive": false + }, + "interpolated_deep": { + "actions": [ + "create" + ], + "before": null, + "after_unknown": true, + "before_sensitive": false, + "after_sensitive": false + }, + "list": { + "actions": [ + "create" + ], + "before": null, + "after": [ + "foo", + "bar" + ], + "after_unknown": false, + "before_sensitive": false, + "after_sensitive": false + }, + "map": { + "actions": [ + "create" + ], + "before": null, + "after": { + "foo": "bar", + "number": 42 + }, + "after_unknown": false, + "before_sensitive": false, + "after_sensitive": false + }, + "referenced": { + "actions": [ + "create" + ], + "before": null, + "after_unknown": true, + "before_sensitive": false, + "after_sensitive": false + }, + "referenced_deep": { + "actions": [ + "create" + ], + "before": null, + "after_unknown": true, + "before_sensitive": false, + "after_sensitive": false + }, + "string": { + "actions": [ + "create" + ], + "before": null, + "after": "foo", + "after_unknown": false, + "before_sensitive": false, + "after_sensitive": false + } + }, + "prior_state": { + "format_version": "1.0", + "terraform_version": "1.2.0", + "values": { + "outputs": { + "foo": { + "sensitive": true, + "value": "bar", + "type": "string" + }, + "list": { + "sensitive": false, + "value": [ + "foo", + "bar" + ], + "type": [ + "tuple", + [ + "string", + "string" + ] + ] + }, + "map": { + "sensitive": false, + "value": { + "foo": "bar", + "number": 42 + }, + "type": [ + "object", + { + "foo": "string", + "number": "number" + } + ] + }, + "string": { + "sensitive": false, + "value": "foo", + "type": "string" + } + }, + "root_module": {} + } + }, + "configuration": { + "provider_config": { + "aws": { + "name": "aws", + "full_name": "registry.terraform.io/hashicorp/aws", + "expressions": { + "region": { + "constant_value": "us-west-2" + } + } + }, + "aws.east": { + "name": "aws", + "full_name": "registry.terraform.io/hashicorp/aws", + "alias": "east", + "expressions": { + "region": { + "constant_value": "us-east-1" + } + } + }, + "null": { + "name": "null", + "full_name": "registry.terraform.io/hashicorp/null" + } + }, + "root_module": { + "outputs": { + "foo": { + "sensitive": true, + "expression": { + "constant_value": "bar" + } + }, + "interpolated": { + "expression": { + "references": [ + "null_resource.foo.id", + "null_resource.foo" + ] + } + }, + "interpolated_deep": { + "expression": { + "references": [ + "null_resource.foo.id", + "null_resource.foo" + ] + } + }, + "list": { + "expression": { + "constant_value": [ + "foo", + "bar" + ] + } + }, + "map": { + "expression": { + "constant_value": { + "foo": "bar", + "number": 42 + } + } + }, + "referenced": { + "expression": { + "references": [ + "null_resource.foo.id", + "null_resource.foo" + ] + } + }, + "referenced_deep": { + "expression": { + "references": [ + "null_resource.foo.id", + "null_resource.foo" + ] + } + }, + "string": { + "expression": { + "constant_value": "foo" + } + } + }, + "resources": [ + { + "address": "null_resource.bar", + "mode": "managed", + "type": "null_resource", + "name": "bar", + "provider_config_key": "null", + "expressions": { + "triggers": { + "references": [ + "null_resource.foo.id", + "null_resource.foo" + ] + } + }, + "schema_version": 0 + }, + { + "address": "null_resource.baz", + "mode": "managed", + "type": "null_resource", + "name": "baz", + "provider_config_key": "null", + "expressions": { + "triggers": { + "references": [ + "null_resource.foo.id", + "null_resource.foo" + ] + } + }, + "schema_version": 0, + "count_expression": { + "constant_value": 3 + } + }, + { + "address": "null_resource.foo", + "mode": "managed", + "type": "null_resource", + "name": "foo", + "provider_config_key": "null", + "provisioners": [ + { + "type": "local-exec", + "expressions": { + "command": { + "constant_value": "echo hello" + } + } + } + ], + "expressions": { + "triggers": { + "constant_value": { + "foo": "bar" + } + } + }, + "schema_version": 0 + } + ], + "module_calls": { + "foo": { + "source": "./foo", + "expressions": { + "bar": { + "constant_value": "baz" + }, + "one": { + "constant_value": "two" + } + }, + "module": { + "outputs": { + "foo": { + "expression": { + "constant_value": "bar" + } + } + }, + "resources": [ + { + "address": "null_resource.aliased", + "mode": "managed", + "type": "null_resource", + "name": "aliased", + "provider_config_key": "null", + "schema_version": 0 + }, + { + "address": "null_resource.foo", + "mode": "managed", + "type": "null_resource", + "name": "foo", + "provider_config_key": "null", + "expressions": { + "triggers": { + "constant_value": { + "foo": "bar" + } + } + }, + "schema_version": 0 + } + ], + "variables": { + "bar": {}, + "one": {} + } + } + } + }, + "variables": { + "foo": { + "default": {}}}}}}}, + "description": "foobar", + "sensitive": true, + }, + "map": { + "default": { + "foo": "bar", + "number": 42 + } + }, + "number": { + "default": 42 + } + } + } + }, + "relevant_attributes": [ + { + "resource": "null_resource.foo", + "attribute": [ + "id" + ] + } + ] +}
diff --git a/testdata/invalid/state.json b/testdata/invalid/state.json new file mode 100644 index 0000000..e4ddd48 --- /dev/null +++ b/testdata/invalid/state.json
@@ -0,0 +1,17 @@ +{ + "format_version" : "1.2", + "terraform_version": "1.5.0", + "variables": { + "my-secret": { + "value": "PASSWORD123" + } + }, + "configuration": { + "root_module": { + "variables": { + "my-secret": {}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}, + "sensitive": true + } + } + } +} \ No newline at end of file
diff --git a/validate.go b/validate.go index 53652ef..8b101dd 100644 --- a/validate.go +++ b/validate.go
@@ -137,6 +137,10 @@ return nil } +// UnmarshalJSON implements json.Unmarshaler for ValidateOutput. +// +// As per established convention this method should only ever +// be invoked *indirectly* via [encoding/json] library. func (vo *ValidateOutput) UnmarshalJSON(b []byte) error { type rawOutput ValidateOutput var schemas rawOutput