Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: key management support #496

Merged
merged 7 commits into from
Jul 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ jobs:
timeout-minutes: 10
env:
HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY }}
HONEYCOMB_KEY_ID: ${{ secrets.HONEYCOMB_KEY_ID }}
HONEYCOMB_KEY_SECRET: ${{ secrets.HONEYCOMB_KEY_SECRET }}
HONEYCOMB_DATASET: testacc
HONEYCOMB_ENVIRONMENT_ID: hcaen_01j1d7t02zf7wgw7q89z3t60vf # TODO: remove and do a lookup or create in tests
run: go test -v -coverprofile=client-coverage.txt -covermode=atomic ./client/...

- uses: hashicorp/setup-terraform@v3
Expand All @@ -73,7 +76,10 @@ jobs:
timeout-minutes: 10
env:
HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY }}
HONEYCOMB_KEY_ID: ${{ secrets.HONEYCOMB_KEY_ID }}
HONEYCOMB_KEY_SECRET: ${{ secrets.HONEYCOMB_KEY_SECRET }}
HONEYCOMB_DATASET: testacc
HONEYCOMB_ENVIRONMENT_ID: hcaen_01j1d7t02zf7wgw7q89z3t60vf # TODO: remove and do a lookup or create in tests
Copy link
Collaborator Author

@jharley jharley Jul 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will drop these (here and below) when the v2client has Environments API support

TF_ACC: 1
TF_ACC_TERRAFORM_VERSION: ${{ env.TERRAFORM_VERSION }}
run: go test -v -coverprofile=tf-coverage.txt -covermode=atomic ./internal/... ./honeycombio/...
Expand Down Expand Up @@ -112,7 +118,10 @@ jobs:
env:
HONEYCOMB_API_ENDPOINT: https://api.eu1.honeycomb.io
HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY_EU }}
HONEYCOMB_KEY_ID: ${{ secrets.HONEYCOMB_KEY_ID_EU }}
HONEYCOMB_KEY_SECRET: ${{ secrets.HONEYCOMB_KEY_SECRET_EU }}
HONEYCOMB_DATASET: testacc
HONEYCOMB_ENVIRONMENT_ID: hcben_01hvp28qbgzaeebbz29qvbb7gt # TODO: remove and do a lookup or create in tests
run: go test -v -coverprofile=client-coverage.txt -covermode=atomic ./client/...

