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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,18 @@ jobs:
steps:
- uses: actions/checkout@v7

- name: Set up Go
- name: Set up Go from go.mod
if: matrix.go == ''
uses: actions/setup-go@v7
with:
go-version: ${{ matrix.go }}
go-version-file: go.mod

- name: Set up stable Go
if: matrix.go != ''
uses: actions/setup-go@v7
with:
go-version: ${{ matrix.go }}

- name: Install just
uses: extractions/setup-just@v4

Expand Down
13 changes: 11 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,21 @@ jobs:
- name: Install just
uses: extractions/setup-just@v4

- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest

# A tag can be cut from any commit, not only one that master already proved
# green. Twenty seconds here is cheaper than a published binary that does
# not work and a version number that cannot be reused.
# green. Rechecking both the library and binary here is cheaper than a
# published artifact that does not work and a version that cannot be reused.
- name: Run unit tests with the race detector
run: just test-race

- name: Run end-to-end tests
run: just test-e2e

- name: Scan Go dependencies for known vulnerabilities
run: govulncheck ./...

# The release body is the tag's section of CHANGELOG.md, nothing else. This
# fails when that section is missing or empty, which stops the release
# before a single artifact is published -- the recoverable moment to find
Expand Down
11 changes: 8 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ are marked **Breaking** and listed first in their section.

## [Unreleased]

## [0.4.0] - 2026-08-30

### Added

