diff --git a/README.md b/README.md index 057d2ab..6d5f7fa 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Features include: - [Content negotiation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation) between server and client - Support for gzip ([RFC 1952](https://tools.ietf.org/html/rfc1952)) & Brotli ([RFC 7932](https://tools.ietf.org/html/rfc7932)) content encoding via the `Accept-Encoding` header. - Support for JSON ([RFC 8259](https://tools.ietf.org/html/rfc8259)), YAML, and CBOR ([RFC 7049](https://tools.ietf.org/html/rfc7049)) content types via the `Accept` header. +- Conditional requests support, e.g. `If-Match` or `If-Unmodified-Since` header utilities. - Annotated Go types for input and output models - Generates JSON Schema from Go types - Automatic input model validation & error handling @@ -446,6 +447,73 @@ func (m *MyInput) Resolve(ctx huma.Context, r *http.Request) { } ``` +### Conditional Requests + +There are built-in utilities for handling [conditional requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditional_requests), which serve two broad purposes: + +1. Sparing bandwidth on reading a document that has not changed, i.e. "only send if the version is different from what I already have" +2. Preventing multiple writers from clobbering each other's changes, i.e. "only save if the version on the server matches what I saw last" + +Adding support for handling conditional requests requires four steps: + +1. Import the `github.com/danielgtaylor/huma/conditional` package. +2. Add the response definition (`304 Not Modified` for reads or `412 Precondition Failed` for writes) +3. Add `conditional.Params` to your input struct. +4. Check if conditional params were passed and handle them. The `HasConditionalParams()` and `PreconditionFailed(...)` methods can help with this. + +Implementing a conditional read might look like: + +```go +app.Resource("/resource").Get("get-resource", "Get a resource", + responses.OK(), + responses.NotModified(), +).Run(func(ctx huma.Context, input struct { + conditional.Params +}) { + if input.HasConditionalParams() { + // TODO: Get the ETag and last modified time from the resource. + etag := "" + modified := time.Time{} + + // If preconditions fail, abort the request processing. Response status + // codes are already set for you, but you can optionally provide a body. + // Returns an HTTP 304 not modified. + if input.PreconditionFailed(ctx, etag, modified) { + return + } + } + + // Otherwise do the normal request processing here... + // ... +}) +``` + +Similarly a write operation may look like: + +```go +app.Resource("/resource").Put("put-resource", "Put a resource", + responses.OK(), + responses.PreconditionFailed(), +).Run(func(ctx huma.Context, input struct { + conditional.Params +}) { + if input.HasConditionalParams() { + // TODO: Get the ETag and last modified time from the resource. + etag := "" + modified := time.Time{} + + // If preconditions fail, abort the request processing. Response status and + // errors have already been set. Returns an HTTP 412 Precondition Failed. + if input.PreconditionFailed(ctx, etag, modified) { + return + } + } + + // Otherwise do the normal request processing here... + // ... +}) +``` + ## Validation Go struct tags are used to annotate inputs/output structs with information that gets turned into [JSON Schema](https://json-schema.org/) for documentation and validation. diff --git a/conditional/params.go b/conditional/params.go new file mode 100644 index 0000000..7e7e57c --- /dev/null +++ b/conditional/params.go @@ -0,0 +1,137 @@ +package conditional + +import ( + "net/http" + "strings" + "time" + + "github.com/danielgtaylor/huma" +) + +// trimETag removes the quotes and `W/` prefix for incoming ETag values to +// make comparisons easier. +func trimETag(value string) string { + if strings.HasPrefix(value, "W/") && len(value) > 2 { + value = value[2:] + } + return strings.Trim(value, "\"") +} + +// Params allow clients to send ETags or times to make a read or +// write conditional based on the state of the resource on the server, e.g. +// when it was last modified. This is useful for determining when a cache +// should be updated or to prevent multiple writers from overwriting each +// other's changes. +type Params struct { + IfMatch []string `header:"If-Match" doc:"Succeeds if the server's resource matches one of the passed values."` + IfNoneMatch []string `header:"If-None-Match" doc:"Succeeds if the server's resource matches none of the passed values. On writes, the special value * may be used to match any existing value."` + IfModifiedSince time.Time `header:"If-Modified-Since" doc:"Succeeds if the server's resource date is more recent than the passed date."` + IfUnmodifiedSince time.Time `header:"If-Unmodified-Since" doc:"Succeeds if the server's resource date is older or the same as the passed date."` + + // isWrite tracks whether we should emit errors vs. a 304 Not Modified from + // the `PreconditionFailed` method. + isWrite bool +} + +func (p *Params) Resolve(ctx huma.Context, r *http.Request) { + switch r.Method { + case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: + p.isWrite = true + } +} + +// HasConditionalParams returns true if any conditional request headers have +// been set on the incoming request. +func (p *Params) HasConditionalParams() bool { + return len(p.IfMatch) > 0 || len(p.IfNoneMatch) > 0 || !p.IfModifiedSince.IsZero() || !p.IfUnmodifiedSince.IsZero() +} + +// PreconditionFailed returns false if no conditional headers are present, or if +// the values passed fail based on the conditional read/write rules. See also: +// https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditional_requests. +// This method assumes there is some fast/efficient way to get a resource's +// current ETag and/or last-modified time before it is run. +func (p *Params) PreconditionFailed(ctx huma.Context, etag string, modified time.Time) bool { + failed := false + + foundMsg := "found no existing resource" + if etag != "" { + foundMsg = "found resource with ETag " + etag + } + + // If-None-Match fails on the first match. The `*` is a special case meaning + // to match any existing value. + for _, match := range p.IfNoneMatch { + trimmed := trimETag(match) + if trimmed == etag || (trimmed == "*" && etag != "") { + // We matched an existing resource, abort! + if p.isWrite { + ctx.AddError(&huma.ErrorDetail{ + Message: "If-None-Match: " + match + " precondition failed, " + foundMsg, + Location: "request.headers.If-None-Match", + Value: match, + }) + } + failed = true + } + } + + // If-Match fails if none of the passed ETags matches the current resource. + if len(p.IfMatch) > 0 { + found := false + for _, match := range p.IfMatch { + if trimETag(match) == etag { + found = true + break + } + } + + if !found { + // We did not match the expected resource, abort! + if p.isWrite { + ctx.AddError(&huma.ErrorDetail{ + Message: "If-Match precondition failed, " + foundMsg, + Location: "request.headers.If-Match", + Value: p.IfMatch, + }) + } + failed = true + } + } + + if !p.IfModifiedSince.IsZero() && !modified.After(p.IfModifiedSince) { + // Resource was modified *before* the date that was passed, abort! + if p.isWrite { + ctx.AddError(&huma.ErrorDetail{ + Message: "If-Modified-Since: " + p.IfModifiedSince.Format(http.TimeFormat) + " precondition failed, resource was modified at " + modified.Format(http.TimeFormat), + Location: "request.headers.If-Modified-Since", + Value: p.IfModifiedSince.Format(http.TimeFormat), + }) + } + failed = true + } + + if !p.IfUnmodifiedSince.IsZero() && modified.After(p.IfUnmodifiedSince) { + // Resource was modified *after* the date that was passed, abort! + if p.isWrite { + ctx.AddError(&huma.ErrorDetail{ + Message: "If-Unmodified-Since: " + p.IfUnmodifiedSince.Format(http.TimeFormat) + " precondition failed, resource was modified at " + modified.Format(http.TimeFormat), + Location: "request.headers.If-Unmodified-Since", + Value: p.IfUnmodifiedSince.Format(http.TimeFormat), + }) + } + failed = true + } + + if failed { + if p.isWrite { + ctx.WriteError(http.StatusPreconditionFailed, http.StatusText(http.StatusPreconditionFailed)) + } else { + ctx.WriteHeader(http.StatusNotModified) + } + + return true + } + + return false +} diff --git a/conditional/params_test.go b/conditional/params_test.go new file mode 100644 index 0000000..3eb11de --- /dev/null +++ b/conditional/params_test.go @@ -0,0 +1,182 @@ +package conditional + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/danielgtaylor/huma" + "github.com/stretchr/testify/assert" +) + +func TestHasConditional(t *testing.T) { + p := Params{} + assert.False(t, p.HasConditionalParams()) + + p = Params{IfMatch: []string{"test"}} + assert.True(t, p.HasConditionalParams()) + + p = Params{IfNoneMatch: []string{"test"}} + assert.True(t, p.HasConditionalParams()) + + p = Params{IfModifiedSince: time.Now()} + assert.True(t, p.HasConditionalParams()) + + p = Params{IfUnmodifiedSince: time.Now()} + assert.True(t, p.HasConditionalParams()) +} + +func TestIfMatch(t *testing.T) { + p := Params{} + + // Read request + r, _ := http.NewRequest(http.MethodGet, "https://example.com/resource", nil) + w := httptest.NewRecorder() + ctx := huma.ContextFromRequest(w, r) + + p.IfMatch = []string{`"abc123"`, `W/"def456"`} + p.Resolve(ctx, r) + assert.False(t, p.PreconditionFailed(ctx, "abc123", time.Time{})) + assert.False(t, p.PreconditionFailed(ctx, "def456", time.Time{})) + assert.True(t, p.PreconditionFailed(ctx, "bad", time.Time{})) + assert.True(t, p.PreconditionFailed(ctx, "", time.Time{})) + + assert.False(t, ctx.HasError()) + assert.Equal(t, http.StatusNotModified, w.Result().StatusCode) + + // Write request + r, _ = http.NewRequest(http.MethodPut, "https://example.com/resource", nil) + w = httptest.NewRecorder() + ctx = huma.ContextFromRequest(w, r) + + p.IfMatch = []string{`"abc123"`, `W/"def456"`} + p.Resolve(ctx, r) + assert.False(t, p.PreconditionFailed(ctx, "abc123", time.Time{})) + assert.False(t, ctx.HasError()) + + assert.True(t, p.PreconditionFailed(ctx, "bad", time.Time{})) + assert.True(t, ctx.HasError()) + assert.Equal(t, http.StatusPreconditionFailed, w.Result().StatusCode) +} + +func TestIfNoneMatch(t *testing.T) { + p := Params{} + + // Read request + r, _ := http.NewRequest(http.MethodGet, "https://example.com/resource", nil) + w := httptest.NewRecorder() + ctx := huma.ContextFromRequest(w, r) + + p.IfNoneMatch = []string{`"abc123"`, `W/"def456"`} + p.Resolve(ctx, r) + assert.False(t, p.PreconditionFailed(ctx, "bad", time.Time{})) + assert.False(t, p.PreconditionFailed(ctx, "", time.Time{})) + assert.True(t, p.PreconditionFailed(ctx, "abc123", time.Time{})) + assert.True(t, p.PreconditionFailed(ctx, "def456", time.Time{})) + + assert.False(t, ctx.HasError()) + assert.Equal(t, http.StatusNotModified, w.Result().StatusCode) + + // Write request + r, _ = http.NewRequest(http.MethodPut, "https://example.com/resource", nil) + w = httptest.NewRecorder() + ctx = huma.ContextFromRequest(w, r) + + p.IfNoneMatch = []string{`"abc123"`, `W/"def456"`} + p.Resolve(ctx, r) + assert.True(t, p.PreconditionFailed(ctx, "abc123", time.Time{})) + assert.True(t, ctx.HasError()) + + ctx = huma.ContextFromRequest(w, r) + assert.False(t, p.PreconditionFailed(ctx, "bad", time.Time{})) + assert.False(t, ctx.HasError()) + + // Write with special `*` syntax to match any. + p.IfNoneMatch = []string{"*"} + ctx = huma.ContextFromRequest(w, r) + assert.False(t, p.PreconditionFailed(ctx, "", time.Time{})) + assert.False(t, ctx.HasError()) + + assert.True(t, p.PreconditionFailed(ctx, "abc123", time.Time{})) + assert.True(t, ctx.HasError()) + assert.Equal(t, http.StatusPreconditionFailed, w.Result().StatusCode) +} + +func TestIfModifiedSince(t *testing.T) { + p := Params{} + + now, err := time.Parse(time.RFC3339, "2021-01-01T12:00:00Z") + assert.NoError(t, err) + + before, err := time.Parse(time.RFC3339, "2020-01-01T12:00:00Z") + assert.NoError(t, err) + + after, err := time.Parse(time.RFC3339, "2022-01-01T12:00:00Z") + assert.NoError(t, err) + + // Read request + r, _ := http.NewRequest(http.MethodGet, "https://example.com/resource", nil) + w := httptest.NewRecorder() + ctx := huma.ContextFromRequest(w, r) + + p.IfModifiedSince = now + + p.Resolve(ctx, r) + assert.True(t, p.PreconditionFailed(ctx, "", before)) + assert.True(t, p.PreconditionFailed(ctx, "", now)) + assert.False(t, p.PreconditionFailed(ctx, "", after)) + + assert.False(t, ctx.HasError()) + + // Write request + r, _ = http.NewRequest(http.MethodPut, "https://example.com/resource", nil) + w = httptest.NewRecorder() + ctx = huma.ContextFromRequest(w, r) + + p.IfModifiedSince = now + + p.Resolve(ctx, r) + assert.True(t, p.PreconditionFailed(ctx, "", before)) + assert.True(t, ctx.HasError()) + assert.Equal(t, http.StatusPreconditionFailed, w.Result().StatusCode) +} + +func TestIfUnmodifiedSince(t *testing.T) { + p := Params{} + + now, err := time.Parse(time.RFC3339, "2021-01-01T12:00:00Z") + assert.NoError(t, err) + + before, err := time.Parse(time.RFC3339, "2020-01-01T12:00:00Z") + assert.NoError(t, err) + + after, err := time.Parse(time.RFC3339, "2022-01-01T12:00:00Z") + assert.NoError(t, err) + + // Read request + r, _ := http.NewRequest(http.MethodGet, "https://example.com/resource", nil) + w := httptest.NewRecorder() + ctx := huma.ContextFromRequest(w, r) + + p.IfUnmodifiedSince = now + + p.Resolve(ctx, r) + assert.False(t, p.PreconditionFailed(ctx, "", before)) + assert.False(t, p.PreconditionFailed(ctx, "", now)) + assert.True(t, p.PreconditionFailed(ctx, "", after)) + + assert.False(t, ctx.HasError()) + + // Write request + r, _ = http.NewRequest(http.MethodPut, "https://example.com/resource", nil) + w = httptest.NewRecorder() + ctx = huma.ContextFromRequest(w, r) + + p.IfUnmodifiedSince = now + + p.Resolve(ctx, r) + assert.True(t, p.PreconditionFailed(ctx, "", after)) + assert.True(t, ctx.HasError()) + assert.Equal(t, http.StatusPreconditionFailed, w.Result().StatusCode) +} diff --git a/go.mod b/go.mod index cde82dc..fc06c5a 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.13 require ( github.com/Jeffail/gabs/v2 v2.6.0 github.com/andybalholm/brotli v1.0.0 + github.com/evanphx/json-patch/v5 v5.5.0 // indirect github.com/fsnotify/fsnotify v1.4.9 // indirect github.com/fxamacker/cbor v1.5.1 github.com/fxamacker/cbor/v2 v2.2.0 diff --git a/go.sum b/go.sum index c14af37..424c0b4 100644 --- a/go.sum +++ b/go.sum @@ -44,6 +44,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/evanphx/json-patch/v5 v5.5.0 h1:bAmFiUJ+o0o2B4OiTFeE3MqCOtyo+jjPP9iZ0VRxYUc= +github.com/evanphx/json-patch/v5 v5.5.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -117,6 +119,7 @@ github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2p github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=