-
Notifications
You must be signed in to change notification settings - Fork 30
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||||||
// | ||||||
// 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 | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||
// 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 | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit:
Suggested change
|
||||||
// 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, "", " ") | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||
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() | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit:
Suggested change
|
||||||
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 { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Potentially:
Suggested change
|
||||||
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 | ||||||
} |
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)) | ||
}) | ||
} |
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 | ||
) |
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= |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: link to https://tailscale.com/api