Repository: honeycombio/terraform-provider-honeycombio
Pull request #184 (2022-07-11 16:04:41)

Recipients support for provider's client
First pass at making the provider 'Recipients API' aware. This is just the underlying plumbing for the provider's client: HCL-awareness and validation coming next but wanted to break it up a bit.

I also took a the opportunity to surface the API's detailed error message (`type_detail`) if it was present in the response.


Patch:
diff --git a/.go-version b/.go-version
new file mode 100644
index 0000000..b8f1e3f
--- /dev/null
+++ b/.go-version
@@ -0,0 +1,1 @@
+1.17.11
diff --git a/client/burn_alert.go b/client/burn_alert.go
index ebde466..7f5cb14 100644
--- a/client/burn_alert.go
+++ b/client/burn_alert.go
@@ -42,12 +42,12 @@ type SLORef struct {
 }
 
 type BurnAlert struct {
-	ID                string      `json:"id,omitempty"`
-	ExhaustionMinutes int         `json:"exhaustion_minutes"`
-	SLO               SLORef      `json:"slo"`
-	CreatedAt         time.Time   `json:"created_at,omitempty"`
-	UpdatedAt         time.Time   `json:"updated_at,omitempty"`
-	Recipients        []Recipient `json:"recipients,omitempty"`
+	ID                string                  `json:"id,omitempty"`
+	ExhaustionMinutes int                     `json:"exhaustion_minutes"`
+	SLO               SLORef                  `json:"slo"`
+	CreatedAt         time.Time               `json:"created_at,omitempty"`
+	UpdatedAt         time.Time               `json:"updated_at,omitempty"`
+	Recipients        []NotificationRecipient `json:"recipients,omitempty"`
 }
 
 func (s *burnalerts) ListForSLO(ctx context.Context, dataset string, sloId string) ([]BurnAlert, error) {
diff --git a/client/client.go b/client/client.go
index 120f3b5..2f38b55 100644
--- a/client/client.go
+++ b/client/client.go
@@ -73,6 +73,7 @@ type Client struct {
 	Triggers         Triggers
 	SLOs             SLOs
 	BurnAlerts       BurnAlerts
+	Recipients       Recipients
 }
 
 // NewClient creates a new Honeycomb API client.
@@ -110,6 +111,7 @@ func NewClient(config *Config) (*Client, error) {
 	client.Triggers = &triggers{client: client}
 	client.SLOs = &slos{client: client}
 	client.BurnAlerts = &burnalerts{client: client}
+	client.Recipients = &recipients{client: client}
 
 	return client, nil
 }
@@ -177,7 +179,27 @@ func errorFromResponse(resp *http.Response) error {
 }
 
 type honeycombioError struct {
-	ErrorMessage string `json:"error"`
+	Status  int    `json:"status"`
+	Err     string `json:"error"`
+	Details []struct {
+		Code        string `json:"code"`
+		Description string `json:"description"`
+	} `json:"type_detail,omitempty"`
+}
+
+func (e *honeycombioError) Error() string {
+	if len(e.Details) > 0 {
+		var response string
+		for i, d := range e.Details {
+			response += d.Code + " - " + d.Description
+			if i > len(e.Details)-1 {
+				response += ", "
+			}
+		}
+		return response
+	}
+
+	return e.Err
 }
 
 func attemptToExtractHoneycombioError(bodyReader io.Reader) string {
@@ -189,11 +211,11 @@ func attemptToExtractHoneycombioError(bodyReader io.Reader) string {
 	var honeycombioErr honeycombioError
 
 	err = json.Unmarshal(body, &honeycombioErr)
-	if err != nil || honeycombioErr.ErrorMessage == "" {
+	if err != nil {
 		return string(body)
 	}
 
-	return honeycombioErr.ErrorMessage
+	return honeycombioErr.Error()
 }
 
 // urlEncodeDataset sanitizes the dataset name for when it is used as part of
diff --git a/client/recipient.go b/client/recipient.go
index 7d76802..e7fbe0c 100644
--- a/client/recipient.go
+++ b/client/recipient.go
@@ -1,10 +1,70 @@
 package client
 
-// Recipients creates a Recipient definition for use by BurnAlerts and Triggers in the the Honeycomb API
+import (
+	"context"
+	"fmt"
+	"time"
+)
+
+// Recipients describe all the Recipient-related methods that the Honeycomb API supports.
+//
+// API docs: https://docs.honeycomb.io/api/recipients/
+type Recipients interface {
+	// List all Recipients
+	List(ctx context.Context) ([]Recipient, error)
+
+	// Get a Recipient by its ID. Returns ErrNotFound if there is no Recipient with
+	// the given ID
+	Get(ctx context.Context, id string) (*Recipient, error)
+
+	// Create a new Recipient. When creating a new Recipient ID ma not be set.
+	Create(ctx context.Context, r *Recipient) (*Recipient, error)
+
+	// Update an existing Recipient.
+	Update(ctx context.Context, r *Recipient) (*Recipient, error)
+
+	// Delete a Recipient from the dataset.
+	Delete(ctx context.Context, id string) error
+}
+
+// recipients implements Recipients
+type recipients struct {
+	client *Client
+}
+
+// Compile-time proof of interface implementation by type recipients.
+var _ Recipients = (*recipients)(nil)
+
+// Recipient represents a Honeycomb Recipient
 type Recipient struct {
+	ID        string           `json:"id,omitempty"`
+	Type      RecipientType    `json:"type"`
+	Details   RecipientDetails `json:"details"`
+	CreatedAt time.Time        `json:"created_at,omitempty"`
+	UpdatedAt time.Time        `json:"updated_at,omitempty"`
+}
+
+// NotificationRecipient represents a recipient embedded in a Trigger or Burn Alert
+type NotificationRecipient struct {
 	ID     string        `json:"id,omitempty"`
 	Type   RecipientType `json:"type,omitempty"`
-	Target string        `json:"target"`
+	Target string        `json:"target,omitempty"`
+}
+
+type RecipientDetails struct {
+	// email
+	EmailAddress string `json:"email_address,omitempty"`
+	// marker
+	MarkerID string `json:"marker_id,omitempty"`
+	// pagerduty
+	PDIntegrationKey  string `json:"pagerduty_integration_key,omitempty"`
+	PDIntegrationName string `json:"pagerduty_integration_name,omitempty"`
+	// slack
+	SlackChannel string `json:"slack_channel,omitempty"`
+	// webhook
+	WebhookName   string `json:"webhook_name,omitempty"`
+	WebhookURL    string `json:"webhook_url,omitempty"`
+	WebhookSecret string `json:"webhook_secret,omitempty"`
 }
 
 // RecipientType holds all the possible recipient types.
@@ -42,3 +102,31 @@ func BurnAlertRecipientTypes() []RecipientType {
 		RecipientTypeZenoss,
 	}
 }
+
+func (s *recipients) List(ctx context.Context) ([]Recipient, error) {
+	var r []Recipient
+	err := s.client.performRequest(ctx, "GET", "/1/recipients", nil, &r)
+	return r, err
+}
+
+func (s *recipients) Get(ctx context.Context, ID string) (*Recipient, error) {
+	var r Recipient
+	err := s.client.performRequest(ctx, "GET", fmt.Sprintf("/1/recipients/%s", ID), nil, &r)
+	return &r, err
+}
+
+func (s *recipients) Create(ctx context.Context, data *Recipient) (*Recipient, error) {
+	var r Recipient
+	err := s.client.performRequest(ctx, "POST", "/1/recipients", data, &r)
+	return &r, err
+}
+
+func (s *recipients) Update(ctx context.Context, data *Recipient) (*Recipient, error) {
+	var r Recipient
+	err := s.client.performRequest(ctx, "PUT", fmt.Sprintf("/1/recipients/%s", data.ID), data, &r)
+	return &r, err
+}
+
+func (s *recipients) Delete(ctx context.Context, id string) error {
+	return s.client.performRequest(ctx, "DELETE", fmt.Sprintf("/1/recipients/%s", id), nil, nil)
+}
diff --git a/client/trigger.go b/client/trigger.go
index 8c81a26..3479f23 100644
--- a/client/trigger.go
+++ b/client/trigger.go
@@ -67,7 +67,7 @@ type Trigger struct {
 	// divisible by 60 and between 60 and 86400 (between 1 minute and 1 day).
 	Frequency int `json:"frequency,omitempty"`
 	// Recipients are notified when the trigger fires.
-	Recipients []Recipient `json:"recipients,omitempty"`
+	Recipients []NotificationRecipient `json:"recipients,omitempty"`
 }
 
 // TriggerThreshold represents the threshold of a trigger.
diff --git a/honeycombio/type_helpers.go b/honeycombio/type_helpers.go
index 6b4d37d..eea6644 100644
--- a/honeycombio/type_helpers.go
+++ b/honeycombio/type_helpers.go
@@ -150,7 +150,7 @@ func tpmToFloat(t int) float64 {
 	return float64(t) / 10000
 }
 
-func flattenRecipients(rs []honeycombio.Recipient) []map[string]interface{} {
+func flattenRecipients(rs []honeycombio.NotificationRecipient) []map[string]interface{} {
 	result := make([]map[string]interface{}, len(rs))
 
 	for i, r := range rs {
@@ -164,13 +164,13 @@ func flattenRecipients(rs []honeycombio.Recipient) []map[string]interface{} {
 	return result
 }
 
-func expandRecipients(s []interface{}) []honeycombio.Recipient {
-	recipients := make([]honeycombio.Recipient, len(s))
+func expandRecipients(s []interface{}) []honeycombio.NotificationRecipient {
+	recipients := make([]honeycombio.NotificationRecipient, len(s))
 
 	for i, r := range s {
 		rMap := r.(map[string]interface{})
 
-		recipients[i] = honeycombio.Recipient{
+		recipients[i] = honeycombio.NotificationRecipient{
 			ID:     rMap["id"].(string),
 			Type:   honeycombio.RecipientType(rMap["type"].(string)),
 			Target: rMap["target"].(string),
@@ -185,10 +185,10 @@ func expandRecipients(s []interface{}) []honeycombio.Recipient {
 //
 // This cannot currently be handled efficiently by a DiffSuppressFunc.
 // See: https://github.com/hashicorp/terraform-plugin-sdk/issues/477
-func matchRecipientsWithSchema(readRecipients []honeycombio.Recipient, declaredRecipients []interface{}) []honeycombio.Recipient {
-	result := make([]honeycombio.Recipient, len(declaredRecipients))
+func matchRecipientsWithSchema(readRecipients []honeycombio.NotificationRecipient, declaredRecipients []interface{}) []honeycombio.NotificationRecipient {
+	result := make([]honeycombio.NotificationRecipient, len(declaredRecipients))
 
-	rMap := make(map[string]honeycombio.Recipient, len(readRecipients))
+	rMap := make(map[string]honeycombio.NotificationRecipient, len(readRecipients))
 	for _, recipient := range readRecipients {
 		rMap[recipient.ID] = recipient
 	}


Test patch:
diff --git a/client/burn_alert_test.go b/client/burn_alert_test.go
index 98d751b..43a7d87 100644
--- a/client/burn_alert_test.go
+++ b/client/burn_alert_test.go
@@ -42,10 +42,10 @@ func TestBurnAlerts(t *testing.T) {
 		data := &BurnAlert{
 			ExhaustionMinutes: int(24 * 60), // 24 hours
 			SLO:               SLORef{ID: slo.ID},
-			Recipients: []Recipient{
+			Recipients: []NotificationRecipient{
 				{
 					Type:   "email",
-					Target: "testalert@honeycomb.test",
+					Target: "testalert@example.com",
 				},
 			},
 		}
@@ -83,7 +83,7 @@ func TestBurnAlerts(t *testing.T) {
 
 	t.Run("ListForSLO", func(t *testing.T) {
 		results, err := c.BurnAlerts.ListForSLO(ctx, dataset, slo.ID)
-		burnAlert.Recipients = []Recipient{}
+		burnAlert.Recipients = []NotificationRecipient{}
 		assert.NoError(t, err, "failed to list burn alerts for SLO")
 		assert.NotZero(t, len(results))
 		assert.Equal(t, burnAlert.ID, results[0].ID, "newly created BurnAlert not in list of SLO's burn alerts")
diff --git a/client/recipient_test.go b/client/recipient_test.go
new file mode 100644
index 0000000..dff6239
--- /dev/null
+++ b/client/recipient_test.go
@@ -0,0 +1,75 @@
+package client
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestRecipientsEmail(t *testing.T) {
+	ctx := context.Background()
+
+	var rcpt *Recipient
+	var err error
+
+	c := newTestClient(t)
+
+	t.Run("Create", func(t *testing.T) {
+		data := &Recipient{
+			Type: RecipientTypeEmail,
+			Details: RecipientDetails{
+				EmailAddress: "hnytest@example.com",
+			},
+		}
+
+		rcpt, err = c.Recipients.Create(ctx, data)
+
+		assert.NoError(t, err, "unable to create Recipient")
+		assert.NotNil(t, rcpt.ID, "ID is empty")
+		assert.NotNil(t, rcpt.CreatedAt, "created at is empty")
+		assert.NotNil(t, rcpt.UpdatedAt, "updated at is empty")
+		// copy dynamic fields before asserting equality
+		data.ID = rcpt.ID
+		data.CreatedAt = rcpt.CreatedAt
+		data.UpdatedAt = rcpt.UpdatedAt
+		assert.Equal(t, data, rcpt)
+	})
+
+	t.Run("List", func(t *testing.T) {
+		results, err := c.Recipients.List(ctx)
+
+		assert.NoError(t, err, "unable to list Recipients")
+		assert.Contains(t, results, *rcpt, "could not find newly created Recipient with List")
+	})
+
+	t.Run("Get", func(t *testing.T) {
+		result, err := c.Recipients.Get(ctx, rcpt.ID)
+
+		assert.NoError(t, err, "failed to get Recipient by ID")
+		assert.Equal(t, *rcpt, *result)
+	})
+
+	t.Run("Update", func(t *testing.T) {
+		rcpt.Details.EmailAddress = "hnytest2@example.com"
+
+		result, err := c.Recipients.Update(ctx, rcpt)
+
+		assert.NoError(t, err, "failed to update Recipient")
+		// copy dynamic field before asserting equality
+		rcpt.UpdatedAt = result.UpdatedAt
+		assert.Equal(t, result, rcpt)
+	})
+
+	t.Run("Delete", func(t *testing.T) {
+		err = c.Recipients.Delete(ctx, rcpt.ID)
+
+		assert.NoError(t, err, "failed to delete Recipient")
+	})
+
+	t.Run("Get_NotFound", func(t *testing.T) {
+		_, err := c.Recipients.Get(ctx, rcpt.ID)
+
+		assert.Equal(t, ErrNotFound, err)
+	})
+}
diff --git a/client/trigger_test.go b/client/trigger_test.go
index 0f29414..20b50b4 100644
--- a/client/trigger_test.go
+++ b/client/trigger_test.go
@@ -50,7 +50,7 @@ func TestTriggers(t *testing.T) {
 				Op:    TriggerThresholdOpGreaterThan,
 				Value: 10000,
 			},
-			Recipients: []Recipient{
+			Recipients: []NotificationRecipient{
 				{
 					Type:   RecipientTypeEmail,
 					Target: "hello@example.com",
diff --git a/honeycombio/data_source_trigger_recipient_test.go b/honeycombio/data_source_trigger_recipient_test.go
index 4f821f5..cb50535 100644
--- a/honeycombio/data_source_trigger_recipient_test.go
+++ b/honeycombio/data_source_trigger_recipient_test.go
@@ -12,7 +12,7 @@ import (
 func TestAccDataSourceHoneycombioTriggerRecipient_basic(t *testing.T) {
 	dataset := testAccDataset()
 
-	_, deleteFn := createTriggerWithRecipient(t, dataset, honeycombio.Recipient{
+	_, deleteFn := createTriggerWithRecipient(t, dataset, honeycombio.NotificationRecipient{
 		Type:   honeycombio.RecipientTypeEmail,
 		Target: "acctest@example.com",
 	})
diff --git a/honeycombio/helpers_test.go b/honeycombio/helpers_test.go
index b1933e6..128cfcf 100644
--- a/honeycombio/helpers_test.go
+++ b/honeycombio/helpers_test.go
@@ -55,7 +55,7 @@ func testCheckOutputDoesNotContain(name, contains string) resource.TestCheckFunc
 	}
 }
 
-func createTriggerWithRecipient(t *testing.T, dataset string, recipient honeycombio.Recipient) (trigger *honeycombio.Trigger, deleteFn func()) {
+func createTriggerWithRecipient(t *testing.T, dataset string, recipient honeycombio.NotificationRecipient) (trigger *honeycombio.Trigger, deleteFn func()) {
 	ctx := context.Background()
 	c := testAccClient(t)
 
@@ -72,7 +72,7 @@ func createTriggerWithRecipient(t *testing.T, dataset string, recipient honeycom
 			Op:    honeycombio.TriggerThresholdOpGreaterThan,
 			Value: 100,
 		},
-		Recipients: []honeycombio.Recipient{recipient},
+		Recipients: []honeycombio.NotificationRecipient{recipient},
 	}
 	trigger, err := c.Triggers.Create(ctx, dataset, trigger)
 	if err != nil {
diff --git a/honeycombio/resource_burn_alert_test.go b/honeycombio/resource_burn_alert_test.go
index 914fefd..94f8a31 100644
--- a/honeycombio/resource_burn_alert_test.go
+++ b/honeycombio/resource_burn_alert_test.go
@@ -89,7 +89,7 @@ func TestAccHoneycombioBurnAlert_RecipientById(t *testing.T) {
 		c.DerivedColumns.Delete(ctx, dataset, sli.ID)
 	})
 
-	trigger, deleteFn := createTriggerWithRecipient(t, dataset, honeycombio.Recipient{
+	trigger, deleteFn := createTriggerWithRecipient(t, dataset, honeycombio.NotificationRecipient{
 		Type:   honeycombio.RecipientTypeEmail,
 		Target: "acctest@example.com",
 	})
diff --git a/honeycombio/resource_trigger_test.go b/honeycombio/resource_trigger_test.go
index 08a179f..dbc398c 100644
--- a/honeycombio/resource_trigger_test.go
+++ b/honeycombio/resource_trigger_test.go
@@ -93,7 +93,7 @@ func testAccCheckTriggerAttributes(t *honeycombio.Trigger) resource.TestCheckFun
 func TestAccHoneycombioTrigger_triggerRecipientById(t *testing.T) {
 	dataset := testAccDataset()
 
-	trigger, deleteFn := createTriggerWithRecipient(t, dataset, honeycombio.Recipient{
+	trigger, deleteFn := createTriggerWithRecipient(t, dataset, honeycombio.NotificationRecipient{
 		Type:   honeycombio.RecipientTypeEmail,
 		Target: "acctest@example.com",
 	})