Skip to content

feat(client): improve default client options support #57

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

Merged
merged 1 commit into from
Mar 14, 2025
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
16 changes: 11 additions & 5 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,22 @@ type Client struct {
Users *UserService
}

// DefaultClientOptions read from the environment (GITPOD_API_KEY). This should be
// used to initialize new clients.
func DefaultClientOptions() []option.RequestOption {
defaults := []option.RequestOption{option.WithEnvironmentProduction()}
if o, ok := os.LookupEnv("GITPOD_API_KEY"); ok {
defaults = append(defaults, option.WithBearerToken(o))
}
return defaults
}

// NewClient generates a new client with the default option read from the
// environment (GITPOD_API_KEY). The option passed in as arguments are applied
// after these default arguments, and all option will be passed down to the
// services and requests that this client makes.
func NewClient(opts ...option.RequestOption) (r *Client) {
defaults := []option.RequestOption{option.WithEnvironmentProduction()}
if o, ok := os.LookupEnv("GITPOD_API_KEY"); ok {
defaults = append(defaults, option.WithBearerToken(o))
}
opts = append(defaults, opts...)
opts = append(DefaultClientOptions(), opts...)

r = &Client{Options: opts}

Expand Down
43 changes: 38 additions & 5 deletions internal/requestconfig/requestconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/gitpod-io/gitpod-sdk-go/internal/apierror"
"github.com/gitpod-io/gitpod-sdk-go/internal/apiform"
"github.com/gitpod-io/gitpod-sdk-go/internal/apiquery"
"github.com/gitpod-io/gitpod-sdk-go/internal/param"
)

func getDefaultHeaders() map[string]string {
Expand Down Expand Up @@ -77,7 +78,17 @@ func getPlatformProperties() map[string]string {
}
}

