diff --git a/.gitignore b/.gitignore index a66ea48..11d36b2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ .*.sw[a-z] -http-assert +/http-assert dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 9d8b997..ce1495b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -2,6 +2,7 @@ version: 2 builds: - id: http-assert + main: ./cmd/http-assert # A tool whose job is to run inside pipelines and scratch images has no # business linking against a libc that might not be there. env: diff --git a/CHANGELOG.md b/CHANGELOG.md index babe02d..3284c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,27 @@ are marked **Breaking** and listed first in their section. ## [Unreleased] +### Added + +- A reusable Go package at `github.com/korya/http-assert` exposes the HTTP + client, structured results and all response assertion constructors. The + library invokes its configured HTTP client once and leaves retries, logging + and presentation to its caller. +- `ha.Must(...)` keeps assertions built from static expressions inline while + preserving the constructors' explicit error returns for runtime input. + +### Changed + +- **Breaking for source installs:** the CLI now lives at + `github.com/korya/http-assert/cmd/http-assert`; use + `go install github.com/korya/http-assert/cmd/http-assert@latest`. Published + 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. +- 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. + ## [0.3.0] - 2026-08-11 ### Added diff --git a/Justfile b/Justfile index d4061c6..30de8cc 100644 --- a/Justfile +++ b/Justfile @@ -4,11 +4,11 @@ default: [doc("Build the binary")] build: - go build -o http-assert . + go build -o http-assert ./cmd/http-assert [doc("Build for release (optimized)")] build-release: - go build -ldflags="-s -w" -o http-assert . + go build -ldflags="-s -w" -o http-assert ./cmd/http-assert [doc("Run every platform-independent check: build, tidy, config, vet, lint, security")] static-checks: build tidy-check lint-config-check vet lint security @@ -18,7 +18,7 @@ pre-commit: static-checks test test-race [doc("Build and check compilation without creating binary")] check: - go build -o /dev/null . + go build ./... [doc("Fail if go.mod or go.sum is not tidy")] tidy-check: @@ -96,7 +96,7 @@ test: [doc("Run the end-to-end suite (builds and executes the CLI)")] test-e2e: - go test ./... -e2e -count=1 + go test ./cmd/http-assert -e2e -count=1 [doc("Run tests with race detection")] test-race: @@ -112,7 +112,8 @@ test-cover: set -euo pipefail unit=$(mktemp -d); e2e=$(mktemp -d) trap 'rm -rf "$unit" "$e2e"' EXIT - E2E_COVERDIR="$e2e" go test ./... -e2e -count=1 -cover -args -test.gocoverdir="$unit" + go test ./... -cover -args -test.gocoverdir="$unit" + E2E_COVERDIR="$e2e" go test ./cmd/http-assert -e2e -count=1 -cover -args -test.gocoverdir="$unit" go tool covdata percent -i="$unit,$e2e" [doc("Run tests with coverage and generate HTML report")] @@ -121,7 +122,8 @@ test-coverage: set -euo pipefail unit=$(mktemp -d); e2e=$(mktemp -d) trap 'rm -rf "$unit" "$e2e"' EXIT - E2E_COVERDIR="$e2e" go test ./... -e2e -count=1 -cover -args -test.gocoverdir="$unit" + go test ./... -cover -args -test.gocoverdir="$unit" + E2E_COVERDIR="$e2e" go test ./cmd/http-assert -e2e -count=1 -cover -args -test.gocoverdir="$unit" go tool covdata textfmt -i="$unit,$e2e" -o=coverage.out go tool cover -html=coverage.out -o coverage.html echo "Coverage report: coverage.html" @@ -132,7 +134,8 @@ test-cover-func: set -euo pipefail unit=$(mktemp -d); e2e=$(mktemp -d) trap 'rm -rf "$unit" "$e2e"' EXIT - E2E_COVERDIR="$e2e" go test ./... -e2e -count=1 -cover -args -test.gocoverdir="$unit" >/dev/null + go test ./... -cover -args -test.gocoverdir="$unit" >/dev/null + E2E_COVERDIR="$e2e" go test ./cmd/http-assert -e2e -count=1 -cover -args -test.gocoverdir="$unit" >/dev/null go tool covdata func -i="$unit,$e2e" | sort -k2 -n [doc("Clean build artifacts")] @@ -141,7 +144,7 @@ clean: [doc("Install the binary to $GOPATH/bin")] install: - go install . + go install ./cmd/http-assert [doc("Format Go code")] fmt: diff --git a/README.md b/README.md index e401dbe..0b4e275 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ different answer, the deviation is deliberate, and ## Contents - [Installation](#installation) +- [Go Library](#go-library) - [Usage](#usage): [request options](#request-options), [assertions](#assertion-options), [JSON](#json-assertions), [redirects](#redirects), [retries](#retries), [compression](#compression), @@ -119,9 +120,58 @@ http-assert completion zsh --help # per-shell install instructions Requires Go 1.26 or newer. A release binary needs no Go toolchain at all. ```bash -go install github.com/korya/http-assert@latest +go install github.com/korya/http-assert/cmd/http-assert@latest ``` +## Go Library + +The same assertions are available to Go programs. Import the package as `ha` +to keep calls compact and distinct from `net/http`: + +```go +package healthcheck + +import ( + "net/http" + + ha "github.com/korya/http-assert" +) + +func Check(url string) (*ha.Result, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + return (ha.Client{}).Do( + req, + ha.AssertStatusOK(), + ha.AssertHeaderEqual("Content-Type", "application/json"), + ha.Must(ha.AssertJQ(`.status == "healthy"`)), + ) +} +``` + +Constructors that parse a status expression, regular expression or jq query +return `(ha.Assertion, error)`. `ha.Must(...)` keeps static, programmer-owned +expressions inline; it panics on invalid input, so runtime values should handle +the constructor error normally. + +`Client.Do` calls the configured HTTP client once and never retries. A returned +error means no complete response was available, such as a transport or +body-read failure. With a nil error, `Result.Outcomes` contains one result per +assertion, in call order; `Result.Passed()` is the convenient aggregate verdict. +Failures expose a code, kind, target, expected value and actual value instead of +preformatted text, so the calling application controls presentation. Evaluation +errors such as invalid JSON remain distinct from responses that were evaluated +and failed. + +The zero-value client uses a shared HTTP client with a 20-second total timeout, +covering connection setup, redirects and response-body reads. Supply +`HTTPClient` to choose another timeout, transport, TLS or redirect policy; a +request-context deadline can impose a shorter per-call bound. Retry policy, +logging and CLI output intentionally remain outside the library API. + ## Usage ### Basic Syntax @@ -655,7 +705,7 @@ v3.0; see [LICENSE](LICENSE). ```bash git clone https://github.com/korya/http-assert.git cd http-assert -go build -o http-assert . +go build -o http-assert ./cmd/http-assert ``` ### Working on the Code @@ -671,7 +721,8 @@ just test-cover # merged unit + end-to-end coverage ``` The end-to-end tests are opt-in: `go test ./...` runs the unit tests only, and -`-e2e` (or the recipes above) switches the full suite on. +`go test ./cmd/http-assert -e2e` (or the recipes above) switches the full suite +on. ### Releasing diff --git a/api_test.go b/api_test.go new file mode 100644 index 0000000..106f33d --- /dev/null +++ b/api_test.go @@ -0,0 +1,42 @@ +package httpassert_test + +import ( + "fmt" + "net/http" + + ha "github.com/korya/http-assert" +) + +type exampleTransport func(*http.Request) (*http.Response, error) + +func (f exampleTransport) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +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) { + return &http.Response{ + StatusCode: http.StatusNoContent, + Status: "204 No Content", + Header: make(http.Header), + Body: http.NoBody, + Request: req, + }, nil + })}} + + result, err := client.Do(req, ha.AssertStatusOK(), ha.AssertBodyEmpty()) + fmt.Println(err) + fmt.Println(result.Passed()) + + // Output: + // + // true +} + +func ExampleMust() { + assertion := ha.Must(ha.AssertJQ(`.status == "healthy"`)) + fmt.Println(assertion.Kind()) + + // Output: jq +} diff --git a/assertions.go b/assertions.go index 34a4955..ce3b18a 100644 --- a/assertions.go +++ b/assertions.go @@ -1,8 +1,7 @@ -package main +package httpassert import ( "context" - "encoding/json" "fmt" "regexp" "strconv" @@ -26,32 +25,53 @@ type Assertion interface { Kind() string // Check reports (nil, nil) when the assertion holds. - Check(res *httpResponse) (*Failure, error) + Check(res *Response) (*Failure, error) } -// Failure describes an assertion that did not hold, in parts as well as prose. -// -// Message is the human sentence, unchanged from when assertions returned a bare -// error, and is what the failure dump prints. The parts around it exist because -// prose cannot be serialized into anything a dashboard can query; they are not -// a second source of truth for the message, and no formatter derives one from -// the other. Reconstructing sentences like "expected to be non-empty, got -// nothing" from Expected and Actual alone would drift the moment a wording -// changed, and the drift would surface as a failing end-to-end test rather than -// as a compile error. +// Must returns assertion when err is nil and panics otherwise. It makes +// assertions built from static, programmer-controlled expressions convenient +// to declare inline. Runtime input should handle the constructor error instead. +func Must(assertion Assertion, err error) Assertion { + if err != nil { + panic(err) + } + return assertion +} + +// FailureCode identifies why an assertion did not hold without prescribing how +// a caller presents that fact. +type FailureCode string + +const ( + FailureStatusOK FailureCode = "status_ok" + FailureStatusNOK FailureCode = "status_nok" + FailureStatus FailureCode = "status" + FailureHeaderPresent FailureCode = "header_present" + FailureHeaderMissing FailureCode = "header_missing" + FailureHeaderEqual FailureCode = "header_equal" + FailureHeaderMatch FailureCode = "header_match" + FailureBodyEmpty FailureCode = "body_empty" + FailureBodyNotEmpty FailureCode = "body_not_empty" + FailureBodyEqual FailureCode = "body_equal" + FailureBodyMatch FailureCode = "body_match" + FailureJQValue FailureCode = "jq_value" + FailureJQNoOutput FailureCode = "jq_no_output" + FailureRedirectStatus FailureCode = "redirect_status" + FailureRedirectLocationAbsent FailureCode = "redirect_location_absent" + FailureRedirectEqual FailureCode = "redirect_equal" + FailureRedirectMatch FailureCode = "redirect_match" +) + +// 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 // filled in by Check; never set by a constructor + Kind string // 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 Actual any - Message string } -// Error lets a Failure travel the same path as an evaluation error, so the -// caller can collect both into one list and print them in the order the -// assertions were given. -func (f *Failure) Error() string { return f.Message } - // assertionFunc adapts a closure to the Assertion interface. // // The functional style is what makes each constructor readable as a single @@ -60,14 +80,14 @@ func (f *Failure) Error() string { return f.Message } // have said the same thing at ten times the length. type assertionFunc struct { kind string - check func(res *httpResponse) (*Failure, error) + check func(res *Response) (*Failure, error) } func (a assertionFunc) Kind() string { 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. -func (a assertionFunc) Check(res *httpResponse) (*Failure, error) { +func (a assertionFunc) Check(res *Response) (*Failure, error) { f, err := a.check(res) if f != nil { f.Kind = a.kind @@ -76,7 +96,7 @@ func (a assertionFunc) Check(res *httpResponse) (*Failure, error) { return f, err } -func newAssertion(kind string, check func(res *httpResponse) (*Failure, error)) Assertion { +func newAssertion(kind string, check func(res *Response) (*Failure, error)) Assertion { return assertionFunc{kind: kind, check: check} } @@ -84,22 +104,6 @@ func newAssertion(kind string, check func(res *httpResponse) (*Failure, error)) // response exists. They return an error rather than panicking; every other // constructor in this file is infallible and returns an Assertion directly. -// headerValues renders a header's values the way the response carried them. -// -// A header can appear more than once, so the values are a list -- but %q on a -// []string prints Go's own syntax, and `got ["abc123"]` told a user reading a -// failure about a single-valued header that something bracketed had happened -// to their value (#97). One value now reads as one value, and several read as -// a list a person would write. -func headerValues(vs []string) string { - quoted := make([]string, len(vs)) - for i, v := range vs { - quoted[i] = strconv.Quote(v) - } - - return strings.Join(quoted, ", ") -} - // Status codes a response can actually carry. net/http refuses to write // anything outside this range -- 99 and 1000 panic in WriteHeader -- so a spec // naming one of them is a typo in the invocation rather than a fact about the @@ -222,14 +226,14 @@ func parseStatusCode(text string) (int, error) { return code, nil } +// AssertStatusOK accepts any success or redirect status (2xx or 3xx). func AssertStatusOK() Assertion { - return newAssertion("ok", func(res *httpResponse) (*Failure, error) { + return newAssertion("ok", func(res *Response) (*Failure, error) { if s := res.StatusCode; s < 200 || s >= 400 { return &Failure{ + Code: FailureStatusOK, Expected: "2xx-3xx", Actual: res.StatusCode, - Message: fmt.Sprintf("ok: expected OK, got %d (%q)", - res.StatusCode, res.Status), }, nil } @@ -237,14 +241,14 @@ func AssertStatusOK() Assertion { }) } +// AssertStatusNOK accepts any status outside the 2xx and 3xx ranges. func AssertStatusNOK() Assertion { - return newAssertion("nok", func(res *httpResponse) (*Failure, error) { + return newAssertion("nok", func(res *Response) (*Failure, error) { if s := res.StatusCode; s >= 200 && s < 400 { return &Failure{ + Code: FailureStatusNOK, Expected: "not 2xx-3xx", Actual: res.StatusCode, - Message: fmt.Sprintf("nok: expected NOK, got %d (%q)", - res.StatusCode, res.Status), }, nil } @@ -252,15 +256,25 @@ func AssertStatusNOK() Assertion { }) } -// AssertStatus holds when the response carries any status the spec names. -func AssertStatus(spec statusSpec) Assertion { - return newAssertion("status", func(res *httpResponse) (*Failure, error) { +// AssertStatus builds an assertion accepting any status named by text. Text may +// be a code, a class such as "2xx", an inclusive range, or a comma-separated +// list mixing those forms. +func AssertStatus(text string) (Assertion, error) { + spec, err := parseStatusSpec(text) + if err != nil { + return nil, err + } + + return assertStatus(spec), nil +} + +func assertStatus(spec statusSpec) Assertion { + return newAssertion("status", func(res *Response) (*Failure, error) { if !spec.matches(res.StatusCode) { return &Failure{ + Code: FailureStatus, Expected: spec.text, Actual: res.StatusCode, - Message: fmt.Sprintf("status: expected %s, got %d (%q)", - spec.text, res.StatusCode, res.Status), }, nil } @@ -268,14 +282,14 @@ func AssertStatus(spec statusSpec) Assertion { }) } +// AssertHeaderPresent requires at least one value for name. func AssertHeaderPresent(name string) Assertion { - return newAssertion("header", func(res *httpResponse) (*Failure, error) { + return newAssertion("header", func(res *Response) (*Failure, error) { if res.Header.Values(name) == nil { return &Failure{ + Code: FailureHeaderPresent, Target: name, Expected: "present", - Message: fmt.Sprintf("header[%s]: expected to be present, missing", - name), }, nil } @@ -283,15 +297,15 @@ func AssertHeaderPresent(name string) Assertion { }) } +// AssertHeaderMissing requires name to be absent. func AssertHeaderMissing(name string) Assertion { - return newAssertion("header", func(res *httpResponse) (*Failure, error) { + return newAssertion("header", func(res *Response) (*Failure, error) { if vs := res.Header.Values(name); vs != nil { return &Failure{ + Code: FailureHeaderMissing, Target: name, Expected: "missing", Actual: vs, - Message: fmt.Sprintf("header[%s]: expected to be missing, got %s", - name, headerValues(vs)), }, nil } @@ -299,15 +313,16 @@ 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 *httpResponse) (*Failure, error) { + return newAssertion("header", func(res *Response) (*Failure, error) { vs := res.Header.Values(name) if vs == nil { return &Failure{ + Code: FailureHeaderEqual, Target: name, Expected: expValue, - Message: fmt.Sprintf("header[%s]: expected %q, missing", - name, expValue), }, nil } @@ -318,29 +333,29 @@ func AssertHeaderEqual(name, expValue string) Assertion { } return &Failure{ + Code: FailureHeaderEqual, Target: name, Expected: expValue, Actual: vs, - Message: fmt.Sprintf("header[%s]: expected %q, got %s", - name, expValue, headerValues(vs)), }, nil }) } +// AssertHeaderMatch accepts the response when any value of name matches the Go +// regular expression expPattern. func AssertHeaderMatch(name, expPattern string) (Assertion, error) { re, err := regexp.Compile(expPattern) if err != nil { return nil, err } - return newAssertion("header", func(res *httpResponse) (*Failure, error) { + return newAssertion("header", func(res *Response) (*Failure, error) { vs := res.Header.Values(name) if vs == nil { return &Failure{ + Code: FailureHeaderMatch, Target: name, Expected: expPattern, - Message: fmt.Sprintf("header[%s]: expected to match %q, missing", - name, expPattern), }, nil } @@ -351,11 +366,10 @@ func AssertHeaderMatch(name, expPattern string) (Assertion, error) { } return &Failure{ + Code: FailureHeaderMatch, Target: name, Expected: expPattern, Actual: vs, - Message: fmt.Sprintf("header[%s]: expected to match %q, got %s", - name, expPattern, headerValues(vs)), }, nil }), nil } @@ -367,17 +381,22 @@ func AssertHeaderMatch(name, expPattern string) (Assertion, error) { // compressed produced a false failure with an unexplained hex dump -- and, with // a loose enough pattern, a false pass, which is the one outcome this program // exists to refuse (#27). -func bodyOf(res *httpResponse) ([]byte, error) { +func bodyOf(res *Response) ([]byte, error) { if res.DecodeErr != nil { - return nil, fmt.Errorf("body: response is %s-encoded and was not decoded: %s", - res.Encoding, res.DecodeErr) + return nil, &EvaluationError{ + Code: EvaluationBodyDecode, + Kind: "body", + Encoding: res.Encoding, + Cause: res.DecodeErr, + } } return res.BodyBytes, nil } +// AssertBodyEmpty requires the decoded response body to contain zero bytes. func AssertBodyEmpty() Assertion { - return newAssertion("body", func(res *httpResponse) (*Failure, error) { + return newAssertion("body", func(res *Response) (*Failure, error) { body, err := bodyOf(res) if err != nil { return nil, err @@ -385,10 +404,9 @@ func AssertBodyEmpty() Assertion { if len(body) > 0 { return &Failure{ + Code: FailureBodyEmpty, Expected: "empty", Actual: string(body), - Message: fmt.Sprintf("body: expected to be empty, got %q", - string(body)), }, nil } @@ -432,14 +450,14 @@ func AssertJQ(query string) (Assertion, error) { return nil, err } - return newAssertion("jq", func(res *httpResponse) (*Failure, error) { + return newAssertion("jq", func(res *Response) (*Failure, error) { return runJQ(code, query, res, jqTimeout) }), nil } // runJQ evaluates one compiled query. The deadline is a parameter so the test // for it need not wait out the real one; every caller passes jqTimeout. -func runJQ(code *gojq.Code, query string, res *httpResponse, timeout time.Duration) (*Failure, error) { +func runJQ(code *gojq.Code, query string, res *Response, timeout time.Duration) (*Failure, error) { doc, err := res.decodeJSON() if err != nil { return nil, err @@ -466,16 +484,20 @@ func runJQ(code *gojq.Code, query string, res *httpResponse, timeout time.Durati // query never reached a verdict, so there is nothing for Expected // and Actual to describe. if e, isErr := v.(error); isErr { - return nil, fmt.Errorf("jq[%s]: %s", query, e) + return nil, &EvaluationError{ + Code: EvaluationJQ, + Kind: "jq", + Target: query, + Cause: e, + } } if b, isBool := v.(bool); !isBool || !b { return &Failure{ + Code: FailureJQValue, Target: query, Expected: true, Actual: v, - Message: fmt.Sprintf("jq[%s]: expected true, got %s", - query, jqValue(v)), }, nil } } @@ -486,28 +508,19 @@ func runJQ(code *gojq.Code, query string, res *httpResponse, timeout time.Durati // yields no output at all when no user has that id. if outputs == 0 { return &Failure{ + Code: FailureJQNoOutput, Target: query, Expected: true, - Message: fmt.Sprintf("jq[%s]: expected true, got no output", query), }, nil } return nil, nil } -// jqValue renders a query's output for the failure message. jq's own notation -// is JSON, so this is what `jq` would have printed for the same expression. -func jqValue(v any) string { - b, err := json.Marshal(v) - if err != nil { - return fmt.Sprintf("%v", v) - } - - return string(b) -} - +// AssertBodyNotEmpty requires the decoded response body to contain at least +// one byte. func AssertBodyNotEmpty() Assertion { - return newAssertion("body", func(res *httpResponse) (*Failure, error) { + return newAssertion("body", func(res *Response) (*Failure, error) { body, err := bodyOf(res) if err != nil { return nil, err @@ -515,8 +528,8 @@ func AssertBodyNotEmpty() Assertion { if len(body) == 0 { return &Failure{ + Code: FailureBodyNotEmpty, Expected: "non-empty", - Message: "body: expected to be non-empty, got nothing", }, nil } @@ -524,8 +537,9 @@ func AssertBodyNotEmpty() Assertion { }) } +// AssertBodyEqual requires the decoded response body to equal expContent. func AssertBodyEqual(expContent string) Assertion { - return newAssertion("body", func(res *httpResponse) (*Failure, error) { + return newAssertion("body", func(res *Response) (*Failure, error) { body, err := bodyOf(res) if err != nil { return nil, err @@ -538,16 +552,15 @@ func AssertBodyEqual(expContent string) Assertion { // made --assert-body-eq '' impossible to satisfy (#22). if len(body) == 0 { return &Failure{ + Code: FailureBodyEqual, Expected: expContent, - Message: fmt.Sprintf("body: expected %q, missing", - expContent), }, nil } return &Failure{ + Code: FailureBodyEqual, Expected: expContent, Actual: c, - Message: fmt.Sprintf("body: expected %q, got %q", expContent, c), }, nil } @@ -555,13 +568,15 @@ func AssertBodyEqual(expContent string) Assertion { }) } +// AssertBodyMatch requires the decoded response body to match the Go regular +// expression expPattern. func AssertBodyMatch(expPattern string) (Assertion, error) { re, err := regexp.Compile(expPattern) if err != nil { return nil, err } - return newAssertion("body", func(res *httpResponse) (*Failure, error) { + return newAssertion("body", func(res *Response) (*Failure, error) { body, err := bodyOf(res) if err != nil { return nil, err @@ -573,17 +588,15 @@ func AssertBodyMatch(expPattern string) (Assertion, error) { // while emptiness was checked before the pattern was. if len(body) == 0 { return &Failure{ + Code: FailureBodyMatch, Expected: expPattern, - Message: fmt.Sprintf("body: expected to match %q, missing", - expPattern), }, nil } return &Failure{ + Code: FailureBodyMatch, Expected: expPattern, Actual: c, - Message: fmt.Sprintf("body: expected to match %q, got %q", - expPattern, c), }, nil } @@ -594,40 +607,41 @@ func AssertBodyMatch(expPattern string) (Assertion, error) { // redirectPrecondition reports the two ways a redirect assertion fails before // its Location is compared at all. Both redirect assertions share them, and // sharing the code is what keeps their wording identical. -func redirectPrecondition(res *httpResponse, expected any) *Failure { +func redirectPrecondition(res *Response, expected any) *Failure { if s := res.StatusCode; s < 300 || s >= 400 { return &Failure{ + Code: FailureRedirectStatus, Expected: "3xx", Actual: res.StatusCode, - Message: fmt.Sprintf("redirect: wrong HTTP status: got %d (%q)", - res.StatusCode, res.Status), } } if vs := res.Header.Values("Location"); vs == nil { return &Failure{ + Code: FailureRedirectLocationAbsent, Target: "Location", Expected: expected, - Message: "redirect: no Location header", } } return nil } +// AssertRedirectEqual requires a 3xx response whose Location equals +// expLocation. Configure the HTTP client not to follow redirects when using +// this assertion. func AssertRedirectEqual(expLocation string) Assertion { - return newAssertion("redirect", func(res *httpResponse) (*Failure, error) { + return newAssertion("redirect", func(res *Response) (*Failure, error) { if f := redirectPrecondition(res, expLocation); f != nil { return f, nil } if l := res.Header.Get("Location"); l != expLocation { return &Failure{ + Code: FailureRedirectEqual, Target: "Location", Expected: expLocation, Actual: l, - Message: fmt.Sprintf("redirect: wrong Location: expected %q, got %q", - expLocation, l), }, nil } @@ -635,24 +649,26 @@ func AssertRedirectEqual(expLocation string) Assertion { }) } +// AssertRedirectMatch requires a 3xx response whose Location matches the Go +// regular expression expPattern. Configure the HTTP client not to follow +// redirects when using this assertion. func AssertRedirectMatch(expPattern string) (Assertion, error) { re, err := regexp.Compile(expPattern) if err != nil { return nil, err } - return newAssertion("redirect", func(res *httpResponse) (*Failure, error) { + return newAssertion("redirect", func(res *Response) (*Failure, error) { if f := redirectPrecondition(res, expPattern); f != nil { return f, nil } if l := res.Header.Get("Location"); !re.MatchString(l) { return &Failure{ + Code: FailureRedirectMatch, Target: "Location", Expected: expPattern, Actual: l, - Message: fmt.Sprintf("redirect: wrong Location: expected to match %q, got %q", - expPattern, l), }, nil } diff --git a/assertions_test.go b/assertions_test.go index a887315..bd3fdc7 100644 --- a/assertions_test.go +++ b/assertions_test.go @@ -1,4 +1,4 @@ -package main +package httpassert import ( "errors" @@ -10,6 +10,28 @@ import ( "testing" ) +func TestMust(t *testing.T) { + t.Parallel() + + t.Run("returns a successfully constructed assertion", func(t *testing.T) { + assertion := Must(AssertJQ(`.status == "healthy"`)) + if assertion == nil || assertion.Kind() != "jq" { + t.Errorf("Must() = %v, want jq assertion", assertion) + } + }) + + t.Run("panics with the constructor error", func(t *testing.T) { + want := errors.New("invalid assertion") + defer func() { + if got := recover(); got != want { + t.Errorf("panic = %v, want %v", got, want) + } + }() + + Must(nil, want) + }) +} + func Test_AssertStatusOK(t *testing.T) { t.Parallel() @@ -39,7 +61,7 @@ func Test_AssertStatusOK(t *testing.T) { nok := AssertStatusNOK() for _, tc := range testCases { t.Run(strconv.Itoa(tc.StatusCode), func(t *testing.T) { - res := &httpResponse{ + res := &Response{ Response: &http.Response{ StatusCode: tc.StatusCode, Status: tc.Status, @@ -58,17 +80,17 @@ func Test_AssertStatusOK(t *testing.T) { } } -// mustSpec parses a spec that the test author asserts is valid. Parsing rather -// than constructing keeps the tests honest about the only path a caller has. -func mustSpec(t *testing.T, text string) statusSpec { +// mustStatusAssertion builds an assertion from a spec the test author asserts +// is valid. +func mustStatusAssertion(t *testing.T, text string) Assertion { t.Helper() - spec, err := parseStatusSpec(text) + assertion, err := AssertStatus(text) if err != nil { - t.Fatalf("parseStatusSpec(%q): unexpected error: %s", text, err) + t.Fatalf("AssertStatus(%q): unexpected error: %s", text, err) } - return spec + return assertion } func Test_AssertStatus(t *testing.T) { @@ -99,13 +121,13 @@ func Test_AssertStatus(t *testing.T) { // be 1, which is no longer expressible: a spec naming a code no response // can carry is now rejected at the flag rather than failing at runtime. assertions := map[int]Assertion{ - 599: AssertStatus(mustSpec(t, "599")), - 200: AssertStatus(mustSpec(t, "200")), - 429: AssertStatus(mustSpec(t, "429")), + 599: mustStatusAssertion(t, "599"), + 200: mustStatusAssertion(t, "200"), + 429: mustStatusAssertion(t, "429"), } for _, tc := range testCases { t.Run(strconv.Itoa(tc.StatusCode), func(t *testing.T) { - res := &httpResponse{ + res := &Response{ Response: &http.Response{ StatusCode: tc.StatusCode, Status: tc.Status, @@ -225,7 +247,7 @@ func Test_AssertHeader(t *testing.T) { } for _, tc := range testCases { t.Run(tc.CaseName, func(t *testing.T) { - res := &httpResponse{ + res := &Response{ Response: &http.Response{ Header: http.Header(tc.Header), }, @@ -296,7 +318,7 @@ func Test_AssertBody(t *testing.T) { } for _, tc := range testCases { t.Run(tc.CaseName, func(t *testing.T) { - res := &httpResponse{BodyBytes: tc.Body} + res := &Response{BodyBytes: tc.Body} checkErr(t, "empty", check(empty, res), tc.ExpEmptyError) checkErr(t, "equal", check(equal, res), tc.ExpEqualError) @@ -317,11 +339,11 @@ func Test_AssertBody_emptyIsAssertable(t *testing.T) { patterns := []string{"^$", ".*", `\A\z`, ""} t.Run("equal to the empty string", func(t *testing.T) { - res := &httpResponse{BodyBytes: []byte{}} + res := &Response{BodyBytes: []byte{}} checkErr(t, "equal", check(AssertBodyEqual(""), res), "") // And a nil body, which is what a 204 produces. - checkErr(t, "equal, nil body", check(AssertBodyEqual(""), &httpResponse{}), "") + checkErr(t, "equal, nil body", check(AssertBodyEqual(""), &Response{}), "") }) for _, p := range patterns { @@ -331,15 +353,15 @@ func Test_AssertBody_emptyIsAssertable(t *testing.T) { t.Fatalf("cannot build the assertion: %s", err) } - checkErr(t, "match", check(a, &httpResponse{BodyBytes: []byte{}}), "") - checkErr(t, "match, nil body", check(a, &httpResponse{}), "") + checkErr(t, "match", check(a, &Response{BodyBytes: []byte{}}), "") + checkErr(t, "match, nil body", check(a, &Response{}), "") }) } // The verdict moved; the wording did not. A body that is empty when // something was expected still reads as "missing" rather than `got ""`. t.Run("an empty body still reports as missing", func(t *testing.T) { - res := &httpResponse{BodyBytes: []byte{}} + res := &Response{BodyBytes: []byte{}} checkErr(t, "equal", check(AssertBodyEqual("value"), res), `body: expected "value", missing`) a, err := AssertBodyMatch("^value$") @@ -351,7 +373,7 @@ func Test_AssertBody_emptyIsAssertable(t *testing.T) { // The inverse must keep failing: a non-empty body is not the empty string. t.Run("a non-empty body does not equal the empty string", func(t *testing.T) { - res := &httpResponse{BodyBytes: []byte("x")} + res := &Response{BodyBytes: []byte("x")} checkErr(t, "equal", check(AssertBodyEqual(""), res), `body: expected "", got "x"`) }) } @@ -514,7 +536,7 @@ func Test_AssertRedirect(t *testing.T) { } for _, tc := range testCases { t.Run(tc.CaseName, func(t *testing.T) { - res := &httpResponse{ + res := &Response{ Response: &http.Response{ StatusCode: tc.StatusCode, Status: strings.Join(strings.Split(strconv.Itoa(tc.StatusCode), ""), "_"), @@ -561,7 +583,7 @@ func Test_AssertBodyNotEmpty(t *testing.T) { a := AssertBodyNotEmpty() for _, tc := range tests { t.Run(tc.Name, func(t *testing.T) { - checkErr(t, "not-empty", check(a, &httpResponse{BodyBytes: tc.Body}), tc.Want) + checkErr(t, "not-empty", check(a, &Response{BodyBytes: tc.Body}), tc.Want) }) } @@ -570,7 +592,7 @@ func Test_AssertBodyNotEmpty(t *testing.T) { t.Run("it is the exact inverse of AssertBodyEmpty", func(t *testing.T) { empty := AssertBodyEmpty() for _, body := range [][]byte{nil, {}, []byte(" "), []byte("x"), []byte("longer body")} { - res := &httpResponse{BodyBytes: body} + res := &Response{BodyBytes: body} if (check(empty, res) == nil) == (check(a, res) == nil) { t.Errorf("both agree on %q; they must disagree", string(body)) } @@ -628,13 +650,13 @@ func Test_AssertMatchConstructorsRejectBadPatterns(t *testing.T) { func Test_AssertionIdentity(t *testing.T) { t.Parallel() - statusRes := func(code int, status string) *httpResponse { - return &httpResponse{ + statusRes := func(code int, status string) *Response { + return &Response{ Response: &http.Response{StatusCode: code, Status: status}, } } - headerRes := func(h http.Header) *httpResponse { - return &httpResponse{ + headerRes := func(h http.Header) *Response { + return &Response{ Response: &http.Response{StatusCode: 200, Status: "200 OK", Header: h}, } } @@ -647,7 +669,7 @@ func Test_AssertionIdentity(t *testing.T) { tests := []struct { Name string Assertion Assertion - Res *httpResponse + Res *Response Kind string Target string Expected any @@ -664,7 +686,7 @@ func Test_AssertionIdentity(t *testing.T) { Kind: "nok", Expected: "not 2xx-3xx", Actual: 200, }, { - Name: "status", Assertion: AssertStatus(mustSpec(t, "200")), + Name: "status", Assertion: mustStatusAssertion(t, "200"), Res: statusRes(500, "500 Internal Server Error"), Kind: "status", Expected: "200", Actual: 500, }, @@ -680,7 +702,7 @@ func Test_AssertionIdentity(t *testing.T) { }, { Name: "body equal", Assertion: AssertBodyEqual("want"), - Res: &httpResponse{BodyBytes: []byte("got")}, + Res: &Response{BodyBytes: []byte("got")}, Kind: "body", Expected: "want", Actual: "got", }, { @@ -724,8 +746,8 @@ func Test_AssertionIdentity(t *testing.T) { if !reflect.DeepEqual(f.Actual, tc.Actual) { t.Errorf("Actual = %#v, want %#v", f.Actual, tc.Actual) } - if f.Message == "" { - t.Error("Message is empty; the human path reads this") + if f.Code == "" { + t.Error("Code is empty; structured consumers cannot classify the failure") } }) } @@ -740,14 +762,14 @@ func Test_AssertionCheckSeparatesFailureFromError(t *testing.T) { t.Parallel() t.Run("an assertion that holds reports neither", func(t *testing.T) { - f, err := AssertBodyEqual("same").Check(&httpResponse{BodyBytes: []byte("same")}) + f, err := AssertBodyEqual("same").Check(&Response{BodyBytes: []byte("same")}) if f != nil || err != nil { t.Errorf("got (%v, %v), want (nil, nil)", f, err) } }) t.Run("an undecodable body is an error, not a Failure", func(t *testing.T) { - res := &httpResponse{ + res := &Response{ Encoding: "compress", DecodeErr: errors.New(`no decoder for "compress"`), } @@ -764,15 +786,19 @@ func Test_AssertionCheckSeparatesFailureFromError(t *testing.T) { if err == nil { t.Fatal("expected an evaluation error, got nil") } - if !strings.Contains(err.Error(), "was not decoded") { - t.Errorf("error = %q, want it to name the encoding problem", err) + var evaluation *EvaluationError + if !errors.As(err, &evaluation) { + t.Fatalf("error = %T, want *EvaluationError", err) + } + if evaluation.Code != EvaluationBodyDecode || evaluation.Encoding != "compress" { + t.Errorf("evaluation = %+v, want body-decode error for compress", evaluation) } }) } }) t.Run("a status assertion is unaffected by an undecodable body", func(t *testing.T) { - res := &httpResponse{ + res := &Response{ Response: &http.Response{StatusCode: 200, Status: "200 OK"}, Encoding: "compress", DecodeErr: errors.New(`no decoder for "compress"`), diff --git a/client.go b/client.go new file mode 100644 index 0000000..aa775e4 --- /dev/null +++ b/client.go @@ -0,0 +1,179 @@ +package httpassert + +import ( + "errors" + "fmt" + "io" + "net/http" + "reflect" + "time" +) + +const defaultRequestTimeout = 20 * time.Second + +// defaultHTTPClient is shared so its underlying DefaultTransport can reuse +// connections. Its total timeout covers connection setup, redirects, and +// reading the response body; callers needing another policy supply HTTPClient. +var defaultHTTPClient = &http.Client{Timeout: defaultRequestTimeout} + +var ( + // ErrNoAssertions is returned before a request is sent when Do has nothing + // to check. + ErrNoAssertions = errors.New("no assertions defined") + // ErrNilRequest is returned when Do receives a nil request. + ErrNilRequest = errors.New("request is nil") + // ErrNilAssertion is returned before a request is sent when an assertion is + // nil. + ErrNilAssertion = errors.New("assertion is nil") +) + +// EvaluationErrorCode identifies why an assertion could not be evaluated. +type EvaluationErrorCode string + +const ( + EvaluationBodyDecode EvaluationErrorCode = "body_decode" + EvaluationJSON EvaluationErrorCode = "json_decode" + EvaluationJQ EvaluationErrorCode = "jq_evaluation" +) + +// EvaluationError describes an assertion that could not reach a verdict. This +// differs from Failure: a Failure means the response was evaluated and did not +// satisfy the assertion. +type EvaluationError struct { + Code EvaluationErrorCode + Kind string + Target string + Encoding string + Cause error +} + +func (e *EvaluationError) Error() string { + if e == nil { + return "" + } + if e.Cause != nil { + return e.Cause.Error() + } + return string(e.Code) +} + +// Unwrap exposes the underlying decoder, JSON, or jq error. +func (e *EvaluationError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +// Outcome is the result of evaluating one assertion. Passed reports whether +// both Failure and Err are nil. +type Outcome struct { + Kind string + Failure *Failure + Err error +} + +func (o Outcome) Passed() bool { return o.Failure == nil && o.Err == nil } + +// Result contains the received response and one outcome per assertion, in the +// order supplied to Client.Do. +type Result struct { + Response *Response + Outcomes []Outcome +} + +// Passed reports whether at least one assertion was evaluated and every +// assertion held. +func (r *Result) Passed() bool { + if r == nil || len(r.Outcomes) == 0 { + return false + } + for _, outcome := range r.Outcomes { + if !outcome.Passed() { + return false + } + } + return true +} + +// Client invokes an HTTP client and evaluates assertions against its response. +// It never retries; retry policy, logging, and presentation belong to the +// caller. The configured HTTP client's redirect policy still applies. +// +// The zero value uses a shared client with a 20-second total request timeout. +// Set HTTPClient when the caller needs custom transports, redirect behavior, +// TLS settings, or timeouts. +type Client struct { + HTTPClient *http.Client +} + +// Do calls the configured HTTP client's Do method once. A non-nil error means +// no complete response was available for assertions. Assertion failures and +// evaluation errors are returned as structured Outcomes with a nil top-level +// error. When reading the response body fails, Result contains the partial +// response and no outcomes alongside the non-nil error. +func (c Client) Do(req *http.Request, assertions ...Assertion) (*Result, error) { + if req == nil { + return nil, ErrNilRequest + } + if len(assertions) == 0 { + return nil, ErrNoAssertions + } + for i, assertion := range assertions { + if isNilAssertion(assertion) { + return nil, fmt.Errorf("%w at index %d", ErrNilAssertion, i) + } + } + + client := c.HTTPClient + if client == nil { + client = defaultHTTPClient + } + + // The caller supplies both the request and (optionally) the HTTP client; + // issuing that request is the package's contract. Applications accepting + // untrusted URLs must enforce their own destination policy before this API. + res, err := client.Do(req) // #nosec G704 -- this API intentionally sends its caller's request + if err != nil { + return nil, err + } + defer func() { _ = res.Body.Close() }() + + httpRes := &Response{Response: res} + httpRes.BodyBytes, err = io.ReadAll(res.Body) + result := &Result{Response: httpRes} + if err != nil { + return result, fmt.Errorf("read response body: %w", err) + } + httpRes.decodeBody() + + result.Outcomes = make([]Outcome, 0, len(assertions)) + for _, assertion := range assertions { + failure, checkErr := assertion.Check(httpRes) + kind := assertion.Kind() + if failure != nil { + failure.Kind = kind + } + result.Outcomes = append(result.Outcomes, Outcome{ + Kind: kind, + Failure: failure, + Err: checkErr, + }) + } + + return result, nil +} + +func isNilAssertion(assertion Assertion) bool { + if assertion == nil { + return true + } + + value := reflect.ValueOf(assertion) + switch value.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return value.IsNil() + default: + return false + } +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..c4b3d99 --- /dev/null +++ b/client_test.go @@ -0,0 +1,265 @@ +package httpassert + +import ( + "errors" + "io" + "net/http" + "strings" + "testing" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +type testAssertion struct { + kind string + check func(*Response) (*Failure, error) +} + +func (a testAssertion) Kind() string { 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) Check(*Response) (*Failure, error) { return nil, nil } + +type trackingBody struct { + reader io.Reader + closed bool +} + +func (b *trackingBody) Read(p []byte) (int, error) { return b.reader.Read(p) } + +func (b *trackingBody) Close() error { + b.closed = true + return nil +} + +type failingReader struct { + done bool + err error +} + +func (r *failingReader) Read(p []byte) (int, error) { + if !r.done { + r.done = true + return copy(p, "partial"), nil + } + return 0, r.err +} + +func request(t *testing.T) *http.Request { + t.Helper() + req, err := http.NewRequest(http.MethodGet, "http://example.test/health", nil) + if err != nil { + t.Fatalf("NewRequest: %s", err) + } + return req +} + +func TestClientDoRejectsInvalidInputBeforeSending(t *testing.T) { + sent := 0 + client := Client{HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + sent++ + return nil, errors.New("must not send") + })}} + + if result, err := client.Do(nil, AssertStatusOK()); result != nil || !errors.Is(err, ErrNilRequest) { + t.Errorf("nil request = (%v, %v), want (nil, ErrNilRequest)", result, err) + } + if result, err := client.Do(request(t)); result != nil || !errors.Is(err, ErrNoAssertions) { + t.Errorf("no assertions = (%v, %v), want (nil, ErrNoAssertions)", result, err) + } + if result, err := client.Do(request(t), nil); result != nil || !errors.Is(err, ErrNilAssertion) { + t.Errorf("nil assertion = (%v, %v), want (nil, ErrNilAssertion)", result, err) + } + var typedNil *pointerAssertion + if result, err := client.Do(request(t), typedNil); result != nil || !errors.Is(err, ErrNilAssertion) { + t.Errorf("typed-nil assertion = (%v, %v), want (nil, ErrNilAssertion)", result, err) + } + if sent != 0 { + t.Errorf("transport called %d times for invalid input", sent) + } +} + +func TestClientDoPerformsOneRequestAndChecksEveryAssertionInOrder(t *testing.T) { + body := &trackingBody{reader: strings.NewReader("payload")} + sent := 0 + client := Client{HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + sent++ + return &http.Response{ + StatusCode: 200, + Status: "200 OK", + Proto: "HTTP/1.1", + Header: http.Header{"X-Service": {"ready"}}, + Body: body, + Request: req, + }, nil + })}} + + var checked []string + evaluationCause := errors.New("cannot decide") + assertions := []Assertion{ + testAssertion{kind: "first", check: func(res *Response) (*Failure, error) { + checked = append(checked, "first") + if string(res.BodyBytes) != "payload" { + t.Errorf("first assertion saw body %q", res.BodyBytes) + } + return nil, nil + }}, + testAssertion{kind: "second", check: func(*Response) (*Failure, error) { + checked = append(checked, "second") + return &Failure{Code: FailureBodyEqual, Expected: "want", Actual: "got"}, nil + }}, + testAssertion{kind: "third", check: func(*Response) (*Failure, error) { + checked = append(checked, "third") + return nil, evaluationCause + }}, + } + + result, err := client.Do(request(t), assertions...) + if err != nil { + t.Fatalf("Do: %s", err) + } + if sent != 1 { + t.Errorf("requests sent = %d, want 1", sent) + } + if got := strings.Join(checked, ","); got != "first,second,third" { + t.Errorf("assertion order = %q", got) + } + if !body.closed { + t.Error("response body was not closed") + } + if result.Response.StatusCode != 200 || string(result.Response.BodyBytes) != "payload" { + t.Errorf("Response = %+v", result.Response) + } + if len(result.Outcomes) != 3 { + t.Fatalf("outcomes = %d, want 3", len(result.Outcomes)) + } + if !result.Outcomes[0].Passed() { + t.Errorf("first outcome = %+v, want pass", result.Outcomes[0]) + } + if result.Outcomes[1].Failure == nil || result.Outcomes[1].Failure.Kind != "second" { + t.Errorf("second outcome = %+v, want stamped Failure", result.Outcomes[1]) + } + if !errors.Is(result.Outcomes[2].Err, evaluationCause) { + t.Errorf("third outcome error = %v, want %v", result.Outcomes[2].Err, evaluationCause) + } + if result.Passed() { + t.Error("Result.Passed() = true with failed outcomes") + } +} + +func TestClientDoUsesPackageDefaultClient(t *testing.T) { + original := defaultHTTPClient + t.Cleanup(func() { defaultHTTPClient = original }) + + called := false + defaultHTTPClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + called = true + return &http.Response{ + StatusCode: 204, + Status: "204 No Content", + Header: make(http.Header), + Body: http.NoBody, + Request: req, + }, nil + })} + + result, err := (Client{}).Do(request(t), AssertStatusOK(), AssertBodyEmpty()) + if err != nil { + t.Fatalf("Do: %s", err) + } + if !called || !result.Passed() { + t.Errorf("called = %v, result = %+v", called, result) + } +} + +func TestDefaultHTTPClientBoundsTheWholeRequest(t *testing.T) { + if got := defaultHTTPClient.Timeout; got != defaultRequestTimeout { + t.Errorf("default timeout = %s, want %s", got, defaultRequestTimeout) + } + if defaultHTTPClient.Timeout <= 0 { + t.Error("default client has no total request timeout") + } +} + +func TestClientDoReturnsTransportError(t *testing.T) { + want := errors.New("network unavailable") + client := Client{HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, want + })}} + + result, err := client.Do(request(t), AssertStatusOK()) + if result != nil || !errors.Is(err, want) { + t.Errorf("Do = (%v, %v), want (nil, transport error)", result, err) + } +} + +func TestClientDoReturnsPartialResponseOnReadError(t *testing.T) { + want := errors.New("body interrupted") + body := &trackingBody{reader: &failingReader{err: want}} + client := Client{HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Status: "200 OK", + Header: make(http.Header), + Body: body, + Request: req, + }, nil + })}} + + result, err := client.Do(request(t), AssertStatusOK()) + if result == nil || result.Response == nil { + t.Fatal("Do returned no partial response") + } + if !errors.Is(err, want) { + t.Errorf("error = %v, want wrapped read error", err) + } + if got := string(result.Response.BodyBytes); got != "partial" { + t.Errorf("partial body = %q", got) + } + if !body.closed { + t.Error("response body was not closed after read error") + } + if result.Passed() { + t.Error("an incomplete result must not pass") + } +} + +func TestResultAndEvaluationErrorHelpers(t *testing.T) { + if (*Result)(nil).Passed() { + t.Error("nil result passed") + } + if (&Result{}).Passed() { + t.Error("unchecked result passed") + } + if !(Outcome{}).Passed() { + t.Error("empty outcome should represent a pass") + } + if (Outcome{Failure: &Failure{}}).Passed() || (Outcome{Err: errors.New("x")}).Passed() { + t.Error("failed outcomes passed") + } + + cause := errors.New("decoder broke") + err := &EvaluationError{Code: EvaluationBodyDecode, Cause: cause} + if err.Error() != cause.Error() || !errors.Is(err, cause) || err.Unwrap() != cause { + t.Errorf("EvaluationError did not expose cause: %v", err) + } + err = &EvaluationError{Code: EvaluationJSON} + if err.Error() != string(EvaluationJSON) || err.Unwrap() != nil { + t.Errorf("cause-less EvaluationError = %q, unwrap %v", err.Error(), err.Unwrap()) + } + var nilErr *EvaluationError + if nilErr.Error() != "" || nilErr.Unwrap() != nil { + t.Errorf("nil EvaluationError = %q, unwrap %v", nilErr.Error(), nilErr.Unwrap()) + } +} diff --git a/color_test.go b/cmd/http-assert/color_test.go similarity index 100% rename from color_test.go rename to cmd/http-assert/color_test.go diff --git a/e2e_assert_test.go b/cmd/http-assert/e2e_assert_test.go similarity index 100% rename from e2e_assert_test.go rename to cmd/http-assert/e2e_assert_test.go diff --git a/e2e_color_test.go b/cmd/http-assert/e2e_color_test.go similarity index 100% rename from e2e_color_test.go rename to cmd/http-assert/e2e_color_test.go diff --git a/e2e_compression_test.go b/cmd/http-assert/e2e_compression_test.go similarity index 100% rename from e2e_compression_test.go rename to cmd/http-assert/e2e_compression_test.go diff --git a/e2e_config_test.go b/cmd/http-assert/e2e_config_test.go similarity index 100% rename from e2e_config_test.go rename to cmd/http-assert/e2e_config_test.go diff --git a/e2e_dump_test.go b/cmd/http-assert/e2e_dump_test.go similarity index 100% rename from e2e_dump_test.go rename to cmd/http-assert/e2e_dump_test.go diff --git a/e2e_harness_test.go b/cmd/http-assert/e2e_harness_test.go similarity index 97% rename from e2e_harness_test.go rename to cmd/http-assert/e2e_harness_test.go index 37f17e8..c1f4a28 100644 --- a/e2e_harness_test.go +++ b/cmd/http-assert/e2e_harness_test.go @@ -15,8 +15,7 @@ import ( // its externally observable contract only: exit code, stdout, stderr. // // It deliberately avoids reaching into package internals. Every test here must -// keep passing verbatim across the planned rearchitecture (#54 viper removal, -// #55 run() extraction, #56 assertion constructors), which is the property that +// keep passing verbatim across architecture changes, which is the property that // makes those refactors safe to perform. // runE2E gates the whole suite. It is opt-in: `go test ./...` runs the unit diff --git a/e2e_help_test.go b/cmd/http-assert/e2e_help_test.go similarity index 100% rename from e2e_help_test.go rename to cmd/http-assert/e2e_help_test.go diff --git a/e2e_jq_test.go b/cmd/http-assert/e2e_jq_test.go similarity index 100% rename from e2e_jq_test.go rename to cmd/http-assert/e2e_jq_test.go diff --git a/e2e_known_issues_test.go b/cmd/http-assert/e2e_known_issues_test.go similarity index 100% rename from e2e_known_issues_test.go rename to cmd/http-assert/e2e_known_issues_test.go diff --git a/e2e_panic_test.go b/cmd/http-assert/e2e_panic_test.go similarity index 100% rename from e2e_panic_test.go rename to cmd/http-assert/e2e_panic_test.go diff --git a/e2e_redirect_test.go b/cmd/http-assert/e2e_redirect_test.go similarity index 100% rename from e2e_redirect_test.go rename to cmd/http-assert/e2e_redirect_test.go diff --git a/e2e_repeat_test.go b/cmd/http-assert/e2e_repeat_test.go similarity index 100% rename from e2e_repeat_test.go rename to cmd/http-assert/e2e_repeat_test.go diff --git a/e2e_retry_test.go b/cmd/http-assert/e2e_retry_test.go similarity index 100% rename from e2e_retry_test.go rename to cmd/http-assert/e2e_retry_test.go diff --git a/e2e_server_test.go b/cmd/http-assert/e2e_server_test.go similarity index 100% rename from e2e_server_test.go rename to cmd/http-assert/e2e_server_test.go diff --git a/e2e_status_test.go b/cmd/http-assert/e2e_status_test.go similarity index 100% rename from e2e_status_test.go rename to cmd/http-assert/e2e_status_test.go diff --git a/e2e_version_test.go b/cmd/http-assert/e2e_version_test.go similarity index 100% rename from e2e_version_test.go rename to cmd/http-assert/e2e_version_test.go diff --git a/cmd/http-assert/failure.go b/cmd/http-assert/failure.go new file mode 100644 index 0000000..1bf583d --- /dev/null +++ b/cmd/http-assert/failure.go @@ -0,0 +1,119 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + ha "github.com/korya/http-assert" +) + +// formatFailure is deliberately part of the command, not the library. The +// library reports stable fields; this artifact owns the human wording and can +// evolve it without making presentation part of the public API contract. +func formatFailure(f *ha.Failure, res *ha.Response) string { + if f == nil { + return "assertion failed" + } + + expected := fmt.Sprint(f.Expected) + switch f.Code { + case ha.FailureStatusOK: + return fmt.Sprintf("ok: expected OK, got %v (%q)", f.Actual, responseStatus(res)) + case ha.FailureStatusNOK: + return fmt.Sprintf("nok: expected NOK, got %v (%q)", f.Actual, responseStatus(res)) + case ha.FailureStatus: + return fmt.Sprintf("status: expected %s, got %v (%q)", expected, f.Actual, responseStatus(res)) + case ha.FailureHeaderPresent: + return fmt.Sprintf("header[%s]: expected to be present, missing", f.Target) + case ha.FailureHeaderMissing: + return fmt.Sprintf("header[%s]: expected to be missing, got %s", f.Target, headerValues(f.Actual)) + case ha.FailureHeaderEqual: + if f.Actual == nil { + return fmt.Sprintf("header[%s]: expected %q, missing", f.Target, expected) + } + return fmt.Sprintf("header[%s]: expected %q, got %s", f.Target, expected, headerValues(f.Actual)) + case ha.FailureHeaderMatch: + if f.Actual == nil { + return fmt.Sprintf("header[%s]: expected to match %q, missing", f.Target, expected) + } + return fmt.Sprintf("header[%s]: expected to match %q, got %s", f.Target, expected, headerValues(f.Actual)) + case ha.FailureBodyEmpty: + return fmt.Sprintf("body: expected to be empty, got %q", f.Actual) + case ha.FailureBodyNotEmpty: + return "body: expected to be non-empty, got nothing" + case ha.FailureBodyEqual: + if f.Actual == nil { + return fmt.Sprintf("body: expected %q, missing", expected) + } + return fmt.Sprintf("body: expected %q, got %q", expected, f.Actual) + case ha.FailureBodyMatch: + if f.Actual == nil { + return fmt.Sprintf("body: expected to match %q, missing", expected) + } + return fmt.Sprintf("body: expected to match %q, got %q", expected, f.Actual) + case ha.FailureJQValue: + return fmt.Sprintf("jq[%s]: expected true, got %s", f.Target, jqValue(f.Actual)) + case ha.FailureJQNoOutput: + return fmt.Sprintf("jq[%s]: expected true, got no output", f.Target) + case ha.FailureRedirectStatus: + return fmt.Sprintf("redirect: wrong HTTP status: got %v (%q)", f.Actual, responseStatus(res)) + case ha.FailureRedirectLocationAbsent: + return "redirect: no Location header" + case ha.FailureRedirectEqual: + return fmt.Sprintf("redirect: wrong Location: expected %q, got %q", expected, f.Actual) + case ha.FailureRedirectMatch: + return fmt.Sprintf("redirect: wrong Location: expected to match %q, got %q", expected, f.Actual) + default: + return fmt.Sprintf("%s assertion failed: expected %v, got %v", f.Kind, f.Expected, f.Actual) + } +} + +func formatEvaluationError(err error) string { + var evaluation *ha.EvaluationError + if !errors.As(err, &evaluation) { + return err.Error() + } + + switch evaluation.Code { + case ha.EvaluationBodyDecode: + return fmt.Sprintf("body: response is %s-encoded and was not decoded: %s", + evaluation.Encoding, evaluation.Cause) + case ha.EvaluationJSON: + return fmt.Sprintf("body: expected JSON, got %s", evaluation.Cause) + case ha.EvaluationJQ: + return fmt.Sprintf("jq[%s]: %s", evaluation.Target, evaluation.Cause) + default: + return err.Error() + } +} + +func responseStatus(res *ha.Response) string { + if res == nil || res.Response == nil { + return "" + } + return res.Status +} + +func headerValues(value any) string { + values, ok := value.([]string) + if !ok { + return fmt.Sprintf("%q", value) + } + + quoted := make([]string, len(values)) + for i, value := range values { + quoted[i] = strconv.Quote(value) + } + return strings.Join(quoted, ", ") +} + +func jqValue(value any) string { + b, err := json.Marshal(value) + if err != nil { + return fmt.Sprintf("%v", value) + } + return string(b) +} diff --git a/cmd/http-assert/failure_test.go b/cmd/http-assert/failure_test.go new file mode 100644 index 0000000..fe5e06c --- /dev/null +++ b/cmd/http-assert/failure_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "errors" + "fmt" + "net/http" + "testing" + + ha "github.com/korya/http-assert" +) + +func TestFormatFailure(t *testing.T) { + t.Parallel() + + res := &ha.Response{Response: &http.Response{Status: "500 Internal Server Error"}} + tests := []struct { + name string + failure *ha.Failure + want string + }{ + {"nil", nil, "assertion failed"}, + {"ok", &ha.Failure{Code: ha.FailureStatusOK, Actual: 500}, `ok: expected OK, got 500 ("500 Internal Server Error")`}, + {"nok", &ha.Failure{Code: ha.FailureStatusNOK, Actual: 200}, `nok: expected NOK, got 200 ("500 Internal Server Error")`}, + {"status", &ha.Failure{Code: ha.FailureStatus, Expected: "2xx", Actual: 500}, `status: expected 2xx, got 500 ("500 Internal Server Error")`}, + {"header present", &ha.Failure{Code: ha.FailureHeaderPresent, Target: "X-ID"}, `header[X-ID]: expected to be present, missing`}, + {"header missing", &ha.Failure{Code: ha.FailureHeaderMissing, Target: "X-ID", Actual: []string{"a", "b"}}, `header[X-ID]: expected to be missing, got "a", "b"`}, + {"header equal missing", &ha.Failure{Code: ha.FailureHeaderEqual, Target: "X-ID", Expected: "a"}, `header[X-ID]: expected "a", missing`}, + {"header equal differs", &ha.Failure{Code: ha.FailureHeaderEqual, Target: "X-ID", Expected: "a", Actual: []string{"b"}}, `header[X-ID]: expected "a", got "b"`}, + {"header match missing", &ha.Failure{Code: ha.FailureHeaderMatch, Target: "X-ID", Expected: "^a"}, `header[X-ID]: expected to match "^a", missing`}, + {"header match differs", &ha.Failure{Code: ha.FailureHeaderMatch, Target: "X-ID", Expected: "^a", Actual: []string{"b"}}, `header[X-ID]: expected to match "^a", got "b"`}, + {"body empty", &ha.Failure{Code: ha.FailureBodyEmpty, Actual: "body"}, `body: expected to be empty, got "body"`}, + {"body nonempty", &ha.Failure{Code: ha.FailureBodyNotEmpty}, `body: expected to be non-empty, got nothing`}, + {"body equal missing", &ha.Failure{Code: ha.FailureBodyEqual, Expected: "body"}, `body: expected "body", missing`}, + {"body equal differs", &ha.Failure{Code: ha.FailureBodyEqual, Expected: "want", Actual: "got"}, `body: expected "want", got "got"`}, + {"body match missing", &ha.Failure{Code: ha.FailureBodyMatch, Expected: "^body$"}, `body: expected to match "^body$", missing`}, + {"body match differs", &ha.Failure{Code: ha.FailureBodyMatch, Expected: "^want$", Actual: "got"}, `body: expected to match "^want$", got "got"`}, + {"jq value", &ha.Failure{Code: ha.FailureJQValue, Target: ".ok", Actual: false}, `jq[.ok]: expected true, got false`}, + {"jq no output", &ha.Failure{Code: ha.FailureJQNoOutput, Target: ".items[]"}, `jq[.items[]]: expected true, got no output`}, + {"redirect status", &ha.Failure{Code: ha.FailureRedirectStatus, Actual: 500}, `redirect: wrong HTTP status: got 500 ("500 Internal Server Error")`}, + {"redirect absent", &ha.Failure{Code: ha.FailureRedirectLocationAbsent}, `redirect: no Location header`}, + {"redirect equal", &ha.Failure{Code: ha.FailureRedirectEqual, Expected: "/want", Actual: "/got"}, `redirect: wrong Location: expected "/want", got "/got"`}, + {"redirect match", &ha.Failure{Code: ha.FailureRedirectMatch, Expected: "^/want", Actual: "/got"}, `redirect: wrong Location: expected to match "^/want", got "/got"`}, + {"unknown", &ha.Failure{Kind: "custom", Code: "custom", Expected: 1, Actual: 2}, `custom assertion failed: expected 1, got 2`}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := formatFailure(tc.failure, res); got != tc.want { + t.Errorf("formatFailure() = %q, want %q", got, tc.want) + } + }) + } + + if got := responseStatus(nil); got != "" { + t.Errorf("responseStatus(nil) = %q", got) + } + if got := responseStatus(&ha.Response{}); got != "" { + t.Errorf("responseStatus(empty) = %q", got) + } + if got := headerValues("odd"); got != `"odd"` { + t.Errorf("headerValues(fallback) = %q", got) + } + unencodable := make(chan int) + if got, want := jqValue(unencodable), fmt.Sprint(unencodable); got != want { + t.Errorf("jqValue(unencodable) = %q, want %q", got, want) + } +} + +func TestFormatEvaluationError(t *testing.T) { + t.Parallel() + + cause := errors.New("cause") + tests := []struct { + name string + err error + want string + }{ + {"plain", cause, "cause"}, + {"body decode", &ha.EvaluationError{Code: ha.EvaluationBodyDecode, Encoding: "gzip", Cause: cause}, "body: response is gzip-encoded and was not decoded: cause"}, + {"json", &ha.EvaluationError{Code: ha.EvaluationJSON, Cause: cause}, "body: expected JSON, got cause"}, + {"jq", &ha.EvaluationError{Code: ha.EvaluationJQ, Target: ".ok", Cause: cause}, "jq[.ok]: cause"}, + {"unknown", &ha.EvaluationError{Code: "custom", Cause: cause}, "cause"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := formatEvaluationError(tc.err); got != tc.want { + t.Errorf("formatEvaluationError() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/flags_test.go b/cmd/http-assert/flags_test.go similarity index 100% rename from flags_test.go rename to cmd/http-assert/flags_test.go diff --git a/fuzz_test.go b/cmd/http-assert/fuzz_test.go similarity index 100% rename from fuzz_test.go rename to cmd/http-assert/fuzz_test.go diff --git a/cmd/http-assert/helpers_test.go b/cmd/http-assert/helpers_test.go new file mode 100644 index 0000000..ab98b7e --- /dev/null +++ b/cmd/http-assert/helpers_test.go @@ -0,0 +1,21 @@ +package main + +import "testing" + +func checkErr(t *testing.T, label string, err error, want string) { + t.Helper() + + if want == "" { + if err != nil { + t.Errorf("%s: unexpected error: %s", label, err) + } + return + } + if err == nil { + t.Errorf("%s: expected error %q, got nil", label, want) + return + } + if got := err.Error(); got != want { + t.Errorf("%s: error = %q, want %q", label, got, want) + } +} diff --git a/main.go b/cmd/http-assert/main.go similarity index 86% rename from main.go rename to cmd/http-assert/main.go index 8d7c557..6dac03c 100644 --- a/main.go +++ b/cmd/http-assert/main.go @@ -89,12 +89,8 @@ package main import ( "bytes" - "compress/flate" - "compress/gzip" - "compress/zlib" "context" "crypto/tls" - "encoding/json" "errors" "fmt" "io" @@ -107,8 +103,7 @@ import ( "strings" "time" - "github.com/andybalholm/brotli" - "github.com/klauspost/compress/zstd" + ha "github.com/korya/http-assert" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -879,7 +874,7 @@ func checkRepeats(fs *pflag.FlagSet) { // Without this the pattern reached regexp.MustCompile and the process died with // a stack trace and exit code 2, which is not part of the documented contract // and gave the user no idea which flag was at fault (#17). -func mustCompileAssertion(flag, pattern string, build func(string) (Assertion, error)) Assertion { +func mustCompileAssertion(flag, pattern string, build func(string) (ha.Assertion, error)) ha.Assertion { a, err := build(pattern) if err != nil { dief(exitBadInvocation, "Invalid value for %s flag: %s", flag, err) @@ -901,40 +896,36 @@ func mustCompileAssertion(flag, pattern string, build func(string) (Assertion, e // flags drifted apart because nothing connected them; a helper is what connects // them, in the same way rejectRepeats derives from the flag's type rather than // from a list. -func boolAssertion(cmd *cobra.Command, name string, whenTrue, whenFalse func() Assertion) []Assertion { +func boolAssertion(cmd *cobra.Command, name string, whenTrue, whenFalse func() ha.Assertion) []ha.Assertion { if !cmd.Flags().Changed(name) { return nil } if v, _ := cmd.Flags().GetBool(name); v { - return []Assertion{whenTrue()} + return []ha.Assertion{whenTrue()} } - return []Assertion{whenFalse()} + return []ha.Assertion{whenFalse()} } -func parseAssertionFlags(cmd *cobra.Command) []Assertion { - var res []Assertion +func parseAssertionFlags(cmd *cobra.Command) []ha.Assertion { + var res []ha.Assertion - res = append(res, boolAssertion(cmd, "assert-ok", AssertStatusOK, AssertStatusNOK)...) - res = append(res, boolAssertion(cmd, "assert-body-empty", AssertBodyEmpty, AssertBodyNotEmpty)...) + res = append(res, boolAssertion(cmd, "assert-ok", ha.AssertStatusOK, ha.AssertStatusNOK)...) + res = append(res, boolAssertion(cmd, "assert-body-empty", ha.AssertBodyEmpty, ha.AssertBodyNotEmpty)...) if cmd.Flags().Changed("assert-redirect") { v, _ := cmd.Flags().GetString("assert-redirect") - res = append(res, mustCompileAssertion("--assert-redirect", v, AssertRedirectMatch)) + res = append(res, mustCompileAssertion("--assert-redirect", v, ha.AssertRedirectMatch)) } if cmd.Flags().Changed("assert-redirect-eq") { v, _ := cmd.Flags().GetString("assert-redirect-eq") - res = append(res, AssertRedirectEqual(v)) + res = append(res, ha.AssertRedirectEqual(v)) } if cmd.Flags().Changed("assert-status") { v, _ := cmd.Flags().GetString("assert-status") - spec, err := parseStatusSpec(v) - if err != nil { - dief(exitBadInvocation, "Invalid value for --assert-status flag: %s", err) - } - res = append(res, AssertStatus(spec)) + res = append(res, mustCompileAssertion("--assert-status", v, ha.AssertStatus)) } if cmd.Flags().Changed("assert-header") { @@ -948,17 +939,17 @@ func parseAssertionFlags(cmd *cobra.Command) []Assertion { if cmd.Flags().Changed("assert-header-missing") { vs, _ := cmd.Flags().GetStringArray("assert-header-missing") for _, v := range vs { - res = append(res, AssertHeaderMissing(strings.TrimSpace(v))) + res = append(res, ha.AssertHeaderMissing(strings.TrimSpace(v))) } } if cmd.Flags().Changed("assert-body") { v, _ := cmd.Flags().GetString("assert-body") - res = append(res, mustCompileAssertion("--assert-body", v, AssertBodyMatch)) + res = append(res, mustCompileAssertion("--assert-body", v, ha.AssertBodyMatch)) } if cmd.Flags().Changed("assert-body-eq") { v, _ := cmd.Flags().GetString("assert-body-eq") - res = append(res, AssertBodyEqual(v)) + res = append(res, ha.AssertBodyEqual(v)) } // One assertion per occurrence. --assert-jq is a stringArray, so rejectRepeats @@ -967,30 +958,30 @@ func parseAssertionFlags(cmd *cobra.Command) []Assertion { if cmd.Flags().Changed("assert-jq") { vs, _ := cmd.Flags().GetStringArray("assert-jq") for _, v := range vs { - res = append(res, mustCompileAssertion("--assert-jq", v, AssertJQ)) + res = append(res, mustCompileAssertion("--assert-jq", v, ha.AssertJQ)) } } return res } -func parseHeaderAssertions(vs []string, exactMatch bool) []Assertion { - var res []Assertion +func parseHeaderAssertions(vs []string, exactMatch bool) []ha.Assertion { + var res []ha.Assertion for _, v := range vs { name, value := parseHeaderLine(v) if exactMatch { if value == "" { - res = append(res, AssertHeaderPresent(name)) + res = append(res, ha.AssertHeaderPresent(name)) } else { - res = append(res, AssertHeaderEqual(name, value)) + res = append(res, ha.AssertHeaderEqual(name, value)) } } else { if value == "" { - res = append(res, AssertHeaderPresent(name)) + res = append(res, ha.AssertHeaderPresent(name)) } else { res = append(res, mustCompileAssertion("--assert-header", value, - func(p string) (Assertion, error) { return AssertHeaderMatch(name, p) })) + func(p string) (ha.Assertion, error) { return ha.AssertHeaderMatch(name, p) })) } } } @@ -1047,7 +1038,7 @@ var errTooManyRedirects = errors.New("too many redirects") // case retrying exists for is waiting for a service to come up, and there the // response usually arrives perfectly well and says the wrong thing, so a rule // that retried only transport errors would miss the whole point. -func (c Client) Do(req *http.Request, assertions ...Assertion) error { +func (c Client) Do(req *http.Request, assertions ...ha.Assertion) error { if len(assertions) == 0 { // Not a failed attempt but a malformed invocation, so it is reported // once rather than retried into the ground. The CLI checks this before @@ -1099,7 +1090,7 @@ func (c Client) giveUp(attempts int, limit string, err error) error { } // doOnce performs one request and checks it against every assertion. -func (c Client) doOnce(client *http.Client, req *http.Request, assertions []Assertion) error { +func (c Client) doOnce(client *http.Client, req *http.Request, assertions []ha.Assertion) error { next, err := cloneForAttempt(req) if err != nil { var b strings.Builder @@ -1119,7 +1110,7 @@ func (c Client) doOnce(client *http.Client, req *http.Request, assertions []Asse // the server, not the operator. It stays opt-in for exactly that reason, // and net/http drops Authorization and Cookie when a hop leaves the // original domain, so credentials passed with -H do not travel. - res, err := client.Do(req) // #nosec G704 - user asked for this URL + result, err := (ha.Client{HTTPClient: client}).Do(req, assertions...) // #nosec G704 - user asked for this URL if err != nil { var b strings.Builder // The transport did its job here; this program stopped the chain. @@ -1139,37 +1130,28 @@ func (c Client) doOnce(client *http.Client, req *http.Request, assertions []Asse c.writeHttpDetails(&b, req, nil) return &exitError{exitTransportFail, b.String()} } - defer func() { _ = res.Body.Close() }() - - c.logInfo("[:] %s %s\n", res.Proto, res.Status) - httpRes := &httpResponse{Response: res} - httpRes.BodyBytes, _ = io.ReadAll(res.Body) - httpRes.decodeBody() - - var assertErrors []error - for i := range assertions { - // A failed assertion and one that could not be evaluated are both - // failures of the run and both print the same way, so they share a - // list -- which is also what keeps the dump in the order the - // assertions were given. Only a machine-readable consumer needs to - // tell them apart, and that is what Check separates them for (#45). - f, err := assertions[i].Check(httpRes) - switch { - case err != nil: - assertErrors = append(assertErrors, err) - case f != nil: - assertErrors = append(assertErrors, f) + + c.logInfo("[:] %s %s\n", result.Response.Proto, result.Response.Status) + failed := 0 + for _, outcome := range result.Outcomes { + if !outcome.Passed() { + failed++ } } - if len(assertErrors) > 0 { + if failed > 0 { c.logInfo("[-] FAILED %s\n\n", time.Since(startedAt)) var b strings.Builder - fmt.Fprintf(&b, "%d assertions failed:\n", len(assertErrors)) - for i := range assertErrors { - fmt.Fprintf(&b, "- %s\n", assertErrors[i]) + fmt.Fprintf(&b, "%d assertions failed:\n", failed) + for _, outcome := range result.Outcomes { + switch { + case outcome.Err != nil: + fmt.Fprintf(&b, "- %s\n", formatEvaluationError(outcome.Err)) + case outcome.Failure != nil: + fmt.Fprintf(&b, "- %s\n", formatFailure(outcome.Failure, result.Response)) + } } - c.writeHttpDetails(&b, req, httpRes) + c.writeHttpDetails(&b, req, result.Response) return &exitError{exitAssertFail, b.String()} } @@ -1202,7 +1184,7 @@ func cloneForAttempt(req *http.Request) (*http.Request, error) { return res, nil } -func (c Client) writeHttpDetails(w io.Writer, req *http.Request, res *httpResponse) { +func (c Client) writeHttpDetails(w io.Writer, req *http.Request, res *ha.Response) { _, _ = fmt.Fprintf(w, "\nFAILED: %s %s (%s)\n\n", req.Method, req.URL, req.Proto) // With --location the response below came from somewhere else, and the // request dumped after this is the one that started the chain rather than @@ -1214,7 +1196,7 @@ func (c Client) writeHttpDetails(w io.Writer, req *http.Request, res *httpRespon writeRequest(w, req) _, _ = w.Write([]byte("\n\n")) if res != nil { - res.writeTo(w, c.LogLevel >= LInfo) + writeResponse(w, res, c.LogLevel >= LInfo) _, _ = w.Write([]byte("\n\n")) } } @@ -1275,8 +1257,8 @@ func (c Client) getHttpClient() *http.Client { // saw the payload or a compressed blob depended on flags that have // nothing to do with the body (#27). // - // Decoding is done here instead, in decodeBody, on every response. One - // path, and the request carries exactly the headers it was told to. + // Decoding is done by the library on every response instead. One path, + // and the request carries exactly the headers it was told to. DisableCompression: true, MaxIdleConns: 10, IdleConnTimeout: 20 * time.Second, @@ -1344,172 +1326,10 @@ func (c Client) log(l LogLevel, format string, args ...interface{}) { fmt.Fprint(os.Stderr, c.Palette.line(fmt.Sprintf(format, args...))) } -type httpResponse struct { - *http.Response - BodyBytes []byte - // Encoding is the response's Content-Encoding, verbatim, and empty when - // there was none. - // - // The header itself is left alone. net/http deletes it (and Content-Length) - // when it decodes, which makes a response that was compressed - // indistinguishable from one that never was -- and the whole reason to set - // Accept-Encoding by hand is to find out which happened. - Encoding string - // DecodeErr is why BodyBytes is still encoded. Nil means BodyBytes is the - // payload, whether or not anything had to be removed to get there. - DecodeErr error - // The decoded JSON body, filled by decodeJSON on first use. Plain fields - // rather than a sync.Once because httpResponse is passed around by value in - // places, and a value copy of a mutex is what go vet exists to catch. - jsonBody any - jsonErr error - jsonParsed bool -} - -// decodeJSON decodes the body as JSON once and shares the result. -// -// Every --assert-jq in a run reads the same response, so ten queries should -// parse it once rather than ten times. Failure is reported as a property of the -// body, not of the query: a response that is not JSON fails every jq assertion -// for the same reason, and saying so once per assertion is clearer than saying -// the expression did not hold. -func (r *httpResponse) decodeJSON() (any, error) { - if r.jsonParsed { - return r.jsonBody, r.jsonErr - } - r.jsonParsed = true - - // Through bodyOf like every other body assertion, so a body that is still - // compressed reports that rather than reporting invalid JSON (#27). - body, err := bodyOf(r) - if err != nil { - r.jsonErr = err - return nil, r.jsonErr - } - - if err := json.Unmarshal(body, &r.jsonBody); err != nil { - r.jsonErr = fmt.Errorf("body: expected JSON, got %s", err) - return nil, r.jsonErr - } - - return r.jsonBody, nil -} - -// decoders maps a Content-Encoding to something that removes it. Content -// coding names are case-insensitive per RFC 9110, so lookups are lowered. -// -// deflate is absent by name because it is two formats: RFC 9110 says zlib, and -// a good deal of the web sends raw. decodeDeflate tries both. -var decoders = map[string]func([]byte) ([]byte, error){ - "gzip": decodeGzip, - "deflate": decodeDeflate, - "br": decodeBrotli, - "zstd": decodeZstd, -} - -// supportedCodings names the decoders in a stable order, so the failure for an -// encoding with no decoder can say what it does have without drifting from the -// map as it grows. -func supportedCodings() string { - return strings.Join(slices.Sorted(maps.Keys(decoders)), ", ") -} - -// decodeBody removes the Content-Encoding from BodyBytes, leaving every header -// exactly as it arrived. -// -// An encoding nothing here can remove is not an error by itself: a response -// body the tool cannot read still has a status and headers worth asserting on. -// It is recorded instead, and only the body assertions refuse (see bodyOf). -func (r *httpResponse) decodeBody() { - r.Encoding = strings.TrimSpace(r.Header.Get("Content-Encoding")) - - // An empty body has nothing to decode, and an empty gzip stream is an error - // rather than an empty payload -- so a 204 that carries the header anyway - // must not fail --assert-body-empty. - if len(r.BodyBytes) == 0 { - return - } - - switch enc := strings.ToLower(r.Encoding); enc { - case "", "identity": - return - default: - decode, ok := decoders[enc] - if !ok { - r.DecodeErr = fmt.Errorf("no decoder for %q; %s are supported", r.Encoding, supportedCodings()) - return - } - - b, err := decode(r.BodyBytes) - if err != nil { - r.DecodeErr = err - return - } - r.BodyBytes = b - } -} - -// decodeBrotli removes a brotli coding. There is no brotli in the standard -// library, which is the whole reason this took a dependency; andybalholm/brotli -// is pure Go and brings nothing else with it. -func decodeBrotli(b []byte) ([]byte, error) { - return io.ReadAll(brotli.NewReader(bytes.NewReader(b))) -} - -// decodeZstd removes a zstd coding (RFC 8878). -// -// klauspost/compress is a large repository, but only the zstd package links -// into the binary, so the cost is the decoder rather than the library. -func decodeZstd(b []byte) ([]byte, error) { - zr, err := zstd.NewReader(bytes.NewReader(b)) - if err != nil { - return nil, err - } - defer zr.Close() - - return io.ReadAll(zr) -} - -func decodeGzip(b []byte) ([]byte, error) { - zr, err := gzip.NewReader(bytes.NewReader(b)) - if err != nil { - return nil, err - } - defer func() { _ = zr.Close() }() - - return io.ReadAll(zr) -} - -// decodeDeflate tries zlib first and raw DEFLATE second. -// -// RFC 9110 defines the deflate coding as the zlib format, but servers sending -// raw DEFLATE under the same name are common enough that net/http declines to -// negotiate it at all ("Deflate is ambiguous and not as universally supported -// anyway", transport.go). Guessing is safe here because neither reader accepts -// the other's input: a wrong guess fails rather than producing plausible bytes. -func decodeDeflate(b []byte) ([]byte, error) { - if zr, err := zlib.NewReader(bytes.NewReader(b)); err == nil { - defer func() { _ = zr.Close() }() - if out, err := io.ReadAll(zr); err == nil { - return out, nil - } - } - - fr := flate.NewReader(bytes.NewReader(b)) - defer func() { _ = fr.Close() }() - - out, err := io.ReadAll(fr) - if err != nil { - return nil, fmt.Errorf("not valid zlib or raw DEFLATE: %w", err) - } - - return out, nil -} - // maxPayloadBytes is how much of a body the failure dump shows before cropping. const maxPayloadBytes = 256 -// writeTo renders the response for a person reading a failure report. +// writeResponse renders the response for a person reading a failure report. // // Deliberately not http.Response.Write. That is a wire-format serializer: it // honours ContentLength and Transfer-Encoding, which describe the body that @@ -1523,7 +1343,7 @@ const maxPayloadBytes = 256 // // Write errors are ignored, following utils.go, because every caller renders // into an in-memory strings.Builder that cannot fail. -func (r httpResponse) writeTo(w io.Writer, withBody bool) { +func writeResponse(w io.Writer, r *ha.Response, withBody bool) { _, _ = fmt.Fprintf(w, "%s %s\n", r.Proto, r.Status) writeHeaders(w, r.Header) _, _ = fmt.Fprintln(w) diff --git a/main_test.go b/cmd/http-assert/main_test.go similarity index 100% rename from main_test.go rename to cmd/http-assert/main_test.go diff --git a/render_test.go b/cmd/http-assert/render_test.go similarity index 92% rename from render_test.go rename to cmd/http-assert/render_test.go index 99fae3e..8a60682 100644 --- a/render_test.go +++ b/cmd/http-assert/render_test.go @@ -5,12 +5,14 @@ import ( "net/http" "strings" "testing" + + ha "github.com/korya/http-assert" ) // response builds the shape Client.Do hands to the renderer: a real // *http.Response plus the body already read off the wire. -func response(status string, header http.Header, body string) httpResponse { - return httpResponse{ +func response(status string, header http.Header, body string) ha.Response { + return ha.Response{ Response: &http.Response{ Proto: "HTTP/1.1", Status: status, @@ -25,14 +27,14 @@ func response(status string, header http.Header, body string) httpResponse { } } -func Test_httpResponse_writeTo(t *testing.T) { +func Test_writeResponse(t *testing.T) { t.Parallel() plain := http.Header{"Content-Type": {"text/plain"}} tests := []struct { Name string - Response httpResponse + Response ha.Response WithBody bool Want string }{{ @@ -81,7 +83,7 @@ func Test_httpResponse_writeTo(t *testing.T) { for _, tc := range tests { t.Run(tc.Name, func(t *testing.T) { var b strings.Builder - tc.Response.writeTo(&b, tc.WithBody) + writeResponse(&b, &tc.Response, tc.WithBody) if got := b.String(); got != tc.Want { t.Errorf("writeTo()\n got: %q\nwant: %q", got, tc.Want) @@ -90,14 +92,15 @@ func Test_httpResponse_writeTo(t *testing.T) { } } -// Test_httpResponse_writeToCrops covers the branch that reports hidden bytes. +// Test_writeResponseCrops covers the branch that reports hidden bytes. // It is separate because the expected text depends on the crop limit. -func Test_httpResponse_writeToCrops(t *testing.T) { +func Test_writeResponseCrops(t *testing.T) { t.Parallel() body := strings.Repeat("x", maxPayloadBytes+17) var b strings.Builder - response("200 OK", http.Header{}, body).writeTo(&b, true) + res := response("200 OK", http.Header{}, body) + writeResponse(&b, &res, true) got := b.String() if want := strings.Repeat("x", maxPayloadBytes); !strings.Contains(got, want) { @@ -116,8 +119,8 @@ func Test_writeTo_ignoresTransportFraming(t *testing.T) { t.Parallel() var b strings.Builder - response("200 OK", http.Header{"Content-Type": {"text/plain"}}, "boom"). - writeTo(&b, false) + res := response("200 OK", http.Header{"Content-Type": {"text/plain"}}, "boom") + writeResponse(&b, &res, false) for _, unwanted := range []string{"chunked", "Transfer-Encoding", "\r"} { if strings.Contains(b.String(), unwanted) { diff --git a/retry_test.go b/cmd/http-assert/retry_test.go similarity index 67% rename from retry_test.go rename to cmd/http-assert/retry_test.go index bdb8ef0..73e7f75 100644 --- a/retry_test.go +++ b/cmd/http-assert/retry_test.go @@ -1,10 +1,13 @@ package main import ( + "errors" "io" "net/http" "strings" "testing" + + ha "github.com/korya/http-assert" ) // cloneForAttempt is the piece of retrying that cannot be observed from the @@ -93,3 +96,44 @@ func Test_cloneForAttempt_isolatesHeaders(t *testing.T) { t.Errorf("the original gained a header from its clone: %q", got) } } + +func TestClientDoRejectsNoAssertions(t *testing.T) { + t.Parallel() + + req, err := http.NewRequest(http.MethodGet, "http://example.com/", nil) + if err != nil { + t.Fatalf("cannot build the request: %s", err) + } + + err = (Client{}).Do(req) + var exit *exitError + if !errors.As(err, &exit) { + t.Fatalf("Do error = %T, want *exitError", err) + } + if exit.code != exitBadInvocation || exit.msg != "no assertions defined" { + t.Errorf("Do error = %+v", exit) + } +} + +func TestDoOnceReportsBodyReplayFailure(t *testing.T) { + t.Parallel() + + req, err := http.NewRequest(http.MethodPost, "http://example.com/", strings.NewReader("payload")) + if err != nil { + t.Fatalf("cannot build the request: %s", err) + } + want := errors.New("cannot rewind") + req.GetBody = func() (io.ReadCloser, error) { return nil, want } + + err = (Client{}).doOnce(&http.Client{}, req, []ha.Assertion{ha.AssertStatusOK()}) + var exit *exitError + if !errors.As(err, &exit) { + t.Fatalf("doOnce error = %T, want *exitError", err) + } + if exit.code != exitTransportFail { + t.Errorf("exit code = %d, want %d", exit.code, exitTransportFail) + } + if !strings.Contains(exit.msg, "failed to rewind the request body:\n- cannot rewind") { + t.Errorf("error does not explain the replay failure:\n%s", exit.msg) + } +} diff --git a/utils.go b/cmd/http-assert/utils.go similarity index 100% rename from utils.go rename to cmd/http-assert/utils.go diff --git a/utils_test.go b/cmd/http-assert/utils_test.go similarity index 100% rename from utils_test.go rename to cmd/http-assert/utils_test.go diff --git a/version.go b/cmd/http-assert/version.go similarity index 100% rename from version.go rename to cmd/http-assert/version.go diff --git a/version_test.go b/cmd/http-assert/version_test.go similarity index 97% rename from version_test.go rename to cmd/http-assert/version_test.go index 855638b..eb9abed 100644 --- a/version_test.go +++ b/cmd/http-assert/version_test.go @@ -41,7 +41,8 @@ func Test_buildVersion(t *testing.T) { }, Want: "v0.1.0 (commit 4ffe282, built " + when + ", go1.25.5, " + platform + ")", }, { - // `go install github.com/korya/http-assert@v0.0.7`. The module version + // `go install github.com/korya/http-assert/cmd/http-assert@v0.0.7`. + // The module version // is authoritative and there is no VCS information to report. Name: "module install", Info: &debug.BuildInfo{ diff --git a/compression_test.go b/compression_test.go index 33dda65..3e14995 100644 --- a/compression_test.go +++ b/compression_test.go @@ -1,4 +1,4 @@ -package main +package httpassert import ( "bytes" @@ -7,6 +7,9 @@ import ( "compress/zlib" "net/http" "testing" + + "github.com/andybalholm/brotli" + "github.com/klauspost/compress/zstd" ) const payload = `{"status":"success"}` @@ -59,10 +62,41 @@ func deflatedZlib(t *testing.T, s string) []byte { return b.Bytes() } +func brotliEncoded(t *testing.T, s string) []byte { + t.Helper() + + var b bytes.Buffer + w := brotli.NewWriter(&b) + if _, err := w.Write([]byte(s)); err != nil { + t.Fatalf("cannot encode Brotli: %s", err) + } + if err := w.Close(); err != nil { + t.Fatalf("cannot close the Brotli writer: %s", err) + } + return b.Bytes() +} + +func zstdEncoded(t *testing.T, s string) []byte { + t.Helper() + + var b bytes.Buffer + w, err := zstd.NewWriter(&b) + if err != nil { + t.Fatalf("cannot build the zstd writer: %s", err) + } + if _, err := w.Write([]byte(s)); err != nil { + t.Fatalf("cannot encode zstd: %s", err) + } + if err := w.Close(); err != nil { + t.Fatalf("cannot close the zstd writer: %s", err) + } + return b.Bytes() +} + // encoded builds the shape decodeBody operates on: a body and the header that -// claims how it was encoded. It reuses render_test.go's response helper so both -// files describe an httpResponse the same way. -func encoded(enc string, body []byte) *httpResponse { +// claims how it was encoded. It reuses the shared response helper so the tests +// describe a Response consistently. +func encoded(enc string, body []byte) *Response { h := http.Header{} if enc != "" { h.Set("Content-Encoding", enc) @@ -127,6 +161,20 @@ func Test_decodeBody(t *testing.T) { Want: payload, WantEncoding: "gzip", }, + { + Name: "Brotli", + Enc: "br", + Body: brotliEncoded(t, payload), + Want: payload, + WantEncoding: "br", + }, + { + Name: "zstd", + Enc: "zstd", + Body: zstdEncoded(t, payload), + Want: payload, + WantEncoding: "zstd", + }, { // The two formats that share the deflate name. Neither reader // accepts the other's input, so trying both is safe. @@ -166,6 +214,27 @@ func Test_decodeBody(t *testing.T) { WantErr: true, WantEncoding: "gzip", }, + { + Name: "invalid Brotli", + Enc: "br", + Body: []byte{0xff}, + WantErr: true, + WantEncoding: "br", + }, + { + Name: "invalid zstd", + Enc: "zstd", + Body: []byte("not zstd"), + WantErr: true, + WantEncoding: "zstd", + }, + { + Name: "truncated zlib-wrapped deflate", + Enc: "deflate", + Body: deflatedZlib(t, payload)[:10], + WantErr: true, + WantEncoding: "deflate", + }, { // A 204 that carries the header anyway. There is nothing to decode, // and an empty gzip stream is an error rather than an empty @@ -268,9 +337,10 @@ func Test_bodyAssertionsRefuseAnEncodedBody(t *testing.T) { } assertions := map[string]Assertion{ - "AssertBodyMatch": match, - "AssertBodyEqual": AssertBodyEqual(payload), - "AssertBodyEmpty": AssertBodyEmpty(), + "AssertBodyMatch": match, + "AssertBodyEqual": AssertBodyEqual(payload), + "AssertBodyEmpty": AssertBodyEmpty(), + "AssertBodyNotEmpty": AssertBodyNotEmpty(), } for name, a := range assertions { diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..182db82 --- /dev/null +++ b/doc.go @@ -0,0 +1,8 @@ +// Package httpassert performs an HTTP request and evaluates structured +// assertions against the response. +// +// Each call invokes the configured HTTP client once and never retries. The +// client's redirect policy may still produce a redirect chain. The package +// does not log, format results, or terminate the process; applications retain +// control over those policies. +package httpassert diff --git a/helpers_test.go b/helpers_test.go index cfb2bd0..48ea3b7 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -1,10 +1,33 @@ -package main +package httpassert import ( + "encoding/json" + "errors" + "fmt" + "net/http" "regexp" + "strconv" + "strings" "testing" ) +type textError string + +func (e textError) Error() string { return string(e) } + +// response builds the shape Client.Do hands to assertions: the standard +// response metadata plus a body already read off the wire. +func response(status string, header http.Header, body string) Response { + return Response{ + Response: &http.Response{ + Proto: "HTTP/1.1", + Status: status, + Header: header, + }, + BodyBytes: []byte(body), + } +} + // checkErr asserts that err says exactly what the caller expects. An empty want // means "no error at all". // @@ -27,7 +50,7 @@ func checkErr(t *testing.T, label string, err error, want string) { return } - if got := err.Error(); got != want { + if got := testErrorText(err); got != want { t.Errorf("%s: error = %q, want %q", label, got, want) } } @@ -42,10 +65,11 @@ func checkErrMatch(t *testing.T, label string, err error, pattern string) { return } - if ok, reErr := regexp.MatchString(pattern, err.Error()); reErr != nil { + got := testErrorText(err) + if ok, reErr := regexp.MatchString(pattern, got); reErr != nil { t.Fatalf("%s: bad pattern %q: %s", label, pattern, reErr) } else if !ok { - t.Errorf("%s: error = %q, want match %q", label, err.Error(), pattern) + t.Errorf("%s: error = %q, want match %q", label, got, pattern) } } @@ -55,14 +79,105 @@ func checkErrMatch(t *testing.T, label string, err error, pattern string) { // The nil guard is load-bearing: returning a nil *Failure through an error // interface yields a non-nil error holding a nil pointer, which would fail // every "expected no error" case with an unreadable message. -func check(a Assertion, res *httpResponse) error { +func check(a Assertion, res *Response) error { f, err := a.Check(res) if err != nil { return err } if f != nil { - return f + return textError(testFailureText(f, res)) } return nil } + +// These assertion tables predate the reusable API and pin the CLI's wording. +// Deriving the text from the new structured failure proves that every former +// message remains representable without storing presentation in Failure. +func testFailureText(f *Failure, res *Response) string { + expected := fmt.Sprint(f.Expected) + switch f.Code { + case FailureStatusOK: + return fmt.Sprintf("ok: expected OK, got %v (%q)", f.Actual, res.Status) + case FailureStatusNOK: + return fmt.Sprintf("nok: expected NOK, got %v (%q)", f.Actual, res.Status) + case FailureStatus: + return fmt.Sprintf("status: expected %s, got %v (%q)", expected, f.Actual, res.Status) + case FailureHeaderPresent: + return fmt.Sprintf("header[%s]: expected to be present, missing", f.Target) + case FailureHeaderMissing: + return fmt.Sprintf("header[%s]: expected to be missing, got %s", f.Target, testHeaderValues(f.Actual)) + case FailureHeaderEqual: + if f.Actual == nil { + return fmt.Sprintf("header[%s]: expected %q, missing", f.Target, expected) + } + return fmt.Sprintf("header[%s]: expected %q, got %s", f.Target, expected, testHeaderValues(f.Actual)) + case FailureHeaderMatch: + if f.Actual == nil { + return fmt.Sprintf("header[%s]: expected to match %q, missing", f.Target, expected) + } + return fmt.Sprintf("header[%s]: expected to match %q, got %s", f.Target, expected, testHeaderValues(f.Actual)) + case FailureBodyEmpty: + return fmt.Sprintf("body: expected to be empty, got %q", f.Actual) + case FailureBodyNotEmpty: + return "body: expected to be non-empty, got nothing" + case FailureBodyEqual: + if f.Actual == nil { + return fmt.Sprintf("body: expected %q, missing", expected) + } + return fmt.Sprintf("body: expected %q, got %q", expected, f.Actual) + case FailureBodyMatch: + if f.Actual == nil { + return fmt.Sprintf("body: expected to match %q, missing", expected) + } + return fmt.Sprintf("body: expected to match %q, got %q", expected, f.Actual) + case FailureJQValue: + return fmt.Sprintf("jq[%s]: expected true, got %s", f.Target, testJQValue(f.Actual)) + case FailureJQNoOutput: + return fmt.Sprintf("jq[%s]: expected true, got no output", f.Target) + case FailureRedirectStatus: + return fmt.Sprintf("redirect: wrong HTTP status: got %v (%q)", f.Actual, res.Status) + case FailureRedirectLocationAbsent: + return "redirect: no Location header" + case FailureRedirectEqual: + return fmt.Sprintf("redirect: wrong Location: expected %q, got %q", expected, f.Actual) + case FailureRedirectMatch: + return fmt.Sprintf("redirect: wrong Location: expected to match %q, got %q", expected, f.Actual) + default: + return fmt.Sprintf("unknown failure code %q", f.Code) + } +} + +func testErrorText(err error) string { + var evaluation *EvaluationError + if !errors.As(err, &evaluation) { + return err.Error() + } + switch evaluation.Code { + case EvaluationBodyDecode: + return fmt.Sprintf("body: response is %s-encoded and was not decoded: %s", evaluation.Encoding, evaluation.Cause) + case EvaluationJSON: + return fmt.Sprintf("body: expected JSON, got %s", evaluation.Cause) + case EvaluationJQ: + return fmt.Sprintf("jq[%s]: %s", evaluation.Target, evaluation.Cause) + default: + return err.Error() + } +} + +func testHeaderValues(value any) string { + values := value.([]string) + quoted := make([]string, len(values)) + for i, value := range values { + quoted[i] = strconv.Quote(value) + } + return strings.Join(quoted, ", ") +} + +func testJQValue(value any) string { + b, err := json.Marshal(value) + if err != nil { + return fmt.Sprintf("%v", value) + } + return string(b) +} diff --git a/jq_test.go b/jq_test.go index ad7b6ea..913a180 100644 --- a/jq_test.go +++ b/jq_test.go @@ -1,4 +1,4 @@ -package main +package httpassert import ( "errors" @@ -15,7 +15,7 @@ const jqDoc = `{"status":"success","count":5,"active":true,"nothing":null, // jqResponse builds a response carrying the document above, or whatever body a // case needs. -func jqResponse(body string) *httpResponse { +func jqResponse(body string) *Response { r := response("200 OK", http.Header{}, "") r.BodyBytes = []byte(body) @@ -211,7 +211,7 @@ func Test_AssertJQ_boundsARunawayQuery(t *testing.T) { // only cares that it stopped, and why is asserted below. f, err := runJQ(code, query, jqResponse(jqDoc), short) if err == nil && f != nil { - err = f + err = textError("query returned an assertion failure") } done <- err }() diff --git a/response.go b/response.go new file mode 100644 index 0000000..e96f234 --- /dev/null +++ b/response.go @@ -0,0 +1,141 @@ +package httpassert + +import ( + "bytes" + "compress/flate" + "compress/gzip" + "compress/zlib" + "encoding/json" + "fmt" + "io" + "maps" + "net/http" + "slices" + "strings" + + "github.com/andybalholm/brotli" + "github.com/klauspost/compress/zstd" +) + +// Response is the response assertions inspect. Response.Body has already been +// consumed and closed; BodyBytes contains the decoded payload when DecodeErr is +// nil. The original headers, including Content-Encoding and Content-Length, +// remain unchanged. +type Response struct { + *http.Response + // BodyBytes is the complete decoded response payload when DecodeErr is + // nil. Client.Do reads it before evaluating any assertion. + BodyBytes []byte + // Encoding is the response's Content-Encoding value, with surrounding + // whitespace removed. An empty value means no encoding was declared. + Encoding string + // DecodeErr explains why BodyBytes could not be decoded. When non-nil, + // BodyBytes contains the encoded bytes exactly as received. + DecodeErr error + + jsonBody any + jsonErr error + jsonParsed bool +} + +func (r *Response) decodeJSON() (any, error) { + if r.jsonParsed { + return r.jsonBody, r.jsonErr + } + r.jsonParsed = true + + body, err := bodyOf(r) + if err != nil { + r.jsonErr = err + return nil, r.jsonErr + } + + if err := json.Unmarshal(body, &r.jsonBody); err != nil { + r.jsonErr = &EvaluationError{ + Code: EvaluationJSON, + Kind: "body", + Cause: err, + } + return nil, r.jsonErr + } + + return r.jsonBody, nil +} + +var decoders = map[string]func([]byte) ([]byte, error){ + "gzip": decodeGzip, + "deflate": decodeDeflate, + "br": decodeBrotli, + "zstd": decodeZstd, +} + +func supportedCodings() string { + return strings.Join(slices.Sorted(maps.Keys(decoders)), ", ") +} + +func (r *Response) decodeBody() { + r.Encoding = strings.TrimSpace(r.Header.Get("Content-Encoding")) + if len(r.BodyBytes) == 0 { + return + } + + switch enc := strings.ToLower(r.Encoding); enc { + case "", "identity": + return + default: + decode, ok := decoders[enc] + if !ok { + r.DecodeErr = fmt.Errorf("no decoder for %q; %s are supported", r.Encoding, supportedCodings()) + return + } + + body, err := decode(r.BodyBytes) + if err != nil { + r.DecodeErr = err + return + } + r.BodyBytes = body + } +} + +func decodeBrotli(body []byte) ([]byte, error) { + return io.ReadAll(brotli.NewReader(bytes.NewReader(body))) +} + +func decodeZstd(body []byte) ([]byte, error) { + // A response body is consumed synchronously before assertions run. One + // decoder worker avoids starting a background decode pipeline and excess + // per-response workers. + reader, err := zstd.NewReader(bytes.NewReader(body), zstd.WithDecoderConcurrency(1)) + if err != nil { + return nil, err + } + defer reader.Close() + return io.ReadAll(reader) +} + +func decodeGzip(body []byte) ([]byte, error) { + reader, err := gzip.NewReader(bytes.NewReader(body)) + if err != nil { + return nil, err + } + defer func() { _ = reader.Close() }() + return io.ReadAll(reader) +} + +func decodeDeflate(body []byte) ([]byte, error) { + if reader, err := zlib.NewReader(bytes.NewReader(body)); err == nil { + defer func() { _ = reader.Close() }() + if out, err := io.ReadAll(reader); err == nil { + return out, nil + } + } + + reader := flate.NewReader(bytes.NewReader(body)) + defer func() { _ = reader.Close() }() + out, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("not valid zlib or raw DEFLATE: %w", err) + } + return out, nil +} diff --git a/status_test.go b/status_test.go index a849ef6..bb80fe0 100644 --- a/status_test.go +++ b/status_test.go @@ -1,4 +1,4 @@ -package main +package httpassert import ( "strings" @@ -73,6 +73,8 @@ func Test_parseStatusSpec(t *testing.T) { {"000", "no response can carry status"}, {"0xx", "not a status class"}, {"403-401", "counts down"}, + {"abc-401", "not a three-digit status code"}, + {"401-abc", "not a three-digit status code"}, {"200,,204", "empty entry"}, {"200,", "empty entry"}, {"200-", "not a three-digit status code"}, @@ -93,4 +95,11 @@ func Test_parseStatusSpec(t *testing.T) { }) } }) + + t.Run("public constructor rejects an invalid spec", func(t *testing.T) { + assertion, err := AssertStatus("not-a-status") + if assertion != nil || err == nil { + t.Errorf("AssertStatus = (%v, %v), want (nil, error)", assertion, err) + } + }) }