From 5271b9b03c3200f48d22f5ff16fc45148ff79756 Mon Sep 17 00:00:00 2001 From: bclerc <45035370+bclerc@users.noreply.github.com> Date: Fri, 22 May 2026 15:04:44 +0200 Subject: [PATCH 1/5] feat(contact): add support for customer contacts Adds Client.ListContacts, Client.CreateContact, and Client.DeleteContact backed by /customer/{customer_id}/contacts. The endpoint uses JSON:API with the customer_contact resource type. The Fastly API does not expose an update endpoint for contacts, so only list/create/delete are implemented. Includes VCR fixtures and validation tests for missing required inputs. --- CHANGELOG.md | 1 + fastly/contact.go | 160 ++++++++++++++++++ fastly/contact_test.go | 150 ++++++++++++++++ fastly/errors.go | 4 + fastly/fixtures/contacts/cleanup.yaml | 91 ++++++++++ fastly/fixtures/contacts/create.yaml | 55 ++++++ fastly/fixtures/contacts/create_guard.yaml | 55 ++++++ fastly/fixtures/contacts/delete.yaml | 45 +++++ .../fixtures/contacts/get_current_user.yaml | 48 ++++++ fastly/fixtures/contacts/list.yaml | 52 ++++++ 10 files changed, 661 insertions(+) create mode 100644 fastly/contact.go create mode 100644 fastly/contact_test.go create mode 100644 fastly/fixtures/contacts/cleanup.yaml create mode 100644 fastly/fixtures/contacts/create.yaml create mode 100644 fastly/fixtures/contacts/create_guard.yaml create mode 100644 fastly/fixtures/contacts/delete.yaml create mode 100644 fastly/fixtures/contacts/get_current_user.yaml create mode 100644 fastly/fixtures/contacts/list.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 23fd5eadf..f5ff239f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Breaking: ### Enhancements: +- feat(contact): add support for customer contacts (list/create/delete) ### 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/contact.go b/fastly/contact.go new file mode 100644 index 000000000..9b12875c8 --- /dev/null +++ b/fastly/contact.go @@ -0,0 +1,160 @@ +package fastly + +import ( + "context" + "fmt" + "reflect" + "time" + + "github.com/google/jsonapi" +) + +// 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"` +} + +// ListContactsInput is used as input to the ListContacts function. +type ListContactsInput struct { + // CustomerID is an alphanumeric string identifying the customer (required). + CustomerID string +} + +// ListContacts retrieves all contacts for the given customer. +func (c *Client) ListContacts(ctx context.Context, i *ListContactsInput) ([]*Contact, error) { + if i.CustomerID == "" { + return nil, ErrMissingCustomerID + } + + path := ToSafeURL("customer", i.CustomerID, "contacts") + + requestOptions := CreateRequestOptions() + requestOptions.Headers["Accept"] = jsonapi.MediaType + + resp, err := c.Get(ctx, path, requestOptions) + 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 +} + +// CreateContactInput is used as input to the CreateContact function. +type CreateContactInput struct { + // CustomerID is an alphanumeric string identifying the customer (required). + CustomerID string + // UserID is an alphanumeric string identifying a 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 +} + +// createContactPayload is the JSON:API marshaling shape for CreateContact. +type createContactPayload 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"` +} + +// CreateContact creates a new contact. +func (c *Client) CreateContact(ctx context.Context, i *CreateContactInput) (*Contact, error) { + if i.CustomerID == "" { + return nil, ErrMissingCustomerID + } + + path := ToSafeURL("customer", i.CustomerID, "contacts") + + payload := &createContactPayload{ + UserID: i.UserID, + ContactType: i.ContactType, + Name: i.Name, + FirstName: i.FirstName, + LastName: i.LastName, + Email: i.Email, + Phone: i.Phone, + } + + resp, err := c.PostJSONAPI(ctx, path, payload, 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 +} + +// DeleteContactInput is used as input to the DeleteContact function. +type DeleteContactInput struct { + // CustomerID is an alphanumeric string identifying the customer (required). + CustomerID string + // ContactID is an alphanumeric string identifying the contact (required). + ContactID string +} + +// DeleteContact deletes the specified contact. +func (c *Client) DeleteContact(ctx context.Context, i *DeleteContactInput) error { + if i.CustomerID == "" { + return ErrMissingCustomerID + } + if i.ContactID == "" { + return ErrMissingContactID + } + + path := ToSafeURL("customer", i.CustomerID, "contacts", i.ContactID) + + resp, err := c.Delete(ctx, path, CreateRequestOptions()) + if err != nil { + return err + } + defer resp.Body.Close() + return nil +} diff --git a/fastly/contact_test.go b/fastly/contact_test.go new file mode 100644 index 000000000..deea2ff34 --- /dev/null +++ b/fastly/contact_test.go @@ -0,0 +1,150 @@ +package fastly + +import ( + "context" + "errors" + "testing" +) + +func TestClient_Contacts(t *testing.T) { + t.Parallel() + + fixtureBase := "contacts/" + + // Need a customer ID; fetch it from the current user. + var ( + err error + customerID string + ) + Record(t, fixtureBase+"get_current_user", func(c *Client) { + var u *User + u, err = c.GetCurrentUser(context.TODO()) + if err == nil && u != nil && u.CustomerID != nil { + customerID = *u.CustomerID + } + }) + if err != nil { + t.Fatal(err) + } + if customerID == "" { + t.Fatal("missing customer id from current user") + } + + // 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 + Record(t, fixtureBase+"create_guard", func(c *Client) { + guard, err = c.CreateContact(context.TODO(), &CreateContactInput{ + CustomerID: customerID, + ContactType: "emergency", + Name: "guard contact", + Email: guardEmail, + }) + }) + if err != nil { + t.Fatal(err) + } + + // Create the contact under test. + var co *Contact + Record(t, fixtureBase+"create", func(c *Client) { + co, err = c.CreateContact(context.TODO(), &CreateContactInput{ + CustomerID: customerID, + ContactType: "emergency", + Name: "test contact", + Email: email, + }) + }) + if err != nil { + t.Fatal(err) + } + + // Ensure deleted (best-effort; guard deletion may fail if it is the + // last emergency contact on the account, which is fine). + defer func() { + Record(t, fixtureBase+"cleanup", func(c *Client) { + _ = c.DeleteContact(context.TODO(), &DeleteContactInput{ + CustomerID: customerID, + ContactID: co.ContactID, + }) + _ = c.DeleteContact(context.TODO(), &DeleteContactInput{ + CustomerID: customerID, + ContactID: guard.ContactID, + }) + }) + }() + + if co.ContactID == "" { + t.Errorf("bad contact id: %+v", co) + } + if co.Email != email { + t.Errorf("bad email: %q", co.Email) + } + + // List + var cs []*Contact + Record(t, fixtureBase+"list", func(c *Client) { + cs, err = c.ListContacts(context.TODO(), &ListContactsInput{ + CustomerID: customerID, + }) + }) + if err != nil { + t.Fatal(err) + } + if len(cs) < 1 { + t.Errorf("bad contacts: %v", cs) + } + + // Delete + Record(t, fixtureBase+"delete", func(c *Client) { + err = c.DeleteContact(context.TODO(), &DeleteContactInput{ + CustomerID: customerID, + ContactID: co.ContactID, + }) + }) + if err != nil { + t.Fatal(err) + } +} + +func TestClient_ListContacts_validation(t *testing.T) { + _, err := TestClient.ListContacts(context.TODO(), &ListContactsInput{ + CustomerID: "", + }) + if !errors.Is(err, ErrMissingCustomerID) { + t.Errorf("bad error: %s", err) + } +} + +func TestClient_CreateContact_validation(t *testing.T) { + _, err := TestClient.CreateContact(context.TODO(), &CreateContactInput{ + CustomerID: "", + }) + if !errors.Is(err, ErrMissingCustomerID) { + t.Errorf("bad error: %s", err) + } +} + +func TestClient_DeleteContact_validation(t *testing.T) { + err := TestClient.DeleteContact(context.TODO(), &DeleteContactInput{ + CustomerID: "", + ContactID: "abc", + }) + if !errors.Is(err, ErrMissingCustomerID) { + t.Errorf("bad error: %s", err) + } + + err = TestClient.DeleteContact(context.TODO(), &DeleteContactInput{ + CustomerID: "abc", + ContactID: "", + }) + if !errors.Is(err, ErrMissingContactID) { + t.Errorf("bad error: %s", err) + } +} 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") diff --git a/fastly/fixtures/contacts/cleanup.yaml b/fastly/fixtures/contacts/cleanup.yaml new file mode 100644 index 000000000..d7fc335a7 --- /dev/null +++ b/fastly/fixtures/contacts/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/fixtures/contacts/create.yaml b/fastly/fixtures/contacts/create.yaml new file mode 100644 index 000000000..ffd4c94b7 --- /dev/null +++ b/fastly/fixtures/contacts/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/fixtures/contacts/create_guard.yaml b/fastly/fixtures/contacts/create_guard.yaml new file mode 100644 index 000000000..4626741ba --- /dev/null +++ b/fastly/fixtures/contacts/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/fixtures/contacts/delete.yaml b/fastly/fixtures/contacts/delete.yaml new file mode 100644 index 000000000..ea5827e08 --- /dev/null +++ b/fastly/fixtures/contacts/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/fixtures/contacts/get_current_user.yaml b/fastly/fixtures/contacts/get_current_user.yaml new file mode 100644 index 000000000..ea83530b5 --- /dev/null +++ b/fastly/fixtures/contacts/get_current_user.yaml @@ -0,0 +1,48 @@ +--- +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/current_user + method: GET + response: + body: | + {"id":"2x3y4z5a6b7c8d9e0f1g2h","customer_id":"7i6ZbMEdjnTUNyk75XgWO0","email_hash":"00000000000000000000000000000000","limit_services":false,"limit_workspaces":false,"locked":false,"login":"go-fastly-test@example.com","name":"Test User","require_new_password":false,"role":"superuser","roles":["3bbbbbbbbbbbbbbbbbbbbb"],"two_factor_auth_enabled":false,"two_factor_setup_required":false,"created_at":"2026-01-26T09:02:21Z","updated_at":"2026-05-22T12:29:42Z","deleted_at":null,"last_active_at":"2026-05-22T11:50:14Z","bypass_sso":false,"ignores_two_factor_requirement":false} + headers: + Accept-Ranges: + - bytes + Cache-Control: + - no-store + Content-Length: + - "580" + Content-Type: + - application/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-klot8100076-CHI, cache-par-lfpg1960084-PAR + X-Timer: + - S1779453597.030855,VS0,VE126 + status: 200 OK + code: 200 + duration: "" diff --git a/fastly/fixtures/contacts/list.yaml b/fastly/fixtures/contacts/list.yaml new file mode 100644 index 000000000..be444dee3 --- /dev/null +++ b/fastly/fixtures/contacts/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: "" From ade7d68edc14d8b031cad086906379e4d5ea29eb Mon Sep 17 00:00:00 2001 From: bclerc <45035370+bclerc@users.noreply.github.com> Date: Fri, 22 May 2026 15:08:37 +0200 Subject: [PATCH 2/5] chore(changelog): link PR #813 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5ff239f1..54f7b02e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Breaking: ### Enhancements: -- feat(contact): add support for customer contacts (list/create/delete) +- feat(contact): 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)) From 924004fcf222a89cb9a98e4a523da540631d1986 Mon Sep 17 00:00:00 2001 From: bclerc Date: Fri, 22 May 2026 15:47:14 +0200 Subject: [PATCH 3/5] refactor(customer/contacts): move to dedicated sub-package Per review feedback on #813, move the customer contacts API (list/create/delete) out of the root fastly package and into fastly/customer/contacts/, matching the convention established by fastly/apisecurity/operations. - Rename ListContacts/CreateContact/DeleteContact to contacts.{List,Create,Delete} with (ctx, *fastly.Client, *Input) signatures and pointer-valued input fields. - Keep JSON:API marshaling via Client.PostJSONAPI and the google/jsonapi unmarshaling helpers; preserve the internal payload struct that omits CustomerID from the body. - Move fixtures to fastly/customer/contacts/fixtures/ (API paths and cassette contents unchanged). - Update CHANGELOG entry scope to customer/contacts. --- CHANGELOG.md | 2 +- fastly/contact.go | 160 ------------------ fastly/contact_test.go | 150 ---------------- fastly/customer/contacts/api_contacts_test.go | 93 ++++++++++ fastly/customer/contacts/api_create.go | 88 ++++++++++ fastly/customer/contacts/api_delete.go | 40 +++++ fastly/customer/contacts/api_list.go | 52 ++++++ fastly/customer/contacts/api_response.go | 18 ++ .../customer/contacts/api_validation_test.go | 38 +++++ fastly/customer/contacts/doc.go | 3 + .../contacts/fixtures}/cleanup.yaml | 0 .../contacts/fixtures}/create.yaml | 0 .../contacts/fixtures}/create_guard.yaml | 0 .../contacts/fixtures}/delete.yaml | 0 .../contacts/fixtures}/get_current_user.yaml | 0 .../contacts/fixtures}/list.yaml | 0 16 files changed, 333 insertions(+), 311 deletions(-) delete mode 100644 fastly/contact.go delete mode 100644 fastly/contact_test.go create mode 100644 fastly/customer/contacts/api_contacts_test.go create mode 100644 fastly/customer/contacts/api_create.go create mode 100644 fastly/customer/contacts/api_delete.go create mode 100644 fastly/customer/contacts/api_list.go create mode 100644 fastly/customer/contacts/api_response.go create mode 100644 fastly/customer/contacts/api_validation_test.go create mode 100644 fastly/customer/contacts/doc.go rename fastly/{fixtures/contacts => customer/contacts/fixtures}/cleanup.yaml (100%) rename fastly/{fixtures/contacts => customer/contacts/fixtures}/create.yaml (100%) rename fastly/{fixtures/contacts => customer/contacts/fixtures}/create_guard.yaml (100%) rename fastly/{fixtures/contacts => customer/contacts/fixtures}/delete.yaml (100%) rename fastly/{fixtures/contacts => customer/contacts/fixtures}/get_current_user.yaml (100%) rename fastly/{fixtures/contacts => customer/contacts/fixtures}/list.yaml (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54f7b02e7..47e79edc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Breaking: ### Enhancements: -- feat(contact): add support for customer contacts (list/create/delete) ([#813](https://github.com/fastly/go-fastly/pull/813)) +- 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/contact.go b/fastly/contact.go deleted file mode 100644 index 9b12875c8..000000000 --- a/fastly/contact.go +++ /dev/null @@ -1,160 +0,0 @@ -package fastly - -import ( - "context" - "fmt" - "reflect" - "time" - - "github.com/google/jsonapi" -) - -// 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"` -} - -// ListContactsInput is used as input to the ListContacts function. -type ListContactsInput struct { - // CustomerID is an alphanumeric string identifying the customer (required). - CustomerID string -} - -// ListContacts retrieves all contacts for the given customer. -func (c *Client) ListContacts(ctx context.Context, i *ListContactsInput) ([]*Contact, error) { - if i.CustomerID == "" { - return nil, ErrMissingCustomerID - } - - path := ToSafeURL("customer", i.CustomerID, "contacts") - - requestOptions := CreateRequestOptions() - requestOptions.Headers["Accept"] = jsonapi.MediaType - - resp, err := c.Get(ctx, path, requestOptions) - 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 -} - -// CreateContactInput is used as input to the CreateContact function. -type CreateContactInput struct { - // CustomerID is an alphanumeric string identifying the customer (required). - CustomerID string - // UserID is an alphanumeric string identifying a 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 -} - -// createContactPayload is the JSON:API marshaling shape for CreateContact. -type createContactPayload 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"` -} - -// CreateContact creates a new contact. -func (c *Client) CreateContact(ctx context.Context, i *CreateContactInput) (*Contact, error) { - if i.CustomerID == "" { - return nil, ErrMissingCustomerID - } - - path := ToSafeURL("customer", i.CustomerID, "contacts") - - payload := &createContactPayload{ - UserID: i.UserID, - ContactType: i.ContactType, - Name: i.Name, - FirstName: i.FirstName, - LastName: i.LastName, - Email: i.Email, - Phone: i.Phone, - } - - resp, err := c.PostJSONAPI(ctx, path, payload, 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 -} - -// DeleteContactInput is used as input to the DeleteContact function. -type DeleteContactInput struct { - // CustomerID is an alphanumeric string identifying the customer (required). - CustomerID string - // ContactID is an alphanumeric string identifying the contact (required). - ContactID string -} - -// DeleteContact deletes the specified contact. -func (c *Client) DeleteContact(ctx context.Context, i *DeleteContactInput) error { - if i.CustomerID == "" { - return ErrMissingCustomerID - } - if i.ContactID == "" { - return ErrMissingContactID - } - - path := ToSafeURL("customer", i.CustomerID, "contacts", i.ContactID) - - resp, err := c.Delete(ctx, path, CreateRequestOptions()) - if err != nil { - return err - } - defer resp.Body.Close() - return nil -} diff --git a/fastly/contact_test.go b/fastly/contact_test.go deleted file mode 100644 index deea2ff34..000000000 --- a/fastly/contact_test.go +++ /dev/null @@ -1,150 +0,0 @@ -package fastly - -import ( - "context" - "errors" - "testing" -) - -func TestClient_Contacts(t *testing.T) { - t.Parallel() - - fixtureBase := "contacts/" - - // Need a customer ID; fetch it from the current user. - var ( - err error - customerID string - ) - Record(t, fixtureBase+"get_current_user", func(c *Client) { - var u *User - u, err = c.GetCurrentUser(context.TODO()) - if err == nil && u != nil && u.CustomerID != nil { - customerID = *u.CustomerID - } - }) - if err != nil { - t.Fatal(err) - } - if customerID == "" { - t.Fatal("missing customer id from current user") - } - - // 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 - Record(t, fixtureBase+"create_guard", func(c *Client) { - guard, err = c.CreateContact(context.TODO(), &CreateContactInput{ - CustomerID: customerID, - ContactType: "emergency", - Name: "guard contact", - Email: guardEmail, - }) - }) - if err != nil { - t.Fatal(err) - } - - // Create the contact under test. - var co *Contact - Record(t, fixtureBase+"create", func(c *Client) { - co, err = c.CreateContact(context.TODO(), &CreateContactInput{ - CustomerID: customerID, - ContactType: "emergency", - Name: "test contact", - Email: email, - }) - }) - if err != nil { - t.Fatal(err) - } - - // Ensure deleted (best-effort; guard deletion may fail if it is the - // last emergency contact on the account, which is fine). - defer func() { - Record(t, fixtureBase+"cleanup", func(c *Client) { - _ = c.DeleteContact(context.TODO(), &DeleteContactInput{ - CustomerID: customerID, - ContactID: co.ContactID, - }) - _ = c.DeleteContact(context.TODO(), &DeleteContactInput{ - CustomerID: customerID, - ContactID: guard.ContactID, - }) - }) - }() - - if co.ContactID == "" { - t.Errorf("bad contact id: %+v", co) - } - if co.Email != email { - t.Errorf("bad email: %q", co.Email) - } - - // List - var cs []*Contact - Record(t, fixtureBase+"list", func(c *Client) { - cs, err = c.ListContacts(context.TODO(), &ListContactsInput{ - CustomerID: customerID, - }) - }) - if err != nil { - t.Fatal(err) - } - if len(cs) < 1 { - t.Errorf("bad contacts: %v", cs) - } - - // Delete - Record(t, fixtureBase+"delete", func(c *Client) { - err = c.DeleteContact(context.TODO(), &DeleteContactInput{ - CustomerID: customerID, - ContactID: co.ContactID, - }) - }) - if err != nil { - t.Fatal(err) - } -} - -func TestClient_ListContacts_validation(t *testing.T) { - _, err := TestClient.ListContacts(context.TODO(), &ListContactsInput{ - CustomerID: "", - }) - if !errors.Is(err, ErrMissingCustomerID) { - t.Errorf("bad error: %s", err) - } -} - -func TestClient_CreateContact_validation(t *testing.T) { - _, err := TestClient.CreateContact(context.TODO(), &CreateContactInput{ - CustomerID: "", - }) - if !errors.Is(err, ErrMissingCustomerID) { - t.Errorf("bad error: %s", err) - } -} - -func TestClient_DeleteContact_validation(t *testing.T) { - err := TestClient.DeleteContact(context.TODO(), &DeleteContactInput{ - CustomerID: "", - ContactID: "abc", - }) - if !errors.Is(err, ErrMissingCustomerID) { - t.Errorf("bad error: %s", err) - } - - err = TestClient.DeleteContact(context.TODO(), &DeleteContactInput{ - CustomerID: "abc", - ContactID: "", - }) - if !errors.Is(err, ErrMissingContactID) { - t.Errorf("bad error: %s", err) - } -} diff --git a/fastly/customer/contacts/api_contacts_test.go b/fastly/customer/contacts/api_contacts_test.go new file mode 100644 index 000000000..c80b07623 --- /dev/null +++ b/fastly/customer/contacts/api_contacts_test.go @@ -0,0 +1,93 @@ +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 + customerID string + ) + fastly.Record(t, "get_current_user", func(c *fastly.Client) { + var u *fastly.User + u, err = c.GetCurrentUser(ctx) + if err == nil && u != nil && u.CustomerID != nil { + customerID = *u.CustomerID + } + }) + require.NoError(t, err) + require.NotEmpty(t, customerID) + + // 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..d65b78adc --- /dev/null +++ b/fastly/customer/contacts/api_create.go @@ -0,0 +1,88 @@ +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: deref(i.UserID), + ContactType: deref(i.ContactType), + Name: deref(i.Name), + FirstName: deref(i.FirstName), + LastName: deref(i.LastName), + Email: deref(i.Email), + Phone: deref(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 +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} 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/fixtures/contacts/cleanup.yaml b/fastly/customer/contacts/fixtures/cleanup.yaml similarity index 100% rename from fastly/fixtures/contacts/cleanup.yaml rename to fastly/customer/contacts/fixtures/cleanup.yaml diff --git a/fastly/fixtures/contacts/create.yaml b/fastly/customer/contacts/fixtures/create.yaml similarity index 100% rename from fastly/fixtures/contacts/create.yaml rename to fastly/customer/contacts/fixtures/create.yaml diff --git a/fastly/fixtures/contacts/create_guard.yaml b/fastly/customer/contacts/fixtures/create_guard.yaml similarity index 100% rename from fastly/fixtures/contacts/create_guard.yaml rename to fastly/customer/contacts/fixtures/create_guard.yaml diff --git a/fastly/fixtures/contacts/delete.yaml b/fastly/customer/contacts/fixtures/delete.yaml similarity index 100% rename from fastly/fixtures/contacts/delete.yaml rename to fastly/customer/contacts/fixtures/delete.yaml diff --git a/fastly/fixtures/contacts/get_current_user.yaml b/fastly/customer/contacts/fixtures/get_current_user.yaml similarity index 100% rename from fastly/fixtures/contacts/get_current_user.yaml rename to fastly/customer/contacts/fixtures/get_current_user.yaml diff --git a/fastly/fixtures/contacts/list.yaml b/fastly/customer/contacts/fixtures/list.yaml similarity index 100% rename from fastly/fixtures/contacts/list.yaml rename to fastly/customer/contacts/fixtures/list.yaml From 6302425199d801de824d75a91805870b67ede6f2 Mon Sep 17 00:00:00 2001 From: bclerc Date: Fri, 29 May 2026 09:00:04 +0200 Subject: [PATCH 4/5] refactor(customer/contacts): use fastly.ToValue instead of local deref helper --- fastly/customer/contacts/api_create.go | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/fastly/customer/contacts/api_create.go b/fastly/customer/contacts/api_create.go index d65b78adc..37340cd8a 100644 --- a/fastly/customer/contacts/api_create.go +++ b/fastly/customer/contacts/api_create.go @@ -58,13 +58,13 @@ func Create(ctx context.Context, c *fastly.Client, i *CreateInput) (*Contact, er path := fastly.ToSafeURL("customer", *i.CustomerID, "contacts") payload := &createPayload{ - UserID: deref(i.UserID), - ContactType: deref(i.ContactType), - Name: deref(i.Name), - FirstName: deref(i.FirstName), - LastName: deref(i.LastName), - Email: deref(i.Email), - Phone: deref(i.Phone), + 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()) @@ -79,10 +79,3 @@ func Create(ctx context.Context, c *fastly.Client, i *CreateInput) (*Contact, er } return &co, nil } - -func deref(s *string) string { - if s == nil { - return "" - } - return *s -} From 0e7b3971e1d95623c7b6a252d24c46f1bf7d618c Mon Sep 17 00:00:00 2001 From: bclerc Date: Fri, 29 May 2026 09:00:18 +0200 Subject: [PATCH 5/5] test(customer/contacts): source customer ID from a constant GetCurrentUser is an API call outside the package being tested, so it should not be recorded as a fixture. Use a hardcoded customer ID instead, matching the apisecurity/operations pattern. --- fastly/customer/contacts/api_contacts_test.go | 18 ++----- .../contacts/fixtures/get_current_user.yaml | 48 ------------------- 2 files changed, 5 insertions(+), 61 deletions(-) delete mode 100644 fastly/customer/contacts/fixtures/get_current_user.yaml diff --git a/fastly/customer/contacts/api_contacts_test.go b/fastly/customer/contacts/api_contacts_test.go index c80b07623..d99ba85fd 100644 --- a/fastly/customer/contacts/api_contacts_test.go +++ b/fastly/customer/contacts/api_contacts_test.go @@ -12,19 +12,11 @@ import ( func TestClient_Contacts(t *testing.T) { ctx := context.TODO() - var ( - err error - customerID string - ) - fastly.Record(t, "get_current_user", func(c *fastly.Client) { - var u *fastly.User - u, err = c.GetCurrentUser(ctx) - if err == nil && u != nil && u.CustomerID != nil { - customerID = *u.CustomerID - } - }) - require.NoError(t, err) - require.NotEmpty(t, customerID) + 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 diff --git a/fastly/customer/contacts/fixtures/get_current_user.yaml b/fastly/customer/contacts/fixtures/get_current_user.yaml deleted file mode 100644 index ea83530b5..000000000 --- a/fastly/customer/contacts/fixtures/get_current_user.yaml +++ /dev/null @@ -1,48 +0,0 @@ ---- -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/current_user - method: GET - response: - body: | - {"id":"2x3y4z5a6b7c8d9e0f1g2h","customer_id":"7i6ZbMEdjnTUNyk75XgWO0","email_hash":"00000000000000000000000000000000","limit_services":false,"limit_workspaces":false,"locked":false,"login":"go-fastly-test@example.com","name":"Test User","require_new_password":false,"role":"superuser","roles":["3bbbbbbbbbbbbbbbbbbbbb"],"two_factor_auth_enabled":false,"two_factor_setup_required":false,"created_at":"2026-01-26T09:02:21Z","updated_at":"2026-05-22T12:29:42Z","deleted_at":null,"last_active_at":"2026-05-22T11:50:14Z","bypass_sso":false,"ignores_two_factor_requirement":false} - headers: - Accept-Ranges: - - bytes - Cache-Control: - - no-store - Content-Length: - - "580" - Content-Type: - - application/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-klot8100076-CHI, cache-par-lfpg1960084-PAR - X-Timer: - - S1779453597.030855,VS0,VE126 - status: 200 OK - code: 200 - duration: ""