mirror of
https://github.com/Fishwaldo/huma.git
synced 2025-03-15 11:21:42 +00:00
feat: add utilities for conditional requests
This commit is contained in:
parent
1206386cf5
commit
fc06489523
5 changed files with 391 additions and 0 deletions
68
README.md
68
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.
|
||||
|
|
137
conditional/params.go
Normal file
137
conditional/params.go
Normal 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
182
conditional/params_test.go
Normal 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
1
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
|
||||
|
|
3
go.sum
3
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=
|
||||
|
|
Loading…
Add table
Reference in a new issue