Repository: cloudflare/cloudflare-go
Pull request #1232 (2023-03-09 22:43:13)

addressing: add support for Address Map resource
## Description

This pull request adds support for the public `Address Map` apis.

About `Address Map`: https://developers.cloudflare.com/byoip/proxy-ips/
The API docs: https://api.cloudflare.com/#ip-address-management-address-maps-properties

## 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.
- [x] 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.


github-actions[bot]: Oops! It looks like no changelog entry is attached to this PR. Please include a release note as described in https://github.com/cloudflare/cloudflare-go/blob/master/docs/changelog-process.md.

Example: 

~~~
```release-note:TYPE
Release note
```
~~~

If you do not require a release note to be included, please add the `workflow/skip-changelog-entry` label.
hunts: The `Coverage` action seems to be broken. @jacobbednarz . Is it a known issue?
jacobbednarz: I'll have to have a look into it as to where the comparison is happening but that's fine to ignore it for now. 

Patch:
diff --git a/.changelog/1232.txt b/.changelog/1232.txt
new file mode 100644
index 000000000..c8e3ce2bd
--- /dev/null
+++ b/.changelog/1232.txt
@@ -0,0 +1,3 @@
+```release-note:enhancement
+addressing: Add `Address Map` support
+```
diff --git a/README.md b/README.md
index 988d1a3ca..2a2635ca1 100644
--- a/README.md
+++ b/README.md
@@ -121,6 +121,6 @@ This library is starting to ship with experimental improvements that are not yet
 ready for production but will be introduced before the next major version. See
 [experimental README](/docs/experimental.md) for full details.
 
-# License
+## License
 
 BSD licensed. See the [LICENSE](LICENSE) file for details.