- A reusable Go package at `github.com/korya/http-assert` exposes the HTTP
Expand All @@ -25,8 +27,10 @@ are marked **Breaking** and listed first in their section.
`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.
- Assertion failures and evaluation errors are structured library data. Each
outcome is exclusively a pass, a failed assertion or an evaluation error,
and its assertion family is consistently typed. The CLI owns human-readable
formatting and preserves its existing output.
- Assertion families use the exported `AssertionKind` type and constants
instead of requiring consumers to compare raw strings.
- The library's zero-value client uses a 20-second total request timeout instead
Expand Down Expand Up @@ -160,7 +164,8 @@ Conventional Commits, and the transfer from `PlanitarInc`. See the
[git history](https://github.com/korya/http-assert/commits/v0.0.7) for that
period.

[Unreleased]: https://github.com/korya/http-assert/compare/v0.3.0...HEAD
[Unreleased]: https://github.com/korya/http-assert/compare/v0.4.0...HEAD
[0.4.0]: https://github.com/korya/http-assert/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/korya/http-assert/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/korya/http-assert/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/korya/http-assert/compare/v0.0.7...v0.1.0
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ amd64 and arm64 are attached to every [release](https://github.com/korya/http-as

```bash
# Pick the latest tag from https://github.com/korya/http-assert/releases
VERSION=v0.3.0
VERSION=v0.4.0
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m | sed 's/x86_64/amd64/; s/aarch64/arm64/')
ARCHIVE="http-assert_${VERSION#v}_${OS}_${ARCH}.tar.gz"
Expand Down Expand Up @@ -229,7 +229,9 @@ func CheckWithPolicy(ctx context.Context, url string) (*ha.Result, error) {
}
```

`Client.Do` consumes and closes the response body before returning. Use `Result.Response.BodyBytes` for the decoded payload rather than reading `Result.Response.Body`; HTTP status, headers and other `http.Response` metadata remain available. If content decoding fails, `DecodeErr` describes the problem and `BodyBytes` contains the encoded bytes as received.
`Client.Do` consumes and closes the response body before returning. After a nil top-level error, use `Result.Response.BodyBytes` for the decoded payload rather than reading `Result.Response.Body`; HTTP status, headers and other `http.Response` metadata remain available. If reading the body fails, `BodyBytes` contains only the bytes received before the error and no decoding was attempted. If content decoding fails, `DecodeErr` describes the problem and `BodyBytes` contains the encoded bytes as received.

Response bodies are currently buffered completely in memory without a configurable limit, including decoded payloads that may be larger than their wire representation. See [#106](https://github.com/korya/http-assert/issues/106).

The library sends one request and never retries. Retry policy, destination validation, logging and presentation intentionally remain application concerns.

Expand Down
33 changes: 26 additions & 7 deletions assertions.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package httpassert

import (
"context"
"errors"
"fmt"
"regexp"
"strconv"
Expand Down Expand Up @@ -38,7 +39,11 @@ type Assertion interface {
// Kind names the family this assertion belongs to.
Kind() AssertionKind

// Check reports (nil, nil) when the assertion holds.
// Check reports exactly one of three states: (nil, nil) when the assertion
// holds; (failure, nil) when the response does not satisfy it; or
// (nil, error) when no verdict could be reached. Implementations must not
// return both a failure and an error. Client.Do treats an error as
// authoritative if a custom implementation violates that contract.
Check(res *Response) (*Failure, error)
}

Expand Down Expand Up @@ -89,15 +94,29 @@ type assertionFunc struct {

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

// Check stamps the failure with the assertion's kind, so Kind() and
// Failure.Kind cannot disagree and no constructor has to repeat itself.
// Check normalizes the outcome so its structured kind cannot disagree with
// Kind() and no constructor has to repeat itself.
func (a assertionFunc) Check(res *Response) (*Failure, error) {
f, err := a.check(res)
if f != nil {
f.Kind = a.kind
}
return normalizeOutcome(a.kind, f, err)
}

return f, err
// normalizeOutcome stamps structured results with the assertion family and
// defensively preserves the interface's mutually exclusive result states.
// An evaluation error wins over a failure because it means no valid verdict
// exists to describe as Expected versus Actual.
func normalizeOutcome(kind AssertionKind, failure *Failure, err error) (*Failure, error) {
if err != nil {
var evaluation *EvaluationError
if errors.As(err, &evaluation) && evaluation != nil {
evaluation.Kind = kind
}
return nil, err
}
if failure != nil {
failure.Kind = kind
}
return failure, nil
}

func newAssertion(kind AssertionKind, check func(res *Response) (*Failure, error)) Assertion {
Expand Down
17 changes: 17 additions & 0 deletions assertions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -788,3 +788,20 @@ func Test_AssertionCheckSeparatesFailureFromError(t *testing.T) {
}
})
}

func Test_AssertionCheckStampsEvaluationErrorKind(t *testing.T) {
t.Parallel()

assertion := Must(AssertJQ(".healthy"))
failure, err := assertion.Check(jqResponse("not JSON"))
if failure != nil {
t.Fatalf("Failure = %+v, want nil for an evaluation error", failure)
}
var evaluation *EvaluationError
if !errors.As(err, &evaluation) {
t.Fatalf("error = %T %v, want *EvaluationError", err, err)
}
if evaluation.Code != EvaluationJSON || evaluation.Kind != assertion.Kind() {
t.Errorf("EvaluationError = %+v, want JSON error for %q assertion", evaluation, assertion.Kind())
}
}
18 changes: 9 additions & 9 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ const (
// satisfy the assertion.
type EvaluationError struct {
Code EvaluationErrorCode
Kind AssertionKind
Target string
Encoding string
Cause error
Kind AssertionKind // assertion family; Client and built-in assertions populate it
Target string // jq query or "" when the evaluation needs no subject
Encoding string // response content encoding for body-decode errors
Cause error // underlying decoder, JSON, context, or jq error
}

func (e *EvaluationError) Error() string {
Expand All @@ -65,8 +65,10 @@ func (e *EvaluationError) Unwrap() error {
return e.Cause
}

// Outcome is the result of evaluating one assertion. Passed reports whether
// both Failure and Err are nil.
// Outcome is the result of evaluating one assertion. Client.Do guarantees that
// Failure and Err are not both set; an error from a custom assertion takes
// precedence over a simultaneously returned failure. Passed reports whether
// both are nil.
type Outcome struct {
Kind AssertionKind
Failure *Failure
Expand Down Expand Up @@ -151,9 +153,7 @@ func (c Client) Do(req *http.Request, assertions ...Assertion) (*Result, error)
for _, assertion := range assertions {
failure, checkErr := assertion.Check(httpRes)
kind := assertion.Kind()
if failure != nil {
failure.Kind = kind
}
failure, checkErr = normalizeOutcome(kind, failure, checkErr)
result.Outcomes = append(result.Outcomes, Outcome{
Kind: kind,
Failure: failure,
Expand Down
39 changes: 39 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,42 @@ func TestClientDoPerformsOneRequestAndChecksEveryAssertionInOrder(t *testing.T)
}
}

func TestClientDoNormalizesCustomAssertionOutcome(t *testing.T) {
cause := errors.New("cannot decide")
assertion := testAssertion{kind: "custom", check: func(*Response) (*Failure, error) {
return &Failure{Code: FailureBodyEqual}, &EvaluationError{
Code: EvaluationJQ,
Kind: "wrong",
Cause: cause,
}
}}
client := Client{HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: make(http.Header),
Body: http.NoBody,
Request: req,
}, nil
})}}

result, err := client.Do(request(t), assertion)
if err != nil {
t.Fatalf("Do: %s", err)
}
outcome := result.Outcomes[0]
if outcome.Failure != nil {
t.Errorf("Failure = %+v, want nil when assertion also returned an error", outcome.Failure)
}
var evaluation *EvaluationError
if !errors.As(outcome.Err, &evaluation) {
t.Fatalf("Err = %T %v, want *EvaluationError", outcome.Err, outcome.Err)
}
if evaluation.Kind != assertion.Kind() || !errors.Is(evaluation, cause) {
t.Errorf("EvaluationError = %+v, want kind %q wrapping cause", evaluation, assertion.Kind())
}
}

func TestClientDoUsesPackageDefaultClient(t *testing.T) {
original := defaultHTTPClient
t.Cleanup(func() { defaultHTTPClient = original })
Expand Down Expand Up @@ -227,6 +263,9 @@ func TestClientDoReturnsPartialResponseOnReadError(t *testing.T) {
if got := string(result.Response.BodyBytes); got != "partial" {
t.Errorf("partial body = %q", got)
}
if result.Response.DecodeErr != nil {
t.Errorf("DecodeErr = %v, want nil because decoding was not attempted", result.Response.DecodeErr)
}
if !body.closed {
t.Error("response body was not closed after read error")
}
Expand Down
11 changes: 11 additions & 0 deletions cmd/http-assert/e2e_assert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ func TestE2EAssertions(t *testing.T) {
}
}

// TestE2EAssertOKAcceptsRedirects keeps the intentional 2xx-or-3xx contract
// aligned with the flag help and README. Issue #20 corrected the documentation;
// this is ordinary regression coverage rather than a known defect.
func TestE2EAssertOKAcceptsRedirects(t *testing.T) {
for _, path := range []string{"/redirect", "/redirect-rel"} {
t.Run(path, func(t *testing.T) {
assertExit(t, run(t, nil, "--assert-ok", url(path)), exitOK)
})
}
}

// TestE2EAssertionAggregation pins the behaviour that separates this tool from
// `curl && grep`: every failing assertion is reported, not just the first.
func TestE2EAssertionAggregation(t *testing.T) {
Expand Down
11 changes: 0 additions & 11 deletions cmd/http-assert/e2e_known_issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,6 @@ import "testing"
//
// go test -run 'TestKnown' ./...

// TestKnownIssue20AssertOkAcceptsRedirects: --assert-ok is documented as "2xx"
// but implemented as 200-399.
func TestKnownIssue20AssertOkAcceptsRedirects(t *testing.T) {
for _, path := range []string{"/redirect", "/redirect-rel"} {
t.Run(path, func(t *testing.T) {
characterizes(t, 20, "--assert-ok passes on a 3xx despite the docs saying 2xx")
assertExit(t, run(t, nil, "--assert-ok", url(path)), exitOK)
})
}
}

// TestKnownIssue23WildcardMaphostUnreachable: hostMapping.Matches handles "*"
// and "*:*", but the parser rejects both, so the branches are dead code.
func TestKnownIssue23WildcardMaphostUnreachable(t *testing.T) {
Expand Down
6 changes: 4 additions & 2 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
// per assertion. Failures describe responses that did not satisfy an assertion;
// evaluation errors describe assertions that could not reach a verdict.
//
// Client.Do consumes and closes the response body. The decoded payload remains
// available as Result.Response.BodyBytes with the original HTTP metadata.
// Client.Do consumes and closes the response body. After a nil top-level error,
// the decoded payload remains available as Result.Response.BodyBytes with the
// original HTTP metadata. A body-read error instead leaves the partial encoded
// bytes in BodyBytes and returns a non-nil top-level error.
package httpassert
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module github.com/korya/http-assert

go 1.26

toolchain go1.26.5
toolchain go1.26.7

require (
github.com/andybalholm/brotli v1.2.2
Expand Down
13 changes: 8 additions & 5 deletions response.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,16 @@ import (
)

// 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.
// consumed and closed. After a successful Client.Do, BodyBytes contains the
// decoded payload when DecodeErr is nil. If Client.Do returns a body-read
// error, BodyBytes contains only the bytes read before that error and no
// decoding was attempted. 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 is the complete decoded response payload after a successful
// Client.Do. On a body-read error it is the partial encoded payload received
// before the error; callers must check Client.Do's top-level error first.
BodyBytes []byte
// Encoding is the response's Content-Encoding value, with surrounding
// whitespace removed. An empty value means no encoding was declared.
Expand Down