infra/blueprint-test/pkg/utils/asserthttp.go (149 lines of code) (raw):
/**
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package utils
import (
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/mitchellh/go-testing-interface"
)
// AssertHTTP provides a collection of HTTP asserts.
type AssertHTTP struct {
httpClient *http.Client
retryCount int
retryInterval time.Duration
}
type assertOption func(*AssertHTTP)
// WithHTTPClient specifies an HTTP client for the AssertHTTP use.
func WithHTTPClient(c *http.Client) assertOption {
return func(ah *AssertHTTP) {
ah.httpClient = c
}
}
// WithHTTPRequestRetries specifies a HTTP request retry policy.
func WithHTTPRequestRetries(count int, interval time.Duration) assertOption {
return func(ah *AssertHTTP) {
ah.retryCount = count
ah.retryInterval = interval
}
}
// NewAssertHTTP creates a new AssertHTTP with option overrides.
func NewAssertHTTP(opts ...assertOption) *AssertHTTP {
ah := &AssertHTTP{
httpClient: http.DefaultClient,
retryCount: 3,
retryInterval: 2 * time.Second,
}
for _, opt := range opts {
opt(ah)
}
return ah
}
// AssertSuccessWithRetry runs httpRequest and retries on errors outside client control.
func (ah *AssertHTTP) AssertSuccessWithRetry(t testing.TB, r *http.Request) {
t.Helper()
if ah.retryCount == 0 || ah.retryInterval == 0 {
ah.AssertSuccess(t, r)
return
}
err := PollE(t, ah.httpRequest(t, r), ah.retryCount, ah.retryInterval)
if err != nil {
t.Error(err.Error())
}
}
// AssertSuccess runs httpRequest without retry.
func (ah *AssertHTTP) AssertSuccess(t testing.TB, r *http.Request) {
t.Helper()
_, err := ah.httpRequest(t, r)()
if err != nil {
t.Error(err)
}
}
// AssertResponseWithRetry runs httpResponse and retries on errors outside client control.
func (ah *AssertHTTP) AssertResponseWithRetry(t testing.TB, r *http.Request, wantCode int, want ...string) {
t.Helper()
if ah.retryCount == 0 || ah.retryInterval == 0 {
ah.AssertSuccess(t, r)
return
}
err := PollE(t, ah.httpResponse(t, r, wantCode, want...), ah.retryCount, ah.retryInterval)
if err != nil {
t.Error(err.Error())
}
}
// AssertResponse runs httpResponse without retry.
func (ah *AssertHTTP) AssertResponse(t testing.TB, r *http.Request, wantCode int, want ...string) {
t.Helper()
_, err := ah.httpResponse(t, r, wantCode, want...)()
if err != nil {
t.Error(err)
}
}
// httpRequest verifies the request is successful by HTTP status code.
func (ah *AssertHTTP) httpRequest(t testing.TB, r *http.Request) func() (bool, error) {
t.Helper()
logger := GetLoggerFromT()
return func() (bool, error) {
logger.Logf(t, "Sending HTTP Request %s %s", r.Method, r.URL.String())
got, err := ah.httpClient.Do(r)
if err != nil {
return false, err
}
// Keep trying until the result is success or the request responsibility.
ok, retry := httpRetryCondition(got.StatusCode)
if !ok {
return retry, fmt.Errorf("want 2xx, got %d", got.StatusCode)
}
logger.Logf(t, "Successful HTTP Request %s %s", r.Method, r.URL.String())
return retry, nil
}
}
// httpResponse verifies the requested response has the wanted status code and payload.
func (ah *AssertHTTP) httpResponse(t testing.TB, r *http.Request, wantCode int, want ...string) func() (bool, error) {
t.Helper()
logger := GetLoggerFromT()
return func() (bool, error) {
t.Logf("Sending HTTP Request %s %s", r.Method, r.URL.String())
got, err := ah.httpClient.Do(r)
if err != nil {
return false, err
}
defer got.Body.Close()
// Determine if the request is successful, and if the response indicates
// we should attempt a retry.
ok, retry := httpRetryCondition(got.StatusCode)
if ok {
logger.Logf(t, "Successful HTTP Request %s %s", r.Method, r.URL.String())
}
// e is the wrapped error for all expectation mismatches.
var e error
if got.StatusCode != wantCode {
e = errors.Join(e, fmt.Errorf("response code: got %d, want %d", got.StatusCode, wantCode))
}
// No further processing required.
if len(want) == 0 {
return false, e
}
b, err := io.ReadAll(got.Body)
if err != nil {
return retry, errors.Join(e, err)
}
if len(b) == 0 {
return retry, errors.Join(e, errors.New("empty response body"))
}
out := string(b)
var bodyErr error
for _, fragment := range want {
if !strings.Contains(out, fragment) {
bodyErr = errors.Join(bodyErr, fmt.Errorf("response body does not contain %q", fragment))
}
}
// Only log errors and response body once.
if bodyErr != nil {
logger.Logf(t, "response output:")
logger.Logf(t, strings.TrimSpace(out))
return retry, errors.Join(e, bodyErr)
}
return retry, e
}
}
// httpRetryCondition indicates retry should be attempted on HTTP 1xx, 401, 403, and 5xx errors.
// 401 and 403 are retried in case of lagging authorization configuration.
// First return value indicates successful response.
// Second return value, on true a retry is preferred.
func httpRetryCondition(code int) (bool, bool) {
switch {
case code >= http.StatusOK && code < http.StatusMultipleChoices:
return true, false
case code < http.StatusOK:
return false, false
case code >= http.StatusInternalServerError:
return false, true
// IAM & network configuration propagation is a source of delayed access.
case code == http.StatusUnauthorized || code == http.StatusForbidden:
return false, true
case code >= http.StatusBadRequest:
return false, false
}
return false, false
}