diff --git a/addressing_address_map.go b/addressing_address_map.go
new file mode 100644
index 000000000..9627c8a06
--- /dev/null
+++ b/addressing_address_map.go
@@ -0,0 +1,284 @@
+package cloudflare
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"time"
+)
+
+// AddressMap contains information about an address map.
+type AddressMap struct {
+	ID           string                 `json:"id"`
+	Description  *string                `json:"description,omitempty"`
+	DefaultSNI   *string                `json:"default_sni"`
+	Enabled      *bool                  `json:"enabled"`
+	Deletable    *bool                  `json:"can_delete"`
+	CanModifyIPs *bool                  `json:"can_modify_ips"`
+	Memberships  []AddressMapMembership `json:"memberships"`
+	IPs          []AddressMapIP         `json:"ips"`
+	CreatedAt    time.Time              `json:"created_at"`
+	ModifiedAt   time.Time              `json:"modified_at"`
+}
+
+type AddressMapIP struct {
+	IP        string    `json:"ip"`
+	CreatedAt time.Time `json:"created_at"`
+}
+
+type AddressMapMembershipContainer struct {
+	Identifier string                   `json:"identifier"`
+	Kind       AddressMapMembershipKind `json:"kind"`
+}
+
+type AddressMapMembership struct {
+	Identifier string                   `json:"identifier"`
+	Kind       AddressMapMembershipKind `json:"kind"`
+	Deletable  *bool                    `json:"can_delete"`
+	CreatedAt  time.Time                `json:"created_at"`
+}
+
+func (ammb *AddressMapMembershipContainer) URLFragment() string {
+	switch ammb.Kind {
+	case AddressMapMembershipAccount:
+		return fmt.Sprintf("accounts/%s", ammb.Identifier)
+	case AddressMapMembershipZone:
+		return fmt.Sprintf("zones/%s", ammb.Identifier)
+	default:
+		return fmt.Sprintf("%s/%s", ammb.Kind, ammb.Identifier)
+	}
+}
+
+type AddressMapMembershipKind string
+
+const (
+	AddressMapMembershipZone    AddressMapMembershipKind = "zone"
+	AddressMapMembershipAccount AddressMapMembershipKind = "account"
+)
+
+// ListAddressMapResponse contains a slice of address maps.
+type ListAddressMapResponse struct {
+	Response
+	Result []AddressMap `json:"result"`
+}
+
+// GetAddressMapResponse contains a specific address map's API Response.
+type GetAddressMapResponse struct {
+	Response
+	Result AddressMap `json:"result"`
+}
+
+// CreateAddressMapParams contains information about an address map to be created.
+type CreateAddressMapParams struct {
+	Description *string                         `json:"description"`
+	Enabled     *bool                           `json:"enabled"`
+	IPs         []string                        `json:"ips"`
+	Memberships []AddressMapMembershipContainer `json:"memberships"`
+}
+
+// UpdateAddressMapParams contains information about an address map to be updated.
+type UpdateAddressMapParams struct {
+	ID          string  `json:"id"`
+	Description *string `json:"description,omitempty"`
+	Enabled     *bool   `json:"enabled,omitempty"`
+	DefaultSNI  *string `json:"default_sni,omitempty"`
+}
+
+// AddressMapFilter contains filter parameters for finding a list of address maps.
+type ListAddressMapsParams struct {
+	IP   *string `url:"ip,omitempty"`
+	CIDR *string `url:"cidr,omitempty"`
+}
+
+// CreateIPAddressToAddressMapParams contains request parameters to add/remove IP address to/from an address map.
+type CreateIPAddressToAddressMapParams struct {
+	// ID represents the target address map for adding the IP address.
+	ID string
+	// The IP address.
+	IP string
+}
+
+// CreateMembershipToAddressMapParams contains request parameters to add/remove membership from an address map.
+type CreateMembershipToAddressMapParams struct {
+	// ID represents the target address map for adding the membershp.
+	ID         string
+	Membership AddressMapMembershipContainer
+}
+
+type DeleteMembershipFromAddressMapParams struct {
+	// ID represents the target address map for removing the IP address.
+	ID         string
+	Membership AddressMapMembershipContainer
+}
+
+type DeleteIPAddressFromAddressMapParams struct {
+	// ID represents the target address map for adding the membershp.
+	ID string
+	IP string
+}
+
+// ListAddressMaps lists all address maps owned by the account.
+//
+// API reference: https://developers.cloudflare.com/api/operations/ip-address-management-address-maps-list-address-maps
+func (api *API) ListAddressMaps(ctx context.Context, rc *ResourceContainer, params ListAddressMapsParams) ([]AddressMap, error) {
+	if rc.Level != AccountRouteLevel {
+		return []AddressMap{}, ErrRequiredAccountLevelResourceContainer
+	}
+
+	uri := buildURI(fmt.Sprintf("/%s/addressing/address_maps", rc.URLFragment()), params)
+	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
+	if err != nil {
+		return []AddressMap{}, err
+	}
+
+	result := ListAddressMapResponse{}
+	if err := json.Unmarshal(res, &result); err != nil {
+		return []AddressMap{}, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+
+	return result.Result, nil
+}
+
+// CreateAddressMap creates a new address map under the account.
+//
+// API reference: https://developers.cloudflare.com/api/operations/ip-address-management-address-maps-create-address-map
+func (api *API) CreateAddressMap(ctx context.Context, rc *ResourceContainer, params CreateAddressMapParams) (AddressMap, error) {
+	if rc.Level != AccountRouteLevel {
+		return AddressMap{}, ErrRequiredAccountLevelResourceContainer
+	}
+
+	uri := fmt.Sprintf("/%s/addressing/address_maps", rc.URLFragment())
+	res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params)
+	if err != nil {
+		return AddressMap{}, err
+	}
+
+	result := GetAddressMapResponse{}
+	if err := json.Unmarshal(res, &result); err != nil {
+		return AddressMap{}, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+
+	return result.Result, nil
+}
+
+// GetAddressMap returns a specific address map.
+//
+// API reference: https://developers.cloudflare.com/api/operations/ip-address-management-address-maps-address-map-details
+func (api *API) GetAddressMap(ctx context.Context, rc *ResourceContainer, id string) (AddressMap, error) {
+	if rc.Level != AccountRouteLevel {
+		return AddressMap{}, ErrRequiredAccountLevelResourceContainer
+	}
+
+	uri := fmt.Sprintf("/%s/addressing/address_maps/%s", rc.URLFragment(), id)
+	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
+	if err != nil {
+		return AddressMap{}, err
+	}
+
+	result := GetAddressMapResponse{}
+	if err := json.Unmarshal(res, &result); err != nil {
+		return AddressMap{}, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+
+	return result.Result, nil
+}
+
+// UpdateAddressMap modifies properties of an address map owned by the account.
+//
+// API reference: https://developers.cloudflare.com/api/operations/ip-address-management-address-maps-update-address-map
+func (api *API) UpdateAddressMap(ctx context.Context, rc *ResourceContainer, params UpdateAddressMapParams) (AddressMap, error) {
+	if rc.Level != AccountRouteLevel {
+		return AddressMap{}, ErrRequiredAccountLevelResourceContainer
+	}
+
+	uri := fmt.Sprintf("/%s/addressing/address_maps/%s", rc.URLFragment(), params.ID)
+	res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params)
+	if err != nil {
+		return AddressMap{}, err
+	}
+
+	result := GetAddressMapResponse{}
+	if err := json.Unmarshal(res, &result); err != nil {
+		return AddressMap{}, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+
+	return result.Result, nil
+}
+
+// DeleteAddressMap deletes a particular address map owned by the account.
+//
+// API reference: https://developers.cloudflare.com/api/operations/ip-address-management-address-maps-delete-address-map
+func (api *API) DeleteAddressMap(ctx context.Context, rc *ResourceContainer, id string) error {
+	if rc.Level != AccountRouteLevel {
+		return ErrRequiredAccountLevelResourceContainer
+	}
+
+	uri := fmt.Sprintf("/%s/addressing/address_maps/%s", rc.URLFragment(), id)
+	_, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil)
+	return err
+}
+
+// CreateIPAddressToAddressMap adds an IP address from a prefix owned by the account to a particular address map.
+//
+// API reference: https://developers.cloudflare.com/api/operations/ip-address-management-address-maps-add-an-ip-to-an-address-map
+func (api *API) CreateIPAddressToAddressMap(ctx context.Context, rc *ResourceContainer, params CreateIPAddressToAddressMapParams) error {
+	if rc.Level != AccountRouteLevel {
+		return ErrRequiredAccountLevelResourceContainer
+	}
+
+	uri := fmt.Sprintf("/%s/addressing/address_maps/%s/ips/%s", rc.URLFragment(), params.ID, params.IP)
+	_, err := api.makeRequestContext(ctx, http.MethodPut, uri, nil)
+	return err
+}
+
+// DeleteIPAddressFromAddressMap removes an IP address from a particular address map.
+//
+// API reference: https://developers.cloudflare.com/api/operations/ip-address-management-address-maps-remove-an-ip-from-an-address-map
+func (api *API) DeleteIPAddressFromAddressMap(ctx context.Context, rc *ResourceContainer, params DeleteIPAddressFromAddressMapParams) error {
+	if rc.Level != AccountRouteLevel {
+		return ErrRequiredAccountLevelResourceContainer
+	}
+
+	uri := fmt.Sprintf("/%s/addressing/address_maps/%s/ips/%s", rc.URLFragment(), params.ID, params.IP)
+	_, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil)
+	return err
+}
+
+// AddMembershipToAddressMap adds a zone/account as a member of a particular address map.
+//
+// API reference:
+//   - account: https://developers.cloudflare.com/api/operations/ip-address-management-address-maps-add-an-account-membership-to-an-address-map
+//   - zone: https://developers.cloudflare.com/api/operations/ip-address-management-address-maps-add-a-zone-membership-to-an-address-map
+func (api *API) CreateMembershipToAddressMap(ctx context.Context, rc *ResourceContainer, params CreateMembershipToAddressMapParams) error {
+	if rc.Level != AccountRouteLevel {
+		return ErrRequiredAccountLevelResourceContainer
+	}
+
+	if params.Membership.Kind != AddressMapMembershipZone && params.Membership.Kind != AddressMapMembershipAccount {
+		return fmt.Errorf("requested membershp kind (%q) is not supported", params.Membership.Kind)
+	}
+
+	uri := fmt.Sprintf("/%s/addressing/address_maps/%s/%s", rc.URLFragment(), params.ID, params.Membership.URLFragment())
+	_, err := api.makeRequestContext(ctx, http.MethodPut, uri, nil)
+	return err
+}
+
+// DeleteMembershipFromAddressMap removes a zone/account as a member of a particular address map.
+//
+// API reference:
+//   - account: https://developers.cloudflare.com/api/operations/ip-address-management-address-maps-remove-an-account-membership-from-an-address-map
+//   - zone: https://developers.cloudflare.com/api/operations/ip-address-management-address-maps-remove-a-zone-membership-from-an-address-map
+func (api *API) DeleteMembershipFromAddressMap(ctx context.Context, rc *ResourceContainer, params DeleteMembershipFromAddressMapParams) error {
+	if rc.Level != AccountRouteLevel {
+		return ErrRequiredAccountLevelResourceContainer
+	}
+
+	if params.Membership.Kind != AddressMapMembershipZone && params.Membership.Kind != AddressMapMembershipAccount {
+		return fmt.Errorf("requested membershp kind (%q) is not supported", params.Membership.Kind)
+	}
+
+	uri := fmt.Sprintf("/%s/addressing/address_maps/%s/%s", rc.URLFragment(), params.ID, params.Membership.URLFragment())
+	_, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil)
+	return err
+}
diff --git a/ip_address_management.go b/addressing_ip_prefix.go
similarity index 100%
rename from ip_address_management.go
rename to addressing_ip_prefix.go
diff --git a/ip_address_management_test.go b/addressing_ip_prefix_test.go
similarity index 100%
rename from ip_address_management_test.go
rename to addressing_ip_prefix_test.go
diff --git a/convert_types.go b/convert_types.go
index 1d45d3455..f3ebc83d0 100644
--- a/convert_types.go
+++ b/convert_types.go
@@ -24,24 +24,24 @@ import (
 //
 // Usage: var _ *Type = AnyPtr(Type(value) | value).(*Type)
 //
-//  var _ *bool = AnyPtr(true).(*bool)
-//  var _ *byte = AnyPtr(byte(1)).(*byte)
-//  var _ *complex64 = AnyPtr(complex64(1.1)).(*complex64)
-//  var _ *complex128 = AnyPtr(complex128(1.1)).(*complex128)
-//  var _ *float32 = AnyPtr(float32(1.1)).(*float32)
-//  var _ *float64 = AnyPtr(float64(1.1)).(*float64)
-//  var _ *int = AnyPtr(int(1)).(*int)
-//  var _ *int8 = AnyPtr(int8(8)).(*int8)
-//  var _ *int16 = AnyPtr(int16(16)).(*int16)
-//  var _ *int32 = AnyPtr(int32(32)).(*int32)
-//  var _ *int64 = AnyPtr(int64(64)).(*int64)
-//  var _ *rune = AnyPtr(rune(1)).(*rune)
-//  var _ *string = AnyPtr("ptr").(*string)
-//  var _ *uint = AnyPtr(uint(1)).(*uint)
-//  var _ *uint8 = AnyPtr(uint8(8)).(*uint8)
-//  var _ *uint16 = AnyPtr(uint16(16)).(*uint16)
-//  var _ *uint32 = AnyPtr(uint32(32)).(*uint32)
-//  var _ *uint64 = AnyPtr(uint64(64)).(*uint64)
+//	var _ *bool = AnyPtr(true).(*bool)
+//	var _ *byte = AnyPtr(byte(1)).(*byte)
+//	var _ *complex64 = AnyPtr(complex64(1.1)).(*complex64)
+//	var _ *complex128 = AnyPtr(complex128(1.1)).(*complex128)
+//	var _ *float32 = AnyPtr(float32(1.1)).(*float32)
+//	var _ *float64 = AnyPtr(float64(1.1)).(*float64)
+//	var _ *int = AnyPtr(int(1)).(*int)
+//	var _ *int8 = AnyPtr(int8(8)).(*int8)
+//	var _ *int16 = AnyPtr(int16(16)).(*int16)
+//	var _ *int32 = AnyPtr(int32(32)).(*int32)
+//	var _ *int64 = AnyPtr(int64(64)).(*int64)
+//	var _ *rune = AnyPtr(rune(1)).(*rune)
+//	var _ *string = AnyPtr("ptr").(*string)
+//	var _ *uint = AnyPtr(uint(1)).(*uint)
+//	var _ *uint8 = AnyPtr(uint8(8)).(*uint8)
+//	var _ *uint16 = AnyPtr(uint16(16)).(*uint16)
+//	var _ *uint32 = AnyPtr(uint32(32)).(*uint32)
+//	var _ *uint64 = AnyPtr(uint64(64)).(*uint64)
 func AnyPtr(v interface{}) interface{} {
 	r := reflect.New(reflect.TypeOf(v))
 	reflect.ValueOf(r.Interface()).Elem().Set(reflect.ValueOf(v))
diff --git a/resource.go b/resource.go
index cf0a9cfb6..2113e5eba 100644
--- a/resource.go
+++ b/resource.go
@@ -1,5 +1,7 @@
 package cloudflare
 
+import "fmt"
+
 // RouteLevel holds the "level" where the resource resides.
 type RouteLevel string
 
@@ -17,6 +19,17 @@ type ResourceContainer struct {
 	Identifier string
 }
 
+// Returns a URL fragment of the endpoint scoped by the container.
+//
+// For example, a zone identifier would have a fragment like "zones/foobar" while
+// an account identifier would generate "accounts/foobar".
+func (rc *ResourceContainer) URLFragment() string {
+	if rc.Level == "" {
+		return rc.Identifier
+	}
+	return fmt.Sprintf("%s/%s", rc.Level, rc.Identifier)
+}
+
 // ResourceIdentifier returns a generic *ResourceContainer.
 func ResourceIdentifier(id string) *ResourceContainer {
 	return &ResourceContainer{


Test patch:
diff --git a/addressing_address_map_test.go b/addressing_address_map_test.go
new file mode 100644
index 000000000..0bbb3dd60
--- /dev/null
+++ b/addressing_address_map_test.go
@@ -0,0 +1,384 @@
+package cloudflare
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+var (
+	addressMapDesc       = "My Ecommerce zones"
+	addressMapDefaultSNI = "*.example.com"
+)
+
+func TestListAddressMap(t *testing.T) {
+	setup()
+	defer teardown()
+
+	expectedIP := "127.0.0.1"
+	expectedCIDR := "127.0.0.1/24"
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method)
+
+		actualIP := r.URL.Query().Get("ip")
+		assert.Equal(t, expectedIP, actualIP, "Expected ip %q, got %q", expectedIP, actualIP)
+
+		actualCIDR := r.URL.Query().Get("cidr")
+		assert.Equal(t, expectedCIDR, actualCIDR, "Expected cidr %q, got %q", expectedCIDR, actualCIDR)
+
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprint(w, `{
+			"success": true,
+			"errors": [],
+			"messages": [],
+			"result": [{
+			  "id": "9a7806061c88ada191ed06f989cc3dac",
+			  "description": "My Ecommerce zones",
+			  "can_delete": true,
+			  "can_modify_ips": true,
+			  "default_sni": "*.example.com",
+			  "created_at": "2023-01-01T05:20:00.12345Z",
+			  "modified_at": "2023-01-05T05:20:00.12345Z",
+			  "enabled": true,
+			  "ips": [
+				{
+				  "ip": "192.0.2.1",
+				  "created_at": "2023-01-02T05:20:00.12345Z"
+				}
+			  ],
+			  "memberships": [
+				{
+				  "kind": "zone",
+				  "identifier": "01a7362d577a6c3019a474fd6f485823",
+				  "can_delete": true,
+				  "created_at": "2023-01-03T05:20:00.12345Z"
+				}
+			  ]
+			}]
+		  }`)
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/addressing/address_maps", handler)
+
+	createdAt, _ := time.Parse(time.RFC3339, "2023-01-01T05:20:00.12345Z")
+	modifiedAt, _ := time.Parse(time.RFC3339, "2023-01-05T05:20:00.12345Z")
+	ipCreatedAt, _ := time.Parse(time.RFC3339, "2023-01-02T05:20:00.12345Z")
+	membershipCreatedAt, _ := time.Parse(time.RFC3339, "2023-01-03T05:20:00.12345Z")
+
+	want := []AddressMap{
+		{
+			ID:           "9a7806061c88ada191ed06f989cc3dac",
+			CreatedAt:    createdAt,
+			ModifiedAt:   modifiedAt,
+			Description:  &addressMapDesc,
+			Deletable:    BoolPtr(true),
+			CanModifyIPs: BoolPtr(true),
+			DefaultSNI:   &addressMapDefaultSNI,
+			Enabled:      BoolPtr(true),
+			IPs:          []AddressMapIP{{"192.0.2.1", ipCreatedAt}},
+			Memberships: []AddressMapMembership{{
+				Identifier: "01a7362d577a6c3019a474fd6f485823",
+				Kind:       AddressMapMembershipZone,
+				Deletable:  BoolPtr(true),
+				CreatedAt:  membershipCreatedAt,
+			}},
+		},
+	}
+
+	actual, err := client.ListAddressMaps(context.Background(), AccountIdentifier(testAccountID), ListAddressMapsParams{&expectedIP, &expectedCIDR})
+	if assert.NoError(t, err) {
+		assert.Equal(t, want, actual)
+	}
+}
+
+func TestGetAddressMap(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.Fprint(w, `{
+			"success": true,
+			"errors": [],
+			"messages": [],
+			"result": {
+			  "id": "9a7806061c88ada191ed06f989cc3dac",
+			  "description": "My Ecommerce zones",
+			  "can_delete": true,
+			  "can_modify_ips": true,
+			  "default_sni": "*.example.com",
+			  "created_at": "2023-01-01T05:20:00.12345Z",
+			  "modified_at": "2023-01-05T05:20:00.12345Z",
+			  "enabled": true,
+			  "ips": [
+				{
+				  "ip": "192.0.2.1",
+				  "created_at": "2023-01-02T05:20:00.12345Z"
+				}
+			  ],
+			  "memberships": [
+				{
+				  "kind": "zone",
+				  "identifier": "01a7362d577a6c3019a474fd6f485823",
+				  "can_delete": true,
+				  "created_at": "2023-01-03T05:20:00.12345Z"
+				}
+			  ]
+			}
+		  }`)
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/addressing/address_maps/9a7806061c88ada191ed06f989cc3dac", handler)
+
+	createdAt, _ := time.Parse(time.RFC3339, "2023-01-01T05:20:00.12345Z")
+	modifiedAt, _ := time.Parse(time.RFC3339, "2023-01-05T05:20:00.12345Z")
+	ipCreatedAt, _ := time.Parse(time.RFC3339, "2023-01-02T05:20:00.12345Z")
+	membershipCreatedAt, _ := time.Parse(time.RFC3339, "2023-01-03T05:20:00.12345Z")
+
+	want := AddressMap{
+		ID:           "9a7806061c88ada191ed06f989cc3dac",
+		CreatedAt:    createdAt,
+		ModifiedAt:   modifiedAt,
+		Description:  &addressMapDesc,
+		Deletable:    BoolPtr(true),
+		CanModifyIPs: BoolPtr(true),
+		DefaultSNI:   &addressMapDefaultSNI,
+		Enabled:      BoolPtr(true),
+		IPs:          []AddressMapIP{{"192.0.2.1", ipCreatedAt}},
+		Memberships: []AddressMapMembership{{
+			Identifier: "01a7362d577a6c3019a474fd6f485823",
+			Kind:       AddressMapMembershipZone,
+			Deletable:  BoolPtr(true),
+			CreatedAt:  membershipCreatedAt,
+		}},
+	}
+
+	actual, err := client.GetAddressMap(context.Background(), AccountIdentifier(testAccountID), "9a7806061c88ada191ed06f989cc3dac")
+	if assert.NoError(t, err) {
+		assert.Equal(t, want, actual)
+	}
+}
+
+func TestUpdateAddressMap(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.Fprint(w, `{
+			"success": true,
+			"errors": [],
+			"messages": [],
+			"result": {
+			  "id": "9a7806061c88ada191ed06f989cc3dac",
+			  "description": "My Ecommerce zones",
+			  "can_delete": true,
+			  "can_modify_ips": true,
+			  "default_sni": "*.example.com",
+			  "created_at": "2023-01-01T05:20:00.12345Z",
+			  "modified_at": "2023-01-05T05:20:00.12345Z",
+			  "enabled": true,
+			  "ips": [
+				{
+				  "ip": "192.0.2.1",
+				  "created_at": "2023-01-02T05:20:00.12345Z"
+				}
+			  ],
+			  "memberships": [
+				{
+				  "kind": "zone",
+				  "identifier": "01a7362d577a6c3019a474fd6f485823",
+				  "can_delete": true,
+				  "created_at": "2023-01-03T05:20:00.12345Z"
+				}
+			  ]
+			}
+		  }`)
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/addressing/address_maps/9a7806061c88ada191ed06f989cc3dac", handler)
+
+	createdAt, _ := time.Parse(time.RFC3339, "2023-01-01T05:20:00.12345Z")
+	modifiedAt, _ := time.Parse(time.RFC3339, "2023-01-05T05:20:00.12345Z")
+	ipCreatedAt, _ := time.Parse(time.RFC3339, "2023-01-02T05:20:00.12345Z")
+	membershipCreatedAt, _ := time.Parse(time.RFC3339, "2023-01-03T05:20:00.12345Z")
+
+	want := AddressMap{
+		ID:           "9a7806061c88ada191ed06f989cc3dac",
+		CreatedAt:    createdAt,
+		ModifiedAt:   modifiedAt,
+		Description:  &addressMapDesc,
+		Deletable:    BoolPtr(true),
+		CanModifyIPs: BoolPtr(true),
+		DefaultSNI:   &addressMapDefaultSNI,
+		Enabled:      BoolPtr(true),
+		IPs:          []AddressMapIP{{"192.0.2.1", ipCreatedAt}},
+		Memberships: []AddressMapMembership{{
+			Identifier: "01a7362d577a6c3019a474fd6f485823",
+			Kind:       AddressMapMembershipZone,
+			Deletable:  BoolPtr(true),
+			CreatedAt:  membershipCreatedAt,
+		}},
+	}
+
+	actual, err := client.UpdateAddressMap(context.Background(), AccountIdentifier(testAccountID), UpdateAddressMapParams{ID: "9a7806061c88ada191ed06f989cc3dac"})
+	if assert.NoError(t, err) {
+		assert.Equal(t, want, actual)
+	}
+}
+
+func TestDeleteAddressMap(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)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprint(w, `{
+			"success": true,
+			"errors": [],
+			"messages": [],
+			"result": {
+			  "id": "9a7806061c88ada191ed06f989cc3dac"
+			}
+		  }`)
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/addressing/address_maps/9a7806061c88ada191ed06f989cc3dac", handler)
+
+	err := client.DeleteAddressMap(context.Background(), AccountIdentifier(testAccountID), "9a7806061c88ada191ed06f989cc3dac")
+	assert.NoError(t, err)
+}
+
+func TestAddIPAddressToAddressMap(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprint(w, `{
+			"success": true,
+			"errors": [],
+			"messages": [],
+			"result": []
+		  }`)
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/addressing/address_maps/9a7806061c88ada191ed06f989cc3dac/ips/192.0.2.1", handler)
+
+	err := client.CreateIPAddressToAddressMap(context.Background(), AccountIdentifier(testAccountID), CreateIPAddressToAddressMapParams{"9a7806061c88ada191ed06f989cc3dac", "192.0.2.1"})
+	assert.NoError(t, err)
+}
+
+func TestRemoveIPAddressFromAddressMap(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)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprint(w, `{
+			"success": true,
+			"errors": [],
+			"messages": [],
+			"result": []
+		  }`)
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/addressing/address_maps/9a7806061c88ada191ed06f989cc3dac/ips/192.0.2.1", handler)
+
+	err := client.DeleteIPAddressFromAddressMap(context.Background(), AccountIdentifier(testAccountID), DeleteIPAddressFromAddressMapParams{"9a7806061c88ada191ed06f989cc3dac", "192.0.2.1"})
+	assert.NoError(t, err)
+}
+
+func TestAddZoneToAddressMap(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprint(w, `{
+			"success": true,
+			"errors": [],
+			"messages": [],
+			"result": []
+		  }`)
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/addressing/address_maps/9a7806061c88ada191ed06f989cc3dac/zones/01a7362d577a6c3019a474fd6f485823", handler)
+
+	err := client.CreateMembershipToAddressMap(context.Background(), AccountIdentifier(testAccountID), CreateMembershipToAddressMapParams{"9a7806061c88ada191ed06f989cc3dac", AddressMapMembershipContainer{"01a7362d577a6c3019a474fd6f485823", AddressMapMembershipZone}})
+	assert.NoError(t, err)
+}
+
+func TestRemoveZoneFromAddressMap(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)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprint(w, `{
+			"success": true,
+			"errors": [],
+			"messages": [],
+			"result": []
+		  }`)
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/addressing/address_maps/9a7806061c88ada191ed06f989cc3dac/zones/01a7362d577a6c3019a474fd6f485823", handler)
+
+	err := client.DeleteMembershipFromAddressMap(context.Background(), AccountIdentifier(testAccountID), DeleteMembershipFromAddressMapParams{"9a7806061c88ada191ed06f989cc3dac", AddressMapMembershipContainer{"01a7362d577a6c3019a474fd6f485823", AddressMapMembershipZone}})
+	assert.NoError(t, err)
+}
+
+func TestAddAccountToAddressMap(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprint(w, `{
+			"success": true,
+			"errors": [],
+			"messages": [],
+			"result": []
+		  }`)
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/addressing/address_maps/9a7806061c88ada191ed06f989cc3dac/accounts/01a7362d577a6c3019a474fd6f485823", handler)
+
+	err := client.CreateMembershipToAddressMap(context.Background(), AccountIdentifier(testAccountID), CreateMembershipToAddressMapParams{"9a7806061c88ada191ed06f989cc3dac", AddressMapMembershipContainer{"01a7362d577a6c3019a474fd6f485823", AddressMapMembershipAccount}})
+	assert.NoError(t, err)
+}
+
+func TestRemoveAccountFromAddressMap(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)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprint(w, `{
+			"success": true,
+			"errors": [],
+			"messages": [],
+			"result": []
+		  }`)
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/addressing/address_maps/9a7806061c88ada191ed06f989cc3dac/accounts/01a7362d577a6c3019a474fd6f485823", handler)
+
+	err := client.DeleteMembershipFromAddressMap(context.Background(), AccountIdentifier(testAccountID), DeleteMembershipFromAddressMapParams{"9a7806061c88ada191ed06f989cc3dac", AddressMapMembershipContainer{"01a7362d577a6c3019a474fd6f485823", AddressMapMembershipAccount}})
+	assert.NoError(t, err)
+}
diff --git a/resource_test.go b/resource_test.go
new file mode 100644
index 000000000..ef0c96d31
--- /dev/null
+++ b/resource_test.go
@@ -0,0 +1,25 @@
+package cloudflare
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestResourcURLFragment(t *testing.T) {
+	tests := map[string]struct {
+		container *ResourceContainer
+		want      string
+	}{
+		"account resource":       {container: AccountIdentifier("foo"), want: "accounts/foo"},
+		"zone resource":          {container: ZoneIdentifier("foo"), want: "zones/foo"},
+		"missing level resource": {container: &ResourceContainer{Level: "", Identifier: "foo"}, want: "foo"},
+	}
+
+	for name, tc := range tests {
+		t.Run(name, func(t *testing.T) {
+			got := tc.container.URLFragment()
+			assert.Equal(t, tc.want, got)
+		})
+	}
+}