diff --git a/README.md b/README.md index a6d1c30c..cc1903ec 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # Terraform Provider: HTTP The HTTP provider interacts with generic HTTP servers. -It provides a data source that issues an HTTP request exposing the response headers and body -for use within a Terraform deployment. +It provides: + +- a data source (`http`) that issues an HTTP request exposing the response headers and body. +- a resource (`http`) that performs an HTTP request during apply and stores response data in state. ## Documentation, questions and discussions diff --git a/docs/cdktf/python/resources/http.md b/docs/cdktf/python/resources/http.md new file mode 100644 index 00000000..9a4a93ca --- /dev/null +++ b/docs/cdktf/python/resources/http.md @@ -0,0 +1,233 @@ +--- +page_title: "http Resource - terraform-provider-http" +subcategory: "" +description: |- + The http resource makes an HTTP request to the given URL and exports + information about the response. + The given URL may be either an http or https URL. This resource + will issue a warning if the result is not UTF-8 encoded. + ~> 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. + By default, there are no retries. Configuring the retry block will result in + retries if an error is returned by the client (e.g., connection errors) or if + a 5xx-range (except 501) status code is received. For further details see + go-retryablehttp https://pkg.go.dev/github.com/hashicorp/go-retryablehttp. +--- + + + +# http (Resource) + +The `http` resource makes an HTTP request to the given URL and exports +information about the response. + +The given URL may be either an `http` or `https` URL. This resource +will issue a warning if the result is not UTF-8 encoded. + +~> **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. + +By default, there are no retries. Configuring the retry block will result in +retries if an error is returned by the client (e.g., connection errors) or if +a 5xx-range (except 501) status code is received. For further details see +[go-retryablehttp](https://pkg.go.dev/github.com/hashicorp/go-retryablehttp). + +## Example Usage + +```python +# DO NOT EDIT. Code generated by 'cdktf convert' - Please report bugs at https://cdk.tf/bug +from constructs import Construct +from cdktf import TerraformStack +# +# Provider bindings are generated by running `cdktf get`. +# See https://cdk.tf/provider-generation for more details. +# +from imports.http. import ResourceHttp +class MyConvertedCode(TerraformStack): + def __init__(self, scope, name): + super().__init__(scope, name) + ResourceHttp(self, "example", + request_headers=[{ + "Accept": "application/json" + } + ], + url="https://checkpoint-api.hashicorp.com/v1/check/terraform" + ) + ResourceHttp(self, "example_head", + method="HEAD", + url="https://checkpoint-api.hashicorp.com/v1/check/terraform" + ) + ResourceHttp(self, "example_post", + method="POST", + request_body="request body", + url="https://checkpoint-api.hashicorp.com/v1/check/terraform" + ) +``` + +## Controlling when the request is sent + +Use the resource argument `when` to control whether the HTTP request is executed during apply operations or only during destroy: + +- `apply` (default): request is executed during create and update. +- `destroy`: request is executed only during resource destruction. + +```python +# DO NOT EDIT. Code generated by 'cdktf convert' - Please report bugs at https://cdk.tf/bug +from constructs import Construct +from cdktf import TerraformStack +from imports.http. import ResourceHttp +class MyConvertedCode(TerraformStack): + def __init__(self, scope, name): + super().__init__(scope, name) + ResourceHttp(self, "cleanup", + method="DELETE", + url="https://api.example.com/cleanup", + when="destroy" + ) +``` + +## Usage with Postcondition + +Note: Pre/postconditions validate values during apply. They are only meaningful when the resource executes the HTTP request during apply, i.e., when `when = "apply"` (default). If `when = "destroy"`, these conditions will not evaluate against a fresh request result. + +```python +# DO NOT EDIT. Code generated by 'cdktf convert' - Please report bugs at https://cdk.tf/bug +from constructs import Construct +from cdktf import TerraformSelf, Fn, TerraformStack +# +# Provider bindings are generated by running `cdktf get`. +# See https://cdk.tf/provider-generation for more details. +# +from imports.http. import ResourceHttp +class MyConvertedCode(TerraformStack): + def __init__(self, scope, name): + super().__init__(scope, name) + ResourceHttp(self, "example", + lifecycle={ + "postcondition": [{ + "condition": Fn.contains([201, 204], + TerraformSelf.get_any("status_code")), + "error_message": "Status code invalid" + } + ] + }, + request_headers=[{ + "Accept": "application/json" + } + ], + url="https://checkpoint-api.hashicorp.com/v1/check/terraform", + when="apply" + ) +``` + +## Usage with Precondition + +Note: Pre/postconditions validate values during apply. They are only meaningful when the resource executes the HTTP request during apply, i.e., when `when = "apply"` (default). If `when = "destroy"`, these conditions will not evaluate against a fresh request result. + +```python +# DO NOT EDIT. Code generated by 'cdktf convert' - Please report bugs at https://cdk.tf/bug +from constructs import Construct +from cdktf import Fn, TerraformStack +# +# Provider bindings are generated by running `cdktf get`. +# See https://cdk.tf/provider-generation for more details. +# +from imports.http. import ResourceHttp +class MyConvertedCode(TerraformStack): + def __init__(self, scope, name): + super().__init__(scope, name) + http_example = ResourceHttp(self, "example", + request_headers=[{ + "Accept": "application/json" + } + ], + url="https://checkpoint-api.hashicorp.com/v1/check/terraform", + when="apply" + ) + http_example.add_override("lifecycle.precondition", [{ + "condition": Fn.contains([200, 201, 204], http_example.status_code), + "error_message": "Unexpected status code" + } + ]) +``` + +## Usage with Provisioner + +[Failure Behaviour](https://www.terraform.io/language/resources/provisioners/syntax#failure-behavior) +can be leveraged within a provisioner in order to raise an error and stop applying. + +```python +# DO NOT EDIT. Code generated by 'cdktf convert' - Please report bugs at https://cdk.tf/bug +from constructs import Construct +from cdktf import Fn, TerraformStack +# +# Provider bindings are generated by running `cdktf get`. +# See https://cdk.tf/provider-generation for more details. +# +from imports.null.resource import Resource +from imports.http. import ResourceHttp +class MyConvertedCode(TerraformStack): + def __init__(self, scope, name): + super().__init__(scope, name) + http_example = ResourceHttp(self, "example", + request_headers=[{ + "Accept": "application/json" + } + ], + url="https://checkpoint-api.hashicorp.com/v1/check/terraform" + ) + null_provider_resource_example = Resource(self, "example_1", + provisioners=[{ + "type": "local-exec", + "command": Fn.contains([201, 204], http_example.status_code) + } + ] + ) + # This allows the Terraform resource name to match the original name. You can remove the call if you don't need them to match. + null_provider_resource_example.override_logical_id("example") +``` + + +## Schema + +### Required + +- `url` (String) The URL for the request. Supported schemes are `http` and `https`. + +### Optional + +- `ca_cert_pem` (String) Certificate Authority (CA) in [PEM (RFC 1421)](https://datatracker.ietf.org/doc/html/rfc1421) format. +- `client_cert_pem` (String) Client certificate in [PEM (RFC 1421)](https://datatracker.ietf.org/doc/html/rfc1421) format. +- `client_key_pem` (String) Client key in [PEM (RFC 1421)](https://datatracker.ietf.org/doc/html/rfc1421) format. +- `insecure` (Boolean) Disables verification of the server's certificate chain and hostname. Defaults to `false` +- `method` (String) The HTTP Method for the request. Allowed methods are a subset of methods defined in [RFC7231](https://datatracker.ietf.org/doc/html/rfc7231#section-4.3) namely, `GET`, `HEAD`, and `POST`. `POST` support is only intended for read-only URLs, such as submitting a search. +- `request_body` (String) The request body as a string. +- `request_headers` (Map of String) A map of request header field names and values. +- `request_timeout_ms` (Number) The request timeout in milliseconds. +- `retry` (Block, Optional) Retry request configuration. By default there are no retries. Configuring this block will result in retries if an error is returned by the client (e.g., connection errors) or if a 5xx-range (except 501) status code is received. For further details see [go-retryablehttp](https://pkg.go.dev/github.com/hashicorp/go-retryablehttp). (see [below for nested schema](#nestedblock--retry)) +- `when` (String) When to send the HTTP request. Valid values are `apply` (default) and `destroy`. When set to `apply`, the request is sent during resource creation and updates. When set to `destroy`, the request is only sent during resource destruction. + +### Read-Only + +- `body` (String, Deprecated) The response body returned as a string. **NOTE**: This is deprecated, use `response_body` instead. +- `id` (String) The URL used for the request. +- `response_body` (String) The response body returned as a string. +- `response_body_base64` (String) The response body encoded as base64 (standard) as defined in [RFC 4648](https://datatracker.ietf.org/doc/html/rfc4648#section-4). +- `response_headers` (Map of String) A map of response header field names and values. Duplicate headers are concatenated according to [RFC2616](https://www.rfc-editor.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2). +- `status_code` (Number) The HTTP response status code. + + +### Nested Schema for `retry` + +Optional: + +- `attempts` (Number) The number of times the request is to be retried. For example, if 2 is specified, the request will be tried a maximum of 3 times. +- `max_delay_ms` (Number) The maximum delay between retry requests in milliseconds. +- `min_delay_ms` (Number) The minimum delay between retry requests in milliseconds. + + + diff --git a/docs/cdktf/typescript/resources/http.md b/docs/cdktf/typescript/resources/http.md new file mode 100644 index 00000000..e38be427 --- /dev/null +++ b/docs/cdktf/typescript/resources/http.md @@ -0,0 +1,257 @@ +--- +page_title: "http Resource - terraform-provider-http" +subcategory: "" +description: |- + The http resource makes an HTTP request to the given URL and exports + information about the response. + The given URL may be either an http or https URL. This resource + will issue a warning if the result is not UTF-8 encoded. + ~> 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. + By default, there are no retries. Configuring the retry block will result in + retries if an error is returned by the client (e.g., connection errors) or if + a 5xx-range (except 501) status code is received. For further details see + go-retryablehttp https://pkg.go.dev/github.com/hashicorp/go-retryablehttp. +--- + + + +# http (Resource) + +The `http` resource makes an HTTP request to the given URL and exports +information about the response. + +The given URL may be either an `http` or `https` URL. This resource +will issue a warning if the result is not UTF-8 encoded. + +~> **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. + +By default, there are no retries. Configuring the retry block will result in +retries if an error is returned by the client (e.g., connection errors) or if +a 5xx-range (except 501) status code is received. For further details see +[go-retryablehttp](https://pkg.go.dev/github.com/hashicorp/go-retryablehttp). + +## Example Usage + +```typescript +// DO NOT EDIT. Code generated by 'cdktf convert' - Please report bugs at https://cdk.tf/bug +import { Construct } from "constructs"; +import { TerraformStack } from "cdktf"; +/* + * Provider bindings are generated by running `cdktf get`. + * See https://cdk.tf/provider-generation for more details. + */ +import { ResourceHttp } from "./.gen/providers/http/"; +class MyConvertedCode extends TerraformStack { + constructor(scope: Construct, name: string) { + super(scope, name); + new ResourceHttp(this, "example", { + requestHeaders: [ + { + Accept: "application/json", + }, + ], + url: "https://checkpoint-api.hashicorp.com/v1/check/terraform", + }); + new ResourceHttp(this, "example_head", { + method: "HEAD", + url: "https://checkpoint-api.hashicorp.com/v1/check/terraform", + }); + new ResourceHttp(this, "example_post", { + method: "POST", + requestBody: "request body", + url: "https://checkpoint-api.hashicorp.com/v1/check/terraform", + }); + } +} + +``` + +## Controlling when the request is sent + +Use the resource argument `when` to control whether the HTTP request is executed during apply operations or only during destroy: + +- `apply` (default): request is executed during create and update. +- `destroy`: request is executed only during resource destruction. + +```typescript +// DO NOT EDIT. Code generated by 'cdktf convert' - Please report bugs at https://cdk.tf/bug +import { Construct } from "constructs"; +import { TerraformStack } from "cdktf"; +import { ResourceHttp } from "./.gen/providers/http/"; +class MyConvertedCode extends TerraformStack { + constructor(scope: Construct, name: string) { + super(scope, name); + new ResourceHttp(this, "cleanup", { + method: "DELETE", + url: "https://api.example.com/cleanup", + when: "destroy", + }); + } +} + +``` + +## Usage with Postcondition + +Note: Pre/postconditions validate values during apply. They are only meaningful when the resource executes the HTTP request during apply, i.e., when `when = "apply"` (default). If `when = "destroy"`, these conditions will not evaluate against a fresh request result. + +```typescript +// DO NOT EDIT. Code generated by 'cdktf convert' - Please report bugs at https://cdk.tf/bug +import { Construct } from "constructs"; +import { TerraformSelf, Fn, TerraformStack } from "cdktf"; +/* + * Provider bindings are generated by running `cdktf get`. + * See https://cdk.tf/provider-generation for more details. + */ +import { ResourceHttp } from "./.gen/providers/http/"; +class MyConvertedCode extends TerraformStack { + constructor(scope: Construct, name: string) { + super(scope, name); + new ResourceHttp(this, "example", { + lifecycle: { + postcondition: [ + { + condition: Fn.contains( + [201, 204], + TerraformSelf.getAny("status_code") + ), + errorMessage: "Status code invalid", + }, + ], + }, + requestHeaders: [ + { + Accept: "application/json", + }, + ], + url: "https://checkpoint-api.hashicorp.com/v1/check/terraform", + when: "apply", + }); + } +} + +``` + +## Usage with Precondition + +Note: Pre/postconditions validate values during apply. They are only meaningful when the resource executes the HTTP request during apply, i.e., when `when = "apply"` (default). If `when = "destroy"`, these conditions will not evaluate against a fresh request result. + +```typescript +// DO NOT EDIT. Code generated by 'cdktf convert' - Please report bugs at https://cdk.tf/bug +import { Construct } from "constructs"; +import { Fn, TerraformStack } from "cdktf"; +/* + * Provider bindings are generated by running `cdktf get`. + * See https://cdk.tf/provider-generation for more details. + */ +import { ResourceHttp } from "./.gen/providers/http/"; +class MyConvertedCode extends TerraformStack { + constructor(scope: Construct, name: string) { + super(scope, name); + const httpExample = new ResourceHttp(this, "example", { + requestHeaders: [ + { + Accept: "application/json", + }, + ], + url: "https://checkpoint-api.hashicorp.com/v1/check/terraform", + when: "apply", + }); + httpExample.addOverride("lifecycle.precondition", [ + { + condition: Fn.contains([200, 201, 204], httpExample.statusCode), + errorMessage: "Unexpected status code", + }, + ]); + } +} + +``` + +## Usage with Provisioner + +[Failure Behaviour](https://www.terraform.io/language/resources/provisioners/syntax#failure-behavior) +can be leveraged within a provisioner in order to raise an error and stop applying. + +```typescript +// DO NOT EDIT. Code generated by 'cdktf convert' - Please report bugs at https://cdk.tf/bug +import { Construct } from "constructs"; +import { Fn, TerraformStack } from "cdktf"; +/* + * Provider bindings are generated by running `cdktf get`. + * See https://cdk.tf/provider-generation for more details. + */ +import { Resource } from "./.gen/providers/null/resource"; +import { ResourceHttp } from "./.gen/providers/http/"; +class MyConvertedCode extends TerraformStack { + constructor(scope: Construct, name: string) { + super(scope, name); + const httpExample = new ResourceHttp(this, "example", { + requestHeaders: [ + { + Accept: "application/json", + }, + ], + url: "https://checkpoint-api.hashicorp.com/v1/check/terraform", + }); + const nullProviderResourceExample = new Resource(this, "example_1", { + provisioners: [ + { + type: "local-exec", + command: Fn.contains([201, 204], httpExample.statusCode), + }, + ], + }); + /*This allows the Terraform resource name to match the original name. You can remove the call if you don't need them to match.*/ + nullProviderResourceExample.overrideLogicalId("example"); + } +} + +``` + + +## Schema + +### Required + +- `url` (String) The URL for the request. Supported schemes are `http` and `https`. + +### Optional + +- `caCertPem` (String) Certificate Authority (CA) in [PEM (RFC 1421)](https://datatracker.ietf.org/doc/html/rfc1421) format. +- `client_cert_pem` (String) Client certificate in [PEM (RFC 1421)](https://datatracker.ietf.org/doc/html/rfc1421) format. +- `client_key_pem` (String) Client key in [PEM (RFC 1421)](https://datatracker.ietf.org/doc/html/rfc1421) format. +- `insecure` (Boolean) Disables verification of the server's certificate chain and hostname. Defaults to `false` +- `method` (String) The HTTP Method for the request. Allowed methods are a subset of methods defined in [RFC7231](https://datatracker.ietf.org/doc/html/rfc7231#section-4.3) namely, `GET`, `HEAD`, and `POST`. `POST` support is only intended for read-only URLs, such as submitting a search. +- `requestBody` (String) The request body as a string. +- `requestHeaders` (Map of String) A map of request header field names and values. +- `requestTimeoutMs` (Number) The request timeout in milliseconds. +- `retry` (Block, Optional) Retry request configuration. By default there are no retries. Configuring this block will result in retries if an error is returned by the client (e.g., connection errors) or if a 5xx-range (except 501) status code is received. For further details see [go-retryablehttp](https://pkg.go.dev/github.com/hashicorp/go-retryablehttp). (see [below for nested schema](#nestedblock--retry)) +- `when` (String) When to send the HTTP request. Valid values are `apply` (default) and `destroy`. When set to `apply`, the request is sent during resource creation and updates. When set to `destroy`, the request is only sent during resource destruction. + +### Read-Only + +- `body` (String, Deprecated) The response body returned as a string. **NOTE**: This is deprecated, use `responseBody` instead. +- `id` (String) The URL used for the request. +- `responseBody` (String) The response body returned as a string. +- `responseBodyBase64` (String) The response body encoded as base64 (standard) as defined in [RFC 4648](https://datatracker.ietf.org/doc/html/rfc4648#section-4). +- `responseHeaders` (Map of String) A map of response header field names and values. Duplicate headers are concatenated according to [RFC2616](https://www.rfc-editor.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2). +- `statusCode` (Number) The HTTP response status code. + + +### Nested Schema for `retry` + +Optional: + +- `attempts` (Number) The number of times the request is to be retried. For example, if 2 is specified, the request will be tried a maximum of 3 times. +- `maxDelayMs` (Number) The maximum delay between retry requests in milliseconds. +- `minDelayMs` (Number) The minimum delay between retry requests in milliseconds. + + + diff --git a/docs/resources/http.md b/docs/resources/http.md new file mode 100644 index 00000000..32e464b2 --- /dev/null +++ b/docs/resources/http.md @@ -0,0 +1,250 @@ +--- +page_title: "http Resource - terraform-provider-http" +subcategory: "" +description: |- + The http resource makes an HTTP request to the given URL and exports + information about the response. + The given URL may be either an http or https URL. This resource + will issue a warning if the result is not UTF-8 encoded. + ~> 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. + By default, there are no retries. Configuring the retry block will result in + retries if an error is returned by the client (e.g., connection errors) or if + a 5xx-range (except 501) status code is received. For further details see + go-retryablehttp https://pkg.go.dev/github.com/hashicorp/go-retryablehttp. +--- + +# http (Resource) + +The `http` resource makes an HTTP request to the given URL and exports +information about the response. + +The given URL may be either an `http` or `https` URL. This resource +will issue a warning if the result is not UTF-8 encoded. + +~> **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. + +By default, there are no retries. Configuring the retry block will result in +retries if an error is returned by the client (e.g., connection errors) or if +a 5xx-range (except 501) status code is received. For further details see +[go-retryablehttp](https://pkg.go.dev/github.com/hashicorp/go-retryablehttp). + +## Example Usage + +```terraform +# The following example shows how to issue an HTTP request supplying +# an optional request header. +resource "http" "example" { + url = "https://checkpoint-api.hashicorp.com/v1/check/terraform" + + # Optional request headers + request_headers = { + Accept = "application/json" + } +} + +# The following example shows how to issue an HTTP HEAD request. +resource "http" "example_head" { + url = "https://checkpoint-api.hashicorp.com/v1/check/terraform" + method = "HEAD" +} + +# The following example shows how to issue an HTTP POST request +# supplying an optional request body. +resource "http" "example_post" { + url = "https://checkpoint-api.hashicorp.com/v1/check/terraform" + method = "POST" + + # Optional request body + request_body = "request body" +} +``` + +## Controlling when the request is sent + +Use the resource argument `when` to control whether the HTTP request is executed during apply operations or only during destroy: + +- `apply` (default): request is executed during create and update. +- `destroy`: request is executed only during resource destruction. + +```terraform +# Example 1: HTTP request on apply (default behavior) +resource "http" "example_apply" { + url = "https://httpbin.org/get" + + request_headers = { + Accept = "application/json" + } + + # This is the default behavior - request is sent during apply + when = "apply" +} + +# Example 2: HTTP request only on destroy +resource "http" "example_destroy" { + url = "https://httpbin.org/delete" + method = "DELETE" + + request_headers = { + Accept = "application/json" + } + + # Request is only sent during resource destruction + when = "destroy" +} + +# Example 3: Default behavior (no when attribute specified) +resource "http" "example_default" { + url = "https://httpbin.org/get" + + request_headers = { + Accept = "application/json" + } + + # No "when" attribute specified - defaults to "apply" +} + +output "example_apply_status_code" { + value = http.example_apply.status_code +} + +output "example_destroy_status_code" { + value = http.example_destroy.status_code +} + +output "example_default_status_code" { + value = http.example_default.status_code +} +``` + +## Usage with Postcondition + +Note: Pre/postconditions validate values during apply. They are only meaningful when the resource executes the HTTP request during apply, i.e., when `when = "apply"` (default). If `when = "destroy"`, these conditions will not evaluate against a fresh request result. + +[Precondition and Postcondition](https://www.terraform.io/language/expressions/custom-conditions) +checks are available with Terraform v1.2.0 and later. + +```terraform +resource "http" "example_postcondition" { + url = "https://checkpoint-api.hashicorp.com/v1/check/terraform" + + request_headers = { + Accept = "application/json" + } + + # Ensure the request runs at apply for postconditions + when = "apply" + + lifecycle { + postcondition { + condition = contains([201, 204], self.status_code) + error_message = "Status code invalid" + } + } +} + +output "postcondition_status_code" { + value = http.example_postcondition.status_code +} +``` + +## Usage with Precondition + +Note: Pre/postconditions validate values during apply. They are only meaningful when the resource executes the HTTP request during apply, i.e., when `when = "apply"` (default). If `when = "destroy"`, these conditions will not evaluate against a fresh request result. + +[Precondition and Postcondition](https://www.terraform.io/language/expressions/custom-conditions) +checks are available with Terraform v1.2.0 and later. + +```terraform +resource "http" "example_precondition" { + url = "https://checkpoint-api.hashicorp.com/v1/check/terraform" + + request_headers = { + Accept = "application/json" + } + + # Ensure the request runs at apply for preconditions + when = "apply" + + lifecycle { + precondition { + condition = contains([200, 201, 204], self.status_code) + error_message = "Unexpected status code" + } + } +} + +output "precondition_status_code" { + value = http.example_precondition.status_code +} +``` + +## Usage with Provisioner + +[Failure Behaviour](https://www.terraform.io/language/resources/provisioners/syntax#failure-behavior) +can be leveraged within a provisioner in order to raise an error and stop applying. + +```terraform +resource "http" "example" { + url = "https://checkpoint-api.hashicorp.com/v1/check/terraform" + + request_headers = { + Accept = "application/json" + } +} + +resource "null_resource" "example" { + # On success, this will attempt to execute the true command in the + # shell environment running terraform. + # On failure, this will attempt to execute the false command in the + # shell environment running terraform. + provisioner "local-exec" { + command = contains([201, 204], http.example.status_code) + } +} +``` + + +## Schema + +### Required + +- `url` (String) The URL for the request. Supported schemes are `http` and `https`. + +### Optional + +- `ca_cert_pem` (String) Certificate Authority (CA) in [PEM (RFC 1421)](https://datatracker.ietf.org/doc/html/rfc1421) format. +- `client_cert_pem` (String) Client certificate in [PEM (RFC 1421)](https://datatracker.ietf.org/doc/html/rfc1421) format. +- `client_key_pem` (String) Client key in [PEM (RFC 1421)](https://datatracker.ietf.org/doc/html/rfc1421) format. +- `insecure` (Boolean) Disables verification of the server's certificate chain and hostname. Defaults to `false` +- `method` (String) The HTTP Method for the request. Allowed methods are a subset of methods defined in [RFC7231](https://datatracker.ietf.org/doc/html/rfc7231#section-4.3) namely, `GET`, `HEAD`, and `POST`. `POST` support is only intended for read-only URLs, such as submitting a search. +- `request_body` (String) The request body as a string. +- `request_headers` (Map of String) A map of request header field names and values. +- `request_timeout_ms` (Number) The request timeout in milliseconds. +- `retry` (Block, Optional) Retry request configuration. By default there are no retries. Configuring this block will result in retries if an error is returned by the client (e.g., connection errors) or if a 5xx-range (except 501) status code is received. For further details see [go-retryablehttp](https://pkg.go.dev/github.com/hashicorp/go-retryablehttp). (see [below for nested schema](#nestedblock--retry)) +- `when` (String) When to send the HTTP request. Valid values are `apply` (default) and `destroy`. When set to `apply`, the request is sent during resource creation and updates. When set to `destroy`, the request is only sent during resource destruction. + +### Read-Only + +- `body` (String, Deprecated) The response body returned as a string. **NOTE**: This is deprecated, use `response_body` instead. +- `id` (String) The URL used for the request. +- `response_body` (String) The response body returned as a string. +- `response_body_base64` (String) The response body encoded as base64 (standard) as defined in [RFC 4648](https://datatracker.ietf.org/doc/html/rfc4648#section-4). +- `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. + + +### Nested Schema for `retry` + +Optional: + +- `attempts` (Number) The number of times the request is to be retried. For example, if 2 is specified, the request will be tried a maximum of 3 times. +- `max_delay_ms` (Number) The maximum delay between retry requests in milliseconds. +- `min_delay_ms` (Number) The minimum delay between retry requests in milliseconds. + + diff --git a/examples/resources/http/postcondition.tf b/examples/resources/http/postcondition.tf new file mode 100644 index 00000000..00209649 --- /dev/null +++ b/examples/resources/http/postcondition.tf @@ -0,0 +1,23 @@ +resource "http" "example_postcondition" { + url = "https://checkpoint-api.hashicorp.com/v1/check/terraform" + + request_headers = { + Accept = "application/json" + } + + # Ensure the request runs at apply for postconditions + when = "apply" + + lifecycle { + postcondition { + condition = contains([201, 204], self.status_code) + error_message = "Status code invalid" + } + } +} + +output "postcondition_status_code" { + value = http.example_postcondition.status_code +} + + diff --git a/examples/resources/http/precondition.tf b/examples/resources/http/precondition.tf new file mode 100644 index 00000000..d96e2ede --- /dev/null +++ b/examples/resources/http/precondition.tf @@ -0,0 +1,23 @@ +resource "http" "example_precondition" { + url = "https://checkpoint-api.hashicorp.com/v1/check/terraform" + + request_headers = { + Accept = "application/json" + } + + # Ensure the request runs at apply for preconditions + when = "apply" + + lifecycle { + precondition { + condition = contains([200, 201, 204], self.status_code) + error_message = "Unexpected status code" + } + } +} + +output "precondition_status_code" { + value = http.example_precondition.status_code +} + + diff --git a/examples/resources/http/provisioner.tf b/examples/resources/http/provisioner.tf new file mode 100644 index 00000000..a8cfad21 --- /dev/null +++ b/examples/resources/http/provisioner.tf @@ -0,0 +1,17 @@ +resource "http" "example" { + url = "https://checkpoint-api.hashicorp.com/v1/check/terraform" + + request_headers = { + Accept = "application/json" + } +} + +resource "null_resource" "example" { + # On success, this will attempt to execute the true command in the + # shell environment running terraform. + # On failure, this will attempt to execute the false command in the + # shell environment running terraform. + provisioner "local-exec" { + command = contains([201, 204], http.example.status_code) + } +} diff --git a/examples/resources/http/resource.tf b/examples/resources/http/resource.tf new file mode 100644 index 00000000..26504bf6 --- /dev/null +++ b/examples/resources/http/resource.tf @@ -0,0 +1,26 @@ +# The following example shows how to issue an HTTP request supplying +# an optional request header. +resource "http" "example" { + url = "https://checkpoint-api.hashicorp.com/v1/check/terraform" + + # Optional request headers + request_headers = { + Accept = "application/json" + } +} + +# The following example shows how to issue an HTTP HEAD request. +resource "http" "example_head" { + url = "https://checkpoint-api.hashicorp.com/v1/check/terraform" + method = "HEAD" +} + +# The following example shows how to issue an HTTP POST request +# supplying an optional request body. +resource "http" "example_post" { + url = "https://checkpoint-api.hashicorp.com/v1/check/terraform" + method = "POST" + + # Optional request body + request_body = "request body" +} diff --git a/examples/resources/http/when_attribute.tf b/examples/resources/http/when_attribute.tf new file mode 100644 index 00000000..cd0d95bd --- /dev/null +++ b/examples/resources/http/when_attribute.tf @@ -0,0 +1,49 @@ +# Example 1: HTTP request on apply (default behavior) +resource "http" "example_apply" { + url = "https://httpbin.org/get" + + request_headers = { + Accept = "application/json" + } + + # This is the default behavior - request is sent during apply + when = "apply" +} + +# Example 2: HTTP request only on destroy +resource "http" "example_destroy" { + url = "https://httpbin.org/delete" + method = "DELETE" + + request_headers = { + Accept = "application/json" + } + + # Request is only sent during resource destruction + when = "destroy" +} + +# Example 3: Default behavior (no when attribute specified) +resource "http" "example_default" { + url = "https://httpbin.org/get" + + request_headers = { + Accept = "application/json" + } + + # No "when" attribute specified - defaults to "apply" +} + +output "example_apply_status_code" { + value = http.example_apply.status_code +} + +output "example_destroy_status_code" { + value = http.example_destroy.status_code +} + +output "example_default_status_code" { + value = http.example_default.status_code +} + + diff --git a/internal/provider/provider.go b/internal/provider/provider.go index aa8a3f46..381bb185 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -30,7 +30,9 @@ func (p *httpProvider) Configure(context.Context, provider.ConfigureRequest, *pr } func (p *httpProvider) Resources(context.Context) []func() resource.Resource { - return nil + return []func() resource.Resource{ + NewHttpResource, + } } func (p *httpProvider) DataSources(context.Context) []func() datasource.DataSource { diff --git a/internal/provider/resource_http.go b/internal/provider/resource_http.go new file mode 100644 index 00000000..9a1008ed --- /dev/null +++ b/internal/provider/resource_http.go @@ -0,0 +1,577 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + "unicode/utf8" + + "github.com/hashicorp/go-retryablehttp" + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + rs "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "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-framework/types/basetypes" + "golang.org/x/net/http/httpproxy" +) + +var _ resource.Resource = (*httpResource)(nil) + +func NewHttpResource() resource.Resource { + return &httpResource{} +} + +type httpResource struct{} + +func (r *httpResource) Metadata(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + // Resource name matches the data source name intentionally. + resp.TypeName = "http" +} + +func (r *httpResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = rs.Schema{ + Description: ` +The ` + "`http`" + ` resource makes an HTTP request to the given URL and exports +information about the response. + +The given URL may be either an ` + "`http`" + ` or ` + "`https`" + ` URL. This resource +will issue a warning if the result is not UTF-8 encoded. + +~> **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. + +By default, there are no retries. Configuring the retry block will result in +retries if an error is returned by the client (e.g., connection errors) or if +a 5xx-range (except 501) status code is received. For further details see +[go-retryablehttp](https://pkg.go.dev/github.com/hashicorp/go-retryablehttp). +`, + + Attributes: map[string]rs.Attribute{ + "id": rs.StringAttribute{ + Description: "The URL used for the request.", + Computed: true, + }, + + "url": rs.StringAttribute{ + Description: "The URL for the request. Supported schemes are `http` and `https`.", + Required: true, + }, + + "method": rs.StringAttribute{ + Description: "The HTTP Method for the request. " + + "Allowed methods are a subset of methods defined in [RFC7231](https://datatracker.ietf.org/doc/html/rfc7231#section-4.3) namely, " + + "`GET`, `HEAD`, and `POST`. `POST` support is only intended for read-only URLs, such as submitting a search.", + Optional: true, + Validators: []validator.String{ + stringvalidator.OneOf([]string{ + http.MethodGet, + http.MethodPost, + http.MethodHead, + }...), + }, + }, + + "request_headers": rs.MapAttribute{ + Description: "A map of request header field names and values.", + ElementType: types.StringType, + Optional: true, + }, + + "request_body": rs.StringAttribute{ + Description: "The request body as a string.", + Optional: true, + }, + + "request_timeout_ms": rs.Int64Attribute{ + Description: "The request timeout in milliseconds.", + Optional: true, + Validators: []validator.Int64{ + int64validator.AtLeast(1), + }, + }, + + "response_body": rs.StringAttribute{ + Description: "The response body returned as a string.", + Computed: true, + }, + + "body": rs.StringAttribute{ + Description: "The response body returned as a string. " + + "**NOTE**: This is deprecated, use `response_body` instead.", + Computed: true, + DeprecationMessage: "Use response_body instead", + }, + + "response_body_base64": rs.StringAttribute{ + Description: "The response body encoded as base64 (standard) as defined in [RFC 4648](https://datatracker.ietf.org/doc/html/rfc4648#section-4).", + Computed: true, + }, + + "ca_cert_pem": rs.StringAttribute{ + Description: "Certificate Authority (CA) " + + "in [PEM (RFC 1421)](https://datatracker.ietf.org/doc/html/rfc1421) format.", + Optional: true, + Validators: []validator.String{ + stringvalidator.ConflictsWith(path.MatchRoot("insecure")), + }, + }, + + "client_cert_pem": rs.StringAttribute{ + Description: "Client certificate " + + "in [PEM (RFC 1421)](https://datatracker.ietf.org/doc/html/rfc1421) format.", + Optional: true, + Validators: []validator.String{ + stringvalidator.AlsoRequires(path.MatchRoot("client_key_pem")), + }, + }, + + "client_key_pem": rs.StringAttribute{ + Description: "Client key " + + "in [PEM (RFC 1421)](https://datatracker.ietf.org/doc/html/rfc1421) format.", + Optional: true, + Validators: []validator.String{ + stringvalidator.AlsoRequires(path.MatchRoot("client_cert_pem")), + }, + }, + + "insecure": rs.BoolAttribute{ + Description: "Disables verification of the server's certificate chain and hostname. Defaults to `false`", + Optional: true, + }, + + "when": rs.StringAttribute{ + Description: "When to send the HTTP request. Valid values are `apply` (default) and `destroy`. " + + "When set to `apply`, the request is sent during resource creation and updates. " + + "When set to `destroy`, the request is only sent during resource destruction.", + Optional: true, + Validators: []validator.String{ + stringvalidator.OneOf([]string{ + "apply", + "destroy", + }...), + }, + }, + + "response_headers": rs.MapAttribute{ + 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).`, + ElementType: types.StringType, + Computed: true, + }, + + "status_code": rs.Int64Attribute{ + Description: `The HTTP response status code.`, + Computed: true, + }, + }, + + Blocks: map[string]rs.Block{ + "retry": rs.SingleNestedBlock{ + Description: "Retry request configuration. By default there are no retries. Configuring this block will result in " + + "retries if an error is returned by the client (e.g., connection errors) or if a 5xx-range (except 501) status code is received. " + + "For further details see [go-retryablehttp](https://pkg.go.dev/github.com/hashicorp/go-retryablehttp).", + Attributes: map[string]rs.Attribute{ + "attempts": rs.Int64Attribute{ + Description: "The number of times the request is to be retried. For example, if 2 is specified, the request will be tried a maximum of 3 times.", + Optional: true, + Validators: []validator.Int64{ + int64validator.AtLeast(0), + }, + }, + "min_delay_ms": rs.Int64Attribute{ + Description: "The minimum delay between retry requests in milliseconds.", + Optional: true, + Validators: []validator.Int64{ + int64validator.AtLeast(0), + }, + }, + "max_delay_ms": rs.Int64Attribute{ + Description: "The maximum delay between retry requests in milliseconds.", + Optional: true, + Validators: []validator.Int64{ + int64validator.AtLeast(0), + int64validator.AtLeastSumOf(path.MatchRelative().AtParent().AtName("min_delay_ms")), + }, + }, + }, + }, + }, + } +} + +func (r *httpResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { +} + +func (r *httpResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var model modelV1 + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Only perform request if "when" is set to "apply" (default behavior when not specified) + whenValue := "apply" + if !model.When.IsNull() && !model.When.IsUnknown() { + whenValue = model.When.ValueString() + } + + if whenValue == "apply" { + if err := r.performRequest(ctx, &model, &resp.Diagnostics); err != nil { + return + } + } else { + // Set default values for computed fields when not making request + model.ID = types.StringValue(model.URL.ValueString()) + + // Create an empty map for response headers + emptyHeaders := make(map[string]attr.Value) + model.ResponseHeaders = types.MapValueMust(types.StringType, emptyHeaders) + + model.ResponseBody = types.StringValue("") + model.Body = types.StringValue("") + model.ResponseBodyBase64 = types.StringValue("") + model.StatusCode = types.Int64Value(0) + } + + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) +} + +func (r *httpResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var model modelV1 + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + // No HTTP request is performed during read operations + // Ensure computed fields are properly set if they're null/unknown + if model.ID.IsNull() || model.ID.IsUnknown() { + model.ID = types.StringValue(model.URL.ValueString()) + } + if model.ResponseHeaders.IsNull() || model.ResponseHeaders.IsUnknown() { + emptyHeaders := make(map[string]attr.Value) + model.ResponseHeaders = types.MapValueMust(types.StringType, emptyHeaders) + } + if model.ResponseBody.IsNull() || model.ResponseBody.IsUnknown() { + model.ResponseBody = types.StringValue("") + } + if model.Body.IsNull() || model.Body.IsUnknown() { + model.Body = types.StringValue("") + } + if model.ResponseBodyBase64.IsNull() || model.ResponseBodyBase64.IsUnknown() { + model.ResponseBodyBase64 = types.StringValue("") + } + if model.StatusCode.IsNull() || model.StatusCode.IsUnknown() { + model.StatusCode = types.Int64Value(0) + } + + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) +} + +func (r *httpResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // Preserve computed fields across updates; reflect config changes and optionally perform request + var plan, state modelV1 + var diags diag.Diagnostics + + // Read desired configuration from plan + d := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(d...) + if resp.Diagnostics.HasError() { + return + } + + // Read prior state to retain computed fields when needed + d = req.State.Get(ctx, &state) + resp.Diagnostics.Append(d...) + if resp.Diagnostics.HasError() { + return + } + + whenValue := "apply" + if !plan.When.IsNull() && !plan.When.IsUnknown() { + whenValue = plan.When.ValueString() + } + + // Begin with desired config (plan) + model := plan + + if whenValue == "apply" { + if err := r.performRequest(ctx, &model, &resp.Diagnostics); err != nil { + return + } + } else { + // Keep previous computed fields when not issuing a request + model.ID = state.ID + model.ResponseHeaders = state.ResponseHeaders + model.ResponseBody = state.ResponseBody + model.Body = state.Body + model.ResponseBodyBase64 = state.ResponseBodyBase64 + model.StatusCode = state.StatusCode + } + + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) +} + +func (r *httpResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var model modelV1 + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Only perform request if "when" is set to "destroy" + whenValue := "apply" + if !model.When.IsNull() && !model.When.IsUnknown() { + whenValue = model.When.ValueString() + } + + if whenValue == "destroy" { + if err := r.performRequest(ctx, &model, &resp.Diagnostics); err != nil { + return + } + } +} + +func (r *httpResource) performRequest(ctx context.Context, model *modelV1, diags *diag.Diagnostics) error { + requestURL := model.URL.ValueString() + method := model.Method.ValueString() + requestHeaders := model.RequestHeaders + + if method == "" { + method = http.MethodGet + } + + caCertificate := model.CaCertificate + + tr, ok := http.DefaultTransport.(*http.Transport) + if !ok { + diags.AddError( + "Error configuring http transport", + "Error http: Can't configure http transport.", + ) + return fmt.Errorf("transport clone") + } + + clonedTr := tr.Clone() + + clonedTr.Proxy = func(req *http.Request) (*url.URL, error) { + return httpproxy.FromEnvironment().ProxyFunc()(req.URL) + } + + if clonedTr.TLSClientConfig == nil { + clonedTr.TLSClientConfig = &tls.Config{} + } + + if !model.Insecure.IsNull() { + if clonedTr.TLSClientConfig == nil { + clonedTr.TLSClientConfig = &tls.Config{} + } + clonedTr.TLSClientConfig.InsecureSkipVerify = model.Insecure.ValueBool() + } + + // Use `ca_cert_pem` cert pool + if !caCertificate.IsNull() { + caCertPool := x509.NewCertPool() + if ok := caCertPool.AppendCertsFromPEM([]byte(caCertificate.ValueString())); !ok { + diags.AddError( + "Error configuring TLS client", + "Error tls: Can't add the CA certificate to certificate pool. Only PEM encoded certificates are supported.", + ) + return fmt.Errorf("bad ca cert") + } + + if clonedTr.TLSClientConfig == nil { + clonedTr.TLSClientConfig = &tls.Config{} + } + clonedTr.TLSClientConfig.RootCAs = caCertPool + } + + if !model.ClientCert.IsNull() && !model.ClientKey.IsNull() { + cert, err := tls.X509KeyPair([]byte(model.ClientCert.ValueString()), []byte(model.ClientKey.ValueString())) + if err != nil { + diags.AddError( + "error creating x509 key pair", + fmt.Sprintf("error creating x509 key pair from provided pem blocks\n\nError: %s", err), + ) + return err + } + clonedTr.TLSClientConfig.Certificates = []tls.Certificate{cert} + } + + var retry retryModel + if !model.Retry.IsNull() && !model.Retry.IsUnknown() { + if d := model.Retry.As(ctx, &retry, basetypes.ObjectAsOptions{}); d.HasError() { + diags.Append(d...) + return fmt.Errorf("retry decode") + } + } + + retryClient := retryablehttp.NewClient() + retryClient.HTTPClient.Transport = clonedTr + + var timeout time.Duration + + if model.RequestTimeout.ValueInt64() > 0 { + timeout = time.Duration(model.RequestTimeout.ValueInt64()) * time.Millisecond + retryClient.HTTPClient.Timeout = timeout + } + + retryClient.Logger = levelledLogger{ctx} + retryClient.RetryMax = int(retry.Attempts.ValueInt64()) + + if !retry.MinDelay.IsNull() && !retry.MinDelay.IsUnknown() && retry.MinDelay.ValueInt64() >= 0 { + retryClient.RetryWaitMin = time.Duration(retry.MinDelay.ValueInt64()) * time.Millisecond + } + + if !retry.MaxDelay.IsNull() && !retry.MaxDelay.IsUnknown() && retry.MaxDelay.ValueInt64() >= 0 { + retryClient.RetryWaitMax = time.Duration(retry.MaxDelay.ValueInt64()) * time.Millisecond + } + + request, err := retryablehttp.NewRequestWithContext(ctx, method, requestURL, nil) + if err != nil { + diags.AddError( + "Error creating request", + fmt.Sprintf("Error creating request: %s", err), + ) + return err + } + + if !model.RequestBody.IsNull() { + err = request.SetBody(strings.NewReader(model.RequestBody.ValueString())) + + if err != nil { + diags.AddError( + "Error Setting Request Body", + "An unexpected error occurred while setting the request body: "+err.Error(), + ) + + return err + } + } + + for name, value := range requestHeaders.Elements() { + var header string + d := tfsdk.ValueAs(ctx, value, &header) + diags.Append(d...) + if diags.HasError() { + return fmt.Errorf("header decode") + } + + request.Header.Set(name, header) + if strings.ToLower(name) == "host" { + request.Host = header + } + } + + response, err := retryClient.Do(request) + if err != nil { + target := &url.Error{} + if errors.As(err, &target) { + if target.Timeout() { + detail := fmt.Sprintf("timeout error: %s", err) + + if timeout > 0 { + detail = fmt.Sprintf("request exceeded the specified timeout: %s, err: %s", timeout.String(), err) + } + + diags.AddError( + "Error making request", + detail, + ) + return err + } + } + + diags.AddError( + "Error making request", + fmt.Sprintf("Error making request: %s", err), + ) + return err + } + + defer response.Body.Close() + + bytes, err := io.ReadAll(response.Body) + if err != nil { + diags.AddError( + "Error reading response body", + fmt.Sprintf("Error reading response body: %s", err), + ) + return err + } + + if !utf8.Valid(bytes) { + diags.AddWarning( + "Response body is not recognized as UTF-8", + "Terraform may not properly handle the response_body if the contents are binary.", + ) + } + + responseBody := string(bytes) + responseBodyBase64Std := base64.StdEncoding.EncodeToString(bytes) + + responseHeaders := make(map[string]string) + for k, v := range response.Header { + // Concatenate according to RFC9110 https://www.rfc-editor.org/rfc/rfc9110.html#section-5.2 + responseHeaders[k] = strings.Join(v, ", ") + } + + respHeadersState, d := types.MapValueFrom(ctx, types.StringType, responseHeaders) + diags.Append(d...) + if diags.HasError() { + return fmt.Errorf("headers state") + } + + model.ID = types.StringValue(requestURL) + model.ResponseHeaders = respHeadersState + model.ResponseBody = types.StringValue(responseBody) + model.Body = types.StringValue(responseBody) + model.ResponseBodyBase64 = types.StringValue(responseBodyBase64Std) + model.StatusCode = types.Int64Value(int64(response.StatusCode)) + + return nil +} + +type modelV1 struct { + ID types.String `tfsdk:"id"` + URL types.String `tfsdk:"url"` + Method types.String `tfsdk:"method"` + RequestHeaders types.Map `tfsdk:"request_headers"` + RequestBody types.String `tfsdk:"request_body"` + RequestTimeout types.Int64 `tfsdk:"request_timeout_ms"` + Retry types.Object `tfsdk:"retry"` + When types.String `tfsdk:"when"` + ResponseHeaders types.Map `tfsdk:"response_headers"` + CaCertificate types.String `tfsdk:"ca_cert_pem"` + ClientCert types.String `tfsdk:"client_cert_pem"` + ClientKey types.String `tfsdk:"client_key_pem"` + Insecure types.Bool `tfsdk:"insecure"` + ResponseBody types.String `tfsdk:"response_body"` + Body types.String `tfsdk:"body"` + ResponseBodyBase64 types.String `tfsdk:"response_body_base64"` + StatusCode types.Int64 `tfsdk:"status_code"` +} diff --git a/internal/provider/resource_http_test.go b/internal/provider/resource_http_test.go new file mode 100644 index 00000000..66c8bf47 --- /dev/null +++ b/internal/provider/resource_http_test.go @@ -0,0 +1,302 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "fmt" + "net/http" + "net/http/httptest" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/tfversion" +) + +func TestResource_200(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.Header().Set("X-Single", "foobar") + w.Header().Add("X-Double", "1") + w.Header().Add("X-Double", "2") + _, _ = w.Write([]byte("1.0.0")) + })) + defer testServer.Close() + + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + resource "http" "http_test" { + url = "%s" + }`, testServer.URL), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("http.http_test", "response_body", "1.0.0"), + resource.TestCheckResourceAttr("http.http_test", "response_headers.Content-Type", "text/plain"), + resource.TestCheckResourceAttr("http.http_test", "response_headers.X-Single", "foobar"), + resource.TestCheckResourceAttr("http.http_test", "response_headers.X-Double", "1, 2"), + resource.TestCheckResourceAttr("http.http_test", "status_code", "200"), + ), + }, + }, + }) +} + +func TestResource_200_SlashInPath(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("1.0.0")) + })) + defer testServer.Close() + + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + resource "http" "http_test" { + url = "%s/200" + }`, testServer.URL), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("http.http_test", "response_body", "1.0.0"), + resource.TestCheckResourceAttr("http.http_test", "response_headers.Content-Type", "text/plain"), + resource.TestCheckResourceAttr("http.http_test", "status_code", "200"), + ), + }, + }, + }) +} + +func TestResource_404(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusNotFound) + })) + defer testServer.Close() + + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + resource "http" "http_test" { + url = "%s" + }`, testServer.URL), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("http.http_test", "response_body", ""), + resource.TestCheckResourceAttr("http.http_test", "status_code", "404"), + ), + }, + }, + }) +} + +func TestResource_withAuthorizationRequestHeader_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") + _, _ = w.Write([]byte("1.0.0")) + } else { + w.WriteHeader(http.StatusForbidden) + } + })) + defer testServer.Close() + + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + resource "http" "http_test" { + url = "%s" + + request_headers = { + "Authorization" = "Zm9vOmJhcg==" + } + }`, testServer.URL), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("http.http_test", "response_body", "1.0.0"), + resource.TestCheckResourceAttr("http.http_test", "status_code", "200"), + ), + }, + }, + }) +} + +func TestResource_POST_200(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("created")) + } else { + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + defer testServer.Close() + + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + resource "http" "http_test" { + url = "%s" + method = "POST" + + request_body = "request body" + }`, testServer.URL), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("http.http_test", "response_body", "created"), + resource.TestCheckResourceAttr("http.http_test", "status_code", "200"), + ), + }, + }, + }) +} + +func TestResource_Provisioner(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + })) + defer testServer.Close() + + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + ExternalProviders: map[string]resource.ExternalProvider{ + "null": { + VersionConstraint: "3.1.1", + Source: "hashicorp/null", + }, + }, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + resource "http" "http_test" { + url = "%s" + } + resource "null_resource" "example" { + provisioner "local-exec" { + command = contains([201, 204], http.http_test.status_code) + } + }`, testServer.URL), + ExpectError: regexp.MustCompile(`Error running command 'false': exit status 1. Output:`), + }, + { + Config: fmt.Sprintf(` + resource "http" "http_test" { + url = "%s" + } + resource "null_resource" "example" { + provisioner "local-exec" { + command = contains([200], http.http_test.status_code) + } + }`, testServer.URL), + Check: resource.TestCheckResourceAttr("http.http_test", "status_code", "200"), + }, + }, + }) +} + +func TestResource_x509cert_skip_on_tf_014(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-x509-ca-cert") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("pem")) + })) + defer testServer.Close() + + resource.ParallelTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBetween(tfversion.Version0_14_0, tfversion.Version0_15_0), + }, + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + resource "http" "http_test" { + url = "%s/x509-ca-cert/200" + }`, testServer.URL), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("http.http_test", "response_body", "pem"), + resource.TestCheckResourceAttr("http.http_test", "status_code", "200"), + ), + }, + }, + }) +} + +func TestResource_WhenAttribute_Apply(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("test response")) + })) + defer testServer.Close() + + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + resource "http" "http_test" { + url = "%s" + when = "apply" + }`, testServer.URL), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("http.http_test", "response_body", "test response"), + resource.TestCheckResourceAttr("http.http_test", "status_code", "200"), + ), + }, + }, + }) +} + +func TestResource_WhenAttribute_Destroy(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("test response")) + })) + defer testServer.Close() + + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + resource "http" "http_test" { + url = "%s" + when = "destroy" + }`, testServer.URL), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("http.http_test", "response_body", ""), + resource.TestCheckResourceAttr("http.http_test", "status_code", "0"), + ), + }, + }, + }) +} + +func TestResource_WhenAttribute_Default(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("test response")) + })) + defer testServer.Close() + + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + resource "http" "http_test" { + url = "%s" + }`, testServer.URL), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("http.http_test", "response_body", "test response"), + resource.TestCheckResourceAttr("http.http_test", "status_code", "200"), + ), + }, + }, + }) +} diff --git a/templates/resources/http.md.tmpl b/templates/resources/http.md.tmpl new file mode 100644 index 00000000..3ee0838d --- /dev/null +++ b/templates/resources/http.md.tmpl @@ -0,0 +1,52 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + +## Example Usage + +{{ tffile "examples/resources/http/resource.tf" }} + +## Controlling when the request is sent + +Use the resource argument `when` to control whether the HTTP request is executed during apply operations or only during destroy: + +- `apply` (default): request is executed during create and update. +- `destroy`: request is executed only during resource destruction. + +{{ tffile "examples/resources/http/when_attribute.tf" }} + +## Usage with Postcondition + +Note: Pre/postconditions validate values during apply. They are only meaningful when the resource executes the HTTP request during apply, i.e., when `when = "apply"` (default). If `when = "destroy"`, these conditions will not evaluate against a fresh request result. + +[Precondition and Postcondition](https://www.terraform.io/language/expressions/custom-conditions) +checks are available with Terraform v1.2.0 and later. + +{{ tffile "examples/resources/http/postcondition.tf" }} + +## Usage with Precondition + +Note: Pre/postconditions validate values during apply. They are only meaningful when the resource executes the HTTP request during apply, i.e., when `when = "apply"` (default). If `when = "destroy"`, these conditions will not evaluate against a fresh request result. + +[Precondition and Postcondition](https://www.terraform.io/language/expressions/custom-conditions) +checks are available with Terraform v1.2.0 and later. + +{{ tffile "examples/resources/http/precondition.tf" }} + +## Usage with Provisioner + +[Failure Behaviour](https://www.terraform.io/language/resources/provisioners/syntax#failure-behavior) +can be leveraged within a provisioner in order to raise an error and stop applying. + +{{ tffile "examples/resources/http/provisioner.tf" }} + +{{ .SchemaMarkdown | trimspace }} + +