Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ are marked **Breaking** and listed first in their section.
release archives and the `http-assert` binary name are unchanged.
- Assertion failures and evaluation errors are structured library data. The
CLI owns human-readable formatting and preserves its existing output.
- Assertion families use the exported `AssertionKind` type and constants
instead of requiring consumers to compare raw strings.
- The library's zero-value client uses a 20-second total request timeout instead
of the unbounded `http.DefaultClient`. Callers can still inject an HTTP client
or apply a shorter request-context deadline.
Expand Down
8 changes: 8 additions & 0 deletions api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ func (f exampleTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}

type customAssertion struct{}

func (customAssertion) Kind() ha.AssertionKind { return "custom" }

func (customAssertion) Check(*ha.Response) (*ha.Failure, error) { return nil, nil }

var _ ha.Assertion = customAssertion{}

func ExampleClient_Do() {
req, _ := http.NewRequest(http.MethodGet, "https://example.test/health", nil)
client := ha.Client{HTTPClient: &http.Client{Transport: exampleTransport(func(req *http.Request) (*http.Response, error) {
Expand Down
60 changes: 37 additions & 23 deletions assertions.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,21 @@ import (
"github.com/itchyny/gojq"
)

// AssertionKind identifies an assertion family. Custom assertions may define
// their own values; the constants below name the families built into this
// package.
type AssertionKind string

const (
KindStatusOK AssertionKind = "ok"
KindStatusNOK AssertionKind = "nok"
KindStatus AssertionKind = "status"
KindHeader AssertionKind = "header"
KindBody AssertionKind = "body"
KindRedirect AssertionKind = "redirect"
KindJQ AssertionKind = "jq"
)

// Assertion checks one property of a response.
//
// Check separates the two things an assertion can report, which a single error
Expand All @@ -20,9 +35,8 @@ import (
// failed run; the distinction exists so a machine-readable consumer can tell
// "the service is wrong" from "we could not tell" (#45).
type Assertion interface {
// Kind names the family this assertion belongs to: "ok", "nok",
// "status", "header", "body", "redirect" or "jq".
Kind() string
// Kind names the family this assertion belongs to.
Kind() AssertionKind

// Check reports (nil, nil) when the assertion holds.
Check(res *Response) (*Failure, error)
Expand Down Expand Up @@ -65,7 +79,7 @@ const (
// Failure describes an assertion that was evaluated and did not hold. It is
// deliberately data only: applications decide how (or whether) to format it.
type Failure struct {
Kind string // assertion family; Client and built-in assertions populate it
Kind AssertionKind // assertion family; Client and built-in assertions populate it
Code FailureCode
Target string // header name, jq query, or "" when the kind needs no subject
Expected any
Expand All @@ -79,11 +93,11 @@ type Failure struct {
// result needed to carry more than a string. Thirteen one-method structs would
// have said the same thing at ten times the length.
type assertionFunc struct {
kind string
kind AssertionKind
check func(res *Response) (*Failure, error)
}

func (a assertionFunc) Kind() string { return a.kind }
func (a assertionFunc) Kind() AssertionKind { return a.kind }

// Check stamps the failure with the assertion's kind, so Kind() and
// Failure.Kind cannot disagree and no constructor has to repeat itself.
Expand All @@ -96,7 +110,7 @@ func (a assertionFunc) Check(res *Response) (*Failure, error) {
return f, err
}

func newAssertion(kind string, check func(res *Response) (*Failure, error)) Assertion {
func newAssertion(kind AssertionKind, check func(res *Response) (*Failure, error)) Assertion {
return assertionFunc{kind: kind, check: check}
}

Expand Down Expand Up @@ -228,7 +242,7 @@ func parseStatusCode(text string) (int, error) {

// AssertStatusOK accepts any success or redirect status (2xx or 3xx).
func AssertStatusOK() Assertion {
return newAssertion("ok", func(res *Response) (*Failure, error) {
return newAssertion(KindStatusOK, func(res *Response) (*Failure, error) {
if s := res.StatusCode; s < 200 || s >= 400 {
return &Failure{
Code: FailureStatusOK,
Expand All @@ -243,7 +257,7 @@ func AssertStatusOK() Assertion {

// AssertStatusNOK accepts any status outside the 2xx and 3xx ranges.
func AssertStatusNOK() Assertion {
return newAssertion("nok", func(res *Response) (*Failure, error) {
return newAssertion(KindStatusNOK, func(res *Response) (*Failure, error) {
if s := res.StatusCode; s >= 200 && s < 400 {
return &Failure{
Code: FailureStatusNOK,
Expand All @@ -269,7 +283,7 @@ func AssertStatus(text string) (Assertion, error) {
}

func assertStatus(spec statusSpec) Assertion {
return newAssertion("status", func(res *Response) (*Failure, error) {
return newAssertion(KindStatus, func(res *Response) (*Failure, error) {
if !spec.matches(res.StatusCode) {
return &Failure{
Code: FailureStatus,
Expand All @@ -284,7 +298,7 @@ func assertStatus(spec statusSpec) Assertion {

// AssertHeaderPresent requires at least one value for name.
func AssertHeaderPresent(name string) Assertion {
return newAssertion("header", func(res *Response) (*Failure, error) {
return newAssertion(KindHeader, func(res *Response) (*Failure, error) {
if res.Header.Values(name) == nil {
return &Failure{
Code: FailureHeaderPresent,
Expand All @@ -299,7 +313,7 @@ func AssertHeaderPresent(name string) Assertion {

// AssertHeaderMissing requires name to be absent.
func AssertHeaderMissing(name string) Assertion {
return newAssertion("header", func(res *Response) (*Failure, error) {
return newAssertion(KindHeader, func(res *Response) (*Failure, error) {
if vs := res.Header.Values(name); vs != nil {
return &Failure{
Code: FailureHeaderMissing,
Expand All @@ -316,7 +330,7 @@ func AssertHeaderMissing(name string) Assertion {
// AssertHeaderEqual accepts the response when any value of name equals
// expValue.
func AssertHeaderEqual(name, expValue string) Assertion {
return newAssertion("header", func(res *Response) (*Failure, error) {
return newAssertion(KindHeader, func(res *Response) (*Failure, error) {
vs := res.Header.Values(name)
if vs == nil {
return &Failure{
Expand Down Expand Up @@ -349,7 +363,7 @@ func AssertHeaderMatch(name, expPattern string) (Assertion, error) {
return nil, err
}

return newAssertion("header", func(res *Response) (*Failure, error) {
return newAssertion(KindHeader, func(res *Response) (*Failure, error) {
vs := res.Header.Values(name)
if vs == nil {
return &Failure{
Expand Down Expand Up @@ -385,7 +399,7 @@ func bodyOf(res *Response) ([]byte, error) {
if res.DecodeErr != nil {
return nil, &EvaluationError{
Code: EvaluationBodyDecode,
Kind: "body",
Kind: KindBody,
Encoding: res.Encoding,
Cause: res.DecodeErr,
}
Expand All @@ -396,7 +410,7 @@ func bodyOf(res *Response) ([]byte, error) {

// AssertBodyEmpty requires the decoded response body to contain zero bytes.
func AssertBodyEmpty() Assertion {
return newAssertion("body", func(res *Response) (*Failure, error) {
return newAssertion(KindBody, func(res *Response) (*Failure, error) {
body, err := bodyOf(res)
if err != nil {
return nil, err
Expand Down Expand Up @@ -450,7 +464,7 @@ func AssertJQ(query string) (Assertion, error) {
return nil, err
}

return newAssertion("jq", func(res *Response) (*Failure, error) {
return newAssertion(KindJQ, func(res *Response) (*Failure, error) {
return runJQ(code, query, res, jqTimeout)
}), nil
}
Expand Down Expand Up @@ -486,7 +500,7 @@ func runJQ(code *gojq.Code, query string, res *Response, timeout time.Duration)
if e, isErr := v.(error); isErr {
return nil, &EvaluationError{
Code: EvaluationJQ,
Kind: "jq",
Kind: KindJQ,
Target: query,
Cause: e,
}
Expand Down Expand Up @@ -520,7 +534,7 @@ func runJQ(code *gojq.Code, query string, res *Response, timeout time.Duration)
// AssertBodyNotEmpty requires the decoded response body to contain at least
// one byte.
func AssertBodyNotEmpty() Assertion {
return newAssertion("body", func(res *Response) (*Failure, error) {
return newAssertion(KindBody, func(res *Response) (*Failure, error) {
body, err := bodyOf(res)
if err != nil {
return nil, err
Expand All @@ -539,7 +553,7 @@ func AssertBodyNotEmpty() Assertion {

// AssertBodyEqual requires the decoded response body to equal expContent.
func AssertBodyEqual(expContent string) Assertion {
return newAssertion("body", func(res *Response) (*Failure, error) {
return newAssertion(KindBody, func(res *Response) (*Failure, error) {
body, err := bodyOf(res)
if err != nil {
return nil, err
Expand Down Expand Up @@ -576,7 +590,7 @@ func AssertBodyMatch(expPattern string) (Assertion, error) {
return nil, err
}

return newAssertion("body", func(res *Response) (*Failure, error) {
return newAssertion(KindBody, func(res *Response) (*Failure, error) {
body, err := bodyOf(res)
if err != nil {
return nil, err
Expand Down Expand Up @@ -631,7 +645,7 @@ func redirectPrecondition(res *Response, expected any) *Failure {
// expLocation. Configure the HTTP client not to follow redirects when using
// this assertion.
func AssertRedirectEqual(expLocation string) Assertion {
return newAssertion("redirect", func(res *Response) (*Failure, error) {
return newAssertion(KindRedirect, func(res *Response) (*Failure, error) {
if f := redirectPrecondition(res, expLocation); f != nil {
return f, nil
}
Expand All @@ -658,7 +672,7 @@ func AssertRedirectMatch(expPattern string) (Assertion, error) {
return nil, err
}

return newAssertion("redirect", func(res *Response) (*Failure, error) {
return newAssertion(KindRedirect, func(res *Response) (*Failure, error) {
if f := redirectPrecondition(res, expPattern); f != nil {
return f, nil
}
Expand Down
18 changes: 9 additions & 9 deletions assertions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -670,51 +670,51 @@ func Test_AssertionIdentity(t *testing.T) {
Name string
Assertion Assertion
Res *Response
Kind string
Kind AssertionKind
Target string
Expected any
Actual any
}{
{
Name: "ok", Assertion: AssertStatusOK(),
Res: statusRes(500, "500 Internal Server Error"),
Kind: "ok", Expected: "2xx-3xx", Actual: 500,
Kind: KindStatusOK, Expected: "2xx-3xx", Actual: 500,
},
{
Name: "nok", Assertion: AssertStatusNOK(),
Res: statusRes(200, "200 OK"),
Kind: "nok", Expected: "not 2xx-3xx", Actual: 200,
Kind: KindStatusNOK, Expected: "not 2xx-3xx", Actual: 200,
},
{
Name: "status", Assertion: mustStatusAssertion(t, "200"),
Res: statusRes(500, "500 Internal Server Error"),
Kind: "status", Expected: "200", Actual: 500,
Kind: KindStatus, Expected: "200", Actual: 500,
},
{
Name: "header present", Assertion: AssertHeaderPresent("X-Absent"),
Res: headerRes(http.Header{}),
Kind: "header", Target: "X-Absent", Expected: "present",
Kind: KindHeader, Target: "X-Absent", Expected: "present",
},
{
Name: "header equal", Assertion: AssertHeaderEqual("X-A", "want"),
Res: headerRes(http.Header{"X-A": []string{"got"}}),
Kind: "header", Target: "X-A", Expected: "want", Actual: []string{"got"},
Kind: KindHeader, Target: "X-A", Expected: "want", Actual: []string{"got"},
},
{
Name: "body equal", Assertion: AssertBodyEqual("want"),
Res: &Response{BodyBytes: []byte("got")},
Kind: "body", Expected: "want", Actual: "got",
Kind: KindBody, Expected: "want", Actual: "got",
},
{
Name: "redirect", Assertion: AssertRedirectEqual("/there"),
Res: headerRes(http.Header{"Location": []string{"/elsewhere"}}),
// A 200 never reaches the Location comparison, so this is the
// precondition failure, which reports the status it wanted.
Kind: "redirect", Expected: "3xx", Actual: 200,
Kind: KindRedirect, Expected: "3xx", Actual: 200,
},
{
Name: "jq", Assertion: jq, Res: jqResponse(`{"n":2}`),
Kind: "jq", Target: ".n == 1", Expected: true, Actual: false,
Kind: KindJQ, Target: ".n == 1", Expected: true, Actual: false,
},
}

Expand Down
4 changes: 2 additions & 2 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const (
// satisfy the assertion.
type EvaluationError struct {
Code EvaluationErrorCode
Kind string
Kind AssertionKind
Target string
Encoding string
Cause error
Expand All @@ -68,7 +68,7 @@ func (e *EvaluationError) Unwrap() error {
// Outcome is the result of evaluating one assertion. Passed reports whether
// both Failure and Err are nil.
type Outcome struct {
Kind string
Kind AssertionKind
Failure *Failure
Err error
}
Expand Down
6 changes: 3 additions & 3 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,19 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
}

type testAssertion struct {
kind string
kind AssertionKind
check func(*Response) (*Failure, error)
}

func (a testAssertion) Kind() string { return a.kind }
func (a testAssertion) Kind() AssertionKind { return a.kind }

func (a testAssertion) Check(res *Response) (*Failure, error) {
return a.check(res)
}

type pointerAssertion struct{}

func (*pointerAssertion) Kind() string { return "pointer" }
func (*pointerAssertion) Kind() AssertionKind { return "pointer" }

func (*pointerAssertion) Check(*Response) (*Failure, error) { return nil, nil }

Expand Down
2 changes: 1 addition & 1 deletion response.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ func (r *Response) decodeJSON() (any, error) {
if err := json.Unmarshal(body, &r.jsonBody); err != nil {
r.jsonErr = &EvaluationError{
Code: EvaluationJSON,
Kind: "body",
Kind: KindBody,
Cause: err,
}
return nil, r.jsonErr
Expand Down
Loading