diff --git a/CHANGELOG.md b/CHANGELOG.md index 3284c30..a6b5bb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/api_test.go b/api_test.go index 106f33d..6eb4949 100644 --- a/api_test.go +++ b/api_test.go @@ -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) { diff --git a/assertions.go b/assertions.go index ce3b18a..73c25e7 100644 --- a/assertions.go +++ b/assertions.go @@ -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 @@ -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) @@ -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 @@ -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. @@ -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} } @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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{ @@ -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{ @@ -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, } @@ -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 @@ -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 } @@ -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, } @@ -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 @@ -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 @@ -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 @@ -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 } @@ -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 } diff --git a/assertions_test.go b/assertions_test.go index bd3fdc7..f032d46 100644 --- a/assertions_test.go +++ b/assertions_test.go @@ -670,7 +670,7 @@ func Test_AssertionIdentity(t *testing.T) { Name string Assertion Assertion Res *Response - Kind string + Kind AssertionKind Target string Expected any Actual any @@ -678,43 +678,43 @@ func Test_AssertionIdentity(t *testing.T) { { 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, }, } diff --git a/client.go b/client.go index aa775e4..35c65d6 100644 --- a/client.go +++ b/client.go @@ -41,7 +41,7 @@ const ( // satisfy the assertion. type EvaluationError struct { Code EvaluationErrorCode - Kind string + Kind AssertionKind Target string Encoding string Cause error @@ -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 } diff --git a/client_test.go b/client_test.go index c4b3d99..03449a7 100644 --- a/client_test.go +++ b/client_test.go @@ -15,11 +15,11 @@ 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) @@ -27,7 +27,7 @@ func (a testAssertion) Check(res *Response) (*Failure, error) { type pointerAssertion struct{} -func (*pointerAssertion) Kind() string { return "pointer" } +func (*pointerAssertion) Kind() AssertionKind { return "pointer" } func (*pointerAssertion) Check(*Response) (*Failure, error) { return nil, nil } diff --git a/response.go b/response.go index e96f234..0cec283 100644 --- a/response.go +++ b/response.go @@ -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