From 49b0982b6a4fca1c7f2667c791f4e7584a4afe40 Mon Sep 17 00:00:00 2001 From: Mo Omer Date: Fri, 3 May 2024 07:44:25 -0500 Subject: [PATCH 1/2] Plan API client --- bonsai/client.go | 6 +- bonsai/plan.go | 271 +++++++++++++++++++++++++++++++++++++++ bonsai/plan_impl_test.go | 71 ++++++++++ bonsai/plan_test.go | 179 ++++++++++++++++++++++++++ bonsai/release.go | 10 ++ bonsai/space.go | 1 + 6 files changed, 537 insertions(+), 1 deletion(-) create mode 100644 bonsai/plan.go create mode 100644 bonsai/plan_impl_test.go create mode 100644 bonsai/plan_test.go create mode 100644 bonsai/release.go diff --git a/bonsai/client.go b/bonsai/client.go index 60b08e6..9dda073 100644 --- a/bonsai/client.go +++ b/bonsai/client.go @@ -323,6 +323,7 @@ type Client struct { // Clients Space SpaceClient + Plan PlanClient } func NewClient(options ...ClientOption) *Client { @@ -341,6 +342,7 @@ func NewClient(options ...ClientOption) *Client { // Configure child clients client.Space = SpaceClient{client} + client.Plan = PlanClient{client} return client } @@ -453,10 +455,12 @@ func (c *Client) doRequest(ctx context.Context, req *http.Request, reqBuf *bytes } } + // All error reposes should come with a JSON response per the Error handling + // section @ https://bonsai.io/docs/introduction-to-the-api. if resp.StatusCode >= http.StatusBadRequest { respErr := ResponseError{} if err = json.Unmarshal(resp.BodyBuf.Bytes(), &respErr); err != nil { - return resp, fmt.Errorf("unmarshalling error response: %w", err) + return resp, fmt.Errorf("unmarshaling error response: %w", err) } return resp, respErr } diff --git a/bonsai/plan.go b/bonsai/plan.go new file mode 100644 index 0000000..a1e08b3 --- /dev/null +++ b/bonsai/plan.go @@ -0,0 +1,271 @@ +package bonsai + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" + "reflect" +) + +const ( + PlanAPIBasePath = "/plans" +) + +// planAllResponse represents the JSON response object returned from the +// GET /plans endpoint. +// +// It differs from Plan namely in that the AvailableReleases returned is +// a list of string, not Release. +type planAllResponse struct { + // Represents a machine-readable name for the plan. + Slug string `json:"slug"` + // Represents the human-readable name of the plan. + Name string `json:"name"` + // Represents the plan price in cents. + PriceInCents int64 `json:"price_in_cents"` + // Represents the plan billing interval in months. + BillingIntervalInMonths int `json:"billing_interval_in_months"` + // Indicates whether the plan is single-tenant or not. A value of false + // indicates the Cluster will share hardware with other Clusters. Single + // tenant environments can be reached via the public Internet. + SingleTenant bool `json:"single_tenant"` + // Indicates whether the plan is on a publicly addressable network. + // Private plans provide environments that cannot be reached by the public + // Internet. A VPC connection will be needed to communicate with a private + // cluster. + PrivateNetwork bool `json:"private_network"` + // A collection of search release slugs available for the plan. Additional + // information about a release can be retrieved from the Releases API. + AvailableReleases []string `json:"available_releases"` + AvailableSpaces []string `json:"available_spaces"` +} + +type planAllResponseList struct { + Plans []planAllResponse `json:"plans"` +} + +type planAllResponseConverter struct{} + +// Convert copies a single planAllResponse into a Plan, +// transforming types as needed. +func (c *planAllResponseConverter) Convert(source planAllResponse) Plan { + plan := Plan{ + AvailableReleases: make([]Release, len(source.AvailableReleases)), + AvailableSpaces: make([]Space, len(source.AvailableSpaces)), + } + plan.Slug = source.Slug + plan.Name = source.Name + plan.PriceInCents = source.PriceInCents + plan.BillingIntervalInMonths = source.BillingIntervalInMonths + plan.SingleTenant = source.SingleTenant + plan.PrivateNetwork = source.PrivateNetwork + for i, release := range source.AvailableReleases { + plan.AvailableReleases[i] = Release{Slug: release} + } + for i, space := range source.AvailableSpaces { + plan.AvailableSpaces[i] = Space{Path: space} + } + + return plan +} + +// ConvertItems converts a slice of planAllResponse into a slice of Plan +// by way of the planAllResponseConverter.ConvertItems method. +func (c *planAllResponseConverter) ConvertItems(source []planAllResponse) []Plan { + var planList []Plan + if source != nil { + planList = make([]Plan, len(source)) + for i := range source { + planList[i] = c.Convert(source[i]) + } + } + return planList +} + +// Plan represents a subscription plan. +type Plan struct { + // Represents a machine-readable name for the plan. + Slug string `json:"slug"` + // Represents the human-readable name of the plan. + Name string `json:"name"` + // Represents the plan price in cents. + PriceInCents int64 `json:"price_in_cents"` + // Represents the plan billing interval in months. + BillingIntervalInMonths int `json:"billing_interval_months"` + // Indicates whether the plan is single-tenant or not. A value of false + // indicates the Cluster will share hardware with other Clusters. Single + // tenant environments can be reached via the public Internet. + SingleTenant bool `json:"single_tenant"` + // Indicates whether the plan is on a publicly addressable network. + // Private plans provide environments that cannot be reached by the public + // Internet. A VPC connection will be needed to communicate with a private + // cluster. + PrivateNetwork bool `json:"private_network"` + // A collection of search release slugs available for the plan. Additional + // information about a release can be retrieved from the Releases API. + AvailableReleases []Release `json:"available_releases"` + AvailableSpaces []Space `json:"available_spaces"` +} + +func (p *Plan) UnmarshalJSON(data []byte) error { + intermediary := planAllResponse{} + if err := json.Unmarshal(data, &intermediary); err != nil { + return fmt.Errorf("unmarshaling into intermediary type: %w", err) + } + + converter := planAllResponseConverter{} + converted := converter.Convert(intermediary) + *p = converted + + return nil +} + +// PlansResultList is a wrapper around a slice of +// Plans for json unmarshaling. +type PlansResultList struct { + Plans []Plan `json:"plans"` +} + +func (p *PlansResultList) UnmarshalJSON(data []byte) error { + planAllResponseList := make([]planAllResponse, 0) + + if err := json.Unmarshal(data, &planAllResponseList); err != nil { + return fmt.Errorf("unmarshaling into planAllResponseList type: %w", err) + } + + converter := planAllResponseConverter{} + p.Plans = converter.ConvertItems(planAllResponseList) + return nil +} + +// PlanClient is a client for the Plans API. +type PlanClient struct { + *Client +} + +type planListOptions struct { + listOpts +} + +func (o planListOptions) values() url.Values { + return o.listOpts.values() +} + +// list returns a list of Plans for the page specified, +// by performing a GET request against [spaceAPIBasePath]. +// +// Note: Pagination is not currently supported. +func (c *PlanClient) list(ctx context.Context, opt planListOptions) ([]Plan, *Response, error) { + var ( + req *http.Request + reqURL *url.URL + resp *Response + err error + + results []Plan + ) + // Let's make some initial capacity to reduce allocations + intermediaryResults := planAllResponseList{ + Plans: make([]planAllResponse, 0, defaultResponseCapacity), + } + + reqURL, err = url.Parse(PlanAPIBasePath) + if err != nil { + return results, nil, fmt.Errorf("cannot parse relative url from basepath (%s): %w", PlanAPIBasePath, 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, 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, resp, fmt.Errorf("client.do failed: %w", err) + } + + if err = json.Unmarshal(resp.BodyBuf.Bytes(), &intermediaryResults); err != nil { + return results, resp, fmt.Errorf("json.Unmarshal failed: %w", err) + } + + converter := planAllResponseConverter{} + results = converter.ConvertItems(intermediaryResults.Plans) + + return results, resp, nil +} + +// All lists all Plans from the Plans API. +func (c *PlanClient) All(ctx context.Context) ([]Plan, error) { + var ( + err error + resp *Response + ) + + allResults := make([]Plan, 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 []Plan + + listResults, resp, err = c.list(ctx, planListOptions{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 Plan from the Plans API by its slug. +// +//nolint:dupl // Allow duplicated code blocks in code paths that may change +func (c *PlanClient) GetBySlug(ctx context.Context, slug string) (Plan, error) { + var ( + req *http.Request + reqURL *url.URL + resp *Response + err error + result Plan + ) + + reqURL, err = url.Parse(PlanAPIBasePath) + if err != nil { + return result, fmt.Errorf("cannot parse relative url from basepath (%s): %w", PlanAPIBasePath, 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/plan_impl_test.go b/bonsai/plan_impl_test.go new file mode 100644 index 0000000..f3dc0bc --- /dev/null +++ b/bonsai/plan_impl_test.go @@ -0,0 +1,71 @@ +package bonsai + +import ( + "encoding/json" + + "github.com/google/go-cmp/cmp" +) + +func (s *ClientImplTestSuite) TestPlanAllResponseJsonUnmarshal() { + testCases := []struct { + name string + received string + expect planAllResponse + }{ + { + name: "plan example response from docs site", + received: ` + { + "slug": "sandbox-aws-us-east-1", + "name": "Sandbox", + "price_in_cents": 0, + "billing_interval_in_months": 1, + "single_tenant": false, + "private_network": false, + "available_releases": [ + "elasticsearch-7.2.0" + ], + "available_spaces": [ + "omc/bonsai-gcp/us-east4/common", + "omc/bonsai/ap-northeast-1/common", + "omc/bonsai/ap-southeast-2/common", + "omc/bonsai/eu-central-1/common", + "omc/bonsai/eu-west-1/common", + "omc/bonsai/us-east-1/common", + "omc/bonsai/us-west-2/common" + ] + } + `, + expect: planAllResponse{ + Slug: "sandbox-aws-us-east-1", + Name: "Sandbox", + PriceInCents: 0, + BillingIntervalInMonths: 1, + SingleTenant: false, + PrivateNetwork: false, + AvailableReleases: []string{ + "elasticsearch-7.2.0", + }, + AvailableSpaces: []string{ + "omc/bonsai-gcp/us-east4/common", + "omc/bonsai/ap-northeast-1/common", + "omc/bonsai/ap-southeast-2/common", + "omc/bonsai/eu-central-1/common", + "omc/bonsai/eu-west-1/common", + "omc/bonsai/us-east-1/common", + "omc/bonsai/us-west-2/common", + }, + }, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + result := planAllResponse{} + err := json.Unmarshal([]byte(tc.received), &result) + s.NoError(err) + s.Equal(tc.expect, result) + s.Empty(cmp.Diff(result, tc.expect)) + }) + } +} diff --git a/bonsai/plan_test.go b/bonsai/plan_test.go new file mode 100644 index 0000000..07be289 --- /dev/null +++ b/bonsai/plan_test.go @@ -0,0 +1,179 @@ +package bonsai_test + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "github.com/google/go-cmp/cmp" + "github.com/omc/bonsai-api-go/v1/bonsai" +) + +func (s *ClientTestSuite) TestPlanClient_All() { + s.serveMux.HandleFunc(bonsai.PlanAPIBasePath, func(w http.ResponseWriter, _ *http.Request) { + respStr := ` + { + "plans": [ + { + "slug": "sandbox-aws-us-east-1", + "name": "Sandbox", + "price_in_cents": 0, + "billing_interval_in_months": 1, + "single_tenant": false, + "private_network": false, + "available_releases": [ + "7.2.0" + ], + "available_spaces": [ + "omc/bonsai-gcp/us-east4/common", + "omc/bonsai/ap-northeast-1/common", + "omc/bonsai/ap-southeast-2/common", + "omc/bonsai/eu-central-1/common", + "omc/bonsai/eu-west-1/common", + "omc/bonsai/us-east-1/common", + "omc/bonsai/us-west-2/common" + ] + }, + { + "slug": "standard-sm", + "name": "Standard Small", + "price_in_cents": 5000, + "billing_interval_in_months": 1, + "single_tenant": false, + "private_network": false, + "available_releases": [ + "elasticsearch-5.6.16", + "elasticsearch-6.8.3", + "elasticsearch-7.2.0" + ], + "available_spaces": [ + "omc/bonsai/ap-northeast-1/common", + "omc/bonsai/ap-southeast-2/common", + "omc/bonsai/eu-central-1/common", + "omc/bonsai/eu-west-1/common", + "omc/bonsai/us-east-1/common", + "omc/bonsai/us-west-2/common" + ] + } + ] + } + ` + _, err := w.Write([]byte(respStr)) + s.NoError(err, "write respStr to http.ResponseWriter") + }) + + expect := []bonsai.Plan{ + { + Slug: "sandbox-aws-us-east-1", + Name: "Sandbox", + PriceInCents: 0, + BillingIntervalInMonths: 1, + SingleTenant: false, + PrivateNetwork: false, + AvailableReleases: []bonsai.Release{ + // TODO: we'll see whether the response is actually a + // shortened version like this or a slug + // the documentation is conflicting at + // https://bonsai.io/docs/plans-api-introduction + {Slug: "7.2.0"}, + }, + AvailableSpaces: []bonsai.Space{ + {Path: "omc/bonsai-gcp/us-east4/common"}, + {Path: "omc/bonsai/ap-northeast-1/common"}, + {Path: "omc/bonsai/ap-southeast-2/common"}, + {Path: "omc/bonsai/eu-central-1/common"}, + {Path: "omc/bonsai/eu-west-1/common"}, + {Path: "omc/bonsai/us-east-1/common"}, + {Path: "omc/bonsai/us-west-2/common"}, + }, + }, + { + Slug: "standard-sm", + Name: "Standard Small", + PriceInCents: 5000, + BillingIntervalInMonths: 1, + SingleTenant: false, + PrivateNetwork: false, + AvailableReleases: []bonsai.Release{ + {Slug: "elasticsearch-5.6.16"}, + {Slug: "elasticsearch-6.8.3"}, + {Slug: "elasticsearch-7.2.0"}, + }, + AvailableSpaces: []bonsai.Space{ + {Path: "omc/bonsai/ap-northeast-1/common"}, + {Path: "omc/bonsai/ap-southeast-2/common"}, + {Path: "omc/bonsai/eu-central-1/common"}, + {Path: "omc/bonsai/eu-west-1/common"}, + {Path: "omc/bonsai/us-east-1/common"}, + {Path: "omc/bonsai/us-west-2/common"}, + }, + }, + } + spaces, err := s.client.Plan.All(context.Background()) + 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") +} + +func (s *ClientTestSuite) TestPlanClient_GetByPath() { + const targetPlanPath = "sandbox-aws-us-east-1" + + urlPath, err := url.JoinPath(bonsai.PlanAPIBasePath, "sandbox-aws-us-east-1") + s.NoError(err, "successfully resolved path") + + respStr := fmt.Sprintf(` + { + "slug": "%s", + "name": "Sandbox", + "price_in_cents": 0, + "billing_interval_in_months": 1, + "single_tenant": false, + "private_network": false, + "available_releases": [ + "elasticsearch-7.2.0" + ], + "available_spaces": [ + "omc/bonsai-gcp/us-east4/common", + "omc/bonsai/ap-northeast-1/common", + "omc/bonsai/ap-southeast-2/common", + "omc/bonsai/eu-central-1/common", + "omc/bonsai/eu-west-1/common", + "omc/bonsai/us-east-1/common", + "omc/bonsai/us-west-2/common" + ] + } + `, targetPlanPath) + + s.serveMux.HandleFunc(urlPath, func(w http.ResponseWriter, _ *http.Request) { + _, err = w.Write([]byte(respStr)) + s.NoError(err, "wrote response string to writer") + }) + + expect := bonsai.Plan{ + Slug: "sandbox-aws-us-east-1", + Name: "Sandbox", + PriceInCents: 0, + BillingIntervalInMonths: 1, + SingleTenant: false, + PrivateNetwork: false, + AvailableReleases: []bonsai.Release{ + {Slug: "elasticsearch-7.2.0"}, + }, + AvailableSpaces: []bonsai.Space{ + {Path: "omc/bonsai-gcp/us-east4/common"}, + {Path: "omc/bonsai/ap-northeast-1/common"}, + {Path: "omc/bonsai/ap-southeast-2/common"}, + {Path: "omc/bonsai/eu-central-1/common"}, + {Path: "omc/bonsai/eu-west-1/common"}, + {Path: "omc/bonsai/us-east-1/common"}, + {Path: "omc/bonsai/us-west-2/common"}, + }, + } + + 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") +} diff --git a/bonsai/release.go b/bonsai/release.go new file mode 100644 index 0000000..a776601 --- /dev/null +++ b/bonsai/release.go @@ -0,0 +1,10 @@ +package bonsai + +// 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"` +} diff --git a/bonsai/space.go b/bonsai/space.go index cb50353..8d2cf2c 100644 --- a/bonsai/space.go +++ b/bonsai/space.go @@ -123,6 +123,7 @@ func (c *SpaceClient) All(ctx context.Context) ([]Space, error) { return allResults, err } +//nolint:dupl // Allow duplicated code blocks in code paths that may change func (c *SpaceClient) GetByPath(ctx context.Context, spacePath string) (Space, error) { var ( req *http.Request From f0d7b5ce2298a29174e54a98a877476b1402d3fd Mon Sep 17 00:00:00 2001 From: Mo Omer Date: Fri, 3 May 2024 07:45:29 -0500 Subject: [PATCH 2/2] Go mod tidy --- go.mod | 1 + 1 file changed, 1 insertion(+) diff --git a/go.mod b/go.mod index bae3410..6095c75 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ 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