Skip to content

Give assertions a structured result instead of an opaque error #56

Description

@korya

Rearchitecture step 3 of 6. #54, #55, #48, #18 and #41 have all landed. This and #45 are the last two open.

Status: the panic half of this ticket already shipped

This ticket was filed at 19:01 UTC on 2026-08-07. Three hours later 4d5dc06 landed, closing #17 — and delivering this ticket's entire "Suggested fix" as a side effect. Nobody updated the body, so it read as unstarted work for three days.

At HEAD, all four fallible constructors already return (Assertion, error)AssertHeaderMatch (assertions.go:89), AssertBodyMatch (:277), AssertRedirectMatch (:324) and AssertJQ (:164). regexp.MustCompile is gone from the source, and .golangci.yml now bans it outright via forbidigo. A bad pattern reports the way every other invalid flag value does:

$ http-assert --assert-body '[unclosed' https://example.com
Error: Invalid value for --assert-body flag: error parsing regexp: missing closing ]: `[unclosed`
[exit=71]

(The original ticket predicted 103; exit codes were collapsed to invocation/transport/assertion in 2f4b3e1 afterwards.)

Covered at both tiers: assertions_test.go:574, e2e_panic_test.go:18-19, e2e_assert_test.go:487-497.

Everything below is what actually remains.

Problem

Assertion is an opaque function returning error, so a failure can only ever be a string:

type Assertion func(res *httpResponse) error

There is nothing to serialize, which is why machine-readable output (#45) can't be added cheaply.

The error return also conflates two outcomes that are not the same thing:

  • The assertion did not holdbody: expected "x", got "y" (assertions.go:270 and ~15 siblings)
  • The assertion could not be evaluatedbody: response is br-encoded and was not decoded (:121), a gojq runtime error (:209), a body that isn't JSON (:189)

A consumer of --json wants to tell those apart. Today nothing can.

Solution

Give Assertion an identity, and let the result carry the distinction the error return currently loses:

type Assertion interface {
	Kind() string                            // "status", "header", "body", "redirect", "jq"
	Check(res *httpResponse) (*Failure, error)
}

type Failure struct {
	Kind     string
	Target   string // header name, jq query, or ""
	Expected any
	Actual   any
}

Check returns (nil, nil) when the assertion holds, (*Failure, nil) when it does not, and (nil, error) when it could not be evaluated at all. The earlier sketch of Check(res) *Failure was rejected: an operational error has no meaningful Expected/Actual, so a single return would either lose the distinction or fabricate fields.

The functional style is genuinely good and worth preserving where it can be — this is about the result carrying structure, not about abandoning composability.

Scope

Fourteen constructors in assertions.go, plus two consumers in main.gomustCompileAssertion (:672, whose must prefix is now a leftover) and boolAssertion (:694).

Status

#45 is scheduled, which is what this structure exists for, so the work is done in #89. The refactor is behaviour-preserving: every failure message is byte-identical to before, verified by diffing both binaries across thirteen failing scenarios covering all seven kinds.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions