Repository: cloudflare/cloudflare-go
Pull request #1270 (2023-04-18 19:47:45)

PA-2043 - Add support for regional hostnames
## Description

Adds methods which covers the Regional Hostname API calls documented here: https://developers.cloudflare.com/data-localization/regional-services/get-started/

## Has your change been tested?

- [x] Unit tests
- [x] Manual testing in staging environment 

## Types of changes

What sort of change does your code introduce/modify?

- [x] New feature (non-breaking change which adds functionality)

## 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 (api.cloudflare.com or developers.cloudflare.com) and stable APIs.

[1]: https://help.github.com/articles/closing-issues-using-keywords/



Patch:
diff --git a/.changelog/1270.txt b/.changelog/1270.txt
new file mode 100644
index 000000000..4c09f1f51
--- /dev/null
+++ b/.changelog/1270.txt
@@ -0,0 +1,3 @@
+```release-note:enhancement
+data localization: add support for regional hostnames API
+```
diff --git a/regional_hostnames.go b/regional_hostnames.go
new file mode 100644
index 000000000..72d6dcc11
--- /dev/null
+++ b/regional_hostnames.go
@@ -0,0 +1,190 @@
+package cloudflare
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"time"
+)
+
+type Region struct {
+	Key   string `json:"key"`
+	Label string `json:"label"`
+}
+
+type RegionalHostname struct {
+	Hostname  string     `json:"hostname"`
+	RegionKey string     `json:"region_key"`
+	CreatedOn *time.Time `json:"created_on,omitempty"`
+}
+
+// regionalHostnameResponse contains an API Response from a Create, Get, Update, or Delete call.
+type regionalHostnameResponse struct {
+	Response
+	Result RegionalHostname `json:"result"`
+}
+
+type ListDataLocalizationRegionsParams struct{}
+type ListDataLocalizationRegionalHostnamesParams struct{}
+
+type CreateDataLocalizationRegionalHostnameParams struct {
+	Hostname  string `json:"hostname"`
+	RegionKey string `json:"region_key"`
+}
+
+type UpdateDataLocalizationRegionalHostnameParams struct {
+	Hostname  string `json:"-"`
+	RegionKey string `json:"region_key"`
+}
+
+// ListDataLocalizationRegions lists all available regions.
+//
+// API reference: https://developers.cloudflare.com/data-localization/regional-services/get-started/#configure-regional-services-via-api
+func (api *API) ListDataLocalizationRegions(ctx context.Context, rc *ResourceContainer, params ListDataLocalizationRegionsParams) ([]Region, error) {
+	if rc.Level != AccountRouteLevel {
+		return []Region{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level)
+	}
+
+	if rc.Identifier == "" {
+		return []Region{}, ErrMissingAccountID
+	}
+
+	uri := fmt.Sprintf("/accounts/%s/addressing/regional_hostnames/regions", rc.Identifier)
+
+	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
+	if err != nil {
+		return []Region{}, err
+	}
+	result := struct {
+		Result []Region `json:"result"`
+	}{}
+	if err := json.Unmarshal(res, &result); err != nil {
+		return []Region{}, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+	return result.Result, nil
+}
+
+// ListDataLocalizationRegionalHostnames lists all regional hostnames for a zone.
+//
+// API reference: https://developers.cloudflare.com/data-localization/regional-services/get-started/#configure-regional-services-via-api
+func (api *API) ListDataLocalizationRegionalHostnames(ctx context.Context, rc *ResourceContainer, params ListDataLocalizationRegionalHostnamesParams) ([]RegionalHostname, error) {
+	if rc.Level != ZoneRouteLevel {
+		return []RegionalHostname{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level)
+	}
+
+	if rc.Identifier == "" {
+		return []RegionalHostname{}, ErrMissingZoneID
+	}
+
+	uri := fmt.Sprintf("/zones/%s/addressing/regional_hostnames", rc.Identifier)
+
+	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
+	if err != nil {
+		return []RegionalHostname{}, err
+	}
+	result := struct {
+		Result []RegionalHostname `json:"result"`
+	}{}
+	if err := json.Unmarshal(res, &result); err != nil {
+		return []RegionalHostname{}, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+	return result.Result, nil
+}
+
+// CreateDataLocalizationRegionalHostname lists all regional hostnames for a zone.
+//
+// API reference: https://developers.cloudflare.com/data-localization/regional-services/get-started/#configure-regional-services-via-api
+func (api *API) CreateDataLocalizationRegionalHostname(ctx context.Context, rc *ResourceContainer, params CreateDataLocalizationRegionalHostnameParams) (RegionalHostname, error) {
+	if rc.Level != ZoneRouteLevel {
+		return RegionalHostname{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level)
+	}
+
+	if rc.Identifier == "" {
+		return RegionalHostname{}, ErrMissingZoneID
+	}
+
+	uri := fmt.Sprintf("/zones/%s/addressing/regional_hostnames", rc.Identifier)
+
+	res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params)
+	if err != nil {
+		return RegionalHostname{}, err
+	}
+	result := regionalHostnameResponse{}
+	if err := json.Unmarshal(res, &result); err != nil {
+		return RegionalHostname{}, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+	return result.Result, nil
+}
+
+// GetDataLocalizationRegionalHostname returns the details of a specific regional hostname.
+//
+// API reference: https://developers.cloudflare.com/data-localization/regional-services/get-started/#configure-regional-services-via-api
+func (api *API) GetDataLocalizationRegionalHostname(ctx context.Context, rc *ResourceContainer, hostname string) (RegionalHostname, error) {
+	if rc.Level != ZoneRouteLevel {
+		return RegionalHostname{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level)
+	}
+
+	if rc.Identifier == "" {
+		return RegionalHostname{}, ErrMissingZoneID
+	}
+
+	uri := fmt.Sprintf("/zones/%s/addressing/regional_hostnames/%s", rc.Identifier, hostname)
+
+	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
+	if err != nil {
+		return RegionalHostname{}, err
+	}
+
+	result := regionalHostnameResponse{}
+	if err := json.Unmarshal(res, &result); err != nil {
+		return RegionalHostname{}, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+	return result.Result, nil
+}
+
+// UpdateDataLocalizationRegionalHostname returns the details of a specific regional hostname.
+//
+// API reference: https://developers.cloudflare.com/data-localization/regional-services/get-started/#configure-regional-services-via-api
+func (api *API) UpdateDataLocalizationRegionalHostname(ctx context.Context, rc *ResourceContainer, params UpdateDataLocalizationRegionalHostnameParams) (RegionalHostname, error) {
+	if rc.Level != ZoneRouteLevel {
+		return RegionalHostname{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level)
+	}
+
+	if rc.Identifier == "" {
+		return RegionalHostname{}, ErrMissingZoneID
+	}
+
+	uri := fmt.Sprintf("/zones/%s/addressing/regional_hostnames/%s", rc.Identifier, params.Hostname)
+
+	res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params)
+	if err != nil {
+		return RegionalHostname{}, err
+	}
+	result := regionalHostnameResponse{}
+	if err := json.Unmarshal(res, &result); err != nil {
+		return RegionalHostname{}, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+	return result.Result, nil
+}
+
+// DeleteDataLocalizationRegionalHostname deletes a regional hostname.
+//
+// API reference: https://developers.cloudflare.com/data-localization/regional-services/get-started/#configure-regional-services-via-api
+func (api *API) DeleteDataLocalizationRegionalHostname(ctx context.Context, rc *ResourceContainer, hostname string) error {
+	if rc.Level != ZoneRouteLevel {
+		return fmt.Errorf(errInvalidResourceContainerAccess, rc.Level)
+	}
+
+	if rc.Identifier == "" {
+		return ErrMissingZoneID
+	}
+
+	uri := fmt.Sprintf("/zones/%s/addressing/regional_hostnames/%s", rc.Identifier, hostname)
+
+	_, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil)
+	if err != nil {
+		return err
+	}
+	return nil
+}


Test patch:
diff --git a/regional_hostnames_test.go b/regional_hostnames_test.go
new file mode 100644
index 000000000..0391d3ccb
--- /dev/null
+++ b/regional_hostnames_test.go
@@ -0,0 +1,219 @@
+package cloudflare
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+const regionalHostname = "eu.example.com"
+
+func TestListRegions(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			"result": [
+				{
+				  "key": "ca",
+				  "label": "Canada"
+				},
+				{
+				  "key": "eu",
+				  "label": "Europe"
+				}
+			],
+			"success": true,
+			"errors": [],
+			"messages": []
+		}`)
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/addressing/regional_hostnames/regions", handler)
+
+	want := []Region{
+		{
+			Key:   "ca",
+			Label: "Canada",
+		},
+		{
+			Key:   "eu",
+			Label: "Europe",
+		},
+	}
+
+	actual, err := client.ListDataLocalizationRegions(context.Background(), AccountIdentifier(testAccountID), ListDataLocalizationRegionsParams{})
+	if assert.NoError(t, err) {
+		assert.Equal(t, want, actual)
+	}
+}
+
+func TestListRegionalHostnames(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+		  "result": [
+			{
+			  "hostname": "%s",
+			  "region_key": "ca",
+			  "created_on": "2023-01-14T00:47:57.060267Z"
+			}
+		  ],
+		  "success": true,
+		  "errors": [],
+		  "messages": []
+		}`, regionalHostname)
+	}
+
+	mux.HandleFunc("/zones/"+testZoneID+"/addressing/regional_hostnames", handler)
+
+	createdOn, _ := time.Parse(time.RFC3339, "2023-01-14T00:47:57.060267Z")
+	want := []RegionalHostname{
+		{
+			Hostname:  regionalHostname,
+			RegionKey: "ca",
+			CreatedOn: &createdOn,
+		},
+	}
+
+	actual, err := client.ListDataLocalizationRegionalHostnames(context.Background(), ZoneIdentifier(testZoneID), ListDataLocalizationRegionalHostnamesParams{})
+	if assert.NoError(t, err) {
+		assert.Equal(t, want, actual)
+	}
+}
+
+func TestCreateRegionalHostname(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+		  "result": {
+			  "hostname": "%s",
+			  "region_key": "ca",
+			  "created_on": "2023-01-14T00:47:57.060267Z"
+		  },
+		  "success": true,
+		  "errors": [],
+		  "messages": []
+		}`, regionalHostname)
+	}
+
+	mux.HandleFunc("/zones/"+testZoneID+"/addressing/regional_hostnames", handler)
+
+	params := CreateDataLocalizationRegionalHostnameParams{
+		RegionKey: "ca",
+		Hostname:  regionalHostname,
+	}
+
+	want := RegionalHostname{
+		RegionKey: "ca",
+		Hostname:  regionalHostname,
+	}
+
+	actual, err := client.CreateDataLocalizationRegionalHostname(context.Background(), ZoneIdentifier(testZoneID), params)
+	createdOn, _ := time.Parse(time.RFC3339, "2023-01-14T00:47:57.060267Z")
+	want.CreatedOn = &createdOn
+	if assert.NoError(t, err) {
+		assert.Equal(t, want, actual)
+	}
+}
+
+func TestGetRegionalHostname(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+		  "result": {
+			  "hostname": "%s",
+			  "region_key": "ca",
+			  "created_on": "2023-01-14T00:47:57.060267Z"
+		  },
+		  "success": true,
+		  "errors": [],
+		  "messages": []
+		}`, regionalHostname)
+	}
+
+	mux.HandleFunc("/zones/"+testZoneID+"/addressing/regional_hostnames/"+regionalHostname, handler)
+
+	actual, err := client.GetDataLocalizationRegionalHostname(context.Background(), ZoneIdentifier(testZoneID), regionalHostname)
+	createdOn, _ := time.Parse(time.RFC3339, "2023-01-14T00:47:57.060267Z")
+	want := RegionalHostname{
+		RegionKey: "ca",
+		Hostname:  regionalHostname,
+		CreatedOn: &createdOn,
+	}
+	if assert.NoError(t, err) {
+		assert.Equal(t, want, actual)
+	}
+}
+
+func TestUpdateRegionalHostname(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+		  "result": {
+			  "hostname": "%s",
+			  "region_key": "eu",
+			  "created_on": "2023-01-14T00:47:57.060267Z"
+		  },
+		  "success": true,
+		  "errors": [],
+		  "messages": []
+		}`, regionalHostname)
+	}
+
+	params := UpdateDataLocalizationRegionalHostnameParams{
+		RegionKey: "eu",
+		Hostname:  regionalHostname,
+	}
+
+	want := RegionalHostname{
+		RegionKey: "eu",
+		Hostname:  regionalHostname,
+	}
+
+	mux.HandleFunc("/zones/"+testZoneID+"/addressing/regional_hostnames/"+regionalHostname, handler)
+
+	actual, err := client.UpdateDataLocalizationRegionalHostname(context.Background(), ZoneIdentifier(testZoneID), params)
+	createdOn, _ := time.Parse(time.RFC3339, "2023-01-14T00:47:57.060267Z")
+	want.CreatedOn = &createdOn
+	if assert.NoError(t, err) {
+		assert.Equal(t, want, actual)
+	}
+}
+
+func TestDeleteRegionalHostname(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodDelete, r.Method, "Expected method 'DELETE', got %s", r.Method)
+	}
+
+	mux.HandleFunc("/zones/"+testZoneID+"/addressing/regional_hostnames/"+regionalHostname, handler)
+
+	err := client.DeleteDataLocalizationRegionalHostname(context.Background(), ZoneIdentifier(testZoneID), regionalHostname)
+	assert.NoError(t, err)
+}