func NewRequestConfig(ctx context.Context, method string, u string, body interface{}, dst interface{}, opts ...func(*RequestConfig) error) (*RequestConfig, error) {
type RequestOption interface {
Apply(*RequestConfig) error
}

type RequestOptionFunc func(*RequestConfig) error
type PreRequestOptionFunc func(*RequestConfig) error

func (s RequestOptionFunc) Apply(r *RequestConfig) error { return s(r) }
func (s PreRequestOptionFunc) Apply(r *RequestConfig) error { return s(r) }

func NewRequestConfig(ctx context.Context, method string, u string, body interface{}, dst interface{}, opts ...RequestOption) (*RequestConfig, error) {
var reader io.Reader

contentType := "application/json"
Expand Down Expand Up @@ -174,10 +185,17 @@ func NewRequestConfig(ctx context.Context, method string, u string, body interfa
return &cfg, nil
}

func UseDefaultParam[T any](dst *param.Field[T], src *T) {
if !dst.Present && src != nil {
dst.Value = *src
dst.Present = true
}
}

// RequestConfig represents all the state related to one request.
//
// Editing the variables inside RequestConfig directly is unstable api. Prefer
// composing func(\*RequestConfig) error instead if possible.
// composing the RequestOption instead if possible.
type RequestConfig struct {
MaxRetries int
RequestTimeout time.Duration
Expand Down Expand Up @@ -517,7 +535,7 @@ func (cfg *RequestConfig) Execute() (err error) {
return nil
}

func ExecuteNewRequest(ctx context.Context, method string, u string, body interface{}, dst interface{}, opts ...func(*RequestConfig) error) error {
func ExecuteNewRequest(ctx context.Context, method string, u string, body interface{}, dst interface{}, opts ...RequestOption) error {
cfg, err := NewRequestConfig(ctx, method, u, body, dst, opts...)
if err != nil {
return err
Expand Down Expand Up @@ -551,12 +569,27 @@ func (cfg *RequestConfig) Clone(ctx context.Context) *RequestConfig {
return new
}

func (cfg *RequestConfig) Apply(opts ...func(*RequestConfig) error) error {
func (cfg *RequestConfig) Apply(opts ...RequestOption) error {
for _, opt := range opts {
err := opt(cfg)
err := opt.Apply(cfg)
if err != nil {
return err
}
}
return nil
}

func PreRequestOptions(opts ...RequestOption) (RequestConfig, error) {
cfg := RequestConfig{}
for _, opt := range opts {
if _, ok := opt.(PreRequestOptionFunc); !ok {
continue
}

err := opt.Apply(&cfg)
if err != nil {
return cfg, err
}
}
return cfg, nil
}
70 changes: 35 additions & 35 deletions option/requestoption.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,30 +21,30 @@ import (
// options pattern in our [README].
//
// [README]: https://pkg.go.dev/github.com/gitpod-io/gitpod-sdk-go#readme-requestoptions
type RequestOption = func(*requestconfig.RequestConfig) error
type RequestOption = requestconfig.RequestOption

// WithBaseURL returns a RequestOption that sets the BaseURL for the client.
func WithBaseURL(base string) RequestOption {
u, err := url.Parse(base)
if err != nil {
log.Fatalf("failed to parse BaseURL: %s\n", err)
}
return func(r *requestconfig.RequestConfig) error {
return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error {
if u.Path != "" && !strings.HasSuffix(u.Path, "/") {
u.Path += "/"
}
r.BaseURL = u
return nil
}
})
}

// WithHTTPClient returns a RequestOption that changes the underlying [http.Client] used to make this
// request, which by default is [http.DefaultClient].
func WithHTTPClient(client *http.Client) RequestOption {
return func(r *requestconfig.RequestConfig) error {
return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error {
r.HTTPClient = client
return nil
}
})
}

// MiddlewareNext is a function which is called by a middleware to pass an HTTP request
Expand All @@ -59,10 +59,10 @@ type Middleware = func(*http.Request, MiddlewareNext) (*http.Response, error)
// WithMiddleware returns a RequestOption that applies the given middleware
// to the requests made. Each middleware will execute in the order they were given.
func WithMiddleware(middlewares ...Middleware) RequestOption {
return func(r *requestconfig.RequestConfig) error {
return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error {
r.Middlewares = append(r.Middlewares, middlewares...)
return nil
}
})
}

// WithMaxRetries returns a RequestOption that sets the maximum number of retries that the client
Expand All @@ -74,76 +74,76 @@ func WithMaxRetries(retries int) RequestOption {
if retries < 0 {
panic("option: cannot have fewer than 0 retries")
}
return func(r *requestconfig.RequestConfig) error {
return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error {
r.MaxRetries = retries
return nil
}
})
}

// WithHeader returns a RequestOption that sets the header value to the associated key. It overwrites
// any value if there was one already present.
func WithHeader(key, value string) RequestOption {
return func(r *requestconfig.RequestConfig) error {
return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error {
r.Request.Header.Set(key, value)
return nil
}
})
}

// WithHeaderAdd returns a RequestOption that adds the header value to the associated key. It appends
// onto any existing values.
func WithHeaderAdd(key, value string) RequestOption {
return func(r *requestconfig.RequestConfig) error {
return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error {
r.Request.Header.Add(key, value)
return nil
}
})
}

// WithHeaderDel returns a RequestOption that deletes the header value(s) associated with the given key.
func WithHeaderDel(key string) RequestOption {
return func(r *requestconfig.RequestConfig) error {
return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error {
r.Request.Header.Del(key)
return nil
}
})
}

// WithQuery returns a RequestOption that sets the query value to the associated key. It overwrites
// any value if there was one already present.
func WithQuery(key, value string) RequestOption {
return func(r *requestconfig.RequestConfig) error {
return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error {
query := r.Request.URL.Query()
query.Set(key, value)
r.Request.URL.RawQuery = query.Encode()
return nil
}
})
}

// WithQueryAdd returns a RequestOption that adds the query value to the associated key. It appends
// onto any existing values.
func WithQueryAdd(key, value string) RequestOption {
return func(r *requestconfig.RequestConfig) error {
return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error {
query := r.Request.URL.Query()
query.Add(key, value)
r.Request.URL.RawQuery = query.Encode()
return nil
}
})
}

