feat: add utilities for conditional requests

This commit is contained in:
Daniel G. Taylor 2022-01-16 15:01:33 -08:00
parent 1206386cf5
commit fc06489523
No known key found for this signature in database
GPG key ID: 74AE195C5112E534
5 changed files with 391 additions and 0 deletions

View file

@ -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.

137
conditional/params.go Normal file
View file

@ -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
}

182
conditional/params_test.go Normal file
View file

@ -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)
}

1
go.mod
View file

@ -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

3
go.sum
View file

@ -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=