Skip to content

Commit 7e3809b

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 7e3809b

File tree

3 files changed

+129
-0
lines changed

3 files changed

+129
-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

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

request_hooks_test.go

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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+
defer server.Close()
16+
17+
cfg := &Config{
18+
Address: server.URL,
19+
BasePath: "/anything",
20+
Token: "placeholder",
21+
}
22+
client, err := NewClient(cfg)
23+
if err != nil {
24+
t.Fatal(err)
25+
}
26+
27+
called := false
28+
var gotStatus int
29+
var gotHeader http.Header
30+
ctx := ContextWithResponseHeaderHook(context.Background(), func(status int, header http.Header) {
31+
called = true
32+
gotStatus = status
33+
gotHeader = header
34+
})
35+
36+
req, err := client.NewRequest("GET", "boop", nil)
37+
if err != nil {
38+
t.Fatal(err)
39+
}
40+
41+
err = req.Do(ctx, nil)
42+
if err != nil {
43+
t.Fatal(err)
44+
}
45+
46+
if !called {
47+
t.Fatal("hook was not called")
48+
}
49+
if got, want := gotStatus, http.StatusNoContent; got != want {
50+
t.Fatalf("wrong response status: got %d, want %d", got, want)
51+
}
52+
if got, want := gotHeader.Get("x-thingy"), "boop"; got != want {
53+
t.Fatalf("wrong value for x-thingy field: got %q, want %q", got, want)
54+
}
55+
}

0 commit comments

Comments
 (0)