Skip to content

Commit 9888738

Browse files
committed
feature(resource/http): add when attribute with destroy-time execution
- Introduce optional when attribute for http resource: apply (default) or destroy - Execute HTTP request on Create/Update when when = "apply" - Execute HTTP request only on Delete when when = "destroy" - Do not send HTTP requests on Read; preserve state on Update when when = "destroy" - Ensure all computed fields are set to known values when skipping requests (id, headers, body, body_base64, status_code) - Add tests for apply, destroy, and default behaviors - Update docs and examples to cover when semantics
1 parent 42c3547 commit 9888738

5 files changed

Lines changed: 417 additions & 166 deletions

File tree

docs/resources/http.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ The `http` resource makes an HTTP request to the given URL and exports informati
2323
## Example Usage
2424

2525
```terraform
26+
# Basic usage - request sent during apply (default behavior)
2627
resource "http" "example" {
2728
url = "https://checkpoint-api.hashicorp.com/v1/check/terraform"
2829
@@ -38,6 +39,15 @@ resource "http" "example" {
3839
# method = "POST"
3940
# request_body = "request body"
4041
}
42+
43+
# Request sent only during resource destruction
44+
resource "http" "cleanup" {
45+
url = "https://api.example.com/cleanup"
46+
method = "DELETE"
47+
48+
# Request is only sent when the resource is destroyed
49+
when = "destroy"
50+
}
4151
```
4252

