Skip to content

Commit 63163d0

Browse files
Add delete-time destroy response templating for terracurl_request.
Resolve {response.<path>} placeholders from the stored create response at destroy time so APIs that return server-generated IDs can be deleted without Terraform self-references. Closes #125. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7d1c362 commit 63163d0

11 files changed

Lines changed: 1057 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## 2.9.0
2+
3+
ENHANCEMENTS:
4+
5+
- Add delete-time `{response.<path>}` templating in `terracurl_request` destroy URL, body, headers, and query parameters, resolved from the stored create response. Includes create-time validation and supports nested JSON paths. Closes #125.
6+
17
## 2.8.0
28

39
ENHANCEMENTS:

docs/guides/destroy_templating.md

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
---
2+
page_title: "Destroy Response Templating"
3+
subcategory: "Guides"
4+
description: |-
5+
Use {response.path} placeholders in destroy configuration to inject values from the stored create response at delete time.
6+
---
7+
8+
# Destroy Response Templating
9+
10+
Many REST APIs return a server-generated identifier when a resource is created. The delete API often requires that same identifier in the URL, request body, or query parameters. Terraform cannot reference a resource's own computed `response` attribute inside the same resource block, and TerraCurl reads destroy configuration from **stored state** at delete time rather than re-evaluating HCL expressions.
11+
12+
TerraCurl resolves **`{response.<path>}`** placeholders in destroy fields from the stored create response JSON when the resource is destroyed.
13+
14+
## When to use destroy templating
15+
16+
Use placeholders when:
17+
18+
- Create returns an ID (or other value) needed for delete
19+
- The value lives in the create response JSON at a known path
20+
- You manage create and destroy in a single `terracurl_request` resource
21+
22+
## Placeholder syntax
23+
24+
| Pattern | Meaning |
25+
|---------|---------|
26+
| `{response.id}` | Top-level `id` field in create response |
27+
| `{response.data.uuid}` | Nested field using dot-separated path |
28+
| `{response.items.0.id}` | Array element at index `0` |
29+
30+
`{response...}` refers to the **stored create response JSON**, not the Terraform resource `id` (which is always `name`).
31+
32+
Placeholders work in:
33+
34+
- `destroy_url`
35+
- `destroy_request_body` and `destroy_request_body_wo`
36+
- `destroy_request_parameters` (map values)
37+
- `destroy_headers` and `destroy_headers_wo` (map values)
38+
39+
If a string contains no `{response.` substring, it is passed through unchanged.
40+
41+
## Example: delete by URL path
42+
43+
```terraform
44+
resource "terracurl_request" "object" {
45+
name = "my-object"
46+
url = "https://api.example.com/objects"
47+
method = "POST"
48+
request_body = jsonencode({ name = "example" })
49+
response_codes = [201]
50+
skip_read = true
51+
skip_destroy = false
52+
53+
destroy_url = "https://api.example.com/objects/{response.id}"
54+
destroy_method = "DELETE"
55+
destroy_response_codes = [200, 204]
56+
}
57+
```
58+
59+
Create response `{"id":"abc-123"}` → destroy calls `DELETE .../objects/abc-123`.
60+
61+
## Example: nested response field
62+
63+
```terraform
64+
resource "terracurl_request" "object" {
65+
name = "my-object"
66+
url = "https://api.example.com/objects"
67+
method = "POST"
68+
request_body = jsonencode({ name = "example" })
69+
response_codes = [201]
70+
skip_read = true
71+
skip_destroy = false
72+
73+
destroy_url = "https://api.example.com/objects/{response.result.object_id}"
74+
destroy_method = "DELETE"
75+
destroy_response_codes = [200]
76+
}
77+
```
78+
79+
Create response:
80+
81+
```json
82+
{
83+
"status": "created",
84+
"result": {
85+
"object_id": "550e8400-e29b-41d4-a716-446655440000"
86+
}
87+
}
88+
```
89+
90+
## Example: delete by request body
91+
92+
```terraform
93+
resource "terracurl_request" "user" {
94+
name = "user"
95+
url = "https://api.example.com/users"
96+
method = "POST"
97+
request_body = jsonencode({ name = "john" })
98+
response_codes = [201]
99+
skip_read = true
100+
skip_destroy = false
101+
102+
destroy_url = "https://api.example.com/deactivate"
103+
destroy_method = "POST"
104+
destroy_request_body = jsonencode({ user_id = "{response.data.user.uuid}" })
105+
destroy_response_codes = [200]
106+
}
107+
```
108+
109+
## Create-time validation
110+
111+
After a successful create, TerraCurl validates that all `{response...}` placeholders in destroy configuration resolve against the create response. Mis-typed paths (for example `{response.uuid}` when the API returns `{response.id}`) fail at apply time instead of during a later destroy.
112+
113+
Validation is skipped when `skip_destroy = true`.
114+
115+
## Sensitive responses
116+
117+
When `response_sensitive = true`, placeholders resolve from `sensitive_response` in state. Template strings in destroy configuration remain in state as written; only the resolved values are sent on the wire at destroy time.
118+
119+
## Escaping literal braces
120+
121+
To include a literal `{response.id}` in a value without substitution, wrap the placeholder in double braces:
122+
123+
```text
124+
{{response.id}}
125+
```
126+
127+
This renders as `{response.id}` in the outbound request.
128+
129+
## Limitations
130+
131+
- Placeholders require a valid JSON create response stored in state
132+
- Imported resources without a stored response cannot use destroy templating
133+
- Paths must reference fields that remain after `ignore_response_fields` sanitization
134+
- Only scalar values (string, number, boolean) can be substituted; objects and arrays as whole values are not supported
135+
- Read-time templating for `read_url` / `read_request_body` is not supported in this release
136+
137+
## Related guides
138+
139+
- [Default Headers for Auth Tokens](default_headers) — refresh auth headers on every Terraform run, including destroy
140+
- [Write-Only Headers and Request Bodies](write_only) — secrets in destroy bodies without persisting them in state

