diff --git a/builtin/providers/http/data_source.go b/builtin/providers/http/data_source.go new file mode 100644 index 000000000..221de4adf --- /dev/null +++ b/builtin/providers/http/data_source.go @@ -0,0 +1,104 @@ +package http + +import ( + "fmt" + "io/ioutil" + "net/http" + "regexp" + "time" + + "github.com/hashicorp/terraform/helper/schema" +) + +func dataSource() *schema.Resource { + return &schema.Resource{ + Read: dataSourceRead, + + Schema: map[string]*schema.Schema{ + "url": &schema.Schema{ + Type: schema.TypeString, + Required: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + + "request_headers": &schema.Schema{ + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + + "body": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + } +} + +func dataSourceRead(d *schema.ResourceData, meta interface{}) error { + + url := d.Get("url").(string) + headers := d.Get("request_headers").(map[string]interface{}) + + client := &http.Client{} + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return fmt.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 fmt.Errorf("Error during making a request: %s", url) + } + + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("HTTP request error. Response code: %d", resp.StatusCode) + } + + contentType := resp.Header.Get("Content-Type") + if contentType == "" || isContentTypeAllowed(contentType) == false { + return fmt.Errorf("Content-Type is not a text type. Got: %s", contentType) + } + + bytes, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("Error while reading response body. %s", err) + } + + d.Set("body", string(bytes)) + d.SetId(time.Now().UTC().String()) + + return nil +} + +// This is to prevent potential issues w/ binary files +// and generally unprintable characters +// See https://github.com/hashicorp/terraform/pull/3858#issuecomment-156856738 +func isContentTypeAllowed(contentType string) bool { + allowedContentTypes := []*regexp.Regexp{ + regexp.MustCompile("^text/.+"), + regexp.MustCompile("^application/json$"), + } + + for _, r := range allowedContentTypes { + if r.MatchString(contentType) { + return true + } + } + + return false +} diff --git a/builtin/providers/http/data_source_test.go b/builtin/providers/http/data_source_test.go new file mode 100644 index 000000000..8ad73ce36 --- /dev/null +++ b/builtin/providers/http/data_source_test.go @@ -0,0 +1,166 @@ +package http + +import ( + "fmt" + "net/http" + "net/http/httptest" + "regexp" + "testing" + + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" +) + +type TestHttpMock struct { + server *httptest.Server +} + +const testDataSourceConfig_basic = ` +data "http" "http_test" { + url = "%s/meta_%d.txt" +} + +output "body" { + value = "${data.http.http_test.body}" +} +` + +func TestDataSource_http200(t *testing.T) { + testHttpMock := setUpMockHttpServer() + + defer testHttpMock.server.Close() + + resource.UnitTest(t, resource.TestCase{ + Providers: testProviders, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: fmt.Sprintf(testDataSourceConfig_basic, testHttpMock.server.URL, 200), + 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 != "1.0.0" { + return fmt.Errorf( + `'body' output is %s; want '1.0.0'`, + outputs["body"].Value, + ) + } + + return nil + }, + }, + }, + }) +} + +func TestDataSource_http404(t *testing.T) { + testHttpMock := setUpMockHttpServer() + + defer testHttpMock.server.Close() + + resource.UnitTest(t, resource.TestCase{ + Providers: testProviders, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: fmt.Sprintf(testDataSourceConfig_basic, testHttpMock.server.URL, 404), + ExpectError: regexp.MustCompile("HTTP request error. Response code: 404"), + }, + }, + }) +} + +const testDataSourceConfig_withHeaders = ` +data "http" "http_test" { + url = "%s/restricted/meta_%d.txt" + + request_headers = { + "Authorization" = "Zm9vOmJhcg==" + } +} + +output "body" { + value = "${data.http.http_test.body}" +} +` + +func TestDataSource_withHeaders200(t *testing.T) { + testHttpMock := setUpMockHttpServer() + + defer testHttpMock.server.Close() + + resource.UnitTest(t, resource.TestCase{ + Providers: testProviders, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: fmt.Sprintf(testDataSourceConfig_withHeaders, testHttpMock.server.URL, 200), + 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 != "1.0.0" { + return fmt.Errorf( + `'body' output is %s; want '1.0.0'`, + outputs["body"].Value, + ) + } + + return nil + }, + }, + }, + }) +} + +const testDataSourceConfig_error = ` +data "http" "http_test" { + +} +` + +func TestDataSource_compileError(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + Providers: testProviders, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testDataSourceConfig_error, + ExpectError: regexp.MustCompile("required field is not set"), + }, + }, + }) +} + +func setUpMockHttpServer() *TestHttpMock { + Server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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 == "/meta_404.txt" { + w.WriteHeader(http.StatusNotFound) + } else { + w.WriteHeader(http.StatusNotFound) + } + + w.Header().Add("Content-Type", "text/plain") + }), + ) + + return &TestHttpMock{ + server: Server, + } +} diff --git a/builtin/providers/http/provider.go b/builtin/providers/http/provider.go new file mode 100644 index 000000000..e11b68346 --- /dev/null +++ b/builtin/providers/http/provider.go @@ -0,0 +1,18 @@ +package http + +import ( + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/terraform" +) + +func Provider() terraform.ResourceProvider { + return &schema.Provider{ + Schema: map[string]*schema.Schema{}, + + DataSourcesMap: map[string]*schema.Resource{ + "http": dataSource(), + }, + + ResourcesMap: map[string]*schema.Resource{}, + } +} diff --git a/builtin/providers/http/provider_test.go b/builtin/providers/http/provider_test.go new file mode 100644 index 000000000..dd21abd45 --- /dev/null +++ b/builtin/providers/http/provider_test.go @@ -0,0 +1,18 @@ +package http + +import ( + "testing" + + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/terraform" +) + +var testProviders = map[string]terraform.ResourceProvider{ + "http": Provider(), +} + +func TestProvider(t *testing.T) { + if err := Provider().(*schema.Provider).InternalValidate(); err != nil { + t.Fatalf("err: %s", err) + } +} diff --git a/command/internal_plugin_list.go b/command/internal_plugin_list.go index 1b3302f0a..4f6ca9490 100644 --- a/command/internal_plugin_list.go +++ b/command/internal_plugin_list.go @@ -35,6 +35,7 @@ import ( googleprovider "github.com/hashicorp/terraform/builtin/providers/google" grafanaprovider "github.com/hashicorp/terraform/builtin/providers/grafana" herokuprovider "github.com/hashicorp/terraform/builtin/providers/heroku" + httpprovider "github.com/hashicorp/terraform/builtin/providers/http" icinga2provider "github.com/hashicorp/terraform/builtin/providers/icinga2" ignitionprovider "github.com/hashicorp/terraform/builtin/providers/ignition" influxdbprovider "github.com/hashicorp/terraform/builtin/providers/influxdb" @@ -115,6 +116,7 @@ var InternalProviders = map[string]plugin.ProviderFunc{ "google": googleprovider.Provider, "grafana": grafanaprovider.Provider, "heroku": herokuprovider.Provider, + "http": httpprovider.Provider, "icinga2": icinga2provider.Provider, "ignition": ignitionprovider.Provider, "influxdb": influxdbprovider.Provider, diff --git a/website/source/docs/providers/http/data_source.html.md b/website/source/docs/providers/http/data_source.html.md new file mode 100644 index 000000000..a833959a4 --- /dev/null +++ b/website/source/docs/providers/http/data_source.html.md @@ -0,0 +1,51 @@ +--- +layout: "http" +page_title: "HTTP Data Source" +sidebar_current: "docs-http-data-source" +description: |- + Retrieves the content at an HTTP or HTTPS URL. +--- + +# `http` Data Source + +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. + +## Example Usage + +```hcl +data "http" "example" { + url = "https://checkpoint-api.hashicorp.com/v1/check/terraform" + + # Optional request headers + request_headers { + "Accept" = "application/json" + } +} +``` + +## Argument Reference + +The following arguments are supported: + +* `url` - (Required) The URL to request data from. This URL must respond with + a `200 OK` response and a `text/*` or `application/json` Content-Type. + +* `request_headers` - (Optional) A map of strings representing additional HTTP + headers to include in the request. + +## Attributes Reference + +The following attributes are exported: + +* `body` - The raw body of the HTTP response. diff --git a/website/source/docs/providers/http/index.html.markdown b/website/source/docs/providers/http/index.html.markdown new file mode 100644 index 000000000..c6d37f46c --- /dev/null +++ b/website/source/docs/providers/http/index.html.markdown @@ -0,0 +1,15 @@ +--- +layout: "http" +page_title: "Provider: HTTP" +sidebar_current: "docs-http-index" +description: |- + The HTTP provider interacts with HTTP servers. +--- + +# HTTP Provider + +The HTTP provider is a utility provider for interacting with generic HTTP +servers as part of a Terraform configuration. + +This provider requires no configuration. For information on the resources +it provides, see the navigation bar. diff --git a/website/source/layouts/docs.erb b/website/source/layouts/docs.erb index c2b538e04..a945e8999 100644 --- a/website/source/layouts/docs.erb +++ b/website/source/layouts/docs.erb @@ -288,6 +288,10 @@ Heroku + > + HTTP + + > Icinga2 diff --git a/website/source/layouts/http.erb b/website/source/layouts/http.erb new file mode 100644 index 000000000..782cd70db --- /dev/null +++ b/website/source/layouts/http.erb @@ -0,0 +1,22 @@ +<% wrap_layout :inner do %> + <% content_for :sidebar do %> + + <% end %> + + <%= yield %> +<% end %>