Repository: cloudflare/cloudflare-go (go)

Issue:
Cloudflare Zone Hold Support
### Current cloudflare-go version

v0.74.0

### Description

Add sdk support for creating/deleting holds:

https://developers.cloudflare.com/api/operations/zones-0-hold-post
https://developers.cloudflare.com/api/operations/zones-0-hold-get

### Use cases

Add or remove zone holds via sdk

### Potential cloudflare-go usage

Interact with the API via:
https://github.com/cloudflare/terraform-provider-cloudflare/pull/2671



### References

_No response_

Pull request:
Create Zone Hold functionality
## Description

Adds the ability to create a zone hold based on the hold API.

## Has your change been tested?

I called the apis manually. I am happy to write tests, but I'm not sure where to get started. I could stub the responses, but since this is basically just calling the raw api, im not sure what I would be testing (no additional logic).

## Screenshots (if appropriate):

## Types of changes

What sort of change does your code introduce/modify?

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)

## Checklist:

- [x] My code follows the code style of this project.
- [ ] My change requires a change to the documentation.
- [ ] I have updated the documentation accordingly.
- [x] I have added tests to cover my changes.
- [x] All new and existing tests passed.
- [x] This change is using publicly documented in [cloudflare/api-schemas](https://github.com/cloudflare/api-schemas) 
      and relies on stable APIs.

[1]: https://help.github.com/articles/closing-issues-using-keywords/
closes #1364 

Patch:
diff --git a/.changelog/1365.txt b/.changelog/1365.txt
new file mode 100644
index 000000000..05cd13708
--- /dev/null
+++ b/.changelog/1365.txt
@@ -0,0 +1,3 @@
+```release-note:enhancement
+zone_hold: add support for zone hold API
+```
diff --git a/zone_hold.go b/zone_hold.go
new file mode 100644
index 000000000..c7f30ccc4
--- /dev/null
+++ b/zone_hold.go
@@ -0,0 +1,111 @@
+package cloudflare
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"time"
+
+	"github.com/goccy/go-json"
+)
+
+// Retrieve whether the zone is subject to a zone hold, and metadata about the
+// hold.
+type ZoneHold struct {
+	Hold              *bool      `json:"hold,omitempty"`
+	IncludeSubdomains *bool      `json:"include_subdomains,omitempty"`
+	HoldAfter         *time.Time `json:"hold_after,omitempty"`
+}
+
+// ZoneHoldResponse represents a response from the Zone Hold endpoint.
+type ZoneHoldResponse struct {
+	Result ZoneHold `json:"result"`
+	Response
+	ResultInfo `json:"result_info"`
+}
+
+// CreateZoneHoldParams represents params for the Create Zone Hold
+// endpoint.
+type CreateZoneHoldParams struct {
+	IncludeSubdomains *bool `url:"include_subdomains,omitempty"`
+}
+
+// DeleteZoneHoldParams represents params for the Delete Zone Hold
+// endpoint.
+type DeleteZoneHoldParams struct {
+	HoldAfter *time.Time `url:"hold_after,omitempty"`
+}
+
+type GetZoneHoldParams struct{}
+
+// CreateZoneHold enforces a zone hold on the zone, blocking the creation and
+// activation of zone.
+//
+// API reference: https://developers.cloudflare.com/api/operations/zones-0-hold-post
+func (api *API) CreateZoneHold(ctx context.Context, rc *ResourceContainer, params CreateZoneHoldParams) (ZoneHold, error) {
+	if rc.Level != ZoneRouteLevel {
+		return ZoneHold{}, ErrRequiredZoneLevelResourceContainer
+	}
+
+	uri := buildURI(fmt.Sprintf("/zones/%s/hold", rc.Identifier), params)
+	res, err := api.makeRequestContext(ctx, http.MethodPost, uri, nil)
+	if err != nil {
+		return ZoneHold{}, err
+	}
+
+	response := &ZoneHoldResponse{}
+	err = json.Unmarshal(res, &response)
+	if err != nil {
+		return ZoneHold{}, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+
+	return response.Result, nil
+}
+
+// DeleteZoneHold removes enforcement of a zone hold on the zone, permanently or
+// temporarily, allowing the creation and activation of zones with this hostname.
+//
+// API reference:https://developers.cloudflare.com/api/operations/zones-0-hold-delete
+func (api *API) DeleteZoneHold(ctx context.Context, rc *ResourceContainer, params DeleteZoneHoldParams) (ZoneHold, error) {
+	if rc.Level != ZoneRouteLevel {
+		return ZoneHold{}, ErrRequiredZoneLevelResourceContainer
+	}
+
+	uri := buildURI(fmt.Sprintf("/zones/%s/hold", rc.Identifier), params)
+	res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil)
+	if err != nil {
+		return ZoneHold{}, err
+	}
+
+	response := &ZoneHoldResponse{}
+	err = json.Unmarshal(res, &response)
+	if err != nil {
+		return ZoneHold{}, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+
+	return response.Result, nil
+}
+
+// GetZoneHold retrieves whether the zone is subject to a zone hold, and the
+// metadata about the hold.
+//
+// API reference: https://developers.cloudflare.com/api/operations/zones-0-hold-get
+func (api *API) GetZoneHold(ctx context.Context, rc *ResourceContainer, params GetZoneHoldParams) (ZoneHold, error) {
+	if rc.Level != ZoneRouteLevel {
+		return ZoneHold{}, ErrRequiredZoneLevelResourceContainer
+	}
+
+	uri := fmt.Sprintf("/zones/%s/hold", rc.Identifier)
+	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
+	if err != nil {
+		return ZoneHold{}, err
+	}
+
+	response := &ZoneHoldResponse{}
+	err = json.Unmarshal(res, &response)
+	if err != nil {
+		return ZoneHold{}, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+
+	return response.Result, nil
+}


Test patch:
diff --git a/zone_hold_test.go b/zone_hold_test.go
new file mode 100644
index 000000000..6911e8060
--- /dev/null
+++ b/zone_hold_test.go
@@ -0,0 +1,124 @@
+package cloudflare
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestCreateZoneHold(t *testing.T) {
+	setup()
+	defer teardown()
+
+	mux.HandleFunc(fmt.Sprintf("/zones/%s/hold", testZoneID), func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, "true", r.URL.Query().Get("include_subdomains"))
+		assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method)
+		fmt.Fprint(w, `
+	{
+	  "success": true,
+	  "errors": [],
+	  "messages": [],
+	  "result": {
+		"hold": true,
+		"hold_after": "2023-03-20T15:12:32Z",
+		"include_subdomains": true
+	  }
+	}`)
+	})
+
+	_, err := client.CreateZoneHold(context.Background(), AccountIdentifier(""), CreateZoneHoldParams{})
+	if assert.Error(t, err) {
+		assert.Equal(t, ErrRequiredZoneLevelResourceContainer, err)
+	}
+
+	out, err := client.CreateZoneHold(context.Background(), ZoneIdentifier(testZoneID), CreateZoneHoldParams{IncludeSubdomains: BoolPtr(true)})
+	holdAfter, _ := time.Parse(time.RFC3339Nano, "2023-03-20T15:12:32Z")
+	want := ZoneHold{
+		IncludeSubdomains: BoolPtr(true),
+		Hold:              BoolPtr(true),
+		HoldAfter:         &holdAfter,
+	}
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, want, out)
+	}
+}
+
+func TestDeleteZoneHold(t *testing.T) {
+	setup()
+	defer teardown()
+
+	mux.HandleFunc(fmt.Sprintf("/zones/%s/hold", testZoneID), func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, "2023-03-20T15:12:32Z", r.URL.Query().Get("hold_after"))
+		assert.Equal(t, http.MethodDelete, r.Method, "Expected method 'DELETE', got %s", r.Method)
+		fmt.Fprint(w, `
+	{
+	  "success": true,
+	  "errors": [],
+	  "messages": [],
+	  "result": {
+		"hold": false,
+		"hold_after": "2023-03-20T15:12:32.025799Z",
+		"include_subdomains": false
+	  }
+	}`)
+	})
+
+	_, err := client.DeleteZoneHold(context.Background(), AccountIdentifier(""), DeleteZoneHoldParams{})
+	if assert.Error(t, err) {
+		assert.Equal(t, ErrRequiredZoneLevelResourceContainer, err)
+	}
+
+	holdAfter, _ := time.Parse(time.RFC3339, "2023-03-20T15:12:32.025799Z")
+	out, err := client.DeleteZoneHold(context.Background(), ZoneIdentifier(testZoneID), DeleteZoneHoldParams{HoldAfter: &holdAfter})
+	want := ZoneHold{
+		IncludeSubdomains: BoolPtr(false),
+		Hold:              BoolPtr(false),
+		HoldAfter:         &holdAfter,
+	}
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, want, out)
+	}
+}
+
+func TestGetZoneHold(t *testing.T) {
+	setup()
+	defer teardown()
+
+	mux.HandleFunc(fmt.Sprintf("/zones/%s/hold", testZoneID), func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method)
+		fmt.Fprint(w, `
+	{
+	  "success": true,
+	  "errors": [],
+	  "messages": [],
+	  "result": {
+		"hold": false,
+		"hold_after": "2023-03-20T15:12:32Z",
+		"include_subdomains": false
+	  }
+	}`)
+	})
+
+	_, err := client.GetZoneHold(context.Background(), AccountIdentifier(""), GetZoneHoldParams{})
+	if assert.Error(t, err) {
+		assert.Equal(t, ErrRequiredZoneLevelResourceContainer, err)
+	}
+
+	out, err := client.GetZoneHold(context.Background(), ZoneIdentifier(testZoneID), GetZoneHoldParams{})
+	holdAfter, _ := time.Parse(time.RFC3339Nano, "2023-03-20T15:12:32Z")
+	want := ZoneHold{
+		IncludeSubdomains: BoolPtr(false),
+		Hold:              BoolPtr(false),
+		HoldAfter:         &holdAfter,
+	}
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, want, out)
+	}
+}