Skip to content

Commit 689c150

Browse files
Response header hooks
The API for this client library is focused on returning the primary data associated with each API response, which typically means just the body of the response. Sometimes clients will also need to react to cross-cutting metadata such as expiration times, cache control guidance, and rate limiting information, which isn't a direct part of the data being requested but can nonetheless affect the behavior of the client. This information is typically returned in HTTP response header fields. To give access to this information without a breaking change to the API, this uses the context.Context API to allow the rare caller that needs it to register a hook through which it will be notified about the response header in any case where the request succeeded enough for there to be one. Most clients will not need this facility, which justifies the light abuse of the context.Context API for passing in this optional hook, even though this isn't the sort of cross-cutting concern context.Context should typically be used for.
1 parent 0fd4e6f commit 689c150

File tree

3 files changed

+123
-0
lines changed

3 files changed

+123
-0
lines changed

request.go

+9
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,20 @@ func (r ClientRequest) Do(ctx context.Context, model interface{}) error {
3131
return err
3232
}
3333

34+
// If the caller provided a response header hook then we'll call it
35+
// once we have a response.
36+
respHeaderHook := contextResponseHeaderHook(ctx)
37+
3438
// Add the context to the request.
3539
reqWithCxt := r.retryableRequest.WithContext(ctx)
3640

3741
// Execute the request and check the response.
3842
resp, err := r.http.Do(reqWithCxt)
43+
if resp != nil {
44+
// We call the callback whenever there's any sort of response,
45+
// even if it's returned in conjunction with an error.
46+
respHeaderHook(resp.StatusCode, resp.Header)
47+
}
3948
if err != nil {
4049
// If we got an error, and the context has been canceled,
4150
// the context's error is probably more useful.

request_hooks.go

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package tfe
5+
6+
import (
7+
"context"
8+
"net/http"
9+
)
10+
11+
// ContextWithResponseHeaderHook returns a context that will, if passed to
12+
// [ClientRequest.Do] or to any of the wrapper methods that call it, arrange
13+
// for the given callback to be called with the headers from the raw HTTP
14+
// response.
15+
//
16+
// This is intended for allowing callers to respond to out-of-band metadata
17+
// such as cache-control-related headers, rate limiting headers, etc. Hooks
18+
// must not modify the given [http.Header] or otherwise attempt to change how
19+
// the response is handled by [ClientRequest.Do]; instead, they should just
20+
// save the provided header object and then interact with it only after
21+
// [ClientRequest.Do] has returned.
22+
//
23+
// If the given context already has a response header hook then the returned
24+
// context will call both the existing hook and the newly-provided one, with
25+
// the newer being called first.
26+
func ContextWithResponseHeaderHook(parentCtx context.Context, cb func(status int, header http.Header)) context.Context {
27+
// If the given context already has a notification callback then we'll
28+
// arrange to notify both the previous and the new one. This is not
29+
// a super efficient way to achieve that but we expect it to be rare
30+
// for there to be more than one or two hooks associated with a particular
31+
// request, so it's not warranted to optimize this further.
32+
existingI := parentCtx.Value(contextResponseHeaderHookKey)
33+
finalCb := cb
34+
if existingI != nil {
35+
existing := existingI.(func(int, http.Header))
36+
finalCb = func(status int, header http.Header) {
37+
cb(status, header)
38+
existing(status, header)
39+
}
40+
}
41+
return context.WithValue(parentCtx, contextResponseHeaderHookKey, finalCb)
42+
}
43+
44+
func contextResponseHeaderHook(ctx context.Context) func(int, http.Header) {
45+
cbI := ctx.Value(contextResponseHeaderHookKey)
46+
if cbI == nil {
47+
// Stub callback that does absolutely nothing, then.
48+
return func(int, http.Header) {}
49+
}
50+
return cbI.(func(int, http.Header))
51+
}
52+
53+
// contextResponseHeaderHookKey is the type of the internal key used to store
54+
// the callback for [ContextWithResponseHeaderHook] inside a [context.Context]
55+
// object.
56+
type contextResponseHeaderHookKeyType struct{}
57+
58+
// contextResponseHeaderHookKey is the internal key used to store the callback
59+
// for [ContextWithResponseHeaderHook] inside a [context.Context] object.
60+
var contextResponseHeaderHookKey contextResponseHeaderHookKeyType

request_hooks_test.go

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package tfe
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
)
9+
10+
func TestContextWithResponseHeaderHook(t *testing.T) {
11+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
12+
w.Header().Set("x-thingy", "boop")
13+
w.WriteHeader(http.StatusNoContent)
14+
}))
15+
16+
cfg := &Config{
17+
Address: server.URL,
18+
BasePath: "/anything",
19+
Token: "placeholder",
20+
}
21+
client, err := NewClient(cfg)
22+
if err != nil {
23+
t.Fatal(err)
24+
}
25+
26+
called := false
27+
var gotStatus int
28+
var gotHeader http.Header
29+
ctx := ContextWithResponseHeaderHook(context.Background(), func(status int, header http.Header) {
30+
called = true
31+
gotStatus = status
32+
gotHeader = header
33+
})
34+
35+
req, err := client.NewRequest("GET", "boop", nil)
36+
if err != nil {
37+
t.Fatal(err)
38+
}
39+
40+
err = req.Do(ctx, nil)
41+
if err != nil {
42+
t.Fatal(err)
43+
}
44+
45+
if !called {
46+
t.Fatal("hook was not called")
47+
}
48+
if got, want := gotStatus, http.StatusNoContent; got != want {
49+
t.Fatalf("wrong response status: got %d, want %d", got, want)
50+
}
51+
if got, want := gotHeader.Get("x-thingy"), "boop"; got != want {
52+
t.Fatalf("wrong value for x-thingy field: got %q, want %q", got, want)
53+
}
54+
}

0 commit comments

Comments
 (0)