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

v2: added skeleton of v2 client #89

Merged
merged 1 commit into from
Aug 2, 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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
A client implementation for the [Tailscale](https://tailscale.com) HTTP API.
For more details, please see [API documentation](/~https://github.com/tailscale/tailscale/blob/main/api.md).

A [V2](v2) implementation of the client is under active development, use at your own risk.

# Example

```go
Expand Down
291 changes: 291 additions & 0 deletions v2/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
// Package tailscale contains a basic implementation of a client for the Tailscale HTTP api. Documentation is here:
// /~https://github.com/tailscale/tailscale/blob/main/api.md
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

//
// WARNING - this v2 implementation is under active development, use at your own risk.
package tailscale

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sync"
"time"

"github.com/tailscale/hujson"
"golang.org/x/oauth2/clientcredentials"
)

type (
// Client type is used to perform actions against the Tailscale API.
Client struct {
// BaseURL is the base URL for accessing the Tailscale API server. Defaults to https://api.tailscale.com.
BaseURL *url.URL
// UserAgent configures the User-Agent HTTP header for requests, defaults to "tailscale-client-go"
UserAgent string
// APIKey allows specifying an APIKey to use for authentication.
APIKey string
// Tailnet allows specifying a specific Tailnet by name, to which this Client will connect by default.
Tailnet string

// http is the http client to use for requests to the API server. If specified, this supercedes the above configuration.
http *http.Client
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: might be nice to expose this so end users can swap out the client if desired.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's come up before. We'll need to think about how this interacts with functions like UseOAuth() that blast away the existing client.

// tailnetPathEscaped is the value of tailnet passed to url.PathEscape.
// This value should be used when formatting paths that have tailnet as a segment.
tailnetPathEscaped string

initOnce sync.Once
}

// APIError type describes an error as returned by the Tailscale API.
APIError struct {
Message string `json:"message"`
Data []APIErrorData `json:"data"`
status int
}

// APIErrorData type describes elements of the data field within errors returned by the Tailscale API.
APIErrorData struct {
User string `json:"user"`
Errors []string `json:"errors"`
}
)

var defaultBaseURL *url.URL
var oauthRelTokenURL *url.URL

func init() {
var err error
defaultBaseURL, err = url.Parse("https://api.tailscale.com")
if err != nil {
panic(fmt.Errorf("failed to parse defaultBaseURL: %w", err))
}

oauthRelTokenURL, err = url.Parse("/api/v2/oauth/token")
if err != nil {
panic(fmt.Errorf("failed to parse oauthRelTokenURL: %s", err))
}
}

const defaultContentType = "application/json"
const defaultHttpClientTimeout = time.Minute
const defaultUserAgent = "tailscale-client-go"

// NewClient returns a new instance of the Client type that will perform operations against a chosen tailnet and will
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
// NewClient returns a new instance of the Client type that will perform operations against a chosen tailnet and will
// init returns a new instance of the Client type that will perform operations against a chosen tailnet and will

// provide the apiKey for authorization. Additional options can be provided, see ClientOption for more details.
//
// To use OAuth Client credentials, call [UseOAuth].
func (c *Client) init() {
c.initOnce.Do(func() {
if c.BaseURL == nil {
c.BaseURL = defaultBaseURL
}
c.tailnetPathEscaped = url.PathEscape(c.Tailnet)
if c.UserAgent == "" {
c.UserAgent = defaultUserAgent
}
if c.http == nil {
c.http = &http.Client{Timeout: defaultHttpClientTimeout}
}
})
}

// UseOAuth configures the client to use the specified OAuth credentials.
func (c *Client) UseOAuth(clientID, clientSecret string, scopes []string) {
oauthConfig := clientcredentials.Config{
ClientID: clientID,
ClientSecret: clientSecret,
TokenURL: c.BaseURL.ResolveReference(oauthRelTokenURL).String(),
Scopes: scopes,
}

// use context.Background() here, since this is used to refresh the token in the future
c.http = oauthConfig.Client(context.Background())
c.http.Timeout = defaultHttpClientTimeout
}

type requestParams struct {
headers map[string]string
body any
contentType string
}

type requestOption func(*requestParams)

func requestBody(body any) requestOption {
return func(rof *requestParams) {
rof.body = body
}
}

func requestHeaders(headers map[string]string) requestOption {
return func(rof *requestParams) {
rof.headers = headers
}
}

func requestContentType(ct string) requestOption {
return func(rof *requestParams) {
rof.contentType = ct
}
}

func (c *Client) buildRequest(ctx context.Context, method, uri string, opts ...requestOption) (*http.Request, error) {
rof := &requestParams{
contentType: defaultContentType,
}
for _, opt := range opts {
opt(rof)
}

u, err := c.BaseURL.Parse(uri)
if err != nil {
return nil, err
}

var bodyBytes []byte
if rof.body != nil {
switch body := rof.body.(type) {
case string:
bodyBytes = []byte(body)
case []byte:
bodyBytes = body
default:
bodyBytes, err = json.MarshalIndent(rof.body, "", " ")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this is in the existing v1 but do we just want to do a json.Marshal here and not indent? Not sure if the indent gains us anything and presumably its making the requests marginally larger

if err != nil {
return nil, err
}
}
}

req, err := http.NewRequestWithContext(ctx, method, u.String(), bytes.NewBuffer(bodyBytes))
if err != nil {
return nil, err
}

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

for k, v := range rof.headers {
req.Header.Set(k, v)
}

switch {
case rof.body == nil:
req.Header.Set("Accept", rof.contentType)
default:
req.Header.Set("Content-Type", rof.contentType)
}

// c.apiKey will not be set on the client was configured with WithOAuthClientCredentials()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

Suggested change
// c.apiKey will not be set on the client was configured with WithOAuthClientCredentials()
// c.apiKey will not be set on the client was configured with UseOAuth()

if c.APIKey != "" {
req.SetBasicAuth(c.APIKey, "")
}

return req, nil
}

func (c *Client) performRequest(req *http.Request, out interface{}) error {
res, err := c.http.Do(req)
if err != nil {
return err
}
defer res.Body.Close()

body, err := io.ReadAll(res.Body)
if err != nil {
return err
}

if res.StatusCode >= http.StatusOK && res.StatusCode < http.StatusMultipleChoices {
// If we don't care about the response body, leave. This check is required as some
// API responses have empty bodies, so we don't want to try and standardize them for
// parsing.
if out == nil {
return nil
}

// If we're expected to write result into a []byte, do not attempt to parse it.
if o, ok := out.(*[]byte); ok {
*o = bytes.Clone(body)
return nil
}

// If we've got hujson back, convert it to JSON, so we can natively parse it.
if !json.Valid(body) {
body, err = hujson.Standardize(body)
if err != nil {
return err
}
}

return json.Unmarshal(body, out)
}

if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusCreated {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't have to do this in this PR but leaving a note that we'll want to adjust this logic since it's detecting valid 2XX / 3XX responses as errors (e.g., think we return 202 from some endpoints now, maybe /webhook/test?).

Potentially:

Suggested change
if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusCreated {
if res.StatusCode >= http.StatusBadRequest {

var apiErr APIError
if err = json.Unmarshal(body, &apiErr); err != nil {
return err
}

apiErr.status = res.StatusCode
return apiErr
}

return nil
}

func (err APIError) Error() string {
return fmt.Sprintf("%s (%v)", err.Message, err.status)
}

// IsNotFound returns true if the provided error implementation is an APIError with a status of 404.
func IsNotFound(err error) bool {
var apiErr APIError
if errors.As(err, &apiErr) {
return apiErr.status == http.StatusNotFound
}

return false
}

// ErrorData returns the contents of the APIError.Data field from the provided error if it is of type APIError. Returns
// a nil slice if the given error is not of type APIError.
func ErrorData(err error) []APIErrorData {
var apiErr APIError
if errors.As(err, &apiErr) {
return apiErr.Data
}

return nil
}

// Duration type wraps a time.Duration, allowing it to be JSON marshalled as a string like "20h" rather than
// a numeric value.
type Duration time.Duration

func (d Duration) String() string {
return time.Duration(d).String()
}

func (d Duration) MarshalText() ([]byte, error) {
return []byte(d.String()), nil
}

func (d *Duration) UnmarshalText(b []byte) error {
text := string(b)
if text == "" {
text = "0s"
}
pd, err := time.ParseDuration(text)
if err != nil {
return err
}
*d = Duration(pd)
return nil
}
35 changes: 35 additions & 0 deletions v2/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package tailscale_test

import (
_ "embed"
"io"
"testing"

"github.com/stretchr/testify/assert"

"github.com/tailscale/tailscale-client-go/v2"
)

func TestErrorData(t *testing.T) {
t.Parallel()

t.Run("It should return the data element from a valid error", func(t *testing.T) {
expected := tailscale.APIError{
Data: []tailscale.APIErrorData{
{
User: "user1@example.com",
Errors: []string{
"address \"user2@example.com:400\": want: Accept, got: Drop",
},
},
},
}

actual := tailscale.ErrorData(expected)
assert.EqualValues(t, expected.Data, actual)
})

t.Run("It should return an empty slice for any other error", func(t *testing.T) {
assert.Empty(t, tailscale.ErrorData(io.EOF))
})
}
15 changes: 15 additions & 0 deletions v2/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
module github.com/tailscale/tailscale-client-go/v2

go 1.22.0

require (
github.com/stretchr/testify v1.9.0
github.com/tailscale/hujson v0.0.0-20220506213045-af5ed07155e5
golang.org/x/oauth2 v0.21.0
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
16 changes: 16 additions & 0 deletions v2/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tailscale/hujson v0.0.0-20220506213045-af5ed07155e5 h1:erxeiTyq+nw4Cz5+hLDkOwNF5/9IQWCQPv0gpb3+QHU=
github.com/tailscale/hujson v0.0.0-20220506213045-af5ed07155e5/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Loading
Loading