From b217007f72391078f6f8136bf13b2fa087aec4db Mon Sep 17 00:00:00 2001 From: Mike Ball Date: Mon, 21 Apr 2025 05:48:39 -0700 Subject: [PATCH] feat: support provider-level host configuration This seeks to address issue #34 by offering the ability to specify provider-level host configuration & host-specific request headers. Signed-off-by: Mike Ball --- docs/index.md | 50 ++++++- examples/provider/provider_with_host.tf | 16 +++ internal/provider/data_source_http.go | 26 +++- internal/provider/data_source_http_test.go | 83 ++++++++++++ internal/provider/provider.go | 147 ++++++++++++++++++++- internal/provider/provider_test.go | 73 ++++++++++ templates/index.md.tmpl | 12 +- 7 files changed, 397 insertions(+), 10 deletions(-) create mode 100644 examples/provider/provider_with_host.tf diff --git a/docs/index.md b/docs/index.md index 50774f1f..efdcffb4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,5 +9,51 @@ description: |- 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. \ No newline at end of file +## Example Usage + +This provider requires no configuration, but accepts an optional `host` +configuration. + +### Providing host-specific configuration at the provider level + +```terraform +# Ensure an 'Accept: application/json' header is present on all +# checkpoint-api.hashicorp.com requests. +provider "http" { + # Optional host configuration + host { + name = "checkpoint-api.hashicorp.com" + + request_headers = { + Accept = "application/json" + } + } +} + +data "http" "example" { + url = "https://checkpoint-api.hashicorp.com/v1/check/terraform" +} +``` + + +## Schema + +### Optional + +- `host` (Block List) A host-specific provider configuration. (see [below for nested schema](#nestedblock--host)) + + +### Nested Schema for `host` + +Required: + +- `name` (String) The hostname for which the host configuration should +take affect. If the name matches an HTTP request URL's hostname, the provider's +host configuration takes affect (in addition to any data- or resource-specific +request configuration. + +Optional: + +- `request_headers` (Map of String, Sensitive) A map of request header field names and values to +include in HTTP requests if/when the request URL's hostname matches the provider +host configuration name. diff --git a/examples/provider/provider_with_host.tf b/examples/provider/provider_with_host.tf new file mode 100644 index 00000000..e5e847b6 --- /dev/null +++ b/examples/provider/provider_with_host.tf @@ -0,0 +1,16 @@ +# Ensure an 'Accept: application/json' header is present on all +# checkpoint-api.hashicorp.com requests. +provider "http" { + # Optional host configuration + host { + name = "checkpoint-api.hashicorp.com" + + request_headers = { + Accept = "application/json" + } + } +} + +data "http" "example" { + url = "https://checkpoint-api.hashicorp.com/v1/check/terraform" +} diff --git a/internal/provider/data_source_http.go b/internal/provider/data_source_http.go index 0ab7b095..298e4dfd 100644 --- a/internal/provider/data_source_http.go +++ b/internal/provider/data_source_http.go @@ -37,7 +37,9 @@ func NewHttpDataSource() datasource.DataSource { return &httpDataSource{} } -type httpDataSource struct{} +type httpDataSource struct { + provider *httpProvider +} func (d *httpDataSource) Metadata(_ context.Context, _ datasource.MetadataRequest, resp *datasource.MetadataResponse) { // This data source name unconventionally is equal to the provider name, @@ -47,6 +49,10 @@ func (d *httpDataSource) Metadata(_ context.Context, _ datasource.MetadataReques resp.TypeName = "http" } +func (d *httpDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + d.provider, resp.Diagnostics = toProvider(req.ProviderData) +} + func (d *httpDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { resp.Schema = schema.Schema{ Description: ` @@ -350,6 +356,24 @@ func (d *httpDataSource) Read(ctx context.Context, req datasource.ReadRequest, r } } + parsedRequestURL, err := url.Parse(requestURL) + if err != nil { + resp.Diagnostics.AddError( + "Error parsing URL", + "An unexpected error occurred while parsing the request URL: "+err.Error(), + ) + return + } + + if d.provider.Hostname == parsedRequestURL.Hostname() { + for name, header := range d.provider.RequestHeaders { + request.Header.Set(name, header) + if strings.ToLower(name) == "host" { + request.Host = header + } + } + } + response, err := retryClient.Do(request) if err != nil { target := &url.Error{} diff --git a/internal/provider/data_source_http_test.go b/internal/provider/data_source_http_test.go index 10dab105..52ca88b0 100644 --- a/internal/provider/data_source_http_test.go +++ b/internal/provider/data_source_http_test.go @@ -149,6 +149,89 @@ func TestDataSource_withAuthorizationRequestHeader_200(t *testing.T) { }) } +func TestDataSource_withAuthorizationRequestHeaderFromProvider_200(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "Zm9vOmJhcg==" { + w.Header().Set("Content-Type", "text/plain") + _, err := w.Write([]byte("1.0.0")) + if err != nil { + t.Errorf("error writing body: %s", err) + } + } else { + w.WriteHeader(http.StatusForbidden) + } + })) + defer testServer.Close() + + testServerURL, _ := url.Parse(testServer.URL) + testServerHostname := testServerURL.Hostname() + + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + provider "http" { + host { + name = "%s" + request_headers = { + "Authorization" = "Zm9vOmJhcg==" + } + } + } + data "http" "http_test" { + url = "%s" + }`, testServerHostname, testServer.URL), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.http.http_test", "response_body", "1.0.0"), + resource.TestCheckResourceAttr("data.http.http_test", "status_code", "200"), + resource.TestCheckNoResourceAttr("data.http.http_test", "request_headers"), + ), + }, + }, + }) +} + +func TestDataSource_withDifferentHostnameFromProvider_403(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "Zm9vOmJhcg==" { + w.Header().Set("Content-Type", "text/plain") + _, err := w.Write([]byte("1.0.0")) + if err != nil { + t.Errorf("error writing body: %s", err) + } + } else { + w.WriteHeader(http.StatusForbidden) + } + })) + defer testServer.Close() + + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + provider "http" { + host { + name = "host.com" + request_headers = { + "Authorization" = "Zm9vOmJhcg==" + } + } + } + data "http" "http_test" { + url = "%s" + }`, testServer.URL), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.http.http_test", "response_body", ""), + resource.TestCheckResourceAttr("data.http.http_test", "status_code", "403"), + resource.TestCheckNoResourceAttr("data.http.http_test", "request_headers"), + ), + }, + }, + }) +} + func TestDataSource_withAuthorizationRequestHeader_403(t *testing.T) { testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "Zm9vOmJhcg==" { diff --git a/internal/provider/provider.go b/internal/provider/provider.go index aa8a3f46..2100f120 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -5,28 +5,145 @@ package provider import ( "context" + "fmt" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" ) +type httpProviderConfig struct { +} + +type httpProvider struct { + Hostname string + RequestHeaders map[string]string +} + +var _ provider.Provider = (*httpProvider)(nil) + func New() provider.Provider { return &httpProvider{} } -var _ provider.Provider = (*httpProvider)(nil) +type httpProviderConfigModel struct { + Host types.List `tfsdk:"host"` +} -type httpProvider struct{} +type httpProviderHostConfigModel struct { + Name types.String `tfsdk:"name"` + RequestHeaders types.Map `tfsdk:"request_headers"` +} func (p *httpProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) { resp.TypeName = "http" } -func (p *httpProvider) Schema(context.Context, provider.SchemaRequest, *provider.SchemaResponse) { +func (p *httpProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Configures the HTTP provider", + Blocks: map[string]schema.Block{ + "host": schema.ListNestedBlock{ + Description: "A host-specific provider configuration.", + Validators: []validator.List{ + listvalidator.SizeBetween(0, 1), + }, + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: `The hostname for which the host configuration should +take affect. If the name matches an HTTP request URL's hostname, the provider's +host configuration takes affect (in addition to any data- or resource-specific +request configuration.`, + Required: true, + }, + "request_headers": schema.MapAttribute{ + Description: `A map of request header field names and values to +include in HTTP requests if/when the request URL's hostname matches the provider +host configuration name.`, + ElementType: types.StringType, + Optional: true, + Sensitive: true, + }, + }, + }, + }, + }, + } } -func (p *httpProvider) Configure(context.Context, provider.ConfigureRequest, *provider.ConfigureResponse) { +func (p *httpProvider) Configure(ctx context.Context, req provider.ConfigureRequest, res *provider.ConfigureResponse) { + tflog.Debug(ctx, "Configuring provider") + //p.resetConfig() + + // Ensure these response values are set before early returns, etc. + res.DataSourceData = p + res.ResourceData = p + + // Load configuration into the model + var conf httpProviderConfigModel + res.Diagnostics.Append(req.Config.Get(ctx, &conf)...) + if res.Diagnostics.HasError() { + return + } + if conf.Host.IsNull() || conf.Host.IsUnknown() || len(conf.Host.Elements()) == 0 { + tflog.Debug(ctx, "No host configuration detected; using provider defaults") + return + } + + // Load proxy configuration into model + hostConfSlice := make([]httpProviderHostConfigModel, 1) + res.Diagnostics.Append(conf.Host.ElementsAs(ctx, &hostConfSlice, true)...) + if res.Diagnostics.HasError() { + return + } + if len(hostConfSlice) != 1 { + res.Diagnostics.AddAttributeError( + path.Root("host"), + "Provider Proxy Configuration Handling Error", + "The provider failed to fully load the expected host configuration. "+ + "This is likely a bug in the Terraform Provider and should be reported to the provider developers.", + ) + return + } + hostConf := hostConfSlice[0] + tflog.Debug(ctx, "Loaded provider configuration") + + // Parse the host name + if !hostConf.Name.IsNull() && !hostConf.Name.IsUnknown() { + tflog.Debug(ctx, "Configuring host via name", map[string]interface{}{ + "name": hostConf.Name.ValueString(), + }) + + p.Hostname = hostConf.Name.ValueString() + } + + if !hostConf.RequestHeaders.IsNull() && !hostConf.RequestHeaders.IsUnknown() { + tflog.Debug(ctx, "Configuring request headers") + requestHeaders := map[string]string{} + for name, value := range hostConf.RequestHeaders.Elements() { + var header string + diags := tfsdk.ValueAs(ctx, value, &header) + res.Diagnostics.Append(diags...) + if res.Diagnostics.HasError() { + return + } + + requestHeaders[name] = header + } + + p.RequestHeaders = requestHeaders + } + + tflog.Debug(ctx, "Provider configured") } func (p *httpProvider) Resources(context.Context) []func() resource.Resource { @@ -38,3 +155,25 @@ func (p *httpProvider) DataSources(context.Context) []func() datasource.DataSour NewHttpDataSource, } } + +// toProvider casts a generic provider.Provider reference to this specific provider. +// This can be used in DataSourceType.NewDataSource and ResourceType.NewResource calls. +func toProvider(in any) (*httpProvider, diag.Diagnostics) { + if in == nil { + return nil, nil + } + + var diags diag.Diagnostics + p, ok := in.(*httpProvider) + if !ok { + diags.AddError( + "Unexpected Provider Instance Type", + fmt.Sprintf("While creating the data source or resource, an unexpected provider type (%T) was received. "+ + "This is likely a bug in the provider code and should be reported to the provider developers.", in, + ), + ) + return nil, diags + } + + return p, diags +} diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index cfe168e0..7de7a97b 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -4,8 +4,12 @@ package provider import ( + "regexp" + "testing" + "github.com/hashicorp/terraform-plugin-framework/providerserver" "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" ) //nolint:unparam @@ -14,3 +18,72 @@ func protoV5ProviderFactories() map[string]func() (tfprotov5.ProviderServer, err "http": providerserver.NewProtocol5WithError(New()), } } + +func TestProvider_InvalidHostConfig(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: ` + provider "http" { + host { + } + } + data "http" "test" { + url = "https://host.com" + } + `, + ExpectError: regexp.MustCompile(`The argument "name" is required, but no definition was found`), + }, + { + Config: ` + provider "http" { + host { + name = "host.com" + } + } + data "http" "test" { + url = "https://host.com" + } + `, + }, + { + Config: ` + provider "http" { + host { + name = "host.com" + request_headers = { + foo = "bar" + } + } + host { + name = "host.com" + request_headers = { + foo = "bar" + } + } + } + data "http" "test" { + url = "https://host.com" + } + `, + ExpectError: regexp.MustCompile(`Attribute host list must contain at least 0 elements and at most 1 elements`), + }, + { + Config: ` + provider "http" { + host { + name = "host.com" + request_headers = { + foo = "bar" + } + } + } + data "http" "test" { + url = "https://host.com" + } + `, + }, + }, + }) +} diff --git a/templates/index.md.tmpl b/templates/index.md.tmpl index c0fff439..5564858a 100644 --- a/templates/index.md.tmpl +++ b/templates/index.md.tmpl @@ -9,7 +9,13 @@ description: |- 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. +## Example Usage -{{- /* No schema in this provider, so no need for this: .SchemaMarkdown | trimspace */ -}} \ No newline at end of file +This provider requires no configuration, but accepts an optional `host` +configuration. + +### Providing host-specific configuration at the provider level + +{{ tffile "examples/provider/provider_with_host.tf" }} + +{{ .SchemaMarkdown | trimspace }}