diff --git a/CHANGELOG.md b/CHANGELOG.md index df0b59f5e..bdbc6adaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Breaking: ### Enhancements: +- feat(customer/contacts): add support for customer contacts (list/create/delete) ([#813](https://github.com/fastly/go-fastly/pull/813)) ### Dependencies: - build(deps): `golang.org/x/crypto` from 0.50.0 to 0.51.0 ([#812](https://github.com/fastly/go-fastly/pull/812)) diff --git a/fastly/customer/contacts/api_contacts_test.go b/fastly/customer/contacts/api_contacts_test.go new file mode 100644 index 000000000..d99ba85fd --- /dev/null +++ b/fastly/customer/contacts/api_contacts_test.go @@ -0,0 +1,85 @@ +package contacts + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/fastly/go-fastly/v15/fastly" +) + +func TestClient_Contacts(t *testing.T) { + ctx := context.TODO() + + var err error + + // The customer ID is an opaque identifier that is only replayed against the + // recorded fixtures; its exact value does not matter for the test. + customerID := "7i6ZbMEdjnTUNyk75XgWO0" + + // Fastly refuses to delete the last contact of a given type, so we + // create a guard contact first that we leave in place for the duration + // of the test (and best-effort delete during cleanup). + // + // NOTE: When recreating the fixtures, update both emails. + guardEmail := "go-fastly-test+contact-guard+20260522@example.com" + email := "go-fastly-test+contact+20260522@example.com" + + var guard *Contact + fastly.Record(t, "create_guard", func(c *fastly.Client) { + guard, err = Create(ctx, c, &CreateInput{ + CustomerID: fastly.ToPointer(customerID), + ContactType: fastly.ToPointer("emergency"), + Name: fastly.ToPointer("guard contact"), + Email: fastly.ToPointer(guardEmail), + }) + }) + require.NoError(t, err) + require.NotNil(t, guard) + + var co *Contact + fastly.Record(t, "create", func(c *fastly.Client) { + co, err = Create(ctx, c, &CreateInput{ + CustomerID: fastly.ToPointer(customerID), + ContactType: fastly.ToPointer("emergency"), + Name: fastly.ToPointer("test contact"), + Email: fastly.ToPointer(email), + }) + }) + require.NoError(t, err) + require.NotEmpty(t, co.ContactID) + require.Equal(t, email, co.Email) + + // Best-effort cleanup: guard deletion may fail if it is the last + // emergency contact on the account, which is fine. + defer func() { + fastly.Record(t, "cleanup", func(c *fastly.Client) { + _ = Delete(ctx, c, &DeleteInput{ + CustomerID: fastly.ToPointer(customerID), + ContactID: fastly.ToPointer(co.ContactID), + }) + _ = Delete(ctx, c, &DeleteInput{ + CustomerID: fastly.ToPointer(customerID), + ContactID: fastly.ToPointer(guard.ContactID), + }) + }) + }() + + var cs []*Contact + fastly.Record(t, "list", func(c *fastly.Client) { + cs, err = List(ctx, c, &ListInput{ + CustomerID: fastly.ToPointer(customerID), + }) + }) + require.NoError(t, err) + require.GreaterOrEqual(t, len(cs), 1) + + fastly.Record(t, "delete", func(c *fastly.Client) { + err = Delete(ctx, c, &DeleteInput{ + CustomerID: fastly.ToPointer(customerID), + ContactID: fastly.ToPointer(co.ContactID), + }) + }) + require.NoError(t, err) +} diff --git a/fastly/customer/contacts/api_create.go b/fastly/customer/contacts/api_create.go new file mode 100644 index 000000000..37340cd8a --- /dev/null +++ b/fastly/customer/contacts/api_create.go @@ -0,0 +1,81 @@ +package contacts + +import ( + "context" + + "github.com/google/jsonapi" + + "github.com/fastly/go-fastly/v15/fastly" +) + +// CreateInput specifies the information needed for the Create() function to +// perform the operation. +type CreateInput struct { + // CustomerID is the alphanumeric identifier of the customer (required). + CustomerID *string + // UserID is the alphanumeric identifier of an existing user. Required when + // not providing Email and Name. + UserID *string + // ContactType is the type of contact. One of: primary, billing, technical, + // security, emergency. + ContactType *string + // Name is the name of this contact, when not referencing an existing user. + Name *string + // FirstName is the first name of this contact, when not referencing an + // existing user. + FirstName *string + // LastName is the last name of this contact, when not referencing an + // existing user. + LastName *string + // Email is the email of this contact, when not referencing an existing user. + Email *string + // Phone is the contact's phone number. Required for the primary, technical, + // and security contact types. + Phone *string +} + +// createPayload is the JSON:API marshaling shape for Create. +// +// It mirrors Contact but omits the fields that must not be sent in the body +// (CustomerID lives in the URL). +type createPayload struct { + ContactID string `jsonapi:"primary,customer_contact"` + UserID string `jsonapi:"attr,user_id,omitempty"` + ContactType string `jsonapi:"attr,contact_type,omitempty"` + Name string `jsonapi:"attr,name,omitempty"` + FirstName string `jsonapi:"attr,first_name,omitempty"` + LastName string `jsonapi:"attr,last_name,omitempty"` + Email string `jsonapi:"attr,email,omitempty"` + Phone string `jsonapi:"attr,phone,omitempty"` +} + +// Create creates a new contact for the given customer. +func Create(ctx context.Context, c *fastly.Client, i *CreateInput) (*Contact, error) { + if i.CustomerID == nil { + return nil, fastly.ErrMissingCustomerID + } + + path := fastly.ToSafeURL("customer", *i.CustomerID, "contacts") + + payload := &createPayload{ + UserID: fastly.ToValue(i.UserID), + ContactType: fastly.ToValue(i.ContactType), + Name: fastly.ToValue(i.Name), + FirstName: fastly.ToValue(i.FirstName), + LastName: fastly.ToValue(i.LastName), + Email: fastly.ToValue(i.Email), + Phone: fastly.ToValue(i.Phone), + } + + resp, err := c.PostJSONAPI(ctx, path, payload, fastly.CreateRequestOptions()) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var co Contact + if err := jsonapi.UnmarshalPayload(resp.Body, &co); err != nil { + return nil, err + } + return &co, nil +} diff --git a/fastly/customer/contacts/api_delete.go b/fastly/customer/contacts/api_delete.go new file mode 100644 index 000000000..d3db80c4d --- /dev/null +++ b/fastly/customer/contacts/api_delete.go @@ -0,0 +1,40 @@ +package contacts + +import ( + "context" + "net/http" + + "github.com/fastly/go-fastly/v15/fastly" +) + +// DeleteInput specifies the information needed for the Delete() function to +// perform the operation. +type DeleteInput struct { + // CustomerID is the alphanumeric identifier of the customer (required). + CustomerID *string + // ContactID is the alphanumeric identifier of the contact (required). + ContactID *string +} + +// Delete deletes the specified contact. +func Delete(ctx context.Context, c *fastly.Client, i *DeleteInput) error { + if i.CustomerID == nil { + return fastly.ErrMissingCustomerID + } + if i.ContactID == nil { + return fastly.ErrMissingContactID + } + + path := fastly.ToSafeURL("customer", *i.CustomerID, "contacts", *i.ContactID) + + resp, err := c.Delete(ctx, path, fastly.CreateRequestOptions()) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + return fastly.NewHTTPError(resp) + } + return nil +} diff --git a/fastly/customer/contacts/api_list.go b/fastly/customer/contacts/api_list.go new file mode 100644 index 000000000..af432430c --- /dev/null +++ b/fastly/customer/contacts/api_list.go @@ -0,0 +1,52 @@ +package contacts + +import ( + "context" + "fmt" + "reflect" + + "github.com/google/jsonapi" + + "github.com/fastly/go-fastly/v15/fastly" +) + +// ListInput specifies the information needed for the List() function to +// perform the operation. +type ListInput struct { + // CustomerID is the alphanumeric identifier of the customer (required). + CustomerID *string +} + +// List retrieves all contacts for the given customer. +func List(ctx context.Context, c *fastly.Client, i *ListInput) ([]*Contact, error) { + if i.CustomerID == nil { + return nil, fastly.ErrMissingCustomerID + } + + path := fastly.ToSafeURL("customer", *i.CustomerID, "contacts") + + opts := fastly.CreateRequestOptions() + opts.Headers["Accept"] = jsonapi.MediaType + + resp, err := c.Get(ctx, path, opts) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + data, err := jsonapi.UnmarshalManyPayload(resp.Body, reflect.TypeOf(new(Contact))) + if err != nil { + return nil, err + } + + cs := make([]*Contact, len(data)) + for idx := range data { + typed, ok := data[idx].(*Contact) + if !ok { + return nil, fmt.Errorf("unexpected response type: %T", data[idx]) + } + cs[idx] = typed + } + + return cs, nil +} diff --git a/fastly/customer/contacts/api_response.go b/fastly/customer/contacts/api_response.go new file mode 100644 index 000000000..4efe97937 --- /dev/null +++ b/fastly/customer/contacts/api_response.go @@ -0,0 +1,18 @@ +package contacts + +import "time" + +// Contact represents a customer contact. +type Contact struct { + ContactID string `jsonapi:"primary,customer_contact"` + UserID string `jsonapi:"attr,user_id,omitempty"` + ContactType string `jsonapi:"attr,contact_type,omitempty"` + Name string `jsonapi:"attr,name,omitempty"` + FirstName string `jsonapi:"attr,first_name,omitempty"` + LastName string `jsonapi:"attr,last_name,omitempty"` + Email string `jsonapi:"attr,email,omitempty"` + Phone string `jsonapi:"attr,phone,omitempty"` + CreatedAt *time.Time `jsonapi:"attr,created_at,iso8601"` + UpdatedAt *time.Time `jsonapi:"attr,updated_at,iso8601"` + DeletedAt *time.Time `jsonapi:"attr,deleted_at,iso8601"` +} diff --git a/fastly/customer/contacts/api_validation_test.go b/fastly/customer/contacts/api_validation_test.go new file mode 100644 index 000000000..4829109e0 --- /dev/null +++ b/fastly/customer/contacts/api_validation_test.go @@ -0,0 +1,38 @@ +package contacts + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/fastly/go-fastly/v15/fastly" +) + +func TestClient_List_validation(t *testing.T) { + _, err := List(context.TODO(), fastly.TestClient, &ListInput{ + CustomerID: nil, + }) + require.ErrorIs(t, err, fastly.ErrMissingCustomerID) +} + +func TestClient_Create_validation(t *testing.T) { + _, err := Create(context.TODO(), fastly.TestClient, &CreateInput{ + CustomerID: nil, + }) + require.ErrorIs(t, err, fastly.ErrMissingCustomerID) +} + +func TestClient_Delete_validation(t *testing.T) { + err := Delete(context.TODO(), fastly.TestClient, &DeleteInput{ + CustomerID: nil, + ContactID: fastly.ToPointer("abc"), + }) + require.ErrorIs(t, err, fastly.ErrMissingCustomerID) + + err = Delete(context.TODO(), fastly.TestClient, &DeleteInput{ + CustomerID: fastly.ToPointer("abc"), + ContactID: nil, + }) + require.ErrorIs(t, err, fastly.ErrMissingContactID) +} diff --git a/fastly/customer/contacts/doc.go b/fastly/customer/contacts/doc.go new file mode 100644 index 000000000..811cd211b --- /dev/null +++ b/fastly/customer/contacts/doc.go @@ -0,0 +1,3 @@ +// Package contacts contains functions for managing customer contacts +// (list, create, delete) on a Fastly account. +package contacts diff --git a/fastly/customer/contacts/fixtures/cleanup.yaml b/fastly/customer/contacts/fixtures/cleanup.yaml new file mode 100644 index 000000000..d7fc335a7 --- /dev/null +++ b/fastly/customer/contacts/fixtures/cleanup.yaml @@ -0,0 +1,91 @@ +--- +version: 1 +interactions: +- request: + body: "" + form: {} + headers: + User-Agent: + - FastlyGo/15.0.1 (+github.com/fastly/go-fastly; go1.25.4) + url: https://api.fastly.com/customer/7i6ZbMEdjnTUNyk75XgWO0/contacts/5Wvd5Mi5Gm6LLW7yIQ8a4y + method: DELETE + response: + body: '{"msg":"Record not found","detail":"Cannot find customercontact ''5Wvd5Mi5Gm6LLW7yIQ8a4y''"}' + headers: + Accept-Ranges: + - bytes + Cache-Control: + - no-store + Content-Type: + - application/json + Date: + - Fri, 22 May 2026 12:39:58 GMT + Fastly-Ratelimit-Remaining: + - "981" + Fastly-Ratelimit-Reset: + - "1779454800" + Pragma: + - no-cache + Server: + - fastly + Status: + - 404 Not Found + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Accept-Encoding + Via: + - 1.1 varnish, 1.1 varnish + X-Cache: + - MISS, MISS + X-Cache-Hits: + - 0, 0 + X-Served-By: + - cache-chi-klot8100065-CHI, cache-par-lfpg1960084-PAR + X-Timer: + - S1779453598.996808,VS0,VE174 + status: 404 Not Found + code: 404 + duration: "" +- request: + body: "" + form: {} + headers: + User-Agent: + - FastlyGo/15.0.1 (+github.com/fastly/go-fastly; go1.25.4) + url: https://api.fastly.com/customer/7i6ZbMEdjnTUNyk75XgWO0/contacts/6ojdEaGJGBQHW5RJzwswwy + method: DELETE + response: + body: "" + headers: + Accept-Ranges: + - bytes + Cache-Control: + - no-store + Date: + - Fri, 22 May 2026 12:39:58 GMT + Fastly-Ratelimit-Remaining: + - "980" + Fastly-Ratelimit-Reset: + - "1779454800" + Pragma: + - no-cache + Server: + - fastly + Status: + - 204 No Content + Strict-Transport-Security: + - max-age=31536000 + Via: + - 1.1 varnish, 1.1 varnish + X-Cache: + - MISS, MISS + X-Cache-Hits: + - 0, 0 + X-Served-By: + - cache-chi-klot8100179-CHI, cache-par-lfpg1960084-PAR + X-Timer: + - S1779453598.187194,VS0,VE175 + status: 204 No Content + code: 204 + duration: "" diff --git a/fastly/customer/contacts/fixtures/create.yaml b/fastly/customer/contacts/fixtures/create.yaml new file mode 100644 index 000000000..ffd4c94b7 --- /dev/null +++ b/fastly/customer/contacts/fixtures/create.yaml @@ -0,0 +1,55 @@ +--- +version: 1 +interactions: +- request: + body: | + {"data":{"type":"customer_contact","attributes":{"contact_type":"emergency","email":"go-fastly-test+contact+20260522@example.com","name":"test contact"}}} + form: {} + headers: + Accept: + - application/vnd.api+json + Content-Type: + - application/vnd.api+json + User-Agent: + - FastlyGo/15.0.1 (+github.com/fastly/go-fastly; go1.25.4) + url: https://api.fastly.com/customer/7i6ZbMEdjnTUNyk75XgWO0/contacts + method: POST + response: + body: '{"data":{"id":"5Wvd5Mi5Gm6LLW7yIQ8a4y","type":"customer_contact","attributes":{"created_at":"2026-05-22T12:39:57Z","updated_at":"2026-05-22T12:39:57Z","deleted_at":null,"contact_type":"emergency","phone":null,"name":"test + contact","email":"go-fastly-test+contact+20260522@example.com"}}}' + headers: + Accept-Ranges: + - bytes + Cache-Control: + - no-store + Content-Type: + - application/vnd.api+json + Date: + - Fri, 22 May 2026 12:39:57 GMT + Fastly-Ratelimit-Remaining: + - "983" + Fastly-Ratelimit-Reset: + - "1779454800" + Pragma: + - no-cache + Server: + - fastly + Status: + - 201 Created + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Accept-Encoding + Via: + - 1.1 varnish, 1.1 varnish + X-Cache: + - MISS, MISS + X-Cache-Hits: + - 0, 0 + X-Served-By: + - cache-chi-klot8100080-CHI, cache-par-lfpg1960084-PAR + X-Timer: + - S1779453597.397293,VS0,VE180 + status: 201 Created + code: 201 + duration: "" diff --git a/fastly/customer/contacts/fixtures/create_guard.yaml b/fastly/customer/contacts/fixtures/create_guard.yaml new file mode 100644 index 000000000..4626741ba --- /dev/null +++ b/fastly/customer/contacts/fixtures/create_guard.yaml @@ -0,0 +1,55 @@ +--- +version: 1 +interactions: +- request: + body: | + {"data":{"type":"customer_contact","attributes":{"contact_type":"emergency","email":"go-fastly-test+contact-guard+20260522@example.com","name":"guard contact"}}} + form: {} + headers: + Accept: + - application/vnd.api+json + Content-Type: + - application/vnd.api+json + User-Agent: + - FastlyGo/15.0.1 (+github.com/fastly/go-fastly; go1.25.4) + url: https://api.fastly.com/customer/7i6ZbMEdjnTUNyk75XgWO0/contacts + method: POST + response: + body: '{"data":{"id":"6ojdEaGJGBQHW5RJzwswwy","type":"customer_contact","attributes":{"created_at":"2026-05-22T12:39:57Z","updated_at":"2026-05-22T12:39:57Z","deleted_at":null,"contact_type":"emergency","phone":null,"name":"guard + contact","email":"go-fastly-test+contact-guard+20260522@example.com"}}}' + headers: + Accept-Ranges: + - bytes + Cache-Control: + - no-store + Content-Type: + - application/vnd.api+json + Date: + - Fri, 22 May 2026 12:39:57 GMT + Fastly-Ratelimit-Remaining: + - "984" + Fastly-Ratelimit-Reset: + - "1779454800" + Pragma: + - no-cache + Server: + - fastly + Status: + - 201 Created + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Accept-Encoding + Via: + - 1.1 varnish, 1.1 varnish + X-Cache: + - MISS, MISS + X-Cache-Hits: + - 0, 0 + X-Served-By: + - cache-chi-klot8100080-CHI, cache-par-lfpg1960084-PAR + X-Timer: + - S1779453597.172522,VS0,VE216 + status: 201 Created + code: 201 + duration: "" diff --git a/fastly/customer/contacts/fixtures/delete.yaml b/fastly/customer/contacts/fixtures/delete.yaml new file mode 100644 index 000000000..ea5827e08 --- /dev/null +++ b/fastly/customer/contacts/fixtures/delete.yaml @@ -0,0 +1,45 @@ +--- +version: 1 +interactions: +- request: + body: "" + form: {} + headers: + User-Agent: + - FastlyGo/15.0.1 (+github.com/fastly/go-fastly; go1.25.4) + url: https://api.fastly.com/customer/7i6ZbMEdjnTUNyk75XgWO0/contacts/5Wvd5Mi5Gm6LLW7yIQ8a4y + method: DELETE + response: + body: "" + headers: + Accept-Ranges: + - bytes + Cache-Control: + - no-store + Date: + - Fri, 22 May 2026 12:39:57 GMT + Fastly-Ratelimit-Remaining: + - "982" + Fastly-Ratelimit-Reset: + - "1779454800" + Pragma: + - no-cache + Server: + - fastly + Status: + - 204 No Content + Strict-Transport-Security: + - max-age=31536000 + Via: + - 1.1 varnish, 1.1 varnish + X-Cache: + - MISS, MISS + X-Cache-Hits: + - 0, 0 + X-Served-By: + - cache-chi-klot8100065-CHI, cache-par-lfpg1960084-PAR + X-Timer: + - S1779453598.794424,VS0,VE194 + status: 204 No Content + code: 204 + duration: "" diff --git a/fastly/customer/contacts/fixtures/list.yaml b/fastly/customer/contacts/fixtures/list.yaml new file mode 100644 index 000000000..be444dee3 --- /dev/null +++ b/fastly/customer/contacts/fixtures/list.yaml @@ -0,0 +1,52 @@ +--- +version: 1 +interactions: +- request: + body: "" + form: {} + headers: + Accept: + - application/vnd.api+json + User-Agent: + - FastlyGo/15.0.1 (+github.com/fastly/go-fastly; go1.25.4) + url: https://api.fastly.com/customer/7i6ZbMEdjnTUNyk75XgWO0/contacts + method: GET + response: + body: '{"data":[{"id":"2GpZ8C5uI5jnIS6HHyytP1","type":"customer_contact","attributes":{"created_at":"2026-05-22T12:39:08Z","updated_at":"2026-05-22T12:39:08Z","deleted_at":null,"contact_type":"emergency","phone":null,"name":"guard + contact","email":"go-fastly-test+contact-guard+20260522@example.com"}},{"id":"1aaaaaaaaaaaaaaaaaaaaa","type":"customer_contact","attributes":{"created_at":"2026-05-20T09:06:19Z","updated_at":"2026-05-20T09:06:19Z","deleted_at":null,"contact_type":"primary","phone":"0000000000","name":"Cloud + Team","email":"pre-existing-primary@example.com"}},{"id":"4CMF0j77IO1EYQjmoSqU6S","type":"customer_contact","attributes":{"created_at":"2026-05-22T12:37:38Z","updated_at":"2026-05-22T12:37:38Z","deleted_at":null,"contact_type":"billing","phone":null,"name":"test + contact","email":"go-fastly-test+contact+20260522@example.com"}},{"id":"5Wvd5Mi5Gm6LLW7yIQ8a4y","type":"customer_contact","attributes":{"created_at":"2026-05-22T12:39:57Z","updated_at":"2026-05-22T12:39:57Z","deleted_at":null,"contact_type":"emergency","phone":null,"name":"test + contact","email":"go-fastly-test+contact+20260522@example.com"}},{"id":"6ojdEaGJGBQHW5RJzwswwy","type":"customer_contact","attributes":{"created_at":"2026-05-22T12:39:57Z","updated_at":"2026-05-22T12:39:57Z","deleted_at":null,"contact_type":"emergency","phone":null,"name":"guard + contact","email":"go-fastly-test+contact-guard+20260522@example.com"}}]}' + headers: + Accept-Ranges: + - bytes + Cache-Control: + - no-store + Content-Type: + - application/vnd.api+json + Date: + - Fri, 22 May 2026 12:39:57 GMT + Pragma: + - no-cache + Server: + - fastly + Status: + - 200 OK + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Accept-Encoding + Via: + - 1.1 varnish, 1.1 varnish + X-Cache: + - MISS, MISS + X-Cache-Hits: + - 0, 0 + X-Served-By: + - cache-chi-klot8100080-CHI, cache-par-lfpg1960084-PAR + X-Timer: + - S1779453598.586119,VS0,VE202 + status: 200 OK + code: 200 + duration: "" diff --git a/fastly/errors.go b/fastly/errors.go index 028e39da7..a705f0baf 100644 --- a/fastly/errors.go +++ b/fastly/errors.go @@ -191,6 +191,10 @@ var ErrMissingCustomerID = NewFieldError("CustomerID") // requires a "AccessKeyID" key, but one was not set. var ErrMissingAccessKeyID = NewFieldError("AccessKeyID") +// ErrMissingContactID is an error that is returned when an input struct +// requires a "ContactID" key, but one was not set. +var ErrMissingContactID = NewFieldError("ContactID") + // ErrMissingDescription is an error that is returned when an input struct // requires a "Description" key, but one was not set. var ErrMissingDescription = NewFieldError("Description")