docs/index.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,4 +204,55 @@ resource "terracurl_request" "example" {
204204
}
205205
```
206206

207+
## Destroy Response Templating
208+
209+
TerraCurl resolves `{response.<path>}` placeholders in destroy URL, body, headers, and query parameters from the stored create response at delete time. This supports APIs that return a server-generated ID needed for delete without Terraform self-references.
210+
211+
See the [Destroy Response Templating guide](guides/destroy_templating) for syntax, nested paths, body-based delete, and create-time validation.
212+
213+
```terraform
214+
resource "terracurl_request" "object" {
215+
name = "example-object"
216+
url = "https://api.example.com/objects"
217+
method = "POST"
218+
request_body = jsonencode({ name = "example" })
219+
response_codes = [201]
220+
skip_read = true
221+
skip_destroy = false
222+
223+
destroy_url = "https://api.example.com/objects/{response.id}"
224+
destroy_method = "DELETE"
225+
destroy_response_codes = [200, 204]
226+
}
227+
228+
resource "terracurl_request" "nested_object" {
229+
name = "nested-object"
230+
url = "https://api.example.com/objects"
231+
method = "POST"
232+
request_body = jsonencode({ name = "example" })
233+
response_codes = [201]
234+
skip_read = true
235+
skip_destroy = false
236+
237+
destroy_url = "https://api.example.com/objects/{response.data.object_id}"
238+
destroy_method = "DELETE"
239+
destroy_response_codes = [200]
240+
}
241+
242+
resource "terracurl_request" "body_destroy" {
243+
name = "body-destroy"
244+
url = "https://api.example.com/users"
245+
method = "POST"
246+
request_body = jsonencode({ name = "john" })
247+
response_codes = [201]
248+
skip_read = true
249+
skip_destroy = false
250+
251+
destroy_url = "https://api.example.com/deactivate"
252+
destroy_method = "POST"
253+
destroy_request_body = jsonencode({ user_id = "{response.data.user.uuid}" })
254+
destroy_response_codes = [200]
255+
}
256+
```
257+
207258
## Limitations

docs/resources/request.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,21 +34,21 @@ When `skip_read` is `false` and `read_url`, `read_method`, and `read_response_co
3434
- `destroy_ca_cert_file` (String) Path to a file on local disk that will be used to validate the certificate presented by the server for the destroy call
3535
- `destroy_cert_file` (String) Path to a file on local disk that contains the PEM-encoded certificate to present to the server for the destroy call
3636
- `destroy_digest_auth` (Attributes, Sensitive) HTTP Digest authentication credentials for the destroy request. Overrides provider `default_digest_auth` when configured. (see [below for nested schema](#nestedatt--destroy_digest_auth))
37-
- `destroy_headers` (Map of String) Map of headers to attach to the destroy API call. Host (case-insensitive) overrides the HTTP Host header sent on the wire, independent of the URL hostname.
37+
- `destroy_headers` (Map of String) Map of headers to attach to the destroy API call. Host (case-insensitive) overrides the HTTP Host header sent on the wire, independent of the URL hostname. Values support `{response.<path>}` placeholders resolved from the stored create response at destroy time.
3838
- `destroy_headers_wo` (Map of String, Sensitive, [Write-only](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments)) Write-only headers for the destroy call. Snapshotted in provider private state for use during destroy.
3939
- `destroy_headers_wo_version` (Number) Increment to trigger refreshing snapshotted `destroy_headers_wo` values.
4040
- `destroy_key_file` (String) Path to a file on local disk that contains the PEM-encoded private key for which the authentication certificate was issued for the destroy call
4141
- `destroy_max_retry` (Number) Maximum number of tries until it is marked as failed for the destroy call
4242
- `destroy_method` (String) Destroy HTTP method to use in the API call
43-
- `destroy_request_body` (String) A request body to attach to the destroy API call
43+
- `destroy_request_body` (String) A request body to attach to the destroy API call. Supports `{response.<path>}` placeholders resolved from the stored create response at destroy time.
4444
- `destroy_request_body_wo` (String, Sensitive, [Write-only](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments)) Write-only request body for the destroy call. Not stored in Terraform state.
4545
- `destroy_request_body_wo_version` (Number) Increment to trigger applying an updated `destroy_request_body_wo` value.
46-
- `destroy_request_parameters` (Map of String) Map of parameters to attach to the destroy API call
46+
- `destroy_request_parameters` (Map of String) Map of parameters to attach to the destroy API call. Values support `{response.<path>}` placeholders resolved from the stored create response at destroy time.
4747
- `destroy_response_codes` (List of String) A list of expected response codes for the destroy call
4848
- `destroy_retry_interval` (Number) Interval between each attempt for the destroy call
4949
- `destroy_skip_tls_verify` (Boolean) Set this to true to disable verification of the server's TLS certificate for the destroy call
5050
- `destroy_timeout` (Number) Time in seconds before each request times out for the destroy call. Defaults to 10
51-
- `destroy_url` (String) Destroy API endpoint to call
51+
- `destroy_url` (String) Destroy API endpoint to call. Supports `{response.<path>}` placeholders resolved from the stored create response at destroy time. See the [Destroy Response Templating guide](../guides/destroy_templating).
5252
- `digest_auth` (Attributes, Sensitive) HTTP Digest authentication credentials for the create request. Overrides provider `default_digest_auth` when configured. (see [below for nested schema](#nestedatt--digest_auth))
5353
- `headers` (Map of String) Map of headers to attach to the API call. Host (case-insensitive) overrides the HTTP Host header sent on the wire, independent of the URL hostname.
5454
- `headers_wo` (Map of String, Sensitive, [Write-only](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments)) Write-only headers for the create call. Not stored in Terraform state. Requires Terraform 1.11 or later.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
resource "terracurl_request" "object" {
2+
name = "example-object"
3+
url = "https://api.example.com/objects"
4+
method = "POST"
5+
request_body = jsonencode({ name = "example" })
6+
response_codes = [201]
7+
skip_read = true
8+
skip_destroy = false
9+
10+
destroy_url = "https://api.example.com/objects/{response.id}"
11+
destroy_method = "DELETE"
12+
destroy_response_codes = [200, 204]
13+
}
14+
15+
resource "terracurl_request" "nested_object" {
16+
name = "nested-object"
17+
url = "https://api.example.com/objects"
18+
method = "POST"
19+
request_body = jsonencode({ name = "example" })
20+
response_codes = [201]
21+
skip_read = true
22+
skip_destroy = false
23+
24+
destroy_url = "https://api.example.com/objects/{response.data.object_id}"
25+
destroy_method = "DELETE"
26+
destroy_response_codes = [200]
27+
}
28+
29+
resource "terracurl_request" "body_destroy" {
30+
name = "body-destroy"
31+
url = "https://api.example.com/users"
32+
method = "POST"
33+
request_body = jsonencode({ name = "john" })
34+
response_codes = [201]
35+
skip_read = true
36+
skip_destroy = false
37+
38+
destroy_url = "https://api.example.com/deactivate"
39+
destroy_method = "POST"
40+
destroy_request_body = jsonencode({ user_id = "{response.data.user.uuid}" })
41+
destroy_response_codes = [200]
42+
}

internal/provider/curl_resource.go

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ func (r *CurlResource) Schema(ctx context.Context, req resource.SchemaRequest, r
276276

277277
"destroy_url": schema.StringAttribute{
278278
Optional: true,
279-
MarkdownDescription: "Destroy API endpoint to call",
279+
MarkdownDescription: "Destroy API endpoint to call. Supports `{response.<path>}` placeholders resolved from the stored create response at destroy time. See the [Destroy Response Templating guide](../guides/destroy_templating).",
280280
PlanModifiers: []planmodifier.String{
281281
stringplanmodifier.RequiresReplace(),
282282
},
@@ -290,7 +290,7 @@ func (r *CurlResource) Schema(ctx context.Context, req resource.SchemaRequest, r
290290
},
291291
"destroy_request_body": schema.StringAttribute{
292292
Optional: true,
293-
MarkdownDescription: "A request body to attach to the destroy API call",
293+
MarkdownDescription: "A request body to attach to the destroy API call. Supports `{response.<path>}` placeholders resolved from the stored create response at destroy time.",
294294
PlanModifiers: []planmodifier.String{
295295
stringplanmodifier.RequiresReplace(),
296296
},
@@ -303,7 +303,7 @@ func (r *CurlResource) Schema(ctx context.Context, req resource.SchemaRequest, r
303303
"destroy_headers": schema.MapAttribute{
304304
ElementType: types.StringType,
305305
Optional: true,
306-
MarkdownDescription: "Map of headers to attach to the destroy API call." + hostHeaderMarkdownSuffix,
306+
MarkdownDescription: "Map of headers to attach to the destroy API call." + hostHeaderMarkdownSuffix + " Values support `{response.<path>}` placeholders resolved from the stored create response at destroy time.",
307307
PlanModifiers: []planmodifier.Map{
308308
mapplanmodifier.RequiresReplace(),
309309
},
@@ -317,7 +317,7 @@ func (r *CurlResource) Schema(ctx context.Context, req resource.SchemaRequest, r
317317
"destroy_request_parameters": schema.MapAttribute{
318318
ElementType: types.StringType,
319319
Optional: true,
320-
MarkdownDescription: "Map of parameters to attach to the destroy API call",
320+
MarkdownDescription: "Map of parameters to attach to the destroy API call. Values support `{response.<path>}` placeholders resolved from the stored create response at destroy time.",
321321
PlanModifiers: []planmodifier.Map{
322322
mapplanmodifier.RequiresReplace(),
323323
},
@@ -662,6 +662,14 @@ func (r *CurlResource) Create(ctx context.Context, req resource.CreateRequest, r
662662
data.RequestUrlString = types.StringValue(request.URL.String())
663663
setResourceResponseValues(&data, sanitizedResponse)
664664
data.StatusCode = types.StringValue(strconv.Itoa(statusCode))
665+
666+
if !data.SkipDestroy.ValueBool() {
667+
resp.Diagnostics.Append(validateDestroyTemplates(data)...)
668+
if resp.Diagnostics.HasError() {
669+
return
670+
}
671+
}
672+
665673
resp.Diagnostics.Append(snapshotWriteOnlyToPrivate(ctx, configModel, resp.Private)...)
666674
if resp.Diagnostics.HasError() {
667675
return
@@ -958,26 +966,31 @@ func (r *CurlResource) Delete(ctx context.Context, req resource.DeleteRequest, r
958966
return
959967
}
960968

969+
resolved, templateDiags := resolveDestroyTemplates(&data)
970+
resp.Diagnostics.Append(templateDiags...)
971+
if resp.Diagnostics.HasError() {
972+
return
973+
}
974+
961975
// Build Destroy Request
962976
var reqBody io.Reader
963-
destroyBody, usedWriteOnlyBody := resolveRequestBody(data.DestroyRequestBody, data.DestroyRequestBodyWo)
964-
if len(destroyBody) > 0 {
965-
reqBody = bytes.NewBuffer(destroyBody)
977+
if len(resolved.Body) > 0 {
978+
reqBody = bytes.NewBuffer(resolved.Body)
966979
}
967980

968-
request, err := http.NewRequest(data.DestroyMethod.ValueString(), data.DestroyUrl.ValueString(), reqBody)
981+
request, err := http.NewRequest(data.DestroyMethod.ValueString(), resolved.URL, reqBody)
969982
if err != nil {
970983
resp.Diagnostics.AddError("Destroy Error", fmt.Sprintf("Failed to create request: %s", err))
971984
return
972985
}
973986

974987
// Add Headers
975-
applyRequestHeadersWithWriteOnly(request, data.DestroyHeaders, data.DestroyHeadersWo, r.providerMeta())
988+
applyRequestHeadersWithWriteOnly(request, resolved.DestroyHeaders, resolved.DestroyHeadersWo, r.providerMeta())
976989

977990
// Add Query Parameters
978-
if !data.DestroyRequestParameters.IsNull() && !data.DestroyRequestParameters.IsUnknown() {
991+
if !resolved.DestroyParams.IsNull() && !resolved.DestroyParams.IsUnknown() {
979992
params := request.URL.Query()
980-
for k, v := range data.DestroyRequestParameters.Elements() {
993+
for k, v := range resolved.DestroyParams.Elements() {
981994
if strVal, ok := v.(types.String); ok {
982995
params.Add(k, strVal.ValueString())
983996
}
@@ -990,7 +1003,7 @@ func (r *CurlResource) Delete(ctx context.Context, req resource.DeleteRequest, r
9901003
retryInterval := time.Duration(data.DestroyRetryInterval.ValueInt64()) * time.Second
9911004
maxRetry := int(data.DestroyMaxRetry.ValueInt64())
9921005

993-
tflog.Debug(ctx, fmt.Sprintf("Resource destroy API Call: \nURL: %s\nHeaders: %s\nMethod: %s\nRequest Body: %s\n", request.URL.String(), request.Header, request.Method, requestBodyForLog(data.DestroyRequestBody, usedWriteOnlyBody)))
1006+
tflog.Debug(ctx, fmt.Sprintf("Resource destroy API Call: \nURL: %s\nHeaders: %s\nMethod: %s\nRequest Body: %s\n", request.URL.String(), request.Header, request.Method, requestBodyForLog(data.DestroyRequestBody, resolved.BodyUsedWriteOnly)))
9941007

9951008
var bodyBytes []byte
9961009
var statusCode int

0 commit comments

Comments
 (0)