Skip to content

Commit

Permalink
feat: operation metadata and autopatch disable
Browse files Browse the repository at this point in the history
  • Loading branch information
danielgtaylor committed Oct 31, 2023
1 parent 73a0760 commit 8089a9f
Show file tree
Hide file tree
Showing 4 changed files with 124 additions and 64 deletions.
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,9 @@ http.ListenAndServe(":8888", r)
### Middleware

Huma v2 has support two variants of middlewares:
Huma has support two variants of middlewares:

1. Router-specific - works at the router level, i.e. before router-specific middleware, you can use any middleware that is implemented for your router.
1. Router-specific - works at the router level, i.e. before router-agnostic middleware, you can use any middleware that is implemented for your router.
2. Router-agnostic - runs in the Huma processing chain, i.e. after calls to router-specific middleware.

#### Router-specific
Expand All @@ -196,11 +196,11 @@ router.Use(jwtauth.Verifier(tokenAuth))
api := humachi.New(router, defconfig)
```

> :whale: Huma v1 middleware is compatible with Chi, so if you use that router with v2 you can continue to use the v1 middleware in a v2 application.
> :whale: Huma v1 middleware is compatible with Chi v4, so if you use that router with Huma v2 you can continue to use the Huma v1 middleware.
#### Router-agnostic

You can write you own huma v2 middleware without dependency to router implementation.
You can write you own Huma v2 middleware without any dependency to the specific router implementation.

Example:

Expand Down Expand Up @@ -271,7 +271,7 @@ huma.Register(api, withBearerAuth(huma.Operation{
})
```

Set this up however you like. Even the `huma.Register` function can be wrapped by your organization to ensure that all operations are registered with the same settings.
Set this up however you like. Even the `huma.Register` function can be wrapped or replaced by your organization to ensure that all operations are registered with the same settings.

### Custom OpenAPI Extensions

Expand Down
23 changes: 19 additions & 4 deletions autopatch/autopatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,24 @@ var jsonPatchType = reflect.TypeOf([]jsonPatchOp{})
// `application/json-patch+json`, or `application/merge-patch+shorthand`
// patches, then call PUT with the updated resource. This method may be safely
// called multiple times.
//
// If you wish to disable autopatching for a specific resource, set the
// `autopatch` operation metadata field to `false` on the GET or PUT
// operation and it will be skipped.
func AutoPatch(api huma.API) {
oapi := api.OpenAPI()
registry := oapi.Components.Schemas
Outer:
for _, path := range oapi.Paths {
if path.Get != nil && path.Put != nil && path.Patch == nil {
for _, op := range []*huma.Operation{path.Get, path.Put} {
if op.Metadata != nil && op.Metadata["autopatch"] != nil {
if b, ok := op.Metadata["autopatch"].(bool); ok && !b {
// Special case: explicitly disabled.
continue Outer
}
}
}
body := path.Put.RequestBody
if body != nil && body.Content != nil && body.Content["application/json"] != nil {
ct := body.Content["application/json"]
Expand All @@ -58,17 +71,19 @@ func AutoPatch(api huma.API) {
// Only objects can be patched automatically. No arrays or
// primitives so skip those.
if s.Type == "object" {
generatePatch(api, path)
PatchResource(api, path)
}
}
}
}
}
}