4353
<!-- schema generated by tfplugindocs -->
@@ -58,6 +68,7 @@ resource "http" "example" {
5868
- `request_headers` (Map of String) A map of request header field names and values.
5969
- `request_timeout_ms` (Number) The request timeout in milliseconds.
6070
- `retry` (Block, Optional) Retry request configuration. (attempts, min_delay_ms, max_delay_ms)
71+
- `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.
6172

6273
### Read-Only
6374

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Example 1: HTTP request on apply (default behavior)
2+
resource "http" "example_apply" {
3+
url = "https://httpbin.org/get"
4+
5+
request_headers = {
6+
Accept = "application/json"
7+
}
8+
9+
# This is the default behavior - request is sent during apply
10+
when = "apply"
11+
}
12+
13+
# Example 2: HTTP request only on destroy
14+
resource "http" "example_destroy" {
15+
url = "https://httpbin.org/delete"
16+
method = "DELETE"
17+
18+
request_headers = {
19+
Accept = "application/json"
20+
}
21+
22+
# Request is only sent during resource destruction
23+
when = "destroy"
24+
}
25+
26+
# Example 3: Default behavior (no when attribute specified)
27+
resource "http" "example_default" {
28+
url = "https://httpbin.org/get"
29+
30+
request_headers = {
31+
Accept = "application/json"
32+
}
33+
34+
# No "when" attribute specified - defaults to "apply"
35+
}
36+
37+
output "example_apply_status_code" {
38+
value = http.example_apply.status_code
39+
}
40+
41+
output "example_destroy_status_code" {
42+
value = http.example_destroy.status_code
43+
}
44+
45+
output "example_default_status_code" {
46+
value = http.example_default.status_code
47+
}

internal/provider/data_source_http.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,20 @@ a 5xx-range (except 501) status code is received. For further details see
160160
Optional: true,
161161
},
162162

163+
"when": schema.StringAttribute{
164+
Description: "When to send the HTTP request. Valid values are `apply` (default) and `destroy`. " +
165+
"When set to `apply`, the request is sent during resource creation and updates. " +
166+
"When set to `destroy`, the request is only sent during resource destruction. " +
167+
"This attribute is only applicable to the http resource, not the data source.",
168+
Optional: true,
169+
Validators: []validator.String{
170+
stringvalidator.OneOf([]string{
171+
"apply",
172+
"destroy",
173+
}...),
174+
},
175+
},
176+
163177
"response_headers": schema.MapAttribute{
164178
Description: `A map of response header field names and values.` +
165179
` Duplicate headers are concatenated according to [RFC2616](https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2).`,
@@ -428,6 +442,7 @@ type modelV0 struct {
428442
RequestBody types.String `tfsdk:"request_body"`
429443
RequestTimeout types.Int64 `tfsdk:"request_timeout_ms"`
430444
Retry types.Object `tfsdk:"retry"`
445+
When types.String `tfsdk:"when"`
431446
ResponseHeaders types.Map `tfsdk:"response_headers"`
432447
CaCertificate types.String `tfsdk:"ca_cert_pem"`
433448
ClientCert types.String `tfsdk:"client_cert_pem"`

internal/provider/resource_http.go

Lines changed: 113 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/hashicorp/go-retryablehttp"
2121
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
2222
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
23+
"github.com/hashicorp/terraform-plugin-framework/attr"
2324
"github.com/hashicorp/terraform-plugin-framework/diag"
2425
"github.com/hashicorp/terraform-plugin-framework/path"
2526
"github.com/hashicorp/terraform-plugin-framework/resource"
@@ -157,6 +158,19 @@ func (r *httpResource) Schema(ctx context.Context, req resource.SchemaRequest, r
157158
Optional: true,
158159
},
159160

161+
"when": rs.StringAttribute{
162+
Description: "When to send the HTTP request. Valid values are `apply` (default) and `destroy`. " +
163+
"When set to `apply`, the request is sent during resource creation and updates. " +
164+
"When set to `destroy`, the request is only sent during resource destruction.",
165+
Optional: true,
166+
Validators: []validator.String{
167+
stringvalidator.OneOf([]string{
168+
"apply",
169+
"destroy",
170+
}...),
171+
},
172+
},
173+
160174
"response_headers": rs.MapAttribute{
161175
Description: `A map of response header field names and values.` +
162176
` Duplicate headers are concatenated according to [RFC2616](https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2).`,
@@ -214,9 +228,31 @@ func (r *httpResource) Create(ctx context.Context, req resource.CreateRequest, r
214228
if resp.Diagnostics.HasError() {
215229
return
216230
}
217-
if err := r.performRequest(ctx, &model, &resp.Diagnostics); err != nil {
218-
return
231+
232+
// Only perform request if "when" is set to "apply" (default behavior when not specified)
233+
whenValue := "apply"
234+
if !model.When.IsNull() && !model.When.IsUnknown() {
235+
whenValue = model.When.ValueString()
236+
}
237+
238+
if whenValue == "apply" {
239+
if err := r.performRequest(ctx, &model, &resp.Diagnostics); err != nil {
240+
return
241+
}
242+
} else {
243+
// Set default values for computed fields when not making request
244+
model.ID = types.StringValue(model.URL.ValueString())
245+
246+
// Create an empty map for response headers
247+
emptyHeaders := make(map[string]attr.Value)
248+
model.ResponseHeaders = types.MapValueMust(types.StringType, emptyHeaders)
249+
250+
model.ResponseBody = types.StringValue("")
251+
model.Body = types.StringValue("")
252+
model.ResponseBodyBase64 = types.StringValue("")
253+
model.StatusCode = types.Int64Value(0)
219254
}
255+
220256
diags = resp.State.Set(ctx, model)
221257
resp.Diagnostics.Append(diags...)
222258
}
@@ -228,26 +264,96 @@ func (r *httpResource) Read(ctx context.Context, req resource.ReadRequest, resp
228264
if resp.Diagnostics.HasError() {
229265
return
230266
}
267+
// No HTTP request is performed during read operations
268+
// Ensure computed fields are properly set if they're null/unknown
269+
if model.ID.IsNull() || model.ID.IsUnknown() {
270+
model.ID = types.StringValue(model.URL.ValueString())
271+
}
272+
if model.ResponseHeaders.IsNull() || model.ResponseHeaders.IsUnknown() {
273+
emptyHeaders := make(map[string]attr.Value)
274+
model.ResponseHeaders = types.MapValueMust(types.StringType, emptyHeaders)
275+
}
276+
if model.ResponseBody.IsNull() || model.ResponseBody.IsUnknown() {
277+
model.ResponseBody = types.StringValue("")
278+
}
279+
if model.Body.IsNull() || model.Body.IsUnknown() {
280+
model.Body = types.StringValue("")
281+
}
282+
if model.ResponseBodyBase64.IsNull() || model.ResponseBodyBase64.IsUnknown() {
283+
model.ResponseBodyBase64 = types.StringValue("")
284+
}
285+
if model.StatusCode.IsNull() || model.StatusCode.IsUnknown() {
286+
model.StatusCode = types.Int64Value(0)
287+
}
288+
231289
diags = resp.State.Set(ctx, model)
232290
resp.Diagnostics.Append(diags...)
233291
}
234292

235293
func (r *httpResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
236-
var model modelV0
237-
diags := req.Plan.Get(ctx, &model)
238-
resp.Diagnostics.Append(diags...)
294+
// Preserve computed fields across updates; reflect config changes and optionally perform request
295+
var plan, state modelV0
296+
var diags diag.Diagnostics
297+
298+
// Read desired configuration from plan
299+
d := req.Plan.Get(ctx, &plan)
300+
resp.Diagnostics.Append(d...)
239301
if resp.Diagnostics.HasError() {
240302
return
241303
}
242-
if err := r.performRequest(ctx, &model, &resp.Diagnostics); err != nil {
304+
305+
// Read prior state to retain computed fields when needed
306+
d = req.State.Get(ctx, &state)
307+
resp.Diagnostics.Append(d...)
308+
if resp.Diagnostics.HasError() {
243309
return
244310
}
311+
312+
whenValue := "apply"
313+
if !plan.When.IsNull() && !plan.When.IsUnknown() {
314+
whenValue = plan.When.ValueString()
315+
}
316+
317+
// Begin with desired config (plan)
318+
model := plan
319+
320+
if whenValue == "apply" {
321+
if err := r.performRequest(ctx, &model, &resp.Diagnostics); err != nil {
322+
return
323+
}
324+
} else {
325+
// Keep previous computed fields when not issuing a request
326+
model.ID = state.ID
327+
model.ResponseHeaders = state.ResponseHeaders
328+
model.ResponseBody = state.ResponseBody
329+
model.Body = state.Body
330+
model.ResponseBodyBase64 = state.ResponseBodyBase64
331+
model.StatusCode = state.StatusCode
332+
}
333+
245334
diags = resp.State.Set(ctx, model)
246335
resp.Diagnostics.Append(diags...)
247336
}
248337

249338
func (r *httpResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
250-
// No remote deletion; removing from state is sufficient.
339+
var model modelV0
340+
diags := req.State.Get(ctx, &model)
341+
resp.Diagnostics.Append(diags...)
342+
if resp.Diagnostics.HasError() {
343+
return
344+
}
345+
346+
// Only perform request if "when" is set to "destroy"
347+
whenValue := "apply"
348+
if !model.When.IsNull() && !model.When.IsUnknown() {
349+
whenValue = model.When.ValueString()
350+
}
351+
352+
if whenValue == "destroy" {
353+
if err := r.performRequest(ctx, &model, &resp.Diagnostics); err != nil {
354+
return
355+
}
356+
}
251357
}
252358

253359
func (r *httpResource) performRequest(ctx context.Context, model *modelV0, diags *diag.Diagnostics) error {

0 commit comments

Comments
 (0)