Skip to content

Commit

Permalink
SYSENG-1357: generic client default options
Browse files Browse the repository at this point in the history
  • Loading branch information
Mario Schäfer authored and anx-mschaefer committed May 17, 2024
1 parent dcdfdd7 commit b9644e1
Show file tree
Hide file tree
Showing 12 changed files with 334 additions and 42 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ Some examples, more below in the actual changelog (newer entries are more likely
-->

### Added
* `WithRequestOptions` API option to configure default request options (#361, @anx-mschaefer)

### Changed
* (internal) add "error-return" to request option interfaces (#361, @anx-mschaefer)

## [0.6.4] - 2024-03-15

### Fixed
Expand Down
67 changes: 55 additions & 12 deletions pkg/api/api_implementation.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
Expand All @@ -30,7 +31,8 @@ type defaultAPI struct {
client client.Client
logger *logr.Logger

clientOptions []client.Option
clientOptions []client.Option
requestOptions []types.Option
}

// NewAPIOption is the type for giving options to the NewAPI function.
Expand All @@ -43,6 +45,13 @@ func WithClientOptions(o ...client.Option) NewAPIOption {
}
}

// WithRequestOptions configures default options applied to requests
func WithRequestOptions(opts ...types.Option) NewAPIOption {
return func(a *defaultAPI) {
a.requestOptions = opts
}
}

// WithLogger configures the API to use the given logger. It is recommended to pass a named logger.
// If you don't pass an existing client, the logger you give here will given to the client (with
// added name "client").
Expand Down Expand Up @@ -79,8 +88,12 @@ func NewAPI(opts ...NewAPIOption) (API, error) {
// Get the identified object from the engine.
func (a defaultAPI) Get(ctx context.Context, o types.IdentifiedObject, opts ...types.GetOption) error {
options := types.GetOptions{}
for _, opt := range opts {
opt.ApplyToGet(&options)
var err error
for _, opt := range resolveRequestOptions(a.requestOptions, opts) {
err = errors.Join(err, opt.ApplyToGet(&options))
}
if err != nil {
return fmt.Errorf("apply request options: %w", err)
}

return a.do(ctx, o, o, &options, types.OperationGet)
Expand All @@ -89,8 +102,12 @@ func (a defaultAPI) Get(ctx context.Context, o types.IdentifiedObject, opts ...t
// Create the given object on the engine.
func (a defaultAPI) Create(ctx context.Context, o types.Object, opts ...types.CreateOption) error {
options := types.CreateOptions{}
for _, opt := range opts {
opt.ApplyToCreate(&options)
var err error
for _, opt := range resolveRequestOptions(a.requestOptions, opts) {
err = errors.Join(err, opt.ApplyToCreate(&options))
}
if err != nil {
return fmt.Errorf("apply request options: %w", err)
}

if err := a.do(ctx, o, o, &options, types.OperationCreate); err != nil {
Expand All @@ -115,8 +132,12 @@ func (a defaultAPI) handlePostCreateOptions(ctx context.Context, o types.Identif
// Update the object on the engine.
func (a defaultAPI) Update(ctx context.Context, o types.IdentifiedObject, opts ...types.UpdateOption) error {
options := types.UpdateOptions{}
for _, opt := range opts {
opt.ApplyToUpdate(&options)
var err error
for _, opt := range resolveRequestOptions(a.requestOptions, opts) {
err = errors.Join(err, opt.ApplyToUpdate(&options))
}
if err != nil {
return fmt.Errorf("apply request options: %w", err)
}

return a.do(ctx, o, o, &options, types.OperationUpdate)
Expand All @@ -125,8 +146,12 @@ func (a defaultAPI) Update(ctx context.Context, o types.IdentifiedObject, opts .
// Destroy the identified object.
func (a defaultAPI) Destroy(ctx context.Context, o types.IdentifiedObject, opts ...types.DestroyOption) error {
options := types.DestroyOptions{}
for _, opt := range opts {
opt.ApplyToDestroy(&options)
var err error
for _, opt := range resolveRequestOptions(a.requestOptions, opts) {
err = errors.Join(err, opt.ApplyToDestroy(&options))
}
if err != nil {
return fmt.Errorf("apply request options: %w", err)
}

return a.do(ctx, o, o, &options, types.OperationDestroy)
Expand All @@ -135,11 +160,14 @@ func (a defaultAPI) Destroy(ctx context.Context, o types.IdentifiedObject, opts
// List objects matching the info given in the object.
func (a defaultAPI) List(ctx context.Context, o types.FilterObject, opts ...types.ListOption) error {
options := types.ListOptions{}
for _, opt := range opts {
opt.ApplyToList(&options)
var err error
for _, opt := range resolveRequestOptions(a.requestOptions, opts) {
err = errors.Join(err, opt.ApplyToList(&options))
}
if err != nil {
return fmt.Errorf("apply request options: %w", err)
}

var err error
ctx, err = a.contextPrepare(ctx, o, types.OperationList, &options)

if err != nil {
Expand Down Expand Up @@ -515,3 +543,18 @@ func decodeResponse(ctx context.Context, mediaType string, data io.Reader, res i

return fmt.Errorf("%w: no idea how to handle media type %v", ErrUnsupportedResponseFormat, mediaType)
}

func resolveRequestOptions[T any](commonOptions []types.Option, requestOptions []T) []T {
return append(filterOptions[T](commonOptions), requestOptions...)
}

func filterOptions[T any](opts []types.Option) []T {
ret := make([]T, 0, len(opts))
for _, v := range opts {
if v, ok := v.(T); ok {
ret = append(ret, v)
}
}

return ret
}
107 changes: 97 additions & 10 deletions pkg/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,24 +74,30 @@ var _ = Describe("getResponseType function", func() {

type apiTestAnyopOption string

func (o apiTestAnyopOption) ApplyToGet(opts *types.GetOptions) {
_ = opts.Set("api_test_option", o, false)
func (o apiTestAnyopOption) ApplyToGet(opts *types.GetOptions) error {
return opts.Set("api_test_option", o, false)
}

func (o apiTestAnyopOption) ApplyToList(opts *types.ListOptions) {
_ = opts.Set("api_test_option", o, false)
func (o apiTestAnyopOption) ApplyToList(opts *types.ListOptions) error {
return opts.Set("api_test_option", o, false)
}

func (o apiTestAnyopOption) ApplyToCreate(opts *types.CreateOptions) {
_ = opts.Set("api_test_option", o, false)
func (o apiTestAnyopOption) ApplyToCreate(opts *types.CreateOptions) error {
return opts.Set("api_test_option", o, false)
}

func (o apiTestAnyopOption) ApplyToUpdate(opts *types.UpdateOptions) {
_ = opts.Set("api_test_option", o, false)
func (o apiTestAnyopOption) ApplyToUpdate(opts *types.UpdateOptions) error {
return opts.Set("api_test_option", o, false)
}

func (o apiTestAnyopOption) ApplyToDestroy(opts *types.DestroyOptions) {
_ = opts.Set("api_test_option", o, false)
func (o apiTestAnyopOption) ApplyToDestroy(opts *types.DestroyOptions) error {
return opts.Set("api_test_option", o, false)
}

func errorOption(err error) types.AnyOption {
return func(o types.Options) error {
return err
}
}

type apiTestObject struct {
Expand Down Expand Up @@ -1025,6 +1031,87 @@ var _ = Describe("using an API object", func() {
err = api.Destroy(ctx, &o, opt)
Expect(err).NotTo(HaveOccurred())
})

It("consumes the default options for all operations", func() {
opt := apiTestAnyopOption("hello world")
ctx := context.WithValue(context.TODO(), errAPITest, opt)

server.AppendHandlers(
ghttp.RespondWithJSONEncoded(200, map[string]string{"value": "option-check"}),
ghttp.RespondWithJSONEncoded(200, map[string]string{"value": "option-check"}),
ghttp.RespondWithJSONEncoded(200, []map[string]string{{"value": "option-check"}}),
ghttp.RespondWithJSONEncoded(200, map[string]string{"value": "option-check"}),
ghttp.RespondWithJSONEncoded(200, map[string]string{}),
)

api, err := NewAPI(
WithLogger(logger),
WithClientOptions(
client.BaseURL(server.URL()),
client.IgnoreMissingToken(),
),
WithRequestOptions(opt),
)
Expect(err).NotTo(HaveOccurred())

o := apiTestObject{"option-check"}

err = api.Create(ctx, &o)
Expect(err).NotTo(HaveOccurred())

err = api.Get(ctx, &o)
Expect(err).NotTo(HaveOccurred())

err = api.List(ctx, &o)
Expect(err).NotTo(HaveOccurred())

err = api.Update(ctx, &o)
Expect(err).NotTo(HaveOccurred())

err = api.Destroy(ctx, &o)
Expect(err).NotTo(HaveOccurred())
})

It("returns an error when applying configured options return errors", func() {
mockErr := errors.New("foo")
api, err := NewAPI(
WithLogger(logger),
WithClientOptions(
client.BaseURL(server.URL()),
client.IgnoreMissingToken(),
),
)
Expect(err).NotTo(HaveOccurred())

o := apiTestObject{"foo"}

Expect(api.Create(context.TODO(), &o, errorOption(mockErr))).Error().To(MatchError(mockErr))
Expect(api.Get(context.TODO(), &o, errorOption(mockErr))).Error().To(MatchError(mockErr))
Expect(api.List(context.TODO(), &o, errorOption(mockErr))).Error().To(MatchError(mockErr))
Expect(api.Update(context.TODO(), &o, errorOption(mockErr))).Error().To(MatchError(mockErr))
Expect(api.Destroy(context.TODO(), &o, errorOption(mockErr))).Error().To(MatchError(mockErr))
})

It("returns an error when applying configured default options return errors", func() {
mockErr := errors.New("foo")
api, err := NewAPI(
WithLogger(logger),
WithClientOptions(
client.BaseURL(server.URL()),
client.IgnoreMissingToken(),
),
WithRequestOptions(errorOption(mockErr)),
)
Expect(err).NotTo(HaveOccurred())

o := apiTestObject{"foo"}

Expect(api.Create(context.TODO(), &o)).Error().To(MatchError(mockErr))
Expect(api.Get(context.TODO(), &o)).Error().To(MatchError(mockErr))
Expect(api.List(context.TODO(), &o)).Error().To(MatchError(mockErr))
Expect(api.Update(context.TODO(), &o)).Error().To(MatchError(mockErr))
Expect(api.Destroy(context.TODO(), &o)).Error().To(MatchError(mockErr))
})
})

const contextTestObjectBaseurl = "/v1/context_test_object"
Expand Down
12 changes: 8 additions & 4 deletions pkg/api/internal/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ type PagedOption struct {
}

// ApplyToList applies the Paged option to all the ListOptions.
func (p PagedOption) ApplyToList(o *types.ListOptions) {
func (p PagedOption) ApplyToList(o *types.ListOptions) error {
o.Paged = true
o.Page = p.Page
o.EntriesPerPage = p.Limit
o.PageInfo = p.Info
return nil
}

// ObjectChannelOption configures the List operation to return the objects via the given channel.
Expand All @@ -30,23 +31,26 @@ type ObjectChannelOption struct {
}

// ApplyToList applies the AsObjectChannel option to all the ListOptions.
func (aoc ObjectChannelOption) ApplyToList(o *types.ListOptions) {
func (aoc ObjectChannelOption) ApplyToList(o *types.ListOptions) error {
o.ObjectChannel = aoc.Channel
return nil
}

// FullObjectsOption configures if the List operation shall make a Get operation for each object before
// returning it to the caller.
type FullObjectsOption bool

// ApplyToList applies the FullObjectsOption option to all the ListOptions.
func (foo FullObjectsOption) ApplyToList(o *types.ListOptions) {
func (foo FullObjectsOption) ApplyToList(o *types.ListOptions) error {
o.FullObjects = bool(foo)
return nil
}

// AutoTagOption configures the Create operation to automatically tag objects after creation
type AutoTagOption []string

// ApplyToCreate applies the AutoTagOption to the ListOptions
func (ato AutoTagOption) ApplyToCreate(o *types.CreateOptions) {
func (ato AutoTagOption) ApplyToCreate(o *types.CreateOptions) error {
o.AutoTags = ato
return nil
}
12 changes: 10 additions & 2 deletions pkg/api/mock/mock_api_implementation.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,12 @@ func listDataAggregation(o types.Object, data mockDataView) ([]types.Object, err

func listOutput(ctx context.Context, objects []types.Object, opts []types.ListOption) error {
options := types.ListOptions{}
var err error
for _, opt := range opts {
opt.ApplyToList(&options)
err = errors.Join(err, opt.ApplyToList(&options))
}
if err != nil {
return fmt.Errorf("apply request options: %w", err)
}

var channelPageIterator types.PageInfo
Expand Down Expand Up @@ -183,8 +187,12 @@ func (a *mockAPI) Create(ctx context.Context, o types.Object, opts ...types.Crea
}

options := types.CreateOptions{}
var err error
for _, opt := range opts {
opt.ApplyToCreate(&options)
err = errors.Join(err, opt.ApplyToCreate(&options))
}
if err != nil {
return fmt.Errorf("apply request options: %w", err)
}

a.mu.Lock()
Expand Down
18 changes: 18 additions & 0 deletions pkg/api/object_example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,21 @@ func Example_implementObject() {
// Retrieved object with mode 'tcp' named 'hello TCP 1'
// Retrieved object with mode 'tcp' named 'hello TCP 2'
}

func ExampleWithRequestOptions() {
api, err := NewAPI(
WithRequestOptions(
// automatically assign tags to newly created resources
AutoTag("foo", "bar"),
),
)

if err != nil {
panic(fmt.Errorf("Error creating API instance: %v\n", err))
}

// create resource and automatically apply 'foo' & 'bar' tags
if err := api.Create(context.TODO(), &ExampleObject{Name: "foo"}); err != nil {
panic(err)
}
}
23 changes: 23 additions & 0 deletions pkg/api/options.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package api

import (
"context"
"fmt"

"go.anx.io/go-anxcloud/pkg/api/internal"
"go.anx.io/go-anxcloud/pkg/api/types"
)
Expand Down Expand Up @@ -34,3 +37,23 @@ func FullObjects(fullObjects bool) ListOption {
func AutoTag(tags ...string) CreateOption {
return internal.AutoTagOption(tags)
}

// EnvironmentOption can be used to configure an alternative environment path
// segment for a given API group
func EnvironmentOption(apiGroup, envPathSegment string, override bool) types.AnyOption {
return func(o types.Options) error {
return o.SetEnvironment(fmt.Sprintf("environment/%s", apiGroup), envPathSegment, override)
}
}

// GetEnvironmentPathSegment retrieves the environment path segment of a given API group
// or the provided defaultValue if no environment override is set
func GetEnvironmentPathSegment(ctx context.Context, apiGroup, defaultValue string) string {
if options, err := types.OptionsFromContext(ctx); err != nil {
return defaultValue
} else if env, err := options.GetEnvironment(fmt.Sprintf("environment/%s", apiGroup)); err != nil {
return defaultValue
} else {
return env
}
}
Loading

0 comments on commit b9644e1

Please sign in to comment.