This options allows a client to call hooks on API responses, for
example, to get response header, or other information from the
response.
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
| ... | ... |
@@ -59,6 +59,7 @@ import ( |
| 59 | 59 |
"net/http" |
| 60 | 60 |
"net/url" |
| 61 | 61 |
"path" |
| 62 |
+ "slices" |
|
| 62 | 63 |
"strings" |
| 63 | 64 |
"sync" |
| 64 | 65 |
"sync/atomic" |
| ... | ... |
@@ -241,6 +242,13 @@ func New(ops ...Opt) (*Client, error) {
|
| 241 | 241 |
|
| 242 | 242 |
c.client.Transport = otelhttp.NewTransport(c.client.Transport, c.traceOpts...) |
| 243 | 243 |
|
| 244 |
+ if len(cfg.responseHooks) > 0 {
|
|
| 245 |
+ c.client.Transport = &responseHookTransport{
|
|
| 246 |
+ base: c.client.Transport, |
|
| 247 |
+ hooks: slices.Clone(cfg.responseHooks), |
|
| 248 |
+ } |
|
| 249 |
+ } |
|
| 250 |
+ |
|
| 244 | 251 |
return c, nil |
| 245 | 252 |
} |
| 246 | 253 |
|
| ... | ... |
@@ -2,6 +2,7 @@ package client |
| 2 | 2 |
|
| 3 | 3 |
import ( |
| 4 | 4 |
"context" |
| 5 |
+ "errors" |
|
| 5 | 6 |
"fmt" |
| 6 | 7 |
"net" |
| 7 | 8 |
"net/http" |
| ... | ... |
@@ -55,10 +56,19 @@ type clientConfig struct {
|
| 55 | 55 |
// takes precedence. Either field disables API-version negotiation. |
| 56 | 56 |
envAPIVersion string |
| 57 | 57 |
|
| 58 |
+ // responseHooks is a list of custom response hooks to call on responses. |
|
| 59 |
+ responseHooks []ResponseHook |
|
| 60 |
+ |
|
| 58 | 61 |
// traceOpts is a list of options to configure the tracing span. |
| 59 | 62 |
traceOpts []otelhttp.Option |
| 60 | 63 |
} |
| 61 | 64 |
|
| 65 |
+// ResponseHook is called for each HTTP response returned by the daemon. |
|
| 66 |
+// Hooks are invoked in the order they were added. |
|
| 67 |
+// |
|
| 68 |
+// Hooks must not read or close resp.Body. |
|
| 69 |
+type ResponseHook func(*http.Response) error |
|
| 70 |
+ |
|
| 62 | 71 |
// Opt is a configuration option to initialize a [Client]. |
| 63 | 72 |
type Opt func(*clientConfig) error |
| 64 | 73 |
|
| ... | ... |
@@ -348,3 +358,18 @@ func WithTraceOptions(opts ...otelhttp.Option) Opt {
|
| 348 | 348 |
return nil |
| 349 | 349 |
} |
| 350 | 350 |
} |
| 351 |
+ |
|
| 352 |
+// WithResponseHook adds a ResponseHook to the client. ResponseHooks are called |
|
| 353 |
+// for each HTTP response returned by the daemon. Hooks are invoked in the order |
|
| 354 |
+// they were added. |
|
| 355 |
+// |
|
| 356 |
+// Hooks must not read or close resp.Body. |
|
| 357 |
+func WithResponseHook(h ResponseHook) Opt {
|
|
| 358 |
+ return func(c *clientConfig) error {
|
|
| 359 |
+ if h == nil {
|
|
| 360 |
+ return errors.New("invalid response hook: hook is nil")
|
|
| 361 |
+ } |
|
| 362 |
+ c.responseHooks = append(c.responseHooks, h) |
|
| 363 |
+ return nil |
|
| 364 |
+ } |
|
| 365 |
+} |
| ... | ... |
@@ -2,6 +2,8 @@ package client |
| 2 | 2 |
|
| 3 | 3 |
import ( |
| 4 | 4 |
"crypto/tls" |
| 5 |
+ "errors" |
|
| 6 |
+ "io" |
|
| 5 | 7 |
"net/http" |
| 6 | 8 |
"net/http/cookiejar" |
| 7 | 9 |
"runtime" |
| ... | ... |
@@ -390,3 +392,106 @@ func TestWithHTTPClient(t *testing.T) {
|
| 390 | 390 |
cmpopts.IgnoreUnexported(http.Transport{}, tls.Config{}),
|
| 391 | 391 |
cmpopts.EquateComparable(&cookiejar.Jar{}))
|
| 392 | 392 |
} |
| 393 |
+ |
|
| 394 |
+func TestWithResponseHook(t *testing.T) {
|
|
| 395 |
+ const hdrKey = "X-Test-Header" |
|
| 396 |
+ const hdrVal = "hello-world" |
|
| 397 |
+ |
|
| 398 |
+ t.Run("single hook", func(t *testing.T) {
|
|
| 399 |
+ var got string |
|
| 400 |
+ c, err := New( |
|
| 401 |
+ WithResponseHook(func(resp *http.Response) error {
|
|
| 402 |
+ got = resp.Header.Get(hdrKey) |
|
| 403 |
+ return nil |
|
| 404 |
+ }), |
|
| 405 |
+ WithBaseMockClient(func(req *http.Request) (*http.Response, error) {
|
|
| 406 |
+ resp := &http.Response{
|
|
| 407 |
+ StatusCode: http.StatusOK, |
|
| 408 |
+ Header: make(http.Header), |
|
| 409 |
+ } |
|
| 410 |
+ resp.Header.Set(hdrKey, hdrVal) |
|
| 411 |
+ return resp, nil |
|
| 412 |
+ }), |
|
| 413 |
+ ) |
|
| 414 |
+ assert.NilError(t, err) |
|
| 415 |
+ |
|
| 416 |
+ _, err = c.Ping(t.Context(), PingOptions{})
|
|
| 417 |
+ assert.NilError(t, err) |
|
| 418 |
+ assert.Check(t, is.Equal(got, hdrVal)) |
|
| 419 |
+ |
|
| 420 |
+ assert.NilError(t, c.Close()) |
|
| 421 |
+ }) |
|
| 422 |
+ |
|
| 423 |
+ t.Run("invalid hook", func(t *testing.T) {
|
|
| 424 |
+ _, err := New(WithResponseHook(nil)) |
|
| 425 |
+ assert.Error(t, err, "invalid response hook: hook is nil") |
|
| 426 |
+ }) |
|
| 427 |
+ |
|
| 428 |
+ t.Run("multiple hooks", func(t *testing.T) {
|
|
| 429 |
+ var triggered []string |
|
| 430 |
+ |
|
| 431 |
+ c, err := New( |
|
| 432 |
+ WithResponseHook(func(*http.Response) error {
|
|
| 433 |
+ triggered = append(triggered, "hook 1: "+hdrVal) |
|
| 434 |
+ return nil |
|
| 435 |
+ }), |
|
| 436 |
+ WithResponseHook(func(*http.Response) error {
|
|
| 437 |
+ triggered = append(triggered, "hook 2: "+hdrVal) |
|
| 438 |
+ return nil |
|
| 439 |
+ }), |
|
| 440 |
+ WithBaseMockClient(func(req *http.Request) (*http.Response, error) {
|
|
| 441 |
+ resp := &http.Response{
|
|
| 442 |
+ StatusCode: http.StatusOK, |
|
| 443 |
+ Header: make(http.Header), |
|
| 444 |
+ } |
|
| 445 |
+ resp.Header.Set(hdrKey, hdrVal) |
|
| 446 |
+ return resp, nil |
|
| 447 |
+ }), |
|
| 448 |
+ ) |
|
| 449 |
+ assert.NilError(t, err) |
|
| 450 |
+ |
|
| 451 |
+ _, err = c.Ping(t.Context(), PingOptions{})
|
|
| 452 |
+ assert.NilError(t, err) |
|
| 453 |
+ assert.Check(t, is.DeepEqual(triggered, []string{"hook 1: " + hdrVal, "hook 2: " + hdrVal}))
|
|
| 454 |
+ |
|
| 455 |
+ assert.NilError(t, c.Close()) |
|
| 456 |
+ }) |
|
| 457 |
+ |
|
| 458 |
+ t.Run("hook error", func(t *testing.T) {
|
|
| 459 |
+ closed := false |
|
| 460 |
+ expError := errors.New("hook failed")
|
|
| 461 |
+ |
|
| 462 |
+ c, err := New( |
|
| 463 |
+ WithResponseHook(func(*http.Response) error {
|
|
| 464 |
+ return expError |
|
| 465 |
+ }), |
|
| 466 |
+ WithBaseMockClient(func(req *http.Request) (*http.Response, error) {
|
|
| 467 |
+ return &http.Response{
|
|
| 468 |
+ StatusCode: http.StatusOK, |
|
| 469 |
+ Header: make(http.Header), |
|
| 470 |
+ Body: &closeTracker{onClose: func() { closed = true }},
|
|
| 471 |
+ }, nil |
|
| 472 |
+ }), |
|
| 473 |
+ ) |
|
| 474 |
+ assert.NilError(t, err) |
|
| 475 |
+ |
|
| 476 |
+ _, err = c.Ping(t.Context(), PingOptions{})
|
|
| 477 |
+ assert.Check(t, is.ErrorIs(err, expError)) |
|
| 478 |
+ assert.Check(t, closed) |
|
| 479 |
+ |
|
| 480 |
+ assert.NilError(t, c.Close()) |
|
| 481 |
+ }) |
|
| 482 |
+} |
|
| 483 |
+ |
|
| 484 |
+type closeTracker struct {
|
|
| 485 |
+ onClose func() |
|
| 486 |
+} |
|
| 487 |
+ |
|
| 488 |
+func (c *closeTracker) Read(p []byte) (int, error) { return 0, io.EOF }
|
|
| 489 |
+ |
|
| 490 |
+func (c *closeTracker) Close() error {
|
|
| 491 |
+ if c.onClose != nil {
|
|
| 492 |
+ c.onClose() |
|
| 493 |
+ } |
|
| 494 |
+ return nil |
|
| 495 |
+} |
| 393 | 496 |
new file mode 100644 |
| ... | ... |
@@ -0,0 +1,26 @@ |
| 0 |
+package client |
|
| 1 |
+ |
|
| 2 |
+import ( |
|
| 3 |
+ "net/http" |
|
| 4 |
+) |
|
| 5 |
+ |
|
| 6 |
+type responseHookTransport struct {
|
|
| 7 |
+ base http.RoundTripper |
|
| 8 |
+ hooks []ResponseHook |
|
| 9 |
+} |
|
| 10 |
+ |
|
| 11 |
+func (t *responseHookTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
| 12 |
+ resp, err := t.base.RoundTrip(req) |
|
| 13 |
+ if err != nil {
|
|
| 14 |
+ return resp, err |
|
| 15 |
+ } |
|
| 16 |
+ |
|
| 17 |
+ for _, h := range t.hooks {
|
|
| 18 |
+ if err := h(resp); err != nil {
|
|
| 19 |
+ _ = resp.Body.Close() |
|
| 20 |
+ return nil, err |
|
| 21 |
+ } |
|
| 22 |
+ } |
|
| 23 |
+ |
|
| 24 |
+ return resp, nil |
|
| 25 |
+} |
| ... | ... |
@@ -59,6 +59,7 @@ import ( |
| 59 | 59 |
"net/http" |
| 60 | 60 |
"net/url" |
| 61 | 61 |
"path" |
| 62 |
+ "slices" |
|
| 62 | 63 |
"strings" |
| 63 | 64 |
"sync" |
| 64 | 65 |
"sync/atomic" |
| ... | ... |
@@ -241,6 +242,13 @@ func New(ops ...Opt) (*Client, error) {
|
| 241 | 241 |
|
| 242 | 242 |
c.client.Transport = otelhttp.NewTransport(c.client.Transport, c.traceOpts...) |
| 243 | 243 |
|
| 244 |
+ if len(cfg.responseHooks) > 0 {
|
|
| 245 |
+ c.client.Transport = &responseHookTransport{
|
|
| 246 |
+ base: c.client.Transport, |
|
| 247 |
+ hooks: slices.Clone(cfg.responseHooks), |
|
| 248 |
+ } |
|
| 249 |
+ } |
|
| 250 |
+ |
|
| 244 | 251 |
return c, nil |
| 245 | 252 |
} |
| 246 | 253 |
|
| ... | ... |
@@ -2,6 +2,7 @@ package client |
| 2 | 2 |
|
| 3 | 3 |
import ( |
| 4 | 4 |
"context" |
| 5 |
+ "errors" |
|
| 5 | 6 |
"fmt" |
| 6 | 7 |
"net" |
| 7 | 8 |
"net/http" |
| ... | ... |
@@ -55,10 +56,19 @@ type clientConfig struct {
|
| 55 | 55 |
// takes precedence. Either field disables API-version negotiation. |
| 56 | 56 |
envAPIVersion string |
| 57 | 57 |
|
| 58 |
+ // responseHooks is a list of custom response hooks to call on responses. |
|
| 59 |
+ responseHooks []ResponseHook |
|
| 60 |
+ |
|
| 58 | 61 |
// traceOpts is a list of options to configure the tracing span. |
| 59 | 62 |
traceOpts []otelhttp.Option |
| 60 | 63 |
} |
| 61 | 64 |
|
| 65 |
+// ResponseHook is called for each HTTP response returned by the daemon. |
|
| 66 |
+// Hooks are invoked in the order they were added. |
|
| 67 |
+// |
|
| 68 |
+// Hooks must not read or close resp.Body. |
|
| 69 |
+type ResponseHook func(*http.Response) error |
|
| 70 |
+ |
|
| 62 | 71 |
// Opt is a configuration option to initialize a [Client]. |
| 63 | 72 |
type Opt func(*clientConfig) error |
| 64 | 73 |
|
| ... | ... |
@@ -348,3 +358,18 @@ func WithTraceOptions(opts ...otelhttp.Option) Opt {
|
| 348 | 348 |
return nil |
| 349 | 349 |
} |
| 350 | 350 |
} |
| 351 |
+ |
|
| 352 |
+// WithResponseHook adds a ResponseHook to the client. ResponseHooks are called |
|
| 353 |
+// for each HTTP response returned by the daemon. Hooks are invoked in the order |
|
| 354 |
+// they were added. |
|
| 355 |
+// |
|
| 356 |
+// Hooks must not read or close resp.Body. |
|
| 357 |
+func WithResponseHook(h ResponseHook) Opt {
|
|
| 358 |
+ return func(c *clientConfig) error {
|
|
| 359 |
+ if h == nil {
|
|
| 360 |
+ return errors.New("invalid response hook: hook is nil")
|
|
| 361 |
+ } |
|
| 362 |
+ c.responseHooks = append(c.responseHooks, h) |
|
| 363 |
+ return nil |
|
| 364 |
+ } |
|
| 365 |
+} |
| 351 | 366 |
new file mode 100644 |
| ... | ... |
@@ -0,0 +1,26 @@ |
| 0 |
+package client |
|
| 1 |
+ |
|
| 2 |
+import ( |
|
| 3 |
+ "net/http" |
|
| 4 |
+) |
|
| 5 |
+ |
|
| 6 |
+type responseHookTransport struct {
|
|
| 7 |
+ base http.RoundTripper |
|
| 8 |
+ hooks []ResponseHook |
|
| 9 |
+} |
|
| 10 |
+ |
|
| 11 |
+func (t *responseHookTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
| 12 |
+ resp, err := t.base.RoundTrip(req) |
|
| 13 |
+ if err != nil {
|
|
| 14 |
+ return resp, err |
|
| 15 |
+ } |
|
| 16 |
+ |
|
| 17 |
+ for _, h := range t.hooks {
|
|
| 18 |
+ if err := h(resp); err != nil {
|
|
| 19 |
+ _ = resp.Body.Close() |
|
| 20 |
+ return nil, err |
|
| 21 |
+ } |
|
| 22 |
+ } |
|
| 23 |
+ |
|
| 24 |
+ return resp, nil |
|
| 25 |
+} |