From bb02e5c9bf7ea0984c50bf03f0603ef9882ed1f2 Mon Sep 17 00:00:00 2001 From: korya <148461+korya@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:22:54 -0400 Subject: [PATCH] docs(api): Explain consumer result contracts Document how callers distinguish top-level errors, failed assertions, and evaluation errors. Add examples for structured outcome handling, custom HTTP policy, response-body ownership, and the package's pre-v1 compatibility policy. Co-Authored-By: OpenAI Codex (GPT-5) --- README.md | 90 +++++++++++++++++++++++++++++++++++++++++++++++------ api_test.go | 69 ++++++++++++++++++++++++++++++++++++++++ doc.go | 7 +++++ 3 files changed, 156 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 5a51f30..f0dc514 100644 --- a/README.md +++ b/README.md @@ -152,20 +152,90 @@ constructors. It keeps static, programmer-owned values inline and panics on an error, so applications should handle errors from untrusted runtime input normally. -`Client.Do` calls the configured HTTP client once and never retries. A returned -error means no complete response was available, such as a transport or -body-read failure. With a nil error, `Result.Outcomes` contains one result per -assertion, in call order; `Result.Passed()` is the convenient aggregate verdict. -Failures expose a code, kind, target, expected value and actual value instead of -preformatted text, so the calling application controls presentation. Evaluation -errors such as invalid JSON remain distinct from responses that were evaluated -and failed. +### Inspect Results + +`Client.Do` returns a top-level error when it cannot produce complete assertion outcomes, such as an input-validation, transport or response-body read failure. A body-read failure may include a partial `Result`; check both return values before using it. + +With a nil error, `Result.Outcomes` contains one entry per assertion in call order. `Result.Passed()` provides the aggregate verdict. Each outcome distinguishes three states: + +| State | Meaning | +|-------|---------| +| `outcome.Passed()` | The assertion was evaluated and held. | +| `outcome.Failure != nil` | The response was evaluated and did not satisfy the assertion. | +| `outcome.Err != nil` | The assertion could not reach a verdict, for example because JSON decoding or jq evaluation failed. | + +Failures and evaluation errors are structured data rather than preformatted messages. Applications can switch on their typed codes and choose their own output: + +```go +package healthcheck + +import ( + "errors" + "fmt" + + ha "github.com/korya/http-assert" +) + +func Describe(outcome ha.Outcome) string { + switch { + case outcome.Err != nil: + var evaluation *ha.EvaluationError + if errors.As(outcome.Err, &evaluation) { + return fmt.Sprintf("%s could not be evaluated (%s): %v", outcome.Kind, evaluation.Code, evaluation) + } + return fmt.Sprintf("%s could not be evaluated: %v", outcome.Kind, outcome.Err) + case outcome.Failure != nil: + switch outcome.Failure.Code { + case ha.FailureStatusOK: + return fmt.Sprintf("expected 2xx-3xx, got %v", outcome.Failure.Actual) + default: + return fmt.Sprintf("%s failed (%s)", outcome.Kind, outcome.Failure.Code) + } + default: + return fmt.Sprintf("%s passed", outcome.Kind) + } +} +``` + +`Outcome.Kind`, `Failure.Code` and `EvaluationError.Code` use exported types and constants, so consumers do not need to compare undocumented strings. `errors.As` exposes an `*ha.EvaluationError`; `errors.Is` continues through it to the underlying decoder, context or jq error. + +### Control HTTP Policy 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. +request-context deadline can impose a shorter per-call bound. Request +cancellation also stops jq evaluation. + +```go +package healthcheck + +import ( + "context" + "net/http" + "time" + + ha "github.com/korya/http-assert" +) + +func CheckWithPolicy(ctx context.Context, url string) (*ha.Result, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + client := ha.Client{HTTPClient: &http.Client{Timeout: 5 * time.Second}} + return client.Do(req, ha.AssertStatusOK()) +} +``` + +`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. + +The library sends one request and never retries. Retry policy, destination validation, logging and presentation intentionally remain application concerns. + +### Compatibility + +This module follows semantic versioning, but while its major version is zero a minor release may contain a breaking API change. Pin the version selected in `go.mod`, review the changelog before upgrading, and expect the public contract to stabilize at v1. ## Usage diff --git a/api_test.go b/api_test.go index 0930b34..15aa278 100644 --- a/api_test.go +++ b/api_test.go @@ -1,8 +1,13 @@ package httpassert_test import ( + "context" + "errors" "fmt" + "io" "net/http" + "strings" + "time" ha "github.com/korya/http-assert" ) @@ -52,3 +57,67 @@ func ExampleMust() { // GET // jq } + +func ExampleOutcome() { + req := ha.Must(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.StatusInternalServerError, + Status: "500 Internal Server Error", + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("not JSON")), + Request: req, + }, nil + })}} + + result, err := client.Do(req, ha.AssertStatusOK(), ha.Must(ha.AssertJQ(".healthy"))) + if err != nil { + fmt.Println(err) + return + } + + for _, outcome := range result.Outcomes { + switch { + case outcome.Err != nil: + var evaluation *ha.EvaluationError + if errors.As(outcome.Err, &evaluation) { + fmt.Printf("%s could not be evaluated: %s\n", outcome.Kind, evaluation.Code) + } else { + fmt.Printf("%s could not be evaluated: %v\n", outcome.Kind, outcome.Err) + } + case outcome.Failure != nil: + fmt.Printf("%s failed: %s (got %v)\n", outcome.Kind, outcome.Failure.Code, outcome.Failure.Actual) + } + } + + // Output: + // ok failed: status_ok (got 500) + // jq could not be evaluated: json_decode +} + +func ExampleClient_customHTTPPolicy() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + req := ha.Must(http.NewRequestWithContext(ctx, http.MethodGet, "https://example.test/health", nil)) + client := ha.Client{HTTPClient: &http.Client{ + Timeout: 3 * time.Second, + Transport: exampleTransport(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("healthy")), + Request: req, + }, nil + }), + }} + + result, err := client.Do(req, ha.AssertStatusOK()) + fmt.Println(err) + fmt.Println(string(result.Response.BodyBytes)) + + // Output: + // + // healthy +} diff --git a/doc.go b/doc.go index 182db82..a37ef67 100644 --- a/doc.go +++ b/doc.go @@ -5,4 +5,11 @@ // 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. +// +// A nil top-level error means Result.Outcomes contains one structured outcome +// 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. package httpassert