Skip to content

Commit

Permalink
oxide: refactor exported client API
Browse files Browse the repository at this point in the history
Previously, there were two exported functions to create an Oxide API
client.

- `func NewClient(token, userAgent, host string) (*Client, error)`
- `func NewClientFromEnv(userAgent string) (*Client, error)`

These two functions required a user agent to be passed as an argument,
which should be an optional argument instead. Additionally, having two
separate functions means a user must switch functions when they wish to
switch to or from using an environment to configure the client.

The code always created a custom `http.RoundTripper` to be used as the
transport for the underlying HTTP client. The only function of this
custom tranport was to add HTTP headers to the request.

This patch updates the SDK to export a single function to create an
Oxide API client.

- `func NewClient(cfg *Config) (*Client, error)`

This new function uses a new `Config` type to accept optional
configuration for the client. Now, a user can not only build an API
client from their environment, but also override the user agent or HTTP
client used.

The custom transport was removed and the logic to set HTTP headers for
the request was moved into the `buildRequest` method where it will have
access to the user agent string and the request to set the headers.

Closes: oxidecomputer#165, oxidecomputer#166
  • Loading branch information
sudomateo committed Jan 19, 2024
1 parent 3d15f3d commit 6bb54f5
Show file tree
Hide file tree
Showing 14 changed files with 281 additions and 254 deletions.
12 changes: 10 additions & 2 deletions .changelog/v0.1.0-beta3.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,13 @@ title = ""
description = ""

[[breaking]]
title = "Go verrsion"
description = "Minimum required Go version has been updated to 1.21. [#179](https://github.com/oxidecomputer/oxide.go/pull/179)"
title = "Go version"
description = "Minimum required Go version has been updated to 1.21. [#179](https://github.com/oxidecomputer/oxide.go/pull/179)"

[[breaking]]
title = "NewClient API change"
description = "The `NewClient` function has been updated to no longer require a user agent parameter. [#180](https://github.com/oxidecomputer/oxide.go/pull/180)"

[[breaking]]
title = "NewClientFromEnv removal"
description = "The `NewClientFromEnv` function has been removed. Users should use `NewClient` instead. [#180](https://github.com/oxidecomputer/oxide.go/pull/180)"
4 changes: 3 additions & 1 deletion .github/ISSUE_TEMPLATE/release_checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ labels: release
After completing each task put an `x` in the corresponding box,
and paste the link to the relevant PR.
-->
- [ ] Make sure version is set to the new version in `VERSION` file.
- [ ] Make sure the following files have the new version you want to release.
- [ ] [`VERSION`](./VERSION)
- [ ] [`oxide/version.go`](./oxide/version.go)
- [ ] Make sure all examples and docs reference the new version.
- [ ] Generate changelog by running `make changelog` and add date of the release to the title.
- [ ] Release the new version by running `make tag`.
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ $ make all

## Releasing a new SDK version

1. Make sure the [`VERSION`](./VERSION) file has the new version you want to release.
1. Make sure the following files have the new version you want to release.
1. [`VERSION`](./VERSION)
1. [`oxide/version.go`](./oxide/version.go)
2. Make sure you have run `make all` and pushed any changes. The release
will fail if running `make all` causes any changes to the generated
code.
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ import (
)

func main() {
client, err := oxide.NewClient("<auth token>", "<user-agent>", "<host>")
cfg := oxide.Config{
Address: "https://api.oxide.computer",
Token: "oxide-abc123",
}
client, err := oxide.NewClient(&cfg)
if err != nil {
panic(err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/generate/templates/no_resptype_body_method.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
b := params.Body{{end}}

// Create the request
req, err := buildRequest(
req, err := c.buildRequest(
ctx,
b,
"{{.HTTPMethod}}",
Expand Down
2 changes: 1 addition & 1 deletion internal/generate/templates/no_resptype_method.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
return err
}{{end}}
// Create the request
req, err := buildRequest(
req, err := c.buildRequest(
ctx,
nil,
"{{.HTTPMethod}}",
Expand Down
2 changes: 1 addition & 1 deletion internal/generate/templates/resptype_body_method.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
b := params.Body{{end}}

// Create the request
req, err := buildRequest(
req, err := c.buildRequest(
ctx,
b,
"{{.HTTPMethod}}",
Expand Down
2 changes: 1 addition & 1 deletion internal/generate/templates/resptype_method.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
return nil, err
}{{end}}
// Create the request
req, err := buildRequest(
req, err := c.buildRequest(
ctx,
nil,
"{{.HTTPMethod}}",
Expand Down
10 changes: 5 additions & 5 deletions internal/generate/test_utils/paths_output
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ func (c *Client) IpPoolList(ctx context.Context, params IpPoolListParams, ) (*Ip
return nil, err
}
// Create the request
req, err := buildRequest(
req, err := c.buildRequest(
ctx,
nil,
"GET",
Expand Down Expand Up @@ -95,7 +95,7 @@ func (c *Client) IpPoolCreate(ctx context.Context, params IpPoolCreateParams, )
}

// Create the request
req, err := buildRequest(
req, err := c.buildRequest(
ctx,
b,
"POST",
Expand Down Expand Up @@ -141,7 +141,7 @@ func (c *Client) IpPoolView(ctx context.Context, params IpPoolViewParams, ) (*Ip
return nil, err
}
// Create the request
req, err := buildRequest(
req, err := c.buildRequest(
ctx,
nil,
"GET",
Expand Down Expand Up @@ -194,7 +194,7 @@ func (c *Client) IpPoolUpdate(ctx context.Context, params IpPoolUpdateParams, )
}

// Create the request
req, err := buildRequest(
req, err := c.buildRequest(
ctx,
b,
"PUT",
Expand Down Expand Up @@ -241,7 +241,7 @@ func (c *Client) IpPoolDelete(ctx context.Context, params IpPoolDeleteParams, )
return err
}
// Create the request
req, err := buildRequest(
req, err := c.buildRequest(
ctx,
nil,
"DELETE",
Expand Down
10 changes: 5 additions & 5 deletions internal/generate/test_utils/paths_output_expected
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ func (c *Client) IpPoolList(ctx context.Context, params IpPoolListParams, ) (*Ip
return nil, err
}
// Create the request
req, err := buildRequest(
req, err := c.buildRequest(
ctx,
nil,
"GET",
Expand Down Expand Up @@ -95,7 +95,7 @@ func (c *Client) IpPoolCreate(ctx context.Context, params IpPoolCreateParams, )
}

// Create the request
req, err := buildRequest(
req, err := c.buildRequest(
ctx,
b,
"POST",
Expand Down Expand Up @@ -141,7 +141,7 @@ func (c *Client) IpPoolView(ctx context.Context, params IpPoolViewParams, ) (*Ip
return nil, err
}
// Create the request
req, err := buildRequest(
req, err := c.buildRequest(
ctx,
nil,
"GET",
Expand Down Expand Up @@ -194,7 +194,7 @@ func (c *Client) IpPoolUpdate(ctx context.Context, params IpPoolUpdateParams, )
}

// Create the request
req, err := buildRequest(
req, err := c.buildRequest(
ctx,
b,
"PUT",
Expand Down Expand Up @@ -241,7 +241,7 @@ func (c *Client) IpPoolDelete(ctx context.Context, params IpPoolDeleteParams, )
return err
}
// Create the request
req, err := buildRequest(
req, err := c.buildRequest(
ctx,
nil,
"DELETE",
Expand Down
153 changes: 75 additions & 78 deletions oxide/lib.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,75 +22,98 @@ const TokenEnvVar = "OXIDE_TOKEN"
// HostEnvVar is the environment variable that contains the host.
const HostEnvVar = "OXIDE_HOST"

// Config is the configuration that can be set on a Client.
type Config struct {
// Base URL of the Oxide API including the scheme. For example,
// https://api.oxide.computer.
Address string

// Oxide API authentication token.
Token string

// A custom HTTP client to use for the Client instead of the default.
HTTPClient *http.Client

// A custom user agent string to add to every request instead of the
// default.
UserAgent string
}

// Client which conforms to the OpenAPI3 specification for this service.
type Client struct {
// The endpoint of the server conforming to this interface, with scheme,
// https://api.oxide.computer for example.
// Base URL of the Oxide API including the scheme. For example,
// https://api.oxide.computer.
server string

// Client is the *http.Client for performing requests.
// Oxide API authentication token.
token string

// HTTP client to make API requests.
client *http.Client

// token is the API token used for authentication.
token string
// The user agent string to add to every API request.
userAgent string
}

// NewClient creates a new client for the Oxide API.
// You need to pass in your API token to create the client.
func NewClient(token, userAgent, host string) (*Client, error) {
if token == "" {
return nil, fmt.Errorf("you need to pass in an API token to create the client")
// NewClient creates a new client for the Oxide API. Pass in a non-nil *Config
// to set the various configuration options on a Client.
func NewClient(cfg *Config) (*Client, error) {
token := os.Getenv(TokenEnvVar)
server := os.Getenv(HostEnvVar)
userAgent := defaultUserAgent()
httpClient := &http.Client{
Timeout: 600 * time.Second,
}

baseURL, err := parseBaseURL(host)
if err != nil {
return nil, err
}
// Layer in the user-provided configuration if provided.
if cfg != nil {
if cfg.Address != "" {
server = cfg.Address
}

client := &Client{
server: baseURL,
token: token,
}
if cfg.Token != "" {
token = cfg.Token
}

// Ensure the server URL always has a trailing slash.
if !strings.HasSuffix(client.server, "/") {
client.server += "/"
}
if cfg.UserAgent != "" {
userAgent = cfg.UserAgent
}

uat := userAgentTransport{
base: http.DefaultTransport,
userAgent: userAgent,
client: client,
if cfg.HTTPClient != nil {
httpClient = cfg.HTTPClient
}
}

client.client = &http.Client{
Transport: uat,
// We want a longer timeout since some of the files might take a bit.
Timeout: 600 * time.Second,
server, err := parseBaseURL(server)
if err != nil {
return nil, fmt.Errorf("failed parsing client address: %w", err)
}

return client, nil
}

// NewClientFromEnv creates a new client for the Oxide API, using the token
// stored in the environment variable `OXIDE_TOKEN` and the host stored in the
// environment variable `OXIDE_HOST`.
func NewClientFromEnv(userAgent string) (*Client, error) {
token := os.Getenv(TokenEnvVar)
if token == "" {
return nil, fmt.Errorf("the environment variable %s must be set with your API token", TokenEnvVar)
return nil, errors.New("invalid client configuration: token is required")
}

host := os.Getenv(HostEnvVar)
if host == "" {
return nil, fmt.Errorf("the environment variable %s must be set with the host of the Oxide API", HostEnvVar)
client := &Client{
token: token,
server: server,
userAgent: userAgent,
client: httpClient,
}

return NewClient(token, userAgent, host)
return client, nil
}

// defaultUserAgent builds and returns the default user agent string.
func defaultUserAgent() string {
return fmt.Sprintf("oxide.go/%s", version)
}

// parseBaseURL parses the base URL from the server URL.
func parseBaseURL(baseURL string) (string, error) {
if baseURL == "" {
return "", errors.New("address is empty")
}

if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
// Assume https.
baseURL = "https://" + baseURL
Expand All @@ -111,47 +134,21 @@ func parseBaseURL(baseURL string) (string, error) {
return b, nil
}

// WithToken overrides the token used for authentication.
func (c *Client) WithToken(token string) {
c.token = token
}

type userAgentTransport struct {
userAgent string
base http.RoundTripper
client *Client
}

func (t userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if t.base == nil {
return nil, errors.New("RoundTrip: no Transport specified")
}

newReq := *req
newReq.Header = make(http.Header)
for k, vv := range req.Header {
newReq.Header[k] = vv
}

// Add the user agent header.
newReq.Header["User-Agent"] = []string{t.userAgent}

// Add the content-type header.
newReq.Header["Content-Type"] = []string{"application/json"}

// Add the authorization header.
newReq.Header["Authorization"] = []string{fmt.Sprintf("Bearer %s", t.client.token)}

return t.base.RoundTrip(&newReq)
}

func buildRequest(ctx context.Context, body io.Reader, method, uri string, params, queries map[string]string) (*http.Request, error) {
// buildRequest creates an HTTP request to interact with the Oxide API.
func (c *Client) buildRequest(ctx context.Context, body io.Reader, method, uri string, params, queries map[string]string) (*http.Request, error) {
// Create the request.
req, err := http.NewRequestWithContext(ctx, method, uri, body)
if err != nil {
return nil, fmt.Errorf("error creating request: %v", err)
}

if c.userAgent != "" {
req.Header.Set("User-Agent", c.userAgent)
}

req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", c.token)

// Add the parameters to the url.
if err := expandURL(req.URL, params); err != nil {
return nil, fmt.Errorf("expanding URL with parameters failed: %v", err)
Expand Down
9 changes: 8 additions & 1 deletion oxide/lib_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,16 @@ func Test_buildRequest(t *testing.T) {
// wantErr: "Some error that doesn't exist yet",
// },
}

// Just to get a client to call buildRequest on.
c, err := NewClient(nil)
if err != nil {
t.Fatalf("failed creating api client: %v", err)
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := buildRequest(context.TODO(), tt.args.body, tt.args.method, tt.args.uri, tt.args.params, tt.args.queries)
got, err := c.buildRequest(context.TODO(), tt.args.body, tt.args.method, tt.args.uri, tt.args.params, tt.args.queries)
if err != nil {
assert.ErrorContains(t, err, tt.wantErr)
return
Expand Down
Loading

0 comments on commit 6bb54f5

Please sign in to comment.