diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6282e995..4bfcc686 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,12 +59,9 @@ jobs: - windows-latest - ubuntu-latest terraform: - - '0.12.*' - - '0.13.*' - - '0.14.*' - - '0.15.*' - '1.0.*' - '1.1.*' + - '1.2.*' steps: - name: Setup Go diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ee93140..9736ad06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## 3.0.0 (unreleased) + +NOTES: + +* Provider has been re-written using the new [`terraform-plugin-framework`](https://www.terraform.io/plugin/framework) ([#177](https://github.com/hashicorp/terraform-provider-http/pull/142)). + +BREAKING CHANGES: + +* [Terraform `>=1.0`](https://www.terraform.io/language/upgrade-guides/1-0) is now required to use this provider. + +* data-source/http: There is no longer a check that the status code is 200 following a request. `status_code` attribute has been added and should be used in + [precondition and postcondition](https://www.terraform.io/language/expressions/custom-conditions) checks instead ([114](https://github.com/hashicorp/terraform-provider-http/pull/114)). +* data-source/http: Deprecated `body` has been removed ([#137](https://github.com/hashicorp/terraform-provider-http/pull/137)). + ## 2.2.0 (June 02, 2022) ENHANCEMENTS: diff --git a/docs/data-sources/http.md b/docs/data-sources/http.md index c2819df1..0fa557d7 100644 --- a/docs/data-sources/http.md +++ b/docs/data-sources/http.md @@ -1,5 +1,4 @@ --- -# generated by https://github.com/hashicorp/terraform-plugin-docs page_title: "http Data Source - terraform-provider-http" subcategory: "" description: |- @@ -45,6 +44,54 @@ data "http" "example" { } ``` +## Usage with Postcondition + +[Precondition and Postcondition](https://www.terraform.io/language/expressions/custom-conditions) +checks are available with Terraform v1.2.0 and later. + +```terraform +data "http" "example" { + url = "https://checkpoint-api.hashicorp.com/v1/check/terraform" + + # Optional request headers + request_headers = { + Accept = "application/json" + } + + lifecycle { + postcondition { + condition = contains([201, 204], self.status_code) + error_message = "Status code invalid" + } + } +} +``` + +## Usage with Precondition + +[Precondition and Postcondition](https://www.terraform.io/language/expressions/custom-conditions) +checks are available with Terraform v1.2.0 and later. + +```terraform +data "http" "example" { + url = "https://checkpoint-api.hashicorp.com/v1/check/terraform" + + # Optional request headers + request_headers = { + Accept = "application/json" + } +} + +resource "random_uuid" "example" { + lifecycle { + precondition { + condition = contains([201, 204], data.http.example.status_code) + error_message = "Status code invalid" + } + } +} +``` + ## Schema @@ -58,9 +105,7 @@ data "http" "example" { ### Read-Only -- `body` (String, Deprecated) The response body returned as a string. **NOTE**: This is deprecated, use `response_body` instead. - `id` (String) The ID of this resource. - `response_body` (String) The response body returned as a string. -- `response_headers` (Map of String) A map of response header field names and values. Duplicate headers are concatenated with according to [RFC2616](https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2). - - +- `response_headers` (Map of String) A map of response header field names and values. Duplicate headers are concatenated according to [RFC2616](https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2). +- `status_code` (Number) The HTTP response status code. diff --git a/examples/data-sources/http/postcondition.tf b/examples/data-sources/http/postcondition.tf new file mode 100644 index 00000000..00f13dd4 --- /dev/null +++ b/examples/data-sources/http/postcondition.tf @@ -0,0 +1,15 @@ +data "http" "example" { + url = "https://checkpoint-api.hashicorp.com/v1/check/terraform" + + # Optional request headers + request_headers = { + Accept = "application/json" + } + + lifecycle { + postcondition { + condition = contains([201, 204], self.status_code) + error_message = "Status code invalid" + } + } +} diff --git a/examples/data-sources/http/precondition.tf b/examples/data-sources/http/precondition.tf new file mode 100644 index 00000000..7c5e5040 --- /dev/null +++ b/examples/data-sources/http/precondition.tf @@ -0,0 +1,17 @@ +data "http" "example" { + url = "https://checkpoint-api.hashicorp.com/v1/check/terraform" + + # Optional request headers + request_headers = { + Accept = "application/json" + } +} + +resource "random_uuid" "example" { + lifecycle { + precondition { + condition = contains([201, 204], data.http.example.status_code) + error_message = "Status code invalid" + } + } +} diff --git a/go.mod b/go.mod index e82a3d0d..a762c181 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.17 require ( github.com/hashicorp/terraform-plugin-docs v0.12.0 + github.com/hashicorp/terraform-plugin-framework v0.9.0 + github.com/hashicorp/terraform-plugin-go v0.9.1 github.com/hashicorp/terraform-plugin-sdk/v2 v2.17.0 ) @@ -24,7 +26,7 @@ require ( github.com/hashicorp/go-checkpoint v0.5.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 // indirect - github.com/hashicorp/go-hclog v1.2.0 // indirect + github.com/hashicorp/go-hclog v1.2.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-plugin v1.4.4 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect @@ -34,8 +36,7 @@ require ( github.com/hashicorp/logutils v1.0.0 // indirect github.com/hashicorp/terraform-exec v0.17.1 // indirect github.com/hashicorp/terraform-json v0.14.0 // indirect - github.com/hashicorp/terraform-plugin-go v0.9.1 // indirect - github.com/hashicorp/terraform-plugin-log v0.4.0 // indirect + github.com/hashicorp/terraform-plugin-log v0.4.1 // indirect github.com/hashicorp/terraform-registry-address v0.0.0-20210412075316-9b2996cce896 // indirect github.com/hashicorp/terraform-svchost v0.0.0-20200729002733-f050f53b9734 // indirect github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d // indirect diff --git a/go.sum b/go.sum index ebeda2c4..fe9f34bd 100644 --- a/go.sum +++ b/go.sum @@ -121,8 +121,9 @@ github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/S github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 h1:1/D3zfFHttUKaCaGKZ/dR2roBXv0vKbSCnssIldfQdI= github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320/go.mod h1:EiZBMaudVLy8fmjf9Npq1dq9RalhveqZG5w/yz3mHWs= github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= -github.com/hashicorp/go-hclog v1.2.0 h1:La19f8d7WIlm4ogzNHB0JGqs5AUDAZ2UfCY4sJXcJdM= github.com/hashicorp/go-hclog v1.2.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-hclog v1.2.1 h1:YQsLlGDJgwhXFpucSPyVbCBviQtjlHv3jLTlp8YmtEw= +github.com/hashicorp/go-hclog v1.2.1/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= @@ -153,10 +154,13 @@ github.com/hashicorp/terraform-json v0.14.0 h1:sh9iZ1Y8IFJLx+xQiKHGud6/TSUCM0N8e github.com/hashicorp/terraform-json v0.14.0/go.mod h1:5A9HIWPkk4e5aeeXIBbkcOvaZbIYnAIkEyqP2pNSckM= github.com/hashicorp/terraform-plugin-docs v0.12.0 h1:EAvFVEoV/wj15t/VSeKVpnAd+BBnIxzYepAnScBWrU4= github.com/hashicorp/terraform-plugin-docs v0.12.0/go.mod h1:HVn60yjtl4XxLINPgNmPCwX8SQ4T99Ut9CTD/ac6i5w= +github.com/hashicorp/terraform-plugin-framework v0.9.0 h1:vOKG9+keJv062zGhXFgfOFEuGcfgV6LHciwleFTSek0= +github.com/hashicorp/terraform-plugin-framework v0.9.0/go.mod h1:ActelD2V6yt2m0MwIX4jESGDYJ573rAvZswGjSGm1rY= github.com/hashicorp/terraform-plugin-go v0.9.1 h1:vXdHaQ6aqL+OF076nMSBV+JKPdmXlzG5mzVDD04WyPs= github.com/hashicorp/terraform-plugin-go v0.9.1/go.mod h1:ItjVSlQs70otlzcCwlPcU8FRXLdO973oYFRZwAOxy8M= -github.com/hashicorp/terraform-plugin-log v0.4.0 h1:F3eVnm8r2EfQCe2k9blPIiF/r2TT01SHijXnS7bujvc= github.com/hashicorp/terraform-plugin-log v0.4.0/go.mod h1:9KclxdunFownr4pIm1jdmwKRmE4d6HVG2c9XDq47rpg= +github.com/hashicorp/terraform-plugin-log v0.4.1 h1:xpbmVhvuU3mgHzLetOmx9pkOL2rmgpu302XxddON6eo= +github.com/hashicorp/terraform-plugin-log v0.4.1/go.mod h1:p4R1jWBXRTvL4odmEkFfDdhUjHf9zcs/BCoNHAc7IK4= github.com/hashicorp/terraform-plugin-sdk/v2 v2.17.0 h1:Qr5fWNg1SPSfCRMtou67Y6Kcy9UnMYRNlIJTKRuUvXU= github.com/hashicorp/terraform-plugin-sdk/v2 v2.17.0/go.mod h1:b+LFg8WpYgFgvEBP/6Htk5H9/pJp1V1E8NJAekfH2Ws= github.com/hashicorp/terraform-registry-address v0.0.0-20210412075316-9b2996cce896 h1:1FGtlkJw87UsTMg5s8jrekrHmUPUJaMcu6ELiVhQrNw= @@ -260,8 +264,9 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= @@ -348,6 +353,7 @@ golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b h1:2n253B2r0pYSmEV+UNCQoPfU/FiaizQEK5Gu4Bq4JE8= golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -421,7 +427,8 @@ gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/provider/data_source.go b/internal/provider/data_source.go deleted file mode 100644 index 75fc8508..00000000 --- a/internal/provider/data_source.go +++ /dev/null @@ -1,165 +0,0 @@ -package provider - -import ( - "context" - "fmt" - "io/ioutil" - "mime" - "net/http" - "regexp" - "strings" - - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" -) - -func dataSource() *schema.Resource { - return &schema.Resource{ - Description: ` -The ` + "`http`" + ` data source makes an HTTP GET request to the given URL and exports -information about the response. - -The given URL may be either an ` + "`http`" + ` or ` + "`https`" + ` URL. At present this resource -can only retrieve data from URLs that respond with ` + "`text/*`" + ` or -` + "`application/json`" + ` content types, and expects the result to be UTF-8 encoded -regardless of the returned content type header. - -~> **Important** Although ` + "`https`" + ` URLs can be used, there is currently no -mechanism to authenticate the remote server except for general verification of -the server certificate's chain of trust. Data retrieved from servers not under -your control should be treated as untrustworthy.`, - ReadContext: dataSourceRead, - - Schema: map[string]*schema.Schema{ - "url": { - Description: "The URL for the request. Supported schemes are `http` and `https`.", - Type: schema.TypeString, - Required: true, - }, - - "request_headers": { - Description: "A map of request header field names and values.", - Type: schema.TypeMap, - Optional: true, - Elem: &schema.Schema{ - Type: schema.TypeString, - }, - }, - - "body": { - Description: "The response body returned as a string. " + - "**NOTE**: This is deprecated, use `response_body` instead.", - Type: schema.TypeString, - Computed: true, - Deprecated: "Use response_body instead", - }, - - "response_body": { - Description: "The response body returned as a string.", - Type: schema.TypeString, - Computed: true, - }, - - "response_headers": { - Description: `A map of response header field names and values.` + - ` Duplicate headers are concatenated with according to [RFC2616](https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2).`, - Type: schema.TypeMap, - Computed: true, - Elem: &schema.Schema{ - Type: schema.TypeString, - }, - }, - }, - } -} - -func dataSourceRead(ctx context.Context, d *schema.ResourceData, meta interface{}) (diags diag.Diagnostics) { - url := d.Get("url").(string) - headers := d.Get("request_headers").(map[string]interface{}) - - client := &http.Client{} - - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return append(diags, diag.Errorf("Error creating request: %s", err)...) - } - - for name, value := range headers { - req.Header.Set(name, value.(string)) - } - - resp, err := client.Do(req) - if err != nil { - return append(diags, diag.Errorf("Error making request: %s", err)...) - } - - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return append(diags, diag.Errorf("HTTP request error. Response code: %d", resp.StatusCode)...) - } - - contentType := resp.Header.Get("Content-Type") - if !isContentTypeText(contentType) { - diags = append(diags, diag.Diagnostic{ - Severity: diag.Warning, - Summary: fmt.Sprintf("Content-Type is not recognized as a text type, got %q", contentType), - Detail: "If the content is binary data, Terraform may not properly handle the contents of the response.", - }) - } - - bytes, err := ioutil.ReadAll(resp.Body) - if err != nil { - return append(diags, diag.FromErr(err)...) - } - - responseHeaders := make(map[string]string) - for k, v := range resp.Header { - // Concatenate according to RFC2616 - // cf. https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 - responseHeaders[k] = strings.Join(v, ", ") - } - - if err = d.Set("body", string(bytes)); err != nil { - return append(diags, diag.Errorf("Error setting HTTP response body: %s", err)...) - } - - if err = d.Set("response_body", string(bytes)); err != nil { - return append(diags, diag.Errorf("Error setting HTTP response body: %s", err)...) - } - - if err = d.Set("response_headers", responseHeaders); err != nil { - return append(diags, diag.Errorf("Error setting HTTP response headers: %s", err)...) - } - - // set ID as something more stable than time - d.SetId(url) - - return diags -} - -// This is to prevent potential issues w/ binary files -// and generally unprintable characters -// See https://github.com/hashicorp/terraform/pull/3858#issuecomment-156856738 -func isContentTypeText(contentType string) bool { - - parsedType, params, err := mime.ParseMediaType(contentType) - if err != nil { - return false - } - - allowedContentTypes := []*regexp.Regexp{ - regexp.MustCompile("^text/.+"), - regexp.MustCompile("^application/json$"), - regexp.MustCompile(`^application/samlmetadata\+xml`), - } - - for _, r := range allowedContentTypes { - if r.MatchString(parsedType) { - charset := strings.ToLower(params["charset"]) - return charset == "" || charset == "utf-8" || charset == "us-ascii" - } - } - - return false -} diff --git a/internal/provider/data_source_http.go b/internal/provider/data_source_http.go new file mode 100644 index 00000000..a6a42a92 --- /dev/null +++ b/internal/provider/data_source_http.go @@ -0,0 +1,210 @@ +package provider + +import ( + "context" + "fmt" + "io/ioutil" + "mime" + "net/http" + "regexp" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ tfsdk.DataSourceType = (*httpDataSourceType)(nil) + +type httpDataSourceType struct{} + +func (d *httpDataSourceType) GetSchema(context.Context) (tfsdk.Schema, diag.Diagnostics) { + return tfsdk.Schema{ + Description: ` +The ` + "`http`" + ` data source makes an HTTP GET request to the given URL and exports +information about the response. + +The given URL may be either an ` + "`http`" + ` or ` + "`https`" + ` URL. At present this resource +can only retrieve data from URLs that respond with ` + "`text/*`" + ` or +` + "`application/json`" + ` content types, and expects the result to be UTF-8 encoded +regardless of the returned content type header. + +~> **Important** Although ` + "`https`" + ` URLs can be used, there is currently no +mechanism to authenticate the remote server except for general verification of +the server certificate's chain of trust. Data retrieved from servers not under +your control should be treated as untrustworthy.`, + + Attributes: map[string]tfsdk.Attribute{ + "url": { + Description: "The URL for the request. Supported schemes are `http` and `https`.", + Type: types.StringType, + Required: true, + }, + + "request_headers": { + Description: "A map of request header field names and values.", + Type: types.MapType{ + ElemType: types.StringType, + }, + Optional: true, + }, + + "response_body": { + Description: "The response body returned as a string.", + Type: types.StringType, + Computed: true, + }, + + "response_headers": { + Description: `A map of response header field names and values.` + + ` Duplicate headers are concatenated according to [RFC2616](https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2).`, + Type: types.MapType{ + ElemType: types.StringType, + }, + Computed: true, + }, + + "status_code": { + Description: `The HTTP response status code.`, + Type: types.Int64Type, + Computed: true, + }, + + "id": { + Description: "The ID of this resource.", + Type: types.StringType, + Computed: true, + }, + }, + }, nil +} + +func (d *httpDataSourceType) NewDataSource(context.Context, tfsdk.Provider) (tfsdk.DataSource, diag.Diagnostics) { + return &httpDataSource{}, nil +} + +var _ tfsdk.DataSource = (*httpDataSource)(nil) + +type httpDataSource struct{} + +func (d *httpDataSource) Read(ctx context.Context, req tfsdk.ReadDataSourceRequest, resp *tfsdk.ReadDataSourceResponse) { + var model modelV0 + diags := req.Config.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + url := model.URL.Value + headers := model.RequestHeaders + + client := &http.Client{} + + request, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + resp.Diagnostics.AddError( + "Error creating request", + fmt.Sprintf("Error creating request: %s", err), + ) + return + } + + for name, value := range headers.Elems { + var header string + diags = tfsdk.ValueAs(ctx, value, &header) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + request.Header.Set(name, header) + } + + response, err := client.Do(request) + if err != nil { + resp.Diagnostics.AddError( + "Error making request", + fmt.Sprintf("Error making request: %s", err), + ) + return + } + + defer response.Body.Close() + + contentType := response.Header.Get("Content-Type") + if !isContentTypeText(contentType) { + resp.Diagnostics.AddWarning( + fmt.Sprintf("Content-Type is not recognized as a text type, got %q", contentType), + "If the content is binary data, Terraform may not properly handle the contents of the response.", + ) + } + + bytes, err := ioutil.ReadAll(response.Body) + if err != nil { + resp.Diagnostics.AddError( + "Error reading response body", + fmt.Sprintf("Error reading response body: %s", err), + ) + return + } + + responseBody := string(bytes) + + responseHeaders := make(map[string]string) + for k, v := range response.Header { + // Concatenate according to RFC2616 + // cf. https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 + responseHeaders[k] = strings.Join(v, ", ") + } + + respHeadersState := types.Map{} + + diags = tfsdk.ValueFrom(ctx, responseHeaders, types.Map{ElemType: types.StringType}.Type(ctx), &respHeadersState) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + model.ID = types.String{Value: url} + model.ResponseHeaders = respHeadersState + model.ResponseBody = types.String{Value: responseBody} + model.StatusCode = types.Int64{Value: int64(response.StatusCode)} + + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) +} + +// This is to prevent potential issues w/ binary files +// and generally unprintable characters +// See https://github.com/hashicorp/terraform/pull/3858#issuecomment-156856738 +func isContentTypeText(contentType string) bool { + + parsedType, params, err := mime.ParseMediaType(contentType) + if err != nil { + return false + } + + allowedContentTypes := []*regexp.Regexp{ + regexp.MustCompile("^text/.+"), + regexp.MustCompile("^application/json$"), + regexp.MustCompile(`^application/samlmetadata\+xml`), + } + + for _, r := range allowedContentTypes { + if r.MatchString(parsedType) { + charset := strings.ToLower(params["charset"]) + return charset == "" || charset == "utf-8" || charset == "us-ascii" + } + } + + return false +} + +type modelV0 struct { + ID types.String `tfsdk:"id"` + URL types.String `tfsdk:"url"` + RequestHeaders types.Map `tfsdk:"request_headers"` + ResponseHeaders types.Map `tfsdk:"response_headers"` + ResponseBody types.String `tfsdk:"response_body"` + StatusCode types.Int64 `tfsdk:"status_code"` +} diff --git a/internal/provider/data_source_http_test.go b/internal/provider/data_source_http_test.go new file mode 100644 index 00000000..3dceaa97 --- /dev/null +++ b/internal/provider/data_source_http_test.go @@ -0,0 +1,263 @@ +package provider + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestDataSource_200(t *testing.T) { + testHttpMock := setUpMockHttpServer() + defer testHttpMock.server.Close() + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + data "http" "http_test" { + url = "%s/200" + }`, testHttpMock.server.URL), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.http.http_test", "response_body", "1.0.0"), + resource.TestCheckResourceAttr("data.http.http_test", "response_headers.Content-Type", "text/plain"), + resource.TestCheckResourceAttr("data.http.http_test", "response_headers.X-Single", "foobar"), + resource.TestCheckResourceAttr("data.http.http_test", "response_headers.X-Double", "1, 2"), + resource.TestCheckResourceAttr("data.http.http_test", "status_code", "200"), + ), + }, + }, + }) +} + +func TestDataSource_404(t *testing.T) { + testHttpMock := setUpMockHttpServer() + defer testHttpMock.server.Close() + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + data "http" "http_test" { + url = "%s/404" + }`, testHttpMock.server.URL), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.http.http_test", "response_body", ""), + resource.TestCheckResourceAttr("data.http.http_test", "status_code", "404"), + ), + }, + }, + }) +} + +func TestDataSource_withAuthorizationRequestHeader_200(t *testing.T) { + testHttpMock := setUpMockHttpServer() + defer testHttpMock.server.Close() + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + data "http" "http_test" { + url = "%s/restricted" + + request_headers = { + "Authorization" = "Zm9vOmJhcg==" + } + }`, testHttpMock.server.URL), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.http.http_test", "response_body", "1.0.0"), + resource.TestCheckResourceAttr("data.http.http_test", "status_code", "200"), + ), + }, + }, + }) +} + +func TestDataSource_withAuthorizationRequestHeader_403(t *testing.T) { + testHttpMock := setUpMockHttpServer() + defer testHttpMock.server.Close() + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + data "http" "http_test" { + url = "%s/restricted" + + request_headers = { + "Authorization" = "unauthorized" + } + }`, testHttpMock.server.URL), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.http.http_test", "response_body", ""), + resource.TestCheckResourceAttr("data.http.http_test", "status_code", "403"), + ), + }, + }, + }) +} + +func TestDataSource_utf8_200(t *testing.T) { + testHttpMock := setUpMockHttpServer() + defer testHttpMock.server.Close() + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + data "http" "http_test" { + url = "%s/utf-8/200" + }`, testHttpMock.server.URL), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.http.http_test", "response_body", "1.0.0"), + resource.TestCheckResourceAttr("data.http.http_test", "response_headers.Content-Type", "text/plain; charset=UTF-8"), + resource.TestCheckResourceAttr("data.http.http_test", "status_code", "200"), + ), + }, + }, + }) +} + +func TestDataSource_utf16_200(t *testing.T) { + testHttpMock := setUpMockHttpServer() + defer testHttpMock.server.Close() + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + data "http" "http_test" { + url = "%s/utf-16/200" + }`, testHttpMock.server.URL), + // This should now be a warning, but unsure how to test for it... + // ExpectWarning: regexp.MustCompile("Content-Type is not a text type. Got: application/json; charset=UTF-16"), + }, + }, + }) +} + +func TestDataSource_x509cert(t *testing.T) { + testHttpMock := setUpMockHttpServer() + defer testHttpMock.server.Close() + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + data "http" "http_test" { + url = "%s/x509-ca-cert/200" + }`, testHttpMock.server.URL), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.http.http_test", "response_body", "pem"), + resource.TestCheckResourceAttr("data.http.http_test", "status_code", "200"), + ), + }, + }, + }) +} + +func TestDataSource_UpgradeFromVersion2_2_0(t *testing.T) { + testHttpMock := setUpMockHttpServer() + defer testHttpMock.server.Close() + + resource.Test(t, resource.TestCase{ + Steps: []resource.TestStep{ + { + ExternalProviders: map[string]resource.ExternalProvider{ + "http": { + VersionConstraint: "2.2.0", + Source: "hashicorp/http", + }, + }, + Config: fmt.Sprintf(` + data "http" "http_test" { + url = "%s/200" + }`, testHttpMock.server.URL), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.http.http_test", "response_body", "1.0.0"), + resource.TestCheckResourceAttr("data.http.http_test", "response_headers.Content-Type", "text/plain"), + resource.TestCheckResourceAttr("data.http.http_test", "response_headers.X-Single", "foobar"), + resource.TestCheckResourceAttr("data.http.http_test", "response_headers.X-Double", "1, 2"), + ), + }, + { + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Config: fmt.Sprintf(` + data "http" "http_test" { + url = "%s/200" + }`, testHttpMock.server.URL), + PlanOnly: true, + }, + { + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Config: fmt.Sprintf(` + data "http" "http_test" { + url = "%s/200" + }`, testHttpMock.server.URL), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.http.http_test", "response_body", "1.0.0"), + resource.TestCheckResourceAttr("data.http.http_test", "response_headers.Content-Type", "text/plain"), + resource.TestCheckResourceAttr("data.http.http_test", "response_headers.X-Single", "foobar"), + resource.TestCheckResourceAttr("data.http.http_test", "response_headers.X-Double", "1, 2"), + resource.TestCheckResourceAttr("data.http.http_test", "status_code", "200"), + ), + }, + }, + }) +} + +type TestHttpMock struct { + server *httptest.Server +} + +func setUpMockHttpServer() *TestHttpMock { + Server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.Header().Add("X-Single", "foobar") + w.Header().Add("X-Double", "1") + w.Header().Add("X-Double", "2") + + switch r.URL.Path { + case "/200": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("1.0.0")) + case "/restricted": + if r.Header.Get("Authorization") == "Zm9vOmJhcg==" { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("1.0.0")) + } else { + w.WriteHeader(http.StatusForbidden) + } + case "/utf-8/200": + w.Header().Set("Content-Type", "text/plain; charset=UTF-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("1.0.0")) + case "/utf-16/200": + w.Header().Set("Content-Type", "application/json; charset=UTF-16") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("1.0.0")) + case "/x509-ca-cert/200": + w.Header().Set("Content-Type", "application/x-x509-ca-cert") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("pem")) + default: + w.WriteHeader(http.StatusNotFound) + } + }), + ) + + return &TestHttpMock{ + server: Server, + } +} diff --git a/internal/provider/data_source_test.go b/internal/provider/data_source_test.go deleted file mode 100644 index 8c032a67..00000000 --- a/internal/provider/data_source_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package provider - -import ( - "fmt" - "net/http" - "net/http/httptest" - "regexp" - "testing" - - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" -) - -func TestDataSource_http200(t *testing.T) { - testHttpMock := setUpMockHttpServer() - - defer testHttpMock.server.Close() - - resource.UnitTest(t, resource.TestCase{ - ProviderFactories: testProviders(), - Steps: []resource.TestStep{ - { - Config: fmt.Sprintf(testDataSourceConfigBasic, testHttpMock.server.URL, 200), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("data.http.http_test", "body", "1.0.0"), - resource.TestCheckResourceAttr("data.http.http_test", "response_body", "1.0.0"), - resource.TestCheckResourceAttr("data.http.http_test", "response_headers.X-Single", "foobar"), - resource.TestCheckResourceAttr("data.http.http_test", "response_headers.X-Double", "1, 2"), - ), - }, - }, - }) -} - -func TestDataSource_http404(t *testing.T) { - testHttpMock := setUpMockHttpServer() - - defer testHttpMock.server.Close() - - resource.UnitTest(t, resource.TestCase{ - ProviderFactories: testProviders(), - Steps: []resource.TestStep{ - { - Config: fmt.Sprintf(testDataSourceConfigBasic, testHttpMock.server.URL, 404), - ExpectError: regexp.MustCompile("HTTP request error. Response code: 404"), - }, - }, - }) -} - -func TestDataSource_withHeaders200(t *testing.T) { - testHttpMock := setUpMockHttpServer() - - defer testHttpMock.server.Close() - - resource.UnitTest(t, resource.TestCase{ - ProviderFactories: testProviders(), - Steps: []resource.TestStep{ - { - Config: fmt.Sprintf(testDataSourceConfigWithHeaders, testHttpMock.server.URL, 200), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("data.http.http_test", "body", "1.0.0"), - resource.TestCheckResourceAttr("data.http.http_test", "response_body", "1.0.0"), - ), - }, - }, - }) -} - -func TestDataSource_utf8(t *testing.T) { - testHttpMock := setUpMockHttpServer() - - defer testHttpMock.server.Close() - - resource.UnitTest(t, resource.TestCase{ - ProviderFactories: testProviders(), - Steps: []resource.TestStep{ - { - Config: fmt.Sprintf(testDataSourceConfigUTF8, testHttpMock.server.URL, 200), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("data.http.http_test", "body", "1.0.0"), - resource.TestCheckResourceAttr("data.http.http_test", "response_body", "1.0.0"), - ), - }, - }, - }) -} - -func TestDataSource_utf16(t *testing.T) { - testHttpMock := setUpMockHttpServer() - - defer testHttpMock.server.Close() - - resource.UnitTest(t, resource.TestCase{ - ProviderFactories: testProviders(), - Steps: []resource.TestStep{ - { - Config: fmt.Sprintf(testDataSourceConfigUTF16, testHttpMock.server.URL, 200), - // This should now be a warning, but unsure how to test for it... - //ExpectWarning: regexp.MustCompile("Content-Type is not a text type. Got: application/json; charset=UTF-16"), - }, - }, - }) -} - -// TODO: This test fails under Terraform 0.14. It should be uncommented when we -// are able to include Terraform version logic within acceptance tests, or when -// 0.14 is removed from the test matrix. -// See https://github.com/hashicorp/terraform-provider-http/pull/74 -// -// const testDataSourceConfig_x509cert = ` -// data "http" "http_test" { -// url = "%s/x509/cert.pem" -// } - -// output "body" { -// value = "${data.http.http_test.body}" -// } -// ` - -// func TestDataSource_x509cert(t *testing.T) { -// testHttpMock := setUpMockHttpServer() - -// defer testHttpMock.server.Close() - -// resource.UnitTest(t, resource.TestCase{ -// Providers: testProviders, -// Steps: []resource.TestStep{ -// { -// Config: fmt.Sprintf(testDataSourceConfig_x509cert, testHttpMock.server.URL), -// Check: func(s *terraform.State) error { -// _, ok := s.RootModule().Resources["data.http.http_test"] -// if !ok { -// return fmt.Errorf("missing data resource") -// } - -// outputs := s.RootModule().Outputs - -// if outputs["body"].Value != "pem" { -// return fmt.Errorf( -// `'body' output is %s; want 'pem'`, -// outputs["body"].Value, -// ) -// } - -// return nil -// }, -// }, -// }, -// }) -// } - -const testDataSourceConfigBasic = ` -data "http" "http_test" { - url = "%s/meta_%d.txt" -} -` - -const testDataSourceConfigWithHeaders = ` -data "http" "http_test" { - url = "%s/restricted/meta_%d.txt" - - request_headers = { - "Authorization" = "Zm9vOmJhcg==" - } -} -` - -const testDataSourceConfigUTF8 = ` -data "http" "http_test" { - url = "%s/utf-8/meta_%d.txt" -} -` - -const testDataSourceConfigUTF16 = ` -data "http" "http_test" { - url = "%s/utf-16/meta_%d.txt" -} -` - -type TestHttpMock struct { - server *httptest.Server -} - -func setUpMockHttpServer() *TestHttpMock { - Server := httptest.NewServer( - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - - w.Header().Set("Content-Type", "text/plain") - w.Header().Add("X-Single", "foobar") - w.Header().Add("X-Double", "1") - w.Header().Add("X-Double", "2") - if r.URL.Path == "/meta_200.txt" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("1.0.0")) - } else if r.URL.Path == "/restricted/meta_200.txt" { - if r.Header.Get("Authorization") == "Zm9vOmJhcg==" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("1.0.0")) - } else { - w.WriteHeader(http.StatusForbidden) - } - } else if r.URL.Path == "/utf-8/meta_200.txt" { - w.Header().Set("Content-Type", "text/plain; charset=UTF-8") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("1.0.0")) - } else if r.URL.Path == "/utf-16/meta_200.txt" { - w.Header().Set("Content-Type", "application/json; charset=UTF-16") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("\"1.0.0\"")) - } else if r.URL.Path == "/x509/cert.pem" { - w.Header().Set("Content-Type", "application/x-x509-ca-cert") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("pem")) - } else if r.URL.Path == "/meta_404.txt" { - w.WriteHeader(http.StatusNotFound) - } else { - w.WriteHeader(http.StatusNotFound) - } - }), - ) - - return &TestHttpMock{ - server: Server, - } -} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index a9da1e44..8e894d3a 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -1,17 +1,33 @@ package provider import ( - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" ) -func New() *schema.Provider { - return &schema.Provider{ - Schema: map[string]*schema.Schema{}, +func New() tfsdk.Provider { + return &provider{} +} + +var _ tfsdk.Provider = (*provider)(nil) - DataSourcesMap: map[string]*schema.Resource{ - "http": dataSource(), - }, +type provider struct{} + +func (p *provider) GetSchema(context.Context) (tfsdk.Schema, diag.Diagnostics) { + return tfsdk.Schema{}, nil +} + +func (p *provider) Configure(context.Context, tfsdk.ConfigureProviderRequest, *tfsdk.ConfigureProviderResponse) { +} + +func (p *provider) GetResources(context.Context) (map[string]tfsdk.ResourceType, diag.Diagnostics) { + return map[string]tfsdk.ResourceType{}, nil +} - ResourcesMap: map[string]*schema.Resource{}, - } +func (p *provider) GetDataSources(context.Context) (map[string]tfsdk.DataSourceType, diag.Diagnostics) { + return map[string]tfsdk.DataSourceType{ + "http": &httpDataSourceType{}, + }, nil } diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 8d591fda..361402a5 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -1,20 +1,13 @@ package provider import ( - "testing" - - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" ) -//nolint:unparam // error is always nil -func testProviders() map[string]func() (*schema.Provider, error) { - return map[string]func() (*schema.Provider, error){ - "http": func() (*schema.Provider, error) { return New(), nil }, - } -} - -func TestProvider(t *testing.T) { - if err := New().InternalValidate(); err != nil { - t.Fatalf("err: %s", err) +//nolint:unparam +func protoV6ProviderFactories() map[string]func() (tfprotov6.ProviderServer, error) { + return map[string]func() (tfprotov6.ProviderServer, error){ + "http": providerserver.NewProtocol6WithError(New()), } } diff --git a/main.go b/main.go index 22da7b58..2e238617 100644 --- a/main.go +++ b/main.go @@ -1,16 +1,36 @@ package main import ( - "github.com/hashicorp/terraform-plugin-sdk/v2/plugin" + "context" + "flag" + "log" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" "github.com/terraform-providers/terraform-provider-http/internal/provider" ) +// Run "go generate" to format example terraform files and generate the docs for the registry/website + +// If you do not have terraform installed, you can remove the formatting command, but its suggested to +// ensure the documentation is formatted properly. +//go:generate terraform fmt -recursive ./examples/ + // Run the docs generation tool, check its repository for more information on how it works and how docs // can be customized. //go:generate go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs func main() { - plugin.Serve(&plugin.ServeOpts{ - ProviderFunc: provider.New}) + var debug bool + + flag.BoolVar(&debug, "debug", false, "set to true to run the provider with support for debuggers like delve") + flag.Parse() + + err := providerserver.Serve(context.Background(), provider.New, providerserver.ServeOpts{ + Address: "registry.terraform.io/hashicorp/http", + Debug: debug, + }) + if err != nil { + log.Fatal(err) + } } diff --git a/templates/data-sources/http.md.tmpl b/templates/data-sources/http.md.tmpl new file mode 100644 index 00000000..418b1048 --- /dev/null +++ b/templates/data-sources/http.md.tmpl @@ -0,0 +1,30 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + +## Example Usage + +{{ tffile "examples/data-sources/http/data-source.tf" }} + +## Usage with Postcondition + +[Precondition and Postcondition](https://www.terraform.io/language/expressions/custom-conditions) +checks are available with Terraform v1.2.0 and later. + +{{ tffile "examples/data-sources/http/postcondition.tf" }} + +## Usage with Precondition + +[Precondition and Postcondition](https://www.terraform.io/language/expressions/custom-conditions) +checks are available with Terraform v1.2.0 and later. + +{{ tffile "examples/data-sources/http/precondition.tf" }} + +{{ .SchemaMarkdown | trimspace }} diff --git a/terraform-registry-manifest.json b/terraform-registry-manifest.json index a8286e38..6e86c621 100644 --- a/terraform-registry-manifest.json +++ b/terraform-registry-manifest.json @@ -1,6 +1,6 @@ { "version": 1, "metadata": { - "protocol_versions": ["5.0"] + "protocol_versions": ["6.0"] } } \ No newline at end of file