diff --git a/bonsai/client.go b/bonsai/client.go index 9dda073..69c7d5f 100644 --- a/bonsai/client.go +++ b/bonsai/client.go @@ -322,8 +322,9 @@ type Client struct { userAgent string // Clients - Space SpaceClient - Plan PlanClient + Space SpaceClient + Plan PlanClient + Release ReleaseClient } func NewClient(options ...ClientOption) *Client { @@ -343,6 +344,7 @@ func NewClient(options ...ClientOption) *Client { // Configure child clients client.Space = SpaceClient{client} client.Plan = PlanClient{client} + client.Release = ReleaseClient{client} return client } diff --git a/bonsai/plan_impl_test.go b/bonsai/plan_impl_test.go index f3dc0bc..ddb950e 100644 --- a/bonsai/plan_impl_test.go +++ b/bonsai/plan_impl_test.go @@ -2,8 +2,6 @@ package bonsai import ( "encoding/json" - - "github.com/google/go-cmp/cmp" ) func (s *ClientImplTestSuite) TestPlanAllResponseJsonUnmarshal() { @@ -64,8 +62,7 @@ func (s *ClientImplTestSuite) TestPlanAllResponseJsonUnmarshal() { result := planAllResponse{} err := json.Unmarshal([]byte(tc.received), &result) s.NoError(err) - s.Equal(tc.expect, result) - s.Empty(cmp.Diff(result, tc.expect)) + s.Equal(tc.expect, result, "expected struct matches unmarshaled result") }) } } diff --git a/bonsai/plan_test.go b/bonsai/plan_test.go index 07be289..8237989 100644 --- a/bonsai/plan_test.go +++ b/bonsai/plan_test.go @@ -6,7 +6,6 @@ import ( "net/http" "net/url" - "github.com/google/go-cmp/cmp" "github.com/omc/bonsai-api-go/v1/bonsai" ) @@ -114,7 +113,7 @@ func (s *ClientTestSuite) TestPlanClient_All() { s.NoError(err, "successfully get all spaces") s.Len(spaces, 2) - s.Empty(cmp.Diff(expect, spaces), "diff between received All response and expected should be empty") + s.ElementsMatch(expect, spaces, "elements expected match elements in received spaces") } func (s *ClientTestSuite) TestPlanClient_GetByPath() { @@ -175,5 +174,5 @@ func (s *ClientTestSuite) TestPlanClient_GetByPath() { resultResp, err := s.client.Plan.GetBySlug(context.Background(), "sandbox-aws-us-east-1") s.NoError(err, "successfully get space by path") - s.Empty(cmp.Diff(expect, resultResp), "diff between received plan response and expected should be empty") + s.Equal(expect, resultResp, "expected struct matches unmarshaled result") } diff --git a/bonsai/release.go b/bonsai/release.go index a776601..89d296a 100644 --- a/bonsai/release.go +++ b/bonsai/release.go @@ -1,10 +1,152 @@ package bonsai +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" + "reflect" +) + +const ReleaseAPIBasePath = "/releases" + // Release is a placeholder for now. type Release struct { Name string `json:"name,omitempty"` Slug string `json:"slug"` ServiceType string `json:"service_type,omitempty"` Version string `json:"version,omitempty"` - MultiTenant bool `json:"multi_tenant,omitempty"` + MultiTenant bool `json:"multitenant,omitempty"` +} + +// ReleasesResultList is a wrapper around a slice of +// Releases for json unmarshaling. +type ReleasesResultList struct { + Releases []Release `json:"releases"` +} + +// ReleaseClient is a client for the Releases API. +type ReleaseClient struct { + *Client +} + +type releaseListOptions struct { + listOpts +} + +func (o releaseListOptions) values() url.Values { + return o.listOpts.values() +} + +// list returns a list of Releases for the page specified, +// by performing a GET request against [spaceAPIBasePath]. +// +// Note: Pagination is not currently supported. +func (c *ReleaseClient) list(ctx context.Context, opt releaseListOptions) ([]Release, *Response, error) { + var ( + req *http.Request + reqURL *url.URL + resp *Response + err error + ) + // Let's make some initial capacity to reduce allocations + results := ReleasesResultList{ + Releases: make([]Release, 0, defaultResponseCapacity), + } + + reqURL, err = url.Parse(ReleaseAPIBasePath) + if err != nil { + return results.Releases, nil, fmt.Errorf("cannot parse relative url from basepath (%s): %w", ReleaseAPIBasePath, err) + } + + // Conditionally set options if we received any + if !reflect.ValueOf(opt).IsZero() { + reqURL.RawQuery = opt.values().Encode() + } + + req, err = c.NewRequest(ctx, "GET", reqURL.String(), nil) + if err != nil { + return results.Releases, nil, fmt.Errorf("creating new http request for URL (%s): %w", reqURL.String(), err) + } + + resp, err = c.Do(ctx, req) + if err != nil { + return results.Releases, resp, fmt.Errorf("client.do failed: %w", err) + } + + if err = json.Unmarshal(resp.BodyBuf.Bytes(), &results); err != nil { + return results.Releases, resp, fmt.Errorf("json.Unmarshal failed: %w", err) + } + + return results.Releases, resp, nil +} + +// All lists all Releases from the Releases API. +func (c *ReleaseClient) All(ctx context.Context) ([]Release, error) { + var ( + err error + resp *Response + ) + + allResults := make([]Release, 0, defaultListResultSize) + // No pagination support as yet, but support it for future use + + err = c.all(ctx, newEmptyListOpts(), func(opt listOpts) (*Response, error) { + var listResults []Release + + listResults, resp, err = c.list(ctx, releaseListOptions{listOpts: opt}) + if err != nil { + return resp, fmt.Errorf("client.list failed: %w", err) + } + + allResults = append(allResults, listResults...) + if len(allResults) >= resp.PageSize { + resp.MarkPaginationComplete() + } + return resp, err + }) + + if err != nil { + return allResults, fmt.Errorf("client.all failed: %w", err) + } + + return allResults, err +} + +// GetBySlug gets a Release from the Releases API by its slug. +// +//nolint:dupl // Allow duplicated code blocks in code paths that may change +func (c *ReleaseClient) GetBySlug(ctx context.Context, slug string) (Release, error) { + var ( + req *http.Request + reqURL *url.URL + resp *Response + err error + result Release + ) + + reqURL, err = url.Parse(ReleaseAPIBasePath) + if err != nil { + return result, fmt.Errorf("cannot parse relative url from basepath (%s): %w", ReleaseAPIBasePath, err) + } + + reqURL.Path = path.Join(reqURL.Path, slug) + + req, err = c.NewRequest(ctx, "GET", reqURL.String(), nil) + if err != nil { + return result, fmt.Errorf("creating new http request for URL (%s): %w", reqURL.String(), err) + } + + resp, err = c.Do(ctx, req) + if err != nil { + return result, fmt.Errorf("client.do failed: %w", err) + } + + if err = json.Unmarshal(resp.BodyBuf.Bytes(), &result); err != nil { + return result, fmt.Errorf("json.Unmarshal failed: %w", err) + } + + return result, nil } diff --git a/bonsai/release_test.go b/bonsai/release_test.go new file mode 100644 index 0000000..65b618d --- /dev/null +++ b/bonsai/release_test.go @@ -0,0 +1,118 @@ +package bonsai_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/omc/bonsai-api-go/v1/bonsai" +) + +func (s *ClientTestSuite) TestReleaseClient_All() { + s.serveMux.HandleFunc(bonsai.ReleaseAPIBasePath, func(w http.ResponseWriter, _ *http.Request) { + respStr := ` + { + "releases": [ + { + "name": "Elasticsearch 5.6.16", + "slug": "elasticsearch-5.6.16", + "service_type": "elasticsearch", + "version": "5.6.16", + "multitenant": true + }, + { + "name": "Elasticsearch 6.5.4", + "slug": "elasticsearch-6.5.4", + "service_type": "elasticsearch", + "version": "6.5.4", + "multitenant": true + }, + { + "name": "Elasticsearch 7.2.0", + "slug": "elasticsearch-7.2.0", + "service_type": "elasticsearch", + "version": "7.2.0", + "multitenant": true + } + ] + } + ` + + resp := &bonsai.ReleasesResultList{Releases: make([]bonsai.Release, 0, 3)} + err := json.Unmarshal([]byte(respStr), resp) + s.NoError(err, "unmarshal json into bonsai.ReleasesResultList") + + err = json.NewEncoder(w).Encode(resp) + s.NoError(err, "encode bonsai.ReleasesResultList into json") + }) + + expect := []bonsai.Release{ + { + Name: "Elasticsearch 5.6.16", + Slug: "elasticsearch-5.6.16", + ServiceType: "elasticsearch", + Version: "5.6.16", + MultiTenant: true, + }, + { + Name: "Elasticsearch 6.5.4", + Slug: "elasticsearch-6.5.4", + ServiceType: "elasticsearch", + Version: "6.5.4", + MultiTenant: true, + }, + { + Name: "Elasticsearch 7.2.0", + Slug: "elasticsearch-7.2.0", + ServiceType: "elasticsearch", + Version: "7.2.0", + MultiTenant: true, + }, + } + releases, err := s.client.Release.All(context.Background()) + s.NoError(err, "successfully get all releases") + s.Len(releases, 3) + + s.ElementsMatch(expect, releases, "elements in expect match elements in received releases") +} + +func (s *ClientTestSuite) TestReleaseClient_GetBySlug() { + const targetReleaseSlug = "elasticsearch-7.2.0" + + urlPath, err := url.JoinPath(bonsai.ReleaseAPIBasePath, targetReleaseSlug) + s.NoError(err, "successfully resolved path") + + s.serveMux.HandleFunc(urlPath, func(w http.ResponseWriter, _ *http.Request) { + respStr := fmt.Sprintf(` + { + "name": "Elasticsearch 7.2.0", + "slug": "%s", + "service_type": "elasticsearch", + "version": "7.2.0", + "multitenant": true + } + `, targetReleaseSlug) + + resp := &bonsai.Release{} + err = json.Unmarshal([]byte(respStr), resp) + s.NoError(err, "unmarshals json into bonsai.Space") + + err = json.NewEncoder(w).Encode(resp) + s.NoError(err, "encodes bonsai.Space into json on the writer") + }) + + expect := bonsai.Release{ + Slug: "elasticsearch-7.2.0", + Name: "Elasticsearch 7.2.0", + ServiceType: "elasticsearch", + Version: "7.2.0", + MultiTenant: true, + } + + resultResp, err := s.client.Release.GetBySlug(context.Background(), targetReleaseSlug) + s.NoError(err, "successfully get release by path") + + s.Equal(expect, resultResp, "elements in expect match elements in received release response") +} diff --git a/go.mod b/go.mod index 6095c75..bae3410 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/omc/bonsai-api-go/v1 go 1.22 require ( - github.com/google/go-cmp v0.6.0 github.com/hetznercloud/hcloud-go/v2 v2.7.2 github.com/stretchr/testify v1.9.0 golang.org/x/net v0.24.0