// generatePatch is called for each resource which needs a PATCH operation to
// be added. it registers and provides a handler for this new operation.
func generatePatch(api huma.API, path *huma.PathItem) {
// PatchResource is called for each resource which needs a PATCH operation to
// be added. It registers and provides a handler for this new operation. You
// may call this manually if you prefer to not use `AutoPatch` for all of
// your resources and want more fine-grained control.
func PatchResource(api huma.API, path *huma.PathItem) {
oapi := api.OpenAPI()
get := path.Get
put := path.Put
Expand Down
146 changes: 91 additions & 55 deletions autopatch/autopatch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/iotest"
Expand Down Expand Up @@ -137,90 +136,89 @@ func TestPatch(t *testing.T) {
)
assert.Equal(t, http.StatusNotModified, w.Code, w.Body.String())

app := api.Adapter()
// New change but with wrong manual ETag, should fail!
w = httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPatch, "/things/test", strings.NewReader(`{"price": 4.56}`))
req.Header.Set("Content-Type", "application/merge-patch+json")
req.Header.Set("If-Match", "abc123")
app.ServeHTTP(w, req)
w = api.Patch("/things/test",
"Content-Type: application/merge-patch+json",
"If-Match: abc123",
strings.NewReader(`{"price": 4.56}`),
)
assert.Equal(t, http.StatusPreconditionFailed, w.Code, w.Body.String())

// Correct manual ETag should pass!
w = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodPatch, "/things/test", strings.NewReader(`{"price": 4.56}`))
req.Header.Set("Content-Type", "application/merge-patch+json")
req.Header.Set("If-Match", "test1.23[US123 EU456][]")
app.ServeHTTP(w, req)
w = api.Patch("/things/test",
"Content-Type: application/merge-patch+json",
"If-Match: test1.23[US123 EU456][]",
strings.NewReader(`{"price": 4.56}`),
)
assert.Equal(t, http.StatusOK, w.Code, w.Body.String())
assert.Equal(t, "test4.56[US123 EU456][]", w.Result().Header.Get("ETag"))

// Merge Patch: invalid
w = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodPatch, "/things/test", strings.NewReader(`{`))
req.Header.Set("Content-Type", "application/merge-patch+json")
app.ServeHTTP(w, req)
w = api.Patch("/things/test",
"Content-Type: application/merge-patch+json",
strings.NewReader(`{`),
)
assert.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String())

// JSON Patch Test
w = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodPatch, "/things/test", strings.NewReader(`[
{"op": "add", "path": "/tags", "value": ["b"]},
{"op": "add", "path": "/tags/0", "value": "a"}
]`))
req.Header.Set("Content-Type", "application/json-patch+json")
app.ServeHTTP(w, req)
w = api.Patch("/things/test",
"Content-Type: application/json-patch+json",
strings.NewReader(`[
{"op": "add", "path": "/tags", "value": ["b"]},
{"op": "add", "path": "/tags/0", "value": "a"}
]`),
)
assert.Equal(t, http.StatusOK, w.Code, w.Body.String())
assert.Equal(t, "test4.56[US123 EU456][a b]", w.Result().Header.Get("ETag"))

// JSON Patch: bad JSON
w = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodPatch, "/things/test", strings.NewReader(`[`))
req.Header.Set("Content-Type", "application/json-patch+json")
app.ServeHTTP(w, req)
w = api.Patch("/things/test",
"Content-Type: application/json-patch+json",
strings.NewReader(`[`),
)
assert.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String())

// JSON Patch: invalid patch
w = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodPatch, "/things/test", strings.NewReader(`[{"op": "unsupported"}]`))
req.Header.Set("Content-Type", "application/json-patch+json")
app.ServeHTTP(w, req)
w = api.Patch("/things/test",
"Content-Type: application/json-patch+json",
strings.NewReader(`[{"op": "unsupported"}]`),
)
assert.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String())

// Shorthand Patch Test
w = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodPatch, "/things/test", strings.NewReader(`{tags[]: c}`))
req.Header.Set("Content-Type", "application/merge-patch+shorthand")
app.ServeHTTP(w, req)
w = api.Patch("/things/test",
"Content-Type: application/merge-patch+shorthand",
strings.NewReader(`{tags[]: c}`),
)
assert.Equal(t, http.StatusOK, w.Code, w.Body.String())
assert.Equal(t, "test4.56[US123 EU456][a b c]", w.Result().Header.Get("ETag"))

// Shorthand Patch: bad input
w = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodPatch, "/things/test", strings.NewReader(`[`))
req.Header.Set("Content-Type", "application/merge-patch+shorthand")
app.ServeHTTP(w, req)
w = api.Patch("/things/test",
"Content-Type: application/merge-patch+shorthand",
strings.NewReader(`[`),
)
assert.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String())

// Bad content type
w = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodPatch, "/things/test", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/unsupported-content-type")
app.ServeHTTP(w, req)
w = api.Patch("/things/test",
"Content-Type: application/unsupported-content-type",
strings.NewReader(`{}`),
)
assert.Equal(t, http.StatusUnsupportedMediaType, w.Code, w.Body.String())

// PATCH body read error
w = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodPatch, "/things/notfound", iotest.ErrReader(fmt.Errorf("test error")))
req.Header.Set("Content-Type", "application/merge-patch+json")
app.ServeHTTP(w, req)
w = api.Patch("/things/notfound",
"Content-Type: application/merge-patch+json",
iotest.ErrReader(fmt.Errorf("test error")),
)
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())

// GET error
w = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodPatch, "/things/notfound", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/merge-patch+json")
app.ServeHTTP(w, req)
w = api.Patch("/things/notfound",
"Content-Type: application/merge-patch+json",
strings.NewReader(`{}`),
)
assert.Equal(t, http.StatusNotFound, w.Code, w.Body.String())
}

Expand Down Expand Up @@ -253,9 +251,47 @@ func TestPatchPutNoBody(t *testing.T) {

// There should be no generated PATCH since there is nothing to
// write in the PUT!
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPatch, "/things/test", nil)
api.Adapter().ServeHTTP(w, req)
assert.Nil(t, api.OpenAPI().Paths["/things/{thing-id}"].Patch)
w := api.Patch("/things/test")
assert.Equal(t, http.StatusMethodNotAllowed, w.Code, w.Body.String())
}

func TestExplicitDisable(t *testing.T) {
_, api := humatest.New(t)

huma.Register(api, huma.Operation{
OperationID: "get-thing",
Method: http.MethodGet,
Path: "/things/{thing-id}",
Errors: []int{404},
Metadata: map[string]any{
"autopatch": false, // <-- Disabled here!
},
}, func(ctx context.Context, input *struct {
ThingIDParam
}) (*struct{ Body struct{} }, error) {
return nil, nil
})

huma.Register(api, huma.Operation{
OperationID: "put-thing",
Method: http.MethodPut,
Path: "/things/{thing-id}",
Errors: []int{404, 412},
}, func(ctx context.Context, input *struct {
ThingIDParam
Body ThingModel
IfMatch []string `header:"If-Match" doc:"Succeeds if the server's resource matches one of the passed values."`
}) (*struct{ Body struct{} }, error) {
return nil, nil
})

AutoPatch(api)

// There should be no generated PATCH since there is nothing to
// write in the PUT!
assert.Nil(t, api.OpenAPI().Paths["/things/{thing-id}"].Patch)
w := api.Patch("/things/test")
assert.Equal(t, http.StatusMethodNotAllowed, w.Code, w.Body.String())
}

Expand Down Expand Up @@ -290,5 +326,5 @@ func TestDeprecatedPatch(t *testing.T) {

AutoPatch(api)

assert.Equal(t, true, api.OpenAPI().Paths["/things/{thing-id}"].Patch.Deprecated)
assert.True(t, api.OpenAPI().Paths["/things/{thing-id}"].Patch.Deprecated)
}
9 changes: 9 additions & 0 deletions openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,10 @@ type Operation struct {

// Errors is a list of HTTP status codes that the handler may return. If
// not specified, then a default error response is added to the OpenAPI.
// This is a convenience for handlers that return a fixed set of errors
// where you do not wish to provide each one as an OpenAPI response object.
// Each error specified here is expanded into a response object with the
// schema generated from the type returned by `huma.NewError()`.
Errors []int `yaml:"-"`

// SkipValidateParams disables validation of path, query, and header
Expand All @@ -669,6 +673,11 @@ type Operation struct {
// you'd still like the benefits of using Huma. Generally not recommended.
Hidden bool `yaml:"-"`

// Metadata is a map of arbitrary data that can be attached to the operation.
// This can be used to store custom data, such as custom settings for
// functions which generate operations.
Metadata map[string]any `yaml:"-"`

// --- OpenAPI fields ---

// Tags is a list of tags for API documentation control. Tags can be used for
Expand Down

0 comments on commit 8089a9f

Please sign in to comment.