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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 2 additions & 0 deletions examples/restapi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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
}
1 change: 1 addition & 0 deletions request_internal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
37 changes: 37 additions & 0 deletions request_precognition.go
Original file line number Diff line number Diff line change
@@ -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)
}
212 changes: 212 additions & 0 deletions request_precognition_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
6 changes: 6 additions & 0 deletions request_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading