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/plan client #5

Merged
merged 2 commits into from
May 3, 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
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
Loading