- uses: hashicorp/setup-terraform@v3
Expand All @@ -125,7 +134,10 @@ jobs:
env:
HONEYCOMB_API_ENDPOINT: https://api.eu1.honeycomb.io
HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY_EU }}
HONEYCOMB_KEY_ID: ${{ secrets.HONEYCOMB_KEY_ID_EU }}
HONEYCOMB_KEY_SECRET: ${{ secrets.HONEYCOMB_KEY_SECRET_EU }}
HONEYCOMB_DATASET: testacc
HONEYCOMB_ENVIRONMENT_ID: hcben_01hvp28qbgzaeebbz29qvbb7gt # TODO: remove and do a lookup or create in tests
TF_ACC: 1
TF_ACC_TERRAFORM_VERSION: ${{ env.TERRAFORM_VERSION }}
run: go test -v -coverprofile=tf-coverage.txt -covermode=atomic ./internal/... ./honeycombio/...
Expand Down
2 changes: 1 addition & 1 deletion client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ func (c *Client) Do(ctx context.Context, method, path string, requestBody, respo
defer resp.Body.Close()

if !(resp.StatusCode >= 200 && resp.StatusCode <= 299) {
return errorFromResponse(resp)
return ErrorFromResponse(resp)
}
if responseBody != nil {
err = json.NewDecoder(resp.Body).Decode(responseBody)
Expand Down
88 changes: 61 additions & 27 deletions client/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ package client

import (
"encoding/json"
"fmt"
"io"
"errors"
"net/http"

"github.com/hashicorp/jsonapi"
)

// DetailedError is an RFC7807 'Problem Detail' formatted error message.
Expand Down Expand Up @@ -52,6 +53,14 @@ func (td ErrorTypeDetail) String() string {
return response
}

// IsNotFound returns true if the error is an HTTP 404
func (e *DetailedError) IsNotFound() bool {
if e == nil {
return false
}
return e.Status == http.StatusNotFound
}

// Error returns a pretty-printed representation of the error
func (e DetailedError) Error() string {
if len(e.Details) > 0 {
Expand All @@ -60,7 +69,8 @@ func (e DetailedError) Error() string {
for index, details := range e.Details {
response += details.String()

// If we haven't reached the end of the list of error details, add a newline separator between each error
// If we haven't reached the end of the list of error details,
// add a newline separator between each error
if index < len(e.Details)-1 {
response += "\n"
}
Expand All @@ -72,34 +82,58 @@ func (e DetailedError) Error() string {
return e.Message
}

// IsNotFound returns true if the error is an HTTP 404
func (e *DetailedError) IsNotFound() bool {
if e == nil {
return false
func ErrorFromResponse(r *http.Response) error {
if r == nil {
return errors.New("invalid response")
}
return e.Status == http.StatusNotFound
}

func errorFromResponse(resp *http.Response) error {
e, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("unable to read response body: %w", err)
}
switch r.Header.Get("Content-Type") {
case jsonapi.MediaType:
var detailedError DetailedError

var detailedErr DetailedError
err = json.Unmarshal(e, &detailedErr)
if err != nil {
// we failed to parse the body as a DetailedError, so build one from what we know
return DetailedError{
Status: resp.StatusCode,
Message: resp.Status,
errPayload := new(jsonapi.ErrorsPayload)
err := json.NewDecoder(r.Body).Decode(errPayload)
if err != nil || len(errPayload.Errors) == 0 {
return DetailedError{
Status: r.StatusCode,
Message: r.Status,
}
}
}

// quick sanity check to make sure we got a StatusCode
if detailedErr.Status == 0 {
detailedErr.Status = resp.StatusCode
}
detailedError = DetailedError{
Status: r.StatusCode,
Title: errPayload.Errors[0].Title,
}
if len(errPayload.Errors) == 1 {
// If there's only one error we don't need to build up details
detailedError.Message = errPayload.Errors[0].Detail
detailedError.Type = errPayload.Errors[0].Code
} else {
details := make([]ErrorTypeDetail, len(errPayload.Errors))
for i, e := range errPayload.Errors {
details[i] = ErrorTypeDetail{
Code: e.Code,
Description: e.Detail,
// TODO: field when we have it via pointer
}
}
detailedError.Details = details
}
return detailedError
default:
var detailedError DetailedError
if err := json.NewDecoder(r.Body).Decode(&detailedError); err != nil {
// If we can't decode the error, return a generic error
return DetailedError{
Status: r.StatusCode,
Message: r.Status,
}
}

return detailedErr
// sanity check: ensure we have the status code set
if detailedError.Status == 0 {
detailedError.Status = r.StatusCode
}
return detailedError
}
}
83 changes: 83 additions & 0 deletions client/errors_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package client_test

import (
"bytes"
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"

"github.com/hashicorp/jsonapi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

Expand Down Expand Up @@ -191,3 +194,83 @@ func TestErrors_ErrorTypeDetail_String(t *testing.T) {
})
}
}

func TestErrors_JSONAPI(t *testing.T) {
t.Parallel()

testCases := []struct {
name string
code int
body jsonapi.ErrorsPayload
expectedOutput client.DetailedError
}{
{
name: "single error",
code: http.StatusConflict,
body: jsonapi.ErrorsPayload{
Errors: []*jsonapi.ErrorObject{
{
Status: "409",
Title: "Conflict",
Detail: "The resource already exists.",
Code: "/errors/conflict",
},
},
},
expectedOutput: client.DetailedError{
Status: 409,
Type: "/errors/conflict",
Message: "The resource already exists.",
Title: "Conflict",
},
},
{
name: "multi error",
code: http.StatusUnprocessableEntity,
body: jsonapi.ErrorsPayload{
Errors: []*jsonapi.ErrorObject{
{
Status: "422",
Code: "/errors/validation-failed",
Title: "The provided input is invalid.",
},
{
Status: "422",
Code: "/errors/validation-failed",
Title: "The provided input is invalid.",
},
},
},
expectedOutput: client.DetailedError{
Status: 422,
Title: "The provided input is invalid.",
Details: []client.ErrorTypeDetail{
{
Code: "/errors/validation-failed",
},
{
Code: "/errors/validation-failed",
},
},
},
},
}

for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
resp := &httptest.ResponseRecorder{
Code: testCase.code,
HeaderMap: http.Header{
"Content-Type": []string{jsonapi.MediaType},
},
}

buf := bytes.NewBuffer(nil)
jsonapi.MarshalErrors(buf, testCase.body.Errors)
resp.Body = bytes.NewBuffer(buf.Bytes())

actualOutput := client.ErrorFromResponse(resp.Result())
assert.Equal(t, testCase.expectedOutput, actualOutput)
})
}
}
115 changes: 115 additions & 0 deletions client/v2/api_keys.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package v2

import (
"context"
"fmt"
"net/http"

"github.com/hashicorp/jsonapi"

hnyclient "github.com/honeycombio/terraform-provider-honeycombio/client"
)

// Compile-time proof of interface implementation.
var _ APIKeys = (*apiKeys)(nil)

type APIKeys interface {
Create(ctx context.Context, key *APIKey) (*APIKey, error)
Get(ctx context.Context, id string) (*APIKey, error)
Update(ctx context.Context, key *APIKey) (*APIKey, error)
Delete(ctx context.Context, id string) error
List(ctx context.Context, opts ...ListOption) (*Pager[APIKey], error)
}

const (
apiKeysPath = "/2/teams/%s/api-keys"
apiKeysByIDPath = "/2/teams/%s/api-keys/%s"
)

type apiKeys struct {
client *Client
authinfo *AuthMetadata
}

func (a *apiKeys) Create(ctx context.Context, k *APIKey) (*APIKey, error) {
r, err := a.client.Do(ctx,
http.MethodPost,
fmt.Sprintf(apiKeysPath, a.authinfo.Team.Slug),
k,
)
if err != nil {
return nil, err
}
if r.StatusCode != http.StatusCreated {
return nil, hnyclient.ErrorFromResponse(r)
}

key := new(APIKey)
if err := jsonapi.UnmarshalPayload(r.Body, key); err != nil {
return nil, err
}
return key, nil
}

func (a *apiKeys) Get(ctx context.Context, id string) (*APIKey, error) {
r, err := a.client.Do(ctx,
http.MethodGet,
fmt.Sprintf(apiKeysByIDPath, a.authinfo.Team.Slug, id),
nil,
)
if err != nil {
return nil, err
}
if r.StatusCode != http.StatusOK {
return nil, hnyclient.ErrorFromResponse(r)
}

key := new(APIKey)
if err := jsonapi.UnmarshalPayload(r.Body, key); err != nil {
return nil, err
}
return key, nil
}

func (a *apiKeys) Update(ctx context.Context, k *APIKey) (*APIKey, error) {
r, err := a.client.Do(ctx,
http.MethodPatch,
fmt.Sprintf(apiKeysByIDPath, a.authinfo.Team.Slug, k.ID),
k,
)
if err != nil {
return nil, err
}
if r.StatusCode != http.StatusOK {
return nil, hnyclient.ErrorFromResponse(r)
}

key := new(APIKey)
if err := jsonapi.UnmarshalPayload(r.Body, key); err != nil {
return nil, err
}
return key, nil
}

func (a *apiKeys) Delete(ctx context.Context, id string) error {
r, err := a.client.Do(ctx,
http.MethodDelete,
fmt.Sprintf(apiKeysByIDPath, a.authinfo.Team.Slug, id),
nil,
)
if err != nil {
return err
}
if r.StatusCode != http.StatusNoContent {
return hnyclient.ErrorFromResponse(r)
}
return nil
}

func (a *apiKeys) List(ctx context.Context, os ...ListOption) (*Pager[APIKey], error) {
return NewPager[APIKey](
a.client,
fmt.Sprintf(apiKeysPath, a.authinfo.Team.Slug),
os...,
)
}
Loading