From b44b2d5c09a39814a856f379d22c732128589e2f Mon Sep 17 00:00:00 2001 From: korya <148461+korya@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:47:03 -0400 Subject: [PATCH] feat(api): Add typed assertion-kind constants Structured consumers previously compared assertion families through undocumented string literals. Expose AssertionKind constants and use the type throughout outcomes, failures, and evaluation errors while preserving every serialized value and allowing custom assertion kinds. Co-Authored-By: OpenAI Codex (GPT-5) --- CHANGELOG.md | 2 ++ api_test.go | 8 +++++++ assertions.go | 60 ++++++++++++++++++++++++++++------------------ assertions_test.go | 18 +++++++------- client.go | 4 ++-- client_test.go | 6 ++--- response.go | 2 +- 7 files changed, 62 insertions(+), 38 deletions(-) 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