From 32c60ae13b27a453cd650f630f2603d07c5b3605 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 22:11:50 +0000 Subject: [PATCH] feat: add opt-in Precognition support to ParseRequest Endpoints enable ParseOptions.Precognition; requests with the "Precognition: true" header run decoding, path binding and validation, then stop: 204 No Content on success (ParseRequest returns ErrPrecognitionHandled), 422 with the validator's problem on failure. Parse errors keep their usual 400/413 responses. Closes #20 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHBdej3KvFACHzafbEhw7C --- README.md | 33 ++++++ examples/restapi/main.go | 2 + request.go | 20 +++- request_internal.go | 1 + request_precognition.go | 37 ++++++ request_precognition_test.go | 212 +++++++++++++++++++++++++++++++++++ request_types.go | 6 + 7 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 request_precognition.go create mode 100644 request_precognition_test.go diff --git a/README.md b/README.md index ab49f79..f39e04a 100644 --- a/README.md +++ b/README.md @@ -354,6 +354,39 @@ httpsuite.RespondProblem(problem). Write(w) ``` +## Precognition + +Let clients validate a request against the endpoint's real decoding, binding and validation rules without running business logic. Opt in per endpoint: + +```go +req, err := httpsuite.ParseRequest[*CreateUserRequest](w, r, chi.URLParam, &httpsuite.ParseOptions{ + Precognition: true, +}, "id") +if err != nil { + // Covers parse errors, validation errors and handled precognitive requests. + return +} + +// Never reached for a precognitive request. +user := store.Create(req) +``` + +A client sends the same request with `Precognition: true`: + +| Outcome | Response | `err` | +| --- | --- | --- | +| Valid | `204 No Content`, `Precognition-Success: true` | `ErrPrecognitionHandled` | +| Validation failed | `422` with the validator's `ProblemDetails` | validation error | +| Malformed body / too large / bad path param | usual `400` / `413` problem | parse error | + +Notes: + +* Without `ParseOptions.Precognition`, the header is ignored and the request is processed normally. +* Opted-in endpoints add `Vary: Precognition` to responses. +* Use `httpsuite.IsPrecognitionHandled(err)` to tell a handled precognitive request apart from a failure, and `httpsuite.IsPrecognitionRequest(r)` to inspect the request. +* With `SkipValidation`, a precognitive request only checks decoding and path binding. +* A successful pre-validation does not guarantee the real request will succeed; state may change in between. + ## Architecture * root module: `github.com/rluders/httpsuite/v3` diff --git a/examples/restapi/main.go b/examples/restapi/main.go index 1061753..0ae5a7e 100644 --- a/examples/restapi/main.go +++ b/examples/restapi/main.go @@ -158,6 +158,8 @@ func main() { r.Post("/users", func(w http.ResponseWriter, r *http.Request) { req, err := httpsuite.ParseRequest[*CreateUserRequest](w, r, nilParamExtractor, &httpsuite.ParseOptions{ MaxBodyBytes: 2 << 10, + // Clients may send "Precognition: true" to validate without creating a user. + Precognition: true, }) if err != nil { return diff --git a/request.go b/request.go index a4f2ef7..b71157e 100644 --- a/request.go +++ b/request.go @@ -8,6 +8,8 @@ import ( // ParseRequest parses the incoming HTTP request into a specified struct type, // handling JSON decoding, request body limits, path parameter binding, and // optional validation. Invalid inputs return regular errors instead of panicking. +// With ParseOptions.Precognition enabled, a request carrying the Precognition +// header stops after validation and ParseRequest returns ErrPrecognitionHandled. func ParseRequest[T any](w http.ResponseWriter, r *http.Request, paramExtractor ParamExtractor, opts *ParseOptions, pathParams ...string) (T, error) { var empty T if r == nil { @@ -19,6 +21,13 @@ func ParseRequest[T any](w http.ResponseWriter, r *http.Request, paramExtractor options := normalizeParseOptions(opts) + precognitive := false + if options.Precognition { + // Responses differ by the Precognition header on opted-in endpoints. + w.Header().Add("Vary", PrecognitionHeader) + precognitive = IsPrecognitionRequest(r) + } + request, err := DecodeRequestBody[T](r, options.MaxBodyBytes) if err != nil { var decodeErr *BodyDecodeError @@ -43,10 +52,19 @@ func ParseRequest[T any](w http.ResponseWriter, r *http.Request, paramExtractor if !options.SkipValidation { if problem := ValidateRequest(request, options.Validator); problem != nil { - SendResponse[any](w, validationProblemStatus(problem), nil, problem, nil) + status := validationProblemStatus(problem) + if precognitive { + status = http.StatusUnprocessableEntity + } + SendResponse[any](w, status, nil, problem, nil) return empty, errValidationFailed } } + if precognitive { + writePrecognitionSuccess(w) + return empty, ErrPrecognitionHandled + } + return request, nil } diff --git a/request_internal.go b/request_internal.go index 4296b45..4a9c037 100644 --- a/request_internal.go +++ b/request_internal.go @@ -32,6 +32,7 @@ func normalizeParseOptions(opts *ParseOptions) ParseOptions { normalized.Validator = opts.Validator } normalized.SkipValidation = opts.SkipValidation + normalized.Precognition = opts.Precognition } if normalized.Problems == nil { problems := DefaultProblemConfig() diff --git a/request_precognition.go b/request_precognition.go new file mode 100644 index 0000000..d4cc66c --- /dev/null +++ b/request_precognition.go @@ -0,0 +1,37 @@ +package httpsuite + +import ( + "errors" + "net/http" + "strings" +) + +// PrecognitionHeader is the request header a client sends to ask for +// validation-only processing on an endpoint that enables ParseOptions.Precognition. +const PrecognitionHeader = "Precognition" + +// PrecognitionSuccessHeader is set on the 204 response of a successful precognitive request. +const PrecognitionSuccessHeader = "Precognition-Success" + +// ErrPrecognitionHandled is returned by ParseRequest when a precognitive request +// was validated and the response has already been written. Handlers must stop +// processing; the usual `if err != nil { return }` does exactly that. +var ErrPrecognitionHandled = errors.New("precognition request handled") + +// IsPrecognitionRequest reports whether the request asks for validation-only processing. +func IsPrecognitionRequest(r *http.Request) bool { + if r == nil { + return false + } + return strings.EqualFold(strings.TrimSpace(r.Header.Get(PrecognitionHeader)), "true") +} + +// IsPrecognitionHandled reports whether err signals a handled precognitive request. +func IsPrecognitionHandled(err error) bool { + return errors.Is(err, ErrPrecognitionHandled) +} + +func writePrecognitionSuccess(w http.ResponseWriter) { + w.Header().Set(PrecognitionSuccessHeader, "true") + w.WriteHeader(http.StatusNoContent) +} diff --git a/request_precognition_test.go b/request_precognition_test.go new file mode 100644 index 0000000..fd64f51 --- /dev/null +++ b/request_precognition_test.go @@ -0,0 +1,212 @@ +package httpsuite + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestParseRequestPrecognition(t *testing.T) { + ClearValidator() + t.Cleanup(ClearValidator) + + invalid := stubValidator{problem: NewProblemDetails(http.StatusBadRequest, "", "Validation Error", "name is required")} + + tests := []struct { + name string + body string + path string + header bool + opts *ParseOptions + wantErr error + wantStatus int + wantResponse bool + wantVary bool + }{ + { + name: "valid precognitive request", + body: `{"name":"Test"}`, + path: "/test/1", + header: true, + opts: &ParseOptions{Precognition: true}, + wantErr: ErrPrecognitionHandled, + wantStatus: http.StatusNoContent, + wantVary: true, + }, + { + name: "validation failure returns 422", + body: `{"name":""}`, + path: "/test/1", + header: true, + opts: &ParseOptions{Precognition: true, Validator: invalid}, + wantErr: errValidationFailed, + wantStatus: http.StatusUnprocessableEntity, + wantVary: true, + }, + { + name: "malformed json stays a 400 parse error", + body: `{bad`, + path: "/test/1", + header: true, + opts: &ParseOptions{Precognition: true}, + wantStatus: http.StatusBadRequest, + wantVary: true, + }, + { + name: "oversized body stays 413", + body: `{"name":"` + strings.Repeat("a", 64) + `"}`, + path: "/test/1", + header: true, + opts: &ParseOptions{Precognition: true, MaxBodyBytes: 8}, + wantStatus: http.StatusRequestEntityTooLarge, + wantVary: true, + }, + { + name: "invalid path param stays 400", + body: `{"name":"Test"}`, + path: "/test/nope", + header: true, + opts: &ParseOptions{Precognition: true}, + wantStatus: http.StatusBadRequest, + wantVary: true, + }, + { + name: "ordinary request on opted-in endpoint", + body: `{"name":"Test"}`, + path: "/test/1", + opts: &ParseOptions{Precognition: true}, + wantResponse: true, + wantVary: true, + }, + { + name: "header ignored when not opted in", + body: `{"name":"Test"}`, + path: "/test/1", + header: true, + opts: nil, + wantResponse: true, + }, + { + name: "validation status untouched when not opted in", + body: `{"name":""}`, + path: "/test/1", + header: true, + opts: &ParseOptions{Validator: invalid}, + wantErr: errValidationFailed, + wantStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, tt.path, strings.NewReader(tt.body)) + if tt.header { + r.Header.Set(PrecognitionHeader, "true") + } + w := httptest.NewRecorder() + + got, err := ParseRequest[*testRequest](w, r, testParamExtractor, tt.opts, "id") + + if tt.wantResponse { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil || got.ID != 1 || got.Name != "Test" { + t.Fatalf("unexpected request: %#v", got) + } + if w.Body.Len() != 0 { + t.Fatalf("expected nothing written, got %q", w.Body.String()) + } + } else { + if err == nil { + t.Fatal("expected error") + } + if got != nil { + t.Fatalf("expected zero request, got %#v", got) + } + if tt.wantErr != nil && !errors.Is(err, tt.wantErr) { + t.Fatalf("expected %v, got %v", tt.wantErr, err) + } + if w.Code != tt.wantStatus { + t.Fatalf("expected status %d, got %d", tt.wantStatus, w.Code) + } + } + + if IsPrecognitionHandled(err) != errors.Is(tt.wantErr, ErrPrecognitionHandled) { + t.Fatalf("IsPrecognitionHandled mismatch for %v", err) + } + if vary := w.Header().Get("Vary") == PrecognitionHeader; vary != tt.wantVary { + t.Fatalf("Vary header = %q, want precognition vary %v", w.Header().Get("Vary"), tt.wantVary) + } + }) + } +} + +func TestParseRequestPrecognitionResponses(t *testing.T) { + ClearValidator() + t.Cleanup(ClearValidator) + + t.Run("success has empty body and success header", func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/test/1", strings.NewReader(`{"name":"Test"}`)) + r.Header.Set(PrecognitionHeader, "true") + w := httptest.NewRecorder() + + _, err := ParseRequest[*testRequest](w, r, testParamExtractor, &ParseOptions{Precognition: true}, "id") + if !IsPrecognitionHandled(err) { + t.Fatalf("expected ErrPrecognitionHandled, got %v", err) + } + if w.Body.Len() != 0 { + t.Fatalf("expected empty body, got %q", w.Body.String()) + } + if w.Header().Get(PrecognitionSuccessHeader) != "true" { + t.Fatalf("expected %s header", PrecognitionSuccessHeader) + } + }) + + t.Run("failure keeps problem body and extensions", func(t *testing.T) { + problem := NewProblemDetails(http.StatusBadRequest, "", "Validation Error", "invalid") + problem.Extensions = map[string]interface{}{ + "errors": []ValidationErrorDetail{{Field: "name", Message: "required"}}, + } + r := httptest.NewRequest(http.MethodPost, "/test/1", strings.NewReader(`{"name":""}`)) + r.Header.Set(PrecognitionHeader, "true") + w := httptest.NewRecorder() + + _, _ = ParseRequest[*testRequest](w, r, testParamExtractor, &ParseOptions{ + Precognition: true, + Validator: stubValidator{problem: problem}, + }, "id") + + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + if body["status"] != float64(http.StatusUnprocessableEntity) { + t.Fatalf("expected status 422 in body, got %v", body["status"]) + } + if _, ok := body["errors"]; !ok { + t.Fatalf("expected errors extension, got %v", body) + } + if problem.Status != http.StatusBadRequest { + t.Fatal("validator problem must not be mutated") + } + }) +} + +func TestIsPrecognitionRequest(t *testing.T) { + t.Parallel() + + for value, want := range map[string]bool{"true": true, "TRUE": true, " true ": true, "false": false, "": false, "1": false} { + r := httptest.NewRequest(http.MethodPost, "/", nil) + r.Header.Set(PrecognitionHeader, value) + if got := IsPrecognitionRequest(r); got != want { + t.Errorf("IsPrecognitionRequest(%q) = %v, want %v", value, got, want) + } + } + if IsPrecognitionRequest(nil) { + t.Error("nil request must not be precognitive") + } +} diff --git a/request_types.go b/request_types.go index 4b35c20..081cd1e 100644 --- a/request_types.go +++ b/request_types.go @@ -21,6 +21,12 @@ type ParseOptions struct { Problems *ProblemConfig Validator Validator SkipValidation bool + // Precognition enables validation-only processing for requests that send + // the Precognition: true header. Such requests are decoded, bound and + // validated as usual; on success ParseRequest writes 204 No Content and + // returns ErrPrecognitionHandled, on validation failure it writes the + // validator's problem with status 422. When false, the header is ignored. + Precognition bool } const defaultMaxBodyBytes int64 = 1 << 20