Skip to content

Commit

Permalink
Plan API client
Browse files Browse the repository at this point in the history
  • Loading branch information
momer committed May 3, 2024
1 parent 0864672 commit 49b0982
Show file tree
Hide file tree
Showing 6 changed files with 537 additions and 1 deletion.
6 changes: 5 additions & 1 deletion bonsai/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ type Client struct {

// Clients
Space SpaceClient
Plan PlanClient
}

func NewClient(options ...ClientOption) *Client {
Expand All @@ -341,6 +342,7 @@ func NewClient(options ...ClientOption) *Client {

// Configure child clients
client.Space = SpaceClient{client}
client.Plan = PlanClient{client}

return client
}
Expand Down Expand Up @@ -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
}
Expand Down
271 changes: 271 additions & 0 deletions bonsai/plan.go
Original file line number Diff line number Diff line change
@@ -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
}
71 changes: 71 additions & 0 deletions bonsai/plan_impl_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
}
Loading

0 comments on commit 49b0982

Please sign in to comment.