// WithQueryDel returns a RequestOption that deletes the query value(s) associated with the key.
func WithQueryDel(key string) RequestOption {
return func(r *requestconfig.RequestConfig) error {
return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error {
query := r.Request.URL.Query()
query.Del(key)
r.Request.URL.RawQuery = query.Encode()
return nil
}
})
}

// WithJSONSet returns a RequestOption that sets the body's JSON value associated with the key.
// The key accepts a string as defined by the [sjson format].
//
// [sjson format]: https://github.com/tidwall/sjson
func WithJSONSet(key string, value interface{}) RequestOption {
return func(r *requestconfig.RequestConfig) (err error) {
return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) (err error) {
if buffer, ok := r.Body.(*bytes.Buffer); ok {
b := buffer.Bytes()
b, err = sjson.SetBytes(b, key, value)
Expand All @@ -155,15 +155,15 @@ func WithJSONSet(key string, value interface{}) RequestOption {
}

return fmt.Errorf("cannot use WithJSONSet on a body that is not serialized as *bytes.Buffer")
}
})
}

// WithJSONDel returns a RequestOption that deletes the body's JSON value associated with the key.
// The key accepts a string as defined by the [sjson format].
//
// [sjson format]: https://github.com/tidwall/sjson
func WithJSONDel(key string) RequestOption {
return func(r *requestconfig.RequestConfig) (err error) {
return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) (err error) {
if buffer, ok := r.Body.(*bytes.Buffer); ok {
b := buffer.Bytes()
b, err = sjson.DeleteBytes(b, key)
Expand All @@ -175,32 +175,32 @@ func WithJSONDel(key string) RequestOption {
}

return fmt.Errorf("cannot use WithJSONDel on a body that is not serialized as *bytes.Buffer")
}
})
}

// WithResponseBodyInto returns a RequestOption that overwrites the deserialization target with
// the given destination. If provided, we don't deserialize into the default struct.
func WithResponseBodyInto(dst any) RequestOption {
return func(r *requestconfig.RequestConfig) error {
return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error {
r.ResponseBodyInto = dst
return nil
}
})
}

// WithResponseInto returns a RequestOption that copies the [*http.Response] into the given address.
func WithResponseInto(dst **http.Response) RequestOption {
return func(r *requestconfig.RequestConfig) error {
return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error {
r.ResponseInto = dst
return nil
}
})
}

// WithRequestBody returns a RequestOption that provides a custom serialized body with the given
// content type.
//
// body accepts an io.Reader or raw []bytes.
func WithRequestBody(contentType string, body any) RequestOption {
return func(r *requestconfig.RequestConfig) error {
return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error {
if reader, ok := body.(io.Reader); ok {
r.Body = reader
return r.Apply(WithHeader("Content-Type", contentType))
Expand All @@ -212,17 +212,17 @@ func WithRequestBody(contentType string, body any) RequestOption {
}

return fmt.Errorf("body must be a byte slice or implement io.Reader")
}
})
}

// WithRequestTimeout returns a RequestOption that sets the timeout for
// each request attempt. This should be smaller than the timeout defined in
// the context, which spans all retries.
func WithRequestTimeout(dur time.Duration) RequestOption {
return func(r *requestconfig.RequestConfig) error {
return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error {
r.RequestTimeout = dur
return nil
}
})
}

// WithEnvironmentProduction returns a RequestOption that sets the current
Expand All @@ -234,8 +234,8 @@ func WithEnvironmentProduction() RequestOption {

// WithBearerToken returns a RequestOption that sets the client setting "bearer_token".
func WithBearerToken(value string) RequestOption {
return func(r *requestconfig.RequestConfig) error {
return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error {
r.BearerToken = value
return r.Apply(WithHeader("authorization", fmt.Sprintf("Bearer %s", r.BearerToken)))
}
})
}