Skip to content

Commit bd4d815

Browse files
committed
v2: return ETag with ACL and raw ACL responses
This allows clients to know which ETag to provide when updating an ACL. Updates #119 Signed-off-by: Percy Wegmann <[email protected]>
1 parent 9894791 commit bd4d815

File tree

4 files changed

+71
-28
lines changed

4 files changed

+71
-28
lines changed

v2/client.go

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -262,74 +262,85 @@ func (c *Client) buildRequest(ctx context.Context, method string, uri *url.URL,
262262
return req, nil
263263
}
264264

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

274274
// body calls resource.do, passing a *T to do, and returns
275275
// exactly one non-zero value depending on the result of do.
276276
func body[T any](resource doer, req *http.Request) (*T, error) {
277+
t, _, err := bodyWithResponseHeader[T](resource, req)
278+
return t, err
279+
}
280+
281+
// bodyWithResponseHeader is like [body] but also returns the response header.
282+
func bodyWithResponseHeader[T any](resource doer, req *http.Request) (*T, http.Header, error) {
277283
var v T
278-
err := resource.do(req, &v)
284+
header, err := resource.doWithResponseHeaders(req, &v)
279285
if err != nil {
280-
return nil, err
286+
return nil, nil, err
281287
}
282-
return &v, nil
288+
return &v, header, nil
283289
}
284290

285291
func (c *Client) do(req *http.Request, out any) error {
292+
_, err := c.doWithResponseHeaders(req, out)
293+
return err
294+
}
295+
296+
func (c *Client) doWithResponseHeaders(req *http.Request, out any) (http.Header, error) {
286297
res, err := c.HTTP.Do(req)
287298
if err != nil {
288-
return err
299+
return nil, err
289300
}
290301
defer res.Body.Close()
291302

292303
body, err := io.ReadAll(res.Body)
293304
if err != nil {
294-
return err
305+
return nil, err
295306
}
296307

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

305316
// If we're expected to write result into a []byte, do not attempt to parse it.
306317
if o, ok := out.(*[]byte); ok {
307318
*o = bytes.Clone(body)
308-
return nil
319+
return res.Header, nil
309320
}
310321

311322
// If we've got hujson back, convert it to JSON, so we can natively parse it.
312323
if !json.Valid(body) {
313324
body, err = hujson.Standardize(body)
314325
if err != nil {
315-
return err
326+
return res.Header, err
316327
}
317328
}
318329

319-
return json.Unmarshal(body, out)
330+
return res.Header, json.Unmarshal(body, out)
320331
}
321332

322333
if res.StatusCode >= http.StatusBadRequest {
323334
var apiErr APIError
324335
if err := json.Unmarshal(body, &apiErr); err != nil {
325-
return err
336+
return res.Header, err
326337
}
327338

328339
apiErr.status = res.StatusCode
329-
return apiErr
340+
return res.Header, apiErr
330341
}
331342

332-
return nil
343+
return res.Header, nil
333344
}
334345

335346
func (err APIError) Error() string {

v2/policyfile.go

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ type ACL struct {
3333
// This API is subject to change. Internal bug: corp/13986
3434
Postures map[string][]string `json:"postures,omitempty" hujson:"Postures,omitempty"`
3535
DefaultSourcePosture []string `json:"defaultSrcPosture,omitempty" hujson:"DefaultSrcPosture,omitempty"`
36+
37+
// ETag is the etag corresponding to this version of the ACL
38+
ETag string `json:"-"`
39+
}
40+
41+
// RawACL contains a raw HuJSON ACL and its associated ETag.
42+
type RawACL struct {
43+
// HuJSON is the raw HuJSON ACL string
44+
HuJSON string
45+
46+
// ETag is the etag corresponding to this version of the ACL
47+
ETag string
3648
}
3749

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

119-
return body[ACL](pr, req)
131+
acl, header, err := bodyWithResponseHeader[ACL](pr, req)
132+
if err != nil {
133+
return nil, err
134+
}
135+
acl.ETag = header.Get("Etag")
136+
return acl, nil
120137
}
121138

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

129146
var resp []byte
130-
if err = pr.do(req, &resp); err != nil {
131-
return "", err
147+
header, err := pr.doWithResponseHeaders(req, &resp)
148+
if err != nil {
149+
return nil, err
132150
}
133151

134-
return string(resp), nil
152+
return &RawACL{
153+
HuJSON: string(resp),
154+
ETag: header.Get("Etag"),
155+
}, nil
135156
}
136157

137158
// Set sets the [ACL] for the tailnet. acl can either be an [ACL], or a HuJSON string.

v2/policyfile_test.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -353,11 +353,13 @@ func TestClient_ACL(t *testing.T) {
353353
Allow: []string{"100.60.3.4:22"},
354354
},
355355
},
356+
ETag: "myetag",
356357
}
358+
server.ResponseHeader.Add("ETag", "myetag")
357359

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

371373
server.ResponseCode = http.StatusOK
372374
server.ResponseBody = huJSONACL
375+
server.ResponseHeader.Add("ETag", "myetag")
373376

377+
expectedRawACL := &tsclient.RawACL{
378+
HuJSON: string(huJSONACL),
379+
ETag: "myetag",
380+
}
374381
acl, err := client.PolicyFile().Raw(context.Background())
375382
assert.NoError(t, err)
376-
assert.EqualValues(t, string(huJSONACL), acl)
383+
assert.EqualValues(t, expectedRawACL, acl)
377384
assert.EqualValues(t, http.MethodGet, server.Method)
378385
assert.EqualValues(t, "application/hujson", server.Header.Get("Accept"))
379386
assert.EqualValues(t, "/api/v2/tailnet/example.com/acl", server.Path)

v2/tailscale_test.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"encoding/json"
99
"fmt"
1010
"io"
11+
"maps"
1112
"net"
1213
"net/http"
1314
"net/url"
@@ -28,15 +29,17 @@ type TestServer struct {
2829
Body *bytes.Buffer
2930
Header http.Header
3031

31-
ResponseCode int
32-
ResponseBody interface{}
32+
ResponseCode int
33+
ResponseBody interface{}
34+
ResponseHeader http.Header
3335
}
3436

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

3840
testServer := &TestServer{
39-
t: t,
41+
t: t,
42+
ResponseHeader: make(http.Header),
4043
}
4144

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

86+
maps.Copy(w.Header(), t.ResponseHeader)
8387
w.WriteHeader(t.ResponseCode)
8488
if t.ResponseBody != nil {
8589
switch body := t.ResponseBody.(type) {

0 commit comments

Comments
 (0)