From fdaf784419db9e1bb43bb72ac9d3f46efba10da7 Mon Sep 17 00:00:00 2001 From: korya <148461+korya@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:33:35 -0400 Subject: [PATCH 1/3] refactor(api): Extract reusable HTTP assertion library Previously, every exported assertion and HTTP client lived in package main, so Go programs could not reuse them. Move the CLI to cmd/http-assert and make the root module an importable package with structured failures, evaluation errors, and ordered assertion outcomes. Keep retries, logging, exit codes, and human-readable formatting in the CLI. Preserve its behavior with the relocated subprocess suite while adding focused coverage for the public API and its error paths. Co-Authored-By: OpenAI Codex (GPT-5) --- .gitignore | 2 +- .goreleaser.yaml | 1 + CHANGELOG.md | 16 + Justfile | 19 +- README.md | 50 +++- api_test.go | 35 +++ assertions.go | 226 ++++++++------- assertions_test.go | 78 ++--- client.go | 170 +++++++++++ client_test.go | 256 ++++++++++++++++ .../http-assert/color_test.go | 0 .../http-assert/e2e_assert_test.go | 0 .../http-assert/e2e_color_test.go | 0 .../http-assert/e2e_compression_test.go | 0 .../http-assert/e2e_config_test.go | 0 .../http-assert/e2e_dump_test.go | 0 .../http-assert/e2e_harness_test.go | 3 +- .../http-assert/e2e_help_test.go | 0 .../http-assert/e2e_jq_test.go | 0 .../http-assert/e2e_known_issues_test.go | 0 .../http-assert/e2e_panic_test.go | 0 .../http-assert/e2e_redirect_test.go | 0 .../http-assert/e2e_repeat_test.go | 0 .../http-assert/e2e_retry_test.go | 0 .../http-assert/e2e_server_test.go | 0 .../http-assert/e2e_status_test.go | 0 .../http-assert/e2e_version_test.go | 0 cmd/http-assert/failure.go | 119 ++++++++ cmd/http-assert/failure_test.go | 92 ++++++ .../http-assert/flags_test.go | 0 fuzz_test.go => cmd/http-assert/fuzz_test.go | 0 cmd/http-assert/helpers_test.go | 21 ++ main.go => cmd/http-assert/main.go | 274 +++--------------- main_test.go => cmd/http-assert/main_test.go | 0 .../http-assert/render_test.go | 23 +- .../http-assert/retry_test.go | 44 +++ utils.go => cmd/http-assert/utils.go | 0 .../http-assert/utils_test.go | 0 version.go => cmd/http-assert/version.go | 0 .../http-assert/version_test.go | 3 +- compression_test.go | 84 +++++- doc.go | 8 + helpers_test.go | 127 +++++++- jq_test.go | 6 +- response.go | 141 +++++++++ status_test.go | 11 +- 46 files changed, 1393 insertions(+), 416 deletions(-) create mode 100644 api_test.go create mode 100644 client.go create mode 100644 client_test.go rename color_test.go => cmd/http-assert/color_test.go (100%) rename e2e_assert_test.go => cmd/http-assert/e2e_assert_test.go (100%) rename e2e_color_test.go => cmd/http-assert/e2e_color_test.go (100%) rename e2e_compression_test.go => cmd/http-assert/e2e_compression_test.go (100%) rename e2e_config_test.go => cmd/http-assert/e2e_config_test.go (100%) rename e2e_dump_test.go => cmd/http-assert/e2e_dump_test.go (100%) rename e2e_harness_test.go => cmd/http-assert/e2e_harness_test.go (97%) rename e2e_help_test.go => cmd/http-assert/e2e_help_test.go (100%) rename e2e_jq_test.go => cmd/http-assert/e2e_jq_test.go (100%) rename e2e_known_issues_test.go => cmd/http-assert/e2e_known_issues_test.go (100%) rename e2e_panic_test.go => cmd/http-assert/e2e_panic_test.go (100%) rename e2e_redirect_test.go => cmd/http-assert/e2e_redirect_test.go (100%) rename e2e_repeat_test.go => cmd/http-assert/e2e_repeat_test.go (100%) rename e2e_retry_test.go => cmd/http-assert/e2e_retry_test.go (100%) rename e2e_server_test.go => cmd/http-assert/e2e_server_test.go (100%) rename e2e_status_test.go => cmd/http-assert/e2e_status_test.go (100%) rename e2e_version_test.go => cmd/http-assert/e2e_version_test.go (100%) create mode 100644 cmd/http-assert/failure.go create mode 100644 cmd/http-assert/failure_test.go rename flags_test.go => cmd/http-assert/flags_test.go (100%) rename fuzz_test.go => cmd/http-assert/fuzz_test.go (100%) create mode 100644 cmd/http-assert/helpers_test.go rename main.go => cmd/http-assert/main.go (86%) rename main_test.go => cmd/http-assert/main_test.go (100%) rename render_test.go => cmd/http-assert/render_test.go (92%) rename retry_test.go => cmd/http-assert/retry_test.go (67%) rename utils.go => cmd/http-assert/utils.go (100%) rename utils_test.go => cmd/http-assert/utils_test.go (100%) rename version.go => cmd/http-assert/version.go (100%) rename version_test.go => cmd/http-assert/version_test.go (97%) create mode 100644 doc.go create mode 100644 response.go 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..e7c80d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ 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. + +### 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. + ## [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..5c4dae1 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,51 @@ 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"), + ) +} +``` + +`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 `http.DefaultClient`. Supply `HTTPClient` to control +timeouts, transports, TLS and redirects; redirects may involve multiple HTTP +requests according to that client's policy. Retry policy, logging and CLI +output intentionally remain outside the library API. + ## Usage ### Basic Syntax @@ -655,7 +698,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 +714,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..a1b887d --- /dev/null +++ b/api_test.go @@ -0,0 +1,35 @@ +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 +} diff --git a/assertions.go b/assertions.go index 34a4955..52121ef 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,43 @@ 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. +// 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 +70,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 +86,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 +94,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 +216,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 +231,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 +246,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 +272,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 +287,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 +303,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 +323,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 +356,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 +371,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 +394,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 +440,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 +474,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 +498,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 +518,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 +527,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 +542,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 +558,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 +578,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 +597,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 +639,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..5f554bb 100644 --- a/assertions_test.go +++ b/assertions_test.go @@ -1,4 +1,4 @@ -package main +package httpassert import ( "errors" @@ -39,7 +39,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 +58,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 +99,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 +225,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 +296,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 +317,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 +331,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 +351,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 +514,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 +561,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 +570,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 +628,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 +647,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 +664,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 +680,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 +724,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 +740,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 +764,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..da26424 --- /dev/null +++ b/client.go @@ -0,0 +1,170 @@ +package httpassert + +import ( + "errors" + "fmt" + "io" + "net/http" + "reflect" +) + +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 http.DefaultClient. 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 = http.DefaultClient + } + + // 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..4ba6f36 --- /dev/null +++ b/client_test.go @@ -0,0 +1,256 @@ +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 TestClientDoUsesDefaultClient(t *testing.T) { + original := http.DefaultClient + t.Cleanup(func() { http.DefaultClient = original }) + + called := false + http.DefaultClient = &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 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) + } + }) } From def3e6ef74ef3cd32bd8196ff0a61ac93cd1912d Mon Sep 17 00:00:00 2001 From: korya <148461+korya@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:56:24 -0400 Subject: [PATCH 2/3] fix(client): Bound zero-value requests to 20 seconds The zero-value library client previously inherited net/http's unbounded total request timeout, so a stalled endpoint could prevent a health check from returning. Use a shared client with a 20-second whole-request limit while preserving custom clients and shorter request-context deadlines. Co-Authored-By: OpenAI Codex (GPT-5) --- CHANGELOG.md | 3 +++ README.md | 9 +++++---- client.go | 15 ++++++++++++--- client_test.go | 17 +++++++++++++---- 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7c80d6..4d3a3be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ are marked **Breaking** and listed first in their section. release archives and the `http-assert` binary name are unchanged. - Assertion failures and evaluation errors are structured library data. The CLI owns human-readable formatting and preserves its existing output. +- 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 diff --git a/README.md b/README.md index 5c4dae1..b6cbf9d 100644 --- a/README.md +++ b/README.md @@ -160,10 +160,11 @@ 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 `http.DefaultClient`. Supply `HTTPClient` to control -timeouts, transports, TLS and redirects; redirects may involve multiple HTTP -requests according to that client's policy. Retry policy, logging and CLI -output intentionally remain outside the library API. +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 diff --git a/client.go b/client.go index da26424..aa775e4 100644 --- a/client.go +++ b/client.go @@ -6,8 +6,16 @@ import ( "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. @@ -92,8 +100,9 @@ func (r *Result) Passed() bool { // 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 http.DefaultClient. Set HTTPClient when the caller needs -// custom transports, redirect behavior, TLS settings, or timeouts. +// 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 } @@ -118,7 +127,7 @@ func (c Client) Do(req *http.Request, assertions ...Assertion) (*Result, error) client := c.HTTPClient if client == nil { - client = http.DefaultClient + client = defaultHTTPClient } // The caller supplies both the request and (optionally) the HTTP client; diff --git a/client_test.go b/client_test.go index 4ba6f36..c4b3d99 100644 --- a/client_test.go +++ b/client_test.go @@ -158,12 +158,12 @@ func TestClientDoPerformsOneRequestAndChecksEveryAssertionInOrder(t *testing.T) } } -func TestClientDoUsesDefaultClient(t *testing.T) { - original := http.DefaultClient - t.Cleanup(func() { http.DefaultClient = original }) +func TestClientDoUsesPackageDefaultClient(t *testing.T) { + original := defaultHTTPClient + t.Cleanup(func() { defaultHTTPClient = original }) called := false - http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + defaultHTTPClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { called = true return &http.Response{ StatusCode: 204, @@ -183,6 +183,15 @@ func TestClientDoUsesDefaultClient(t *testing.T) { } } +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) { From 0dbfb0ce914ac8a78ab7eec3e8bf80ea8e84047b Mon Sep 17 00:00:00 2001 From: korya <148461+korya@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:57:47 -0400 Subject: [PATCH 3/3] feat(api): Add Must assertion constructor helper Parsed assertion constructors correctly return errors, but static expressions become noisy when used inline. Add Must to preserve the constructor contract while giving programmer-owned status, regular-expression, and jq assertions the conventional one-line panic form. Co-Authored-By: OpenAI Codex (GPT-5) --- CHANGELOG.md | 2 ++ README.md | 6 ++++++ api_test.go | 7 +++++++ assertions.go | 10 ++++++++++ assertions_test.go | 22 ++++++++++++++++++++++ 5 files changed, 47 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d3a3be..3284c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ are marked **Breaking** and listed first in their section. 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 diff --git a/README.md b/README.md index b6cbf9d..0b4e275 100644 --- a/README.md +++ b/README.md @@ -147,10 +147,16 @@ func Check(url string) (*ha.Result, error) { 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 diff --git a/api_test.go b/api_test.go index a1b887d..106f33d 100644 --- a/api_test.go +++ b/api_test.go @@ -33,3 +33,10 @@ func ExampleClient_Do() { // // 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 52121ef..ce3b18a 100644 --- a/assertions.go +++ b/assertions.go @@ -28,6 +28,16 @@ type Assertion interface { Check(res *Response) (*Failure, 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 diff --git a/assertions_test.go b/assertions_test.go index 5f554bb..bd3fdc7 100644 --- a/assertions_test.go +++ b/assertions_test.go @@ -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()