Skip to content

Commit

Permalink
v2: return ETag with ACL and raw ACL responses
Browse files Browse the repository at this point in the history
This allows clients to know which ETag to provide when updating an ACL.

Updates #119

Signed-off-by: Percy Wegmann <[email protected]>
  • Loading branch information
oxtoacart committed Oct 28, 2024
1 parent 9894791 commit bd4d815
Show file tree
Hide file tree
Showing 4 changed files with 71 additions and 28 deletions.
45 changes: 28 additions & 17 deletions v2/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,74 +262,85 @@ func (c *Client) buildRequest(ctx context.Context, method string, uri *url.URL,
return req, nil
}

// doer is a resource type (such as *ContactsResource) with a do method that
// sends an HTTP request and decodes its body into out.
// doer is a resource type (such as *ContactsResource) with a doWithResponseHeaders
// method that sends an HTTP request and decodes its body into out.
//
// Concretely, the do method will usually be (*Client).do, as all the Resource
// types embed a *Client.
// Concretely, the doWithResponseHeaders method will usually be (*Client).doWithResponseHeaders,
// as all the Resource types embed a *Client.
type doer interface {
do(req *http.Request, out any) error
doWithResponseHeaders(req *http.Request, out any) (http.Header, error)
}

// body calls resource.do, passing a *T to do, and returns
// exactly one non-zero value depending on the result of do.
func body[T any](resource doer, req *http.Request) (*T, error) {
t, _, err := bodyWithResponseHeader[T](resource, req)
return t, err
}

// bodyWithResponseHeader is like [body] but also returns the response header.
func bodyWithResponseHeader[T any](resource doer, req *http.Request) (*T, http.Header, error) {
var v T
err := resource.do(req, &v)
header, err := resource.doWithResponseHeaders(req, &v)
if err != nil {
return nil, err
return nil, nil, err
}
return &v, nil
return &v, header, nil
}

func (c *Client) do(req *http.Request, out any) error {
_, err := c.doWithResponseHeaders(req, out)
return err
}

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

body, err := io.ReadAll(res.Body)
if err != nil {
return err
return nil, 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
return res.Header, 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
return res.Header, 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 res.Header, err
}
}

return json.Unmarshal(body, out)
return res.Header, json.Unmarshal(body, out)
}

if res.StatusCode >= http.StatusBadRequest {
var apiErr APIError
if err := json.Unmarshal(body, &apiErr); err != nil {
return err
return res.Header, err
}

apiErr.status = res.StatusCode
return apiErr
return res.Header, apiErr
}

return nil
return res.Header, nil
}

func (err APIError) Error() string {
Expand Down
33 changes: 27 additions & 6 deletions v2/policyfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ type ACL struct {
// This API is subject to change. Internal bug: corp/13986
Postures map[string][]string `json:"postures,omitempty" hujson:"Postures,omitempty"`
DefaultSourcePosture []string `json:"defaultSrcPosture,omitempty" hujson:"DefaultSrcPosture,omitempty"`

// ETag is the etag corresponding to this version of the ACL
ETag string `json:"-"`
}

// RawACL contains a raw HuJSON ACL and its associated ETag.
type RawACL struct {
// HuJSON is the raw HuJSON ACL string
HuJSON string

// ETag is the etag corresponding to this version of the ACL
ETag string
}

type ACLAutoApprovers struct {
Expand Down Expand Up @@ -116,22 +128,31 @@ func (pr *PolicyFileResource) Get(ctx context.Context) (*ACL, error) {
return nil, err
}

return body[ACL](pr, req)
acl, header, err := bodyWithResponseHeader[ACL](pr, req)
if err != nil {
return nil, err
}
acl.ETag = header.Get("Etag")
return acl, nil
}

// Raw retrieves the [ACL] that is currently set for the tailnet as a HuJSON string.
func (pr *PolicyFileResource) Raw(ctx context.Context) (string, error) {
func (pr *PolicyFileResource) Raw(ctx context.Context) (*RawACL, error) {
req, err := pr.buildRequest(ctx, http.MethodGet, pr.buildTailnetURL("acl"), requestContentType("application/hujson"))
if err != nil {
return "", err
return nil, err
}

var resp []byte
if err = pr.do(req, &resp); err != nil {
return "", err
header, err := pr.doWithResponseHeaders(req, &resp)
if err != nil {
return nil, err
}

return string(resp), nil
return &RawACL{
HuJSON: string(resp),
ETag: header.Get("Etag"),
}, nil
}

// Set sets the [ACL] for the tailnet. acl can either be an [ACL], or a HuJSON string.
Expand Down
11 changes: 9 additions & 2 deletions v2/policyfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -353,11 +353,13 @@ func TestClient_ACL(t *testing.T) {
Allow: []string{"100.60.3.4:22"},
},
},
ETag: "myetag",
}
server.ResponseHeader.Add("ETag", "myetag")

acl, err := client.PolicyFile().Get(context.Background())
assert.NoError(t, err)
assert.EqualValues(t, acl, server.ResponseBody)
assert.EqualValues(t, server.ResponseBody, acl)
assert.EqualValues(t, http.MethodGet, server.Method)
assert.EqualValues(t, "application/json", server.Header.Get("Accept"))
assert.EqualValues(t, "/api/v2/tailnet/example.com/acl", server.Path)
Expand All @@ -370,10 +372,15 @@ func TestClient_RawACL(t *testing.T) {

server.ResponseCode = http.StatusOK
server.ResponseBody = huJSONACL
server.ResponseHeader.Add("ETag", "myetag")

expectedRawACL := &tsclient.RawACL{
HuJSON: string(huJSONACL),
ETag: "myetag",
}
acl, err := client.PolicyFile().Raw(context.Background())
assert.NoError(t, err)
assert.EqualValues(t, string(huJSONACL), acl)
assert.EqualValues(t, expectedRawACL, acl)
assert.EqualValues(t, http.MethodGet, server.Method)
assert.EqualValues(t, "application/hujson", server.Header.Get("Accept"))
assert.EqualValues(t, "/api/v2/tailnet/example.com/acl", server.Path)
Expand Down
10 changes: 7 additions & 3 deletions v2/tailscale_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"io"
"maps"
"net"
"net/http"
"net/url"
Expand All @@ -28,15 +29,17 @@ type TestServer struct {
Body *bytes.Buffer
Header http.Header

ResponseCode int
ResponseBody interface{}
ResponseCode int
ResponseBody interface{}
ResponseHeader http.Header
}

func NewTestHarness(t *testing.T) (*tsclient.Client, *TestServer) {
t.Helper()

testServer := &TestServer{
t: t,
t: t,
ResponseHeader: make(http.Header),
}

mux := http.NewServeMux()
Expand Down Expand Up @@ -80,6 +83,7 @@ func (t *TestServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
_, err := io.Copy(t.Body, r.Body)
assert.NoError(t.t, err)

maps.Copy(w.Header(), t.ResponseHeader)
w.WriteHeader(t.ResponseCode)
if t.ResponseBody != nil {
switch body := t.ResponseBody.(type) {
Expand Down

0 comments on commit bd4d815

Please sign in to comment.