diff --git a/docs/docs/features/groups.md b/docs/docs/features/groups.md new file mode 100644 index 00000000..f17e7e28 --- /dev/null +++ b/docs/docs/features/groups.md @@ -0,0 +1,129 @@ +--- +description: Group operations with common prefixes, middleware, transformers, and more. +--- + +# Groups + +## Groups { .hidden } + +Operations can be grouped under common route prefixes and share middleware, operation modifier functions, and response transformers. This is done using the `huma.Group` wrapper around a `huma.API` instance, which can then be passed to `huma.Register` and its convenience wrappers like `huma.Get`, `huma.Post`, etc. + +```go +grp := huma.NewGroup(api, "/v1") +grp.UseMiddleware(authMiddleware) + +huma.Get(grp, "/users", func(ctx context.Context, input *struct{}) (*UsersResponse, error) { + // ... +}) +``` + +The above example will register a `GET /v1/users` operation with the `authMiddleware` running before the operation handler. + +!!! info "Groups & Documentation" + + Groups assume that `huma.Register` or one of its convenience wrappers is used to register operations. If you are not, then you may need to invoke `group.DocumentOperation(*huma.Operation)` to ensure that the operation is documented correctly. + +## Group Features + +Groups support the following features: + +- One or more path prefixes for all operations in the group. +- Middleware that runs before each operation in the group. +- Operation modifiers that run at operation registration time. +- Response transformers that run after each operation in the group. + +## Prefixes + +Groups can have one or more path prefixes that are prepended to all operations in the group. This is useful for grouping related operations under a common prefix and is typically done with a single prefix. + +```go +grp := huma.NewGroup(api, "/prefix1", "/prefix2", "...") +``` + +This is just a convenience for the following equivalent code: + +```go +grp := huma.NewGroup(api) +grp.UseModifier(huma.PrefixModifier("/prefix1", "/prefix2", "...")) +``` + +The built-in [`huma.PrefixModifier`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#PrefixModifier) will adjust the operation's ID and tags when more than one prefix is used. If you with to customize this behavior, you can write your own operation modifier. + +## Middleware + +Middleware functions are run before each operation handler in the group. They can be used for common tasks like authentication, logging, and error handling. Middleware functions are registered using the `UseMiddleware` method on a group. + +```go +grp.UseMiddleware(func(ctx huma.Context, next func(huma.Context)) { + // Do something before the operation runs + next(ctx) +}) +``` + +## Operation Modifiers + +Operation modifiers are functions that run at operation registration time. They can be used to modify the operation before it is registered. Operation modifiers are registered using the `UseModifier` method on a group. + +```go +grp.UseModifier(func(op *huma.Operation, next func(*huma.Operation)) { + op.Summary = "A summary for all operations in this group" + op.Tags = []string{"my-tag"} + next(op) +}) +``` + +There is also a simplified form you can use: + +```go +grp.UseSimpleModifier(func(op *huma.Operation)) { + op.Summary = "A summary for all operations in this group" + op.Tags = []string{"my-tag"} +}) +``` + +## Response Transformers + +Response transformers are functions that run after each operation handler in the group. They can be used to modify the response before it is returned to the client. Response transformers are registered using the `UseResponseTransformer` method on a group. + +```go +grp.UseTransformer(func(ctx huma.Context, status string, v any) (any, error) { + // Do something with the output + return output, nil +}) +``` + +## Customizing Documentation + +Groups implement [`huma.OperationDocumenter`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#OperationDocumenter) which bypasses the normal flow of documentation generation and instead calls a function. This allows you to customize the documentation for all operations in the group. You can override the `DocumentOperation` method to customize the documentation if needed: + +```go +type MyGroup huma.Group + +func (g *MyGroup) DocumentOperation(op *huma.Operation) { + g.ModifyOperation(op, func(op *huma.Operation) { + if documenter, ok := g.API.(huma.OperationDocumenter); ok { + // Support nested operation documenters (i.e. groups of groups). + documenter.DocumentOperation(op) + } else { + // Default behavior to add operations. + if op.Hidden { + return + } + g.OpenAPI().AddOperation(op) + } + }) +} +``` + +## Dive Deeper + +- Features + - [Operations](./operations.md) registration & workflows + - [Middleware](./middleware.md) for operations + - [Response Transformers](./response-transformers.md) to modify response bodies +- Reference + - [`huma.Register`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#Register) register an operation + - [`huma.Middlewares`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#Middlewares) list of middleware + - [`huma.Transformer`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#Transformer) response transformers + - [`huma.OperationDocumenter`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#OperationDocumenter) to customize OpenAPI generation + - [`huma.API`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#API) the API instance diff --git a/docs/docs/features/middleware.md b/docs/docs/features/middleware.md index d6f61ffe..10ae79c7 100644 --- a/docs/docs/features/middleware.md +++ b/docs/docs/features/middleware.md @@ -100,7 +100,7 @@ func MyMiddleware(ctx huma.Context, next func(huma.Context)) { Then you can get the value in the handler context: -``` go title="handler.go" +```go title="handler.go" huma.Get(api, "/greeting/{name}", func(ctx context.Context, input *struct{ Name string `path:"name" maxLength:"30" example:"world" doc:"Name to greet"` }) (*GreetingOutput, error) { @@ -201,7 +201,7 @@ It's also possible for global middleware to run only for certain paths by checki - Reference - [`huma.Context`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#Context) a router-agnostic request/response context - - [`huma.Middlewares`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#Middlewares) the API instance + - [`huma.Middlewares`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#Middlewares) list of middleware - [`huma.ReadCookie`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#ReadCookie) reads a named cookie from a request - [`huma.ReadCookies`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#ReadCookies) reads cookies from a request - [`huma.WriteErr`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#WriteErr) function to write error responses diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index c8c3edb6..220804b0 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -34,6 +34,7 @@ nav: - "Model Validation": features/model-validation.md - "Operations": - "Operations": features/operations.md + - "Groups": features/groups.md - "Requests": - "Request Inputs": features/request-inputs.md - "Validation": features/request-validation.md @@ -46,8 +47,8 @@ nav: - "Streaming": features/response-streaming.md - "Transformers": features/response-transformers.md - "Extra Packages": - - "Conditional Requests": features/conditional-requests.md - "Auto PATCH Operations": features/auto-patch.md + - "Conditional Requests": features/conditional-requests.md - "Server Sent Events (SSE)": features/server-sent-events-sse.md - "Test Utilities": features/test-utilities.md - "Clients": diff --git a/group.go b/group.go new file mode 100644 index 00000000..ac19b42b --- /dev/null +++ b/group.go @@ -0,0 +1,170 @@ +package huma + +import ( + "strings" +) + +// OperationDocumenter is an interface that can be implemented by an API or +// group to document operations in the OpenAPI document. This bypasses the +// normal `huma.Register` logic and provides complete customization of how +// operations are documented. +type OperationDocumenter interface { + // DocumentOperation adds an operation to the OpenAPI document. This is + // called by `huma.Register` when a new operation is registered. + DocumentOperation(op *Operation) +} + +// PrefixModifier provides a fan-out to register one or more operations with +// the given prefix for every one operation added to a group. +func PrefixModifier(prefixes []string) func(o *Operation, next func(*Operation)) { + return func(o *Operation, next func(*Operation)) { + for _, prefix := range prefixes { + modified := *o + if len(prefixes) > 1 && prefix != "" { + // If there are multiple prefixes, update the ID and tags so you can + // differentiate between them in clients & the UI. + friendlyPrefix := strings.ReplaceAll(strings.Trim(prefix, "/"), "/", "-") + modified.OperationID = friendlyPrefix + "-" + modified.OperationID + tags := append([]string{}, modified.Tags...) + modified.Tags = append(tags, friendlyPrefix) + } + modified.Path = prefix + modified.Path + next(&modified) + } + } +} + +// groupAdapter is an Adapter wrapper that registers multiple operation handlers +// with the underlying adapter based on the group's prefixes. +type groupAdapter struct { + Adapter + group *Group +} + +func (a *groupAdapter) Handle(op *Operation, handler func(Context)) { + a.group.ModifyOperation(op, func(op *Operation) { + a.Adapter.Handle(op, handler) + }) +} + +// Group is a collection of routes that share a common prefix and set of +// operation modifiers, middlewares, and transformers. +// +// This is useful for grouping related routes together and applying common +// settings to them. For example, you might create a group for all routes that +// require authentication. +type Group struct { + API + prefixes []string + adapter Adapter + modifiers []func(o *Operation, next func(*Operation)) + middlewares Middlewares + transformers []Transformer +} + +// NewGroup creates a new group of routes with the given prefixes, if any. A +// group enables a collection of operations to have the same prefix and share +// operation modifiers, middlewares, and transformers. +// +// grp := huma.NewGroup(api, "/v1") +// grp.UseMiddleware(authMiddleware) +// +// huma.Get(grp, "/users", func(ctx huma.Context, input *MyInput) (*MyOutput, error) { +// // Your code here... +// }) +func NewGroup(api API, prefixes ...string) *Group { + group := &Group{API: api, prefixes: prefixes} + group.adapter = &groupAdapter{Adapter: api.Adapter(), group: group} + if len(prefixes) > 0 { + group.UseModifier(PrefixModifier(prefixes)) + } + return group +} + +func (g *Group) Adapter() Adapter { + return g.adapter +} + +// DocumentOperation adds an operation to the OpenAPI document. This is called +// by `huma.Register` when a new operation is registered. All modifiers will be +// run on the operation before it is added to the OpenAPI document, so for +// groups with multiple prefixes this will result in multiple operations in the +// OpenAPI document. +func (g *Group) DocumentOperation(op *Operation) { + g.ModifyOperation(op, func(op *Operation) { + if documenter, ok := g.API.(OperationDocumenter); ok { + documenter.DocumentOperation(op) + } else { + if op.Hidden { + return + } + g.OpenAPI().AddOperation(op) + } + }) +} + +// UseModifier adds an operation modifier function to the group that will be run +// on all operations in the group. Use this to modify the operation before it is +// registered with the router or OpenAPI document. This behaves similar to +// middleware in that you should invoke `next` to continue the chain. Skip it +// to prevent the operation from being registered, and call multiple times for +// a fan-out effect. +func (g *Group) UseModifier(modifier func(o *Operation, next func(*Operation))) { + g.modifiers = append(g.modifiers, modifier) +} + +// UseSimpleModifier adds an operation modifier function to the group that +// will be run on all operations in the group. Use this to modify the operation +// before it is registered with the router or OpenAPI document. +func (g *Group) UseSimpleModifier(modifier func(o *Operation)) { + g.modifiers = append(g.modifiers, func(o *Operation, next func(*Operation)) { + modifier(o) + next(o) + }) +} + +// ModifyOperation runs all operation modifiers in the group on the given +// operation, in the order they were added. This is useful for modifying an +// operation before it is registered with the router or OpenAPI document. +func (g *Group) ModifyOperation(op *Operation, next func(*Operation)) { + chain := next + for i := len(g.modifiers) - 1; i >= 0; i-- { + // Use an inline func to provide a closure around the index & chain. + func(i int, n func(*Operation)) { + chain = func(op *Operation) { g.modifiers[i](op, n) } + }(i, chain) + } + chain(op) +} + +// UseMiddleware adds one or more middleware functions to the group that will be +// run on all operations in the group. Use this to add common functionality to +// all operations in the group, e.g. authentication/authorization. +func (g *Group) UseMiddleware(middlewares ...func(ctx Context, next func(Context))) { + g.middlewares = append(g.middlewares, middlewares...) +} + +func (g *Group) Middlewares() Middlewares { + m := append(Middlewares{}, g.API.Middlewares()...) + return append(m, g.middlewares...) +} + +// UseTransformer adds one or more transformer functions to the group that will +// be run on all responses in the group. +func (g *Group) UseTransformer(transformers ...Transformer) { + g.transformers = append(g.transformers, transformers...) +} + +func (g *Group) Transform(ctx Context, status string, v any) (any, error) { + v, err := g.API.Transform(ctx, status, v) + if err != nil { + return v, err + } + for _, transformer := range g.transformers { + v, err = transformer(ctx, status, v) + if err != nil { + return v, err + } + } + return v, nil +} diff --git a/group_test.go b/group_test.go new file mode 100644 index 00000000..96298626 --- /dev/null +++ b/group_test.go @@ -0,0 +1,166 @@ +package huma_test + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/humatest" + "github.com/stretchr/testify/assert" +) + +func TestGroupNoPrefix(t *testing.T) { + _, api := humatest.New(t) + + grp := huma.NewGroup(api) + + huma.Get(grp, "/users", func(ctx context.Context, input *struct{}) (*struct{}, error) { + return nil, nil + }) + + assert.NotNil(t, api.OpenAPI().Paths["/users"]) + + resp := api.Get("/users") + assert.Equal(t, http.StatusNoContent, resp.Result().StatusCode) +} + +func TestGroupMultiPrefix(t *testing.T) { + _, api := humatest.New(t) + + // Ensure paths exist for when the shallow copy is made. + api.OpenAPI().Paths = map[string]*huma.PathItem{} + + grp := huma.NewGroup(api, "/v1", "/v2") + child := huma.NewGroup(grp, "/prefix") + + huma.Get(child, "/users", func(ctx context.Context, input *struct{}) (*struct{}, error) { + return nil, nil + }) + + assert.Nil(t, api.OpenAPI().Paths["/users"]) + assert.NotNil(t, api.OpenAPI().Paths["/v1/prefix/users"]) + assert.NotNil(t, api.OpenAPI().Paths["/v2/prefix/users"]) + assert.NotEqual(t, api.OpenAPI().Paths["/v1/prefix/users"].Get.OperationID, api.OpenAPI().Paths["/v2/prefix/users"].Get.OperationID) + + resp := api.Get("/v1/prefix/users") + assert.Equal(t, http.StatusNoContent, resp.Result().StatusCode) + + resp = api.Get("/v2/prefix/users") + assert.Equal(t, http.StatusNoContent, resp.Result().StatusCode) +} + +func TestGroupCustomizations(t *testing.T) { + _, api := humatest.New(t) + + grp := huma.NewGroup(api, "/v1") + + opModifier1Called := false + opModifier2Called := false + middleware1Called := false + middleware2Called := false + transformerCalled := false + grp.UseSimpleModifier(func(op *huma.Operation) { + opModifier1Called = true + }) + + grp.UseMiddleware(func(ctx huma.Context, next func(huma.Context)) { + middleware1Called = true + next(ctx) + }) + grp.UseMiddleware(func(ctx huma.Context, next func(huma.Context)) { + middleware2Called = true + next(ctx) + }) + + grp.UseTransformer(func(ctx huma.Context, status string, v any) (any, error) { + transformerCalled = true + return v, nil + }) + + // Ensure nested groups behave properly. + childGrp := huma.NewGroup(grp) + childGrp.UseSimpleModifier(func(op *huma.Operation) { + opModifier2Called = true + }) + + huma.Get(childGrp, "/users", func(ctx context.Context, input *struct{}) (*struct { + Body string + }, error) { + return &struct{ Body string }{Body: ""}, nil + }) + + // Manual OpenAPI modification + childGrp.OpenAPI().Info.Title = "Set from group" + + assert.NotNil(t, api.OpenAPI().Paths["/v1/users"]) + assert.Equal(t, "Set from group", api.OpenAPI().Info.Title) + + resp := api.Get("/v1/users") + assert.Equal(t, 200, resp.Result().StatusCode) + assert.True(t, opModifier1Called) + assert.True(t, opModifier2Called) + assert.True(t, middleware1Called) + assert.True(t, middleware2Called) + assert.True(t, transformerCalled) +} + +func TestGroupHiddenOp(t *testing.T) { + _, api := humatest.New(t) + grp := huma.NewGroup(api, "/v1") + huma.Register(grp, huma.Operation{ + OperationID: "get-users", + Method: http.MethodGet, + Path: "/users", + Hidden: true, + }, func(ctx context.Context, input *struct{}) (*struct{}, error) { + return nil, nil + }) + + assert.Nil(t, api.OpenAPI().Paths["/v1/users"]) +} + +type FailingTransformAPI struct { + huma.API +} + +func (a *FailingTransformAPI) Transform(ctx huma.Context, status string, v any) (any, error) { + return nil, errors.New("whoops") +} + +func TestGroupTransformUnderlyingError(t *testing.T) { + _, api := humatest.New(t) + + grp := huma.NewGroup(&FailingTransformAPI{API: api}, "/v1") + + huma.Get(grp, "/users", func(ctx context.Context, input *struct{}) (*struct { + Body string + }, error) { + return &struct{ Body string }{Body: ""}, nil + }) + + assert.Panics(t, func() { + api.Get("/v1/users") + }) +} + +func TestGroupTransformError(t *testing.T) { + _, api := humatest.New(t) + + grp := huma.NewGroup(api, "/v1") + + grp.UseTransformer(func(ctx huma.Context, status string, v any) (any, error) { + return v, errors.New("whoops") + }) + + huma.Get(grp, "/users", func(ctx context.Context, input *struct{}) (*struct { + Body string + }, error) { + return &struct{ Body string }{Body: ""}, nil + }) + + assert.Panics(t, func() { + api.Get("/v1/users") + }) +} diff --git a/huma.go b/huma.go index 301ec51e..065d163b 100644 --- a/huma.go +++ b/huma.go @@ -640,8 +640,13 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) } defineErrors(&op, registry) - if !op.Hidden { - oapi.AddOperation(&op) + if documenter, ok := api.(OperationDocumenter); ok { + // Enables customization of OpenAPI documentation behavior for operations. + documenter.DocumentOperation(&op) + } else { + if !op.Hidden { + oapi.AddOperation(&op) + } } resolvers := findResolvers(resolverType, inputType)