Repository: danielgtaylor/huma
Pull request #279 (2024-03-04 17:43:56)

feat: add convenience methods
This PR adds convenience methods like `huma.Get`, `huma.Post`, `huma.Put`, etc to make it a little easier to get started. People have said Huma is a bit verbose, so this should help and you can use `huma.Register` to have more control when needed.

Examples and tutorial are updated. Additionally it includes some random doc updates and adds the star chart to the README.


Patch:
diff --git a/README.md b/README.md
index 18a1135..d517632 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@ Features include:
 
 - Declarative interface on top of your router of choice:
   - Operation & model documentation
-  - Request params (path, query, or header)
+  - Request params (path, query, header, or cookie)
   - Request body
   - Responses (including errors)
   - Response headers
@@ -61,6 +61,8 @@ This project was inspired by [FastAPI](https://fastapi.tiangolo.com/). Logo & br
 
 > Thank you Daniel for Huma. Superbly useful project and saves us a lot of time and hassle thanks to the OpenAPI gen — similar to FastAPI in Python. - [WolvesOfAllStreets](https://www.reddit.com/r/golang/comments/1aqj99d/comment/kqfqcml/?utm_source=reddit&utm_medium=web2x&context=3)
 
+> Huma is wonderful, I've started working with it recently, and it's a pleasure, so thank you very much for your efforts 🙏 - [callmemicah](https://www.reddit.com/r/golang/comments/1b32ts4/comment/ksvr9h7/?utm_source=reddit&utm_medium=web2x&context=3)
+
 # Install
 
 Install via `go get`. Note that Go 1.20 or newer is required.
@@ -87,16 +89,11 @@ import (
 	"github.com/go-chi/chi/v5"
 )
 
-// Options for the CLI.
+// Options for the CLI. Pass `--port` or set the `SERVICE_PORT` env var.
 type Options struct {
 	Port int `help:"Port to listen on" short:"p" default:"8888"`
 }
 
-// GreetingInput represents the greeting operation request.
-type GreetingInput struct {
-	Name string `path:"name" maxLength:"30" example:"world" doc:"Name to greet"`
-}
-
 // GreetingOutput represents the greeting operation response.
 type GreetingOutput struct {
 	Body struct {
@@ -111,13 +108,10 @@ func main() {
 		router := chi.NewMux()
 		api := humachi.New(router, huma.DefaultConfig("My API", "1.0.0"))
 
-		// Register GET /greeting/{name}
-		huma.Register(api, huma.Operation{
-			OperationID: "get-greeting",
-			Summary:     "Get a greeting",
-			Method:      http.MethodGet,
-			Path:        "/greeting/{name}",
-		}, func(ctx context.Context, input *GreetingInput) (*GreetingOutput, error) {
+		// Add the operation handler to the API.
+		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) {
 			resp := &GreetingOutput{}
 			resp.Body.Message = fmt.Sprintf("Hello, %s!", input.Name)
 			return resp, nil
@@ -134,6 +128,9 @@ func main() {
 }
 ```
 
+> [!TIP]
+> Replace `chi.NewMux()` → `http.NewServeMux()` and `humachi.New` → `humago.New` to use the standard library router from Go 1.22+. Just make sure your `go.mod` has `go 1.22` or newer listed in it. Everything else stays the same! Switch whenever you are ready.
+
 You can test it with `go run greet.go` (optionally pass `--port` to change the default) and make a sample request using [Restish](https://rest.sh/) (or `curl`):
 
 ```sh
@@ -149,6 +146,8 @@ HTTP/1.1 200 OK
 
 Even though the example is tiny you can also see some generated documentation at http://localhost:8888/docs. The generated OpenAPI is available at http://localhost:8888/openapi.json or http://localhost:8888/openapi.yaml.
 
+Check out the [Huma tutorial](https://huma.rocks/tutorial/installation/) for a step-by-step guide to get started.
+
 # Documentation
 
 See the [https://huma.rocks/](https://huma.rocks/) website for full documentation in a presentation that's easier to navigate and search then this README. You can find the source for the site in the `docs` directory of this repo.
@@ -162,3 +161,8 @@ Official Go package documentation can always be found at https://pkg.go.dev/gith
 - [Golang News & Libs & Jobs shared on Twitter/X](https://twitter.com/golangch/status/1752175499701264532)
 - Featured in [Go Weekly #495](https://golangweekly.com/issues/495)
 - [Bump.sh Deploying Docs from Huma](https://docs.bump.sh/guides/bump-sh-tutorials/huma/)
+- Mentioned in [Composable HTTP Handlers Using Generics](https://www.willem.dev/articles/generic-http-handlers/)
+
+Be sure to star the project if you find it useful!
+
+[![Star History Chart](https://api.star-history.com/svg?repos=danielgtaylor/huma&type=Date)](https://star-history.com/#danielgtaylor/huma&Date)
diff --git a/docs/docs/features/operations.md b/docs/docs/features/operations.md
index 95919fb..d11232b 100644
--- a/docs/docs/features/operations.md
+++ b/docs/docs/features/operations.md
@@ -53,9 +53,31 @@ huma.Register(api, huma.Operation{
     $ restish your-api your-operation-name --param=value ...
     ```
 
+### Convenience Methods
+
+A number of convenience methods are provided if you don't want to use the `huma.Operation` struct directly. The following are available:
+
+-   `huma.Get`
+-   `huma.Post`
+-   `huma.Put`
+-   `huma.Patch`
+-   `huma.Delete`
+
+These methods are equivalent to using `huma.Register` with the `Method` field set to the corresponding HTTP method, and they generate the operation ID for you based on the path. For example:
+
+```go title="code.go"
+huma.Get(api, "/things/{thing-id}", func(ctx context.Context, input *YourInput) (*YourOutput, error) {
+    // ... Implementation goes here ...
+})
+```
+
+The generated operation ID for the above example would be `get-things-by-thing-id`. Override `huma.GenerateOperationID(method, path string, response any)` to customize the generated operation IDs.
+
+This makes it easy to get started, particularly if coming from other frameworks, and you can simply switch to using `huma.Register` if/when you need to set additional fields on the operation.
+
 ## Handler Function
 
-The operation handler function always has the following generic format, where `Input` and `Output` are custom structs defined by the developer that represent the entirety of the request (path/query/header params & body) and response (headers & body), respectively:
+The operation handler function _always_ has the following generic format, where `Input` and `Output` are custom structs defined by the developer that represent the entirety of the request (path/query/header/cookie params & body) and response (headers & body), respectively:
 
 ```go title="code.go"
 func(context.Context, *Input) (*Output, error)
diff --git a/docs/docs/tutorial/sending-data.md b/docs/docs/tutorial/sending-data.md
index 00c7e8f..f661be0 100644
--- a/docs/docs/tutorial/sending-data.md
+++ b/docs/docs/tutorial/sending-data.md
@@ -22,7 +22,7 @@ Response: 201 Created
 
 Add a new operation to our API that allows users to submit reviews of our product.
 
-```go title="main.go" linenums="1" hl_lines="30-37 58-67"
+```go title="main.go" linenums="1" hl_lines="25-32 57-68"
 package main
 
 import (
@@ -40,11 +40,6 @@ type Options struct {
 	Port int `help:"Port to listen on" short:"p" default:"8888"`
 }
 
-// GreetingInput represents the greeting operation request.
-type GreetingInput struct {
-	Name string `path:"name" maxLength:"30" example:"world" doc:"Name to greet"`
-}
-
 // GreetingOutput represents the greeting operation response.
 type GreetingOutput struct {
 	Body struct {
@@ -71,20 +66,26 @@ func main() {
 		// Register GET /greeting/{name}
 		huma.Register(api, huma.Operation{
 			OperationID: "get-greeting",
-			Summary:     "Get a greeting",
 			Method:      http.MethodGet,
 			Path:        "/greeting/{name}",
-		}, func(ctx context.Context, input *GreetingInput) (*GreetingOutput, error) {
+			Summary:     "Get a greeting",
+			Description: "Get a greeting for a person by name.",
+			Tags:        []string{"Greetings"},
+		}, func(ctx context.Context, input *struct{
+			Name string `path:"name" maxLength:"30" example:"world" doc:"Name to greet"`
+		}) (*GreetingOutput, error) {
 			resp := &GreetingOutput{}
 			resp.Body.Message = fmt.Sprintf("Hello, %s!", input.Name)
 			return resp, nil
 		})
 
+		// Register POST /reviews
 		huma.Register(api, huma.Operation{
 			OperationID:   "post-review",
-			Summary:       "Post a review",
 			Method:        http.MethodPost,
 			Path:          "/reviews",
+			Summary:       "Post a review",
+			Tags:          []string{"Reviews"},
 			DefaultStatus: http.StatusCreated,
 		}, func(ctx context.Context, i *ReviewInput) (*struct{}, error) {
 			// TODO: save review in data store.
diff --git a/docs/docs/tutorial/service-configuration.md b/docs/docs/tutorial/service-configuration.md
index d7cb578..6c8e4cd 100644
--- a/docs/docs/tutorial/service-configuration.md
+++ b/docs/docs/tutorial/service-configuration.md
@@ -10,7 +10,7 @@ Huma includes a basic command-line and environment variable option parser that c
 
 [Your first API](your-first-api.md#operation) can be updated to take an optional network port parameter like this:
 
-```go title="main.go" linenums="1" hl_lines="13-16 31-32 49-57"
+```go title="main.go" linenums="1" hl_lines="13-16 26-27 48-56"
 package main
 
 import (
@@ -28,11 +28,6 @@ type Options struct {
 	Port int `help:"Port to listen on" short:"p" default:"8888"`
 }
 
-// GreetingInput represents the greeting operation request.
-type GreetingInput struct {
-	Name string `path:"name" maxLength:"30" example:"world" doc:"Name to greet"`
-}
-
 // GreetingOutput represents the greeting operation response.
 type GreetingOutput struct {
 	Body struct {
@@ -50,10 +45,14 @@ func main() {
 		// Register GET /greeting/{name}
 		huma.Register(api, huma.Operation{
 			OperationID: "get-greeting",
-			Summary:     "Get a greeting",
 			Method:      http.MethodGet,
 			Path:        "/greeting/{name}",
-		}, func(ctx context.Context, input *GreetingInput) (*GreetingOutput, error) {
+			Summary:     "Get a greeting",
+			Description: "Get a greeting for a person by name.",
+			Tags:        []string{"Greetings"},
+		}, func(ctx context.Context, input *struct{
+			Name string `path:"name" maxLength:"30" example:"world" doc:"Name to greet"`
+		}) (*GreetingOutput, error) {
 			resp := &GreetingOutput{}
 			resp.Body.Message = fmt.Sprintf("Hello, %s!", input.Name)
 			return resp, nil
diff --git a/docs/docs/tutorial/your-first-api.md b/docs/docs/tutorial/your-first-api.md
index 00d93e4..c36b748 100644
--- a/docs/docs/tutorial/your-first-api.md
+++ b/docs/docs/tutorial/your-first-api.md
@@ -16,20 +16,24 @@ Response:
 }
 ```
 
-## Request Input
+## Response Output
 
-Start by making a new file `main.go` and adding the greet operation's request input model:
+Start by making a new file `main.go` and adding the greet operation's response output model, which has a body with a `message` field for the greeting message:
 
 ```go title="main.go" linenums="1"
 package main
 
-// GreetingInput represents the greeting operation request.
-type GreetingInput struct {
-	Name string `path:"name" maxLength:"30" example:"world" doc:"Name to greet"`
+// GreetingOutput represents the greeting operation response.
+type GreetingOutput struct {
+	Body struct {
+		Message string `json:"message" example:"Hello, world!" doc:"Greeting message"`
+	}
 }
 ```
 
-The `path` tag tells Huma that this field should be read from the URL path, which we will use when registering the operation. The `maxLength` tag tells Huma that the name should be no longer than 30 characters.
+Requests and responses may define a field `Body` which will be used to marshal or unmarshal the request or response body.
+
+The `example` and `doc` tags are used to generate friendly API documentation via OpenAPI.
 
 You should now have a directory structure that looks like:
 
@@ -40,33 +44,11 @@ my-api/
 	|-- main.go
 ```
 
-## Response Output
-
-Next, let's add the response output model, which has a body with a `message` field for the greeting message.
-
-```go title="main.go" linenums="1" hl_lines="8-13"
-package main
-
-// GreetingInput represents the greeting operation request.
-type GreetingInput struct {
-	Name string `path:"name" maxLength:"30" example:"world" doc:"Name to greet"`
-}
-
-// GreetingOutput represents the greeting operation response.
-type GreetingOutput struct {
-	Body struct {
-		Message string `json:"message" example:"Hello, world!" doc:"Greeting message"`
-	}
-}
-```
-
-Requests and responses may define a field `Body` which will be used to marshal or unmarshal the request or response body.
-
 ## Router & API
 
 Let's create a router, which will handle getting incoming requests to the correct operation handler, and a new API instance where we can register our operation.
 
-```go title="main.go" linenums="1" hl_lines="3-9 23-32"
+```go title="main.go" linenums="1" hl_lines="3-9 18-27"
 package main
 
 import (
@@ -77,11 +59,6 @@ import (
 	"github.com/go-chi/chi/v5"
 )
 
-// GreetingInput represents the greeting operation request.
-type GreetingInput struct {
-	Name string `path:"name" maxLength:"30" example:"world" doc:"Name to greet"`
-}
-
 // GreetingOutput represents the greeting operation response.
 type GreetingOutput struct {
 	Body struct {
@@ -101,11 +78,22 @@ func main() {
 }
 ```
 
+!!! info "Router"
+
+    We are using the [Chi](https://github.com/go-chi/chi) for this example, but if you want to use the built-in Go 1.22+ router, you can do this instead:
+
+    ```go title="main.go"
+    router := http.NewServeMux()
+    api := humago.New(router, huma.DefaultConfig("My API", "1.0.0"))
+    ```
+
+    Lots of other routers are [supported too](../features/bring-your-own-router/).
+
 ## Operation
 
-Register the operation with the Huma API instance, including how it maps to a URL and some human-friendly documentation. The handler function will take in the `GreetingInput` and return the `GreetingOutput` models we built above.
+Register the operation with the Huma API instance, including how it maps to a URL. The handler function will take in a struct that defines its inputs (in this case a path parameter named `name`) and return the `GreetingOutput` model we built above.
 
-```go title="main.go" linenums="1" hl_lines="4-5 30-40"
+```go title="main.go" linenums="1" hl_lines="4-5 25-32"
 package main
 
 import (
@@ -118,11 +106,6 @@ import (
 	"github.com/go-chi/chi/v5"
 )
 
-// GreetingInput represents the greeting operation request.
-type GreetingInput struct {
-	Name string `path:"name" maxLength:"30" example:"world" doc:"Name to greet"`
-}
-
 // GreetingOutput represents the greeting operation response.
 type GreetingOutput struct {
 	Body struct {
@@ -131,17 +114,14 @@ type GreetingOutput struct {
 }
 
 func main() {
-	// Create a new router & API
+	// Create a new router & API.
 	router := chi.NewMux()
 	api := humachi.New(router, huma.DefaultConfig("My API", "1.0.0"))
 
-	// Register GET /greeting/{name}
-	huma.Register(api, huma.Operation{
-		OperationID: "get-greeting",
-		Summary:     "Get a greeting",
-		Method:      http.MethodGet,
-		Path:        "/greeting/{name}",
-	}, func(ctx context.Context, input *GreetingInput) (*GreetingOutput, error) {
+	// Register GET /greeting/{name} handler.
+	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) {
 		resp := &GreetingOutput{}
 		resp.Body.Message = fmt.Sprintf("Hello, %s!", input.Name)
 		return resp, nil
@@ -152,6 +132,8 @@ func main() {
 }
 ```
 
+The `path` tag tells Huma that this field should be read from the URL path. The `maxLength` tag tells Huma that the name should be no longer than 30 characters.
+
 Congratulations! This is a fully functional Huma API!
 
 ## Calling the API
@@ -189,6 +171,58 @@ Using the panel at the top right of the documentation page you can send a reques
 
 These docs are generated from the OpenAPI specification, which is available at [http://localhost:8888/openapi.json](http://localhost:8888/openapi.json). You can use this file to generate documentation, client libraries, commandline clients, mock servers, and more.
 
+### Enhancing Documentation
+
+You can use `huma.Register` to add more information to the OpenAPI specification, such as descriptions with Markdown, examples, tags, and more. The `huma.Operation` struct provides full access to the OpenAPI including the ability to add extensions. See the [`huma.Operation`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#Operation) struct for more details.
+
+```go title="main.go" linenums="1" hl_lines="26-33"
+package main
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/danielgtaylor/huma/v2"
+	"github.com/danielgtaylor/huma/v2/adapters/humachi"
+	"github.com/go-chi/chi/v5"
+)
+
+// GreetingOutput represents the greeting operation response.
+type GreetingOutput struct {
+	Body struct {
+		Message string `json:"message" example:"Hello, world!" doc:"Greeting message"`
+	}
+}
+
+func main() {
+	// Create a new router & API
+	router := chi.NewMux()
+	api := humachi.New(router, huma.DefaultConfig("My API", "1.0.0"))
+
+	// Register GET /greeting/{name}
+	huma.Register(api, huma.Operation{
+		OperationID: "get-greeting",
+		Method:      http.MethodGet,
+		Path:        "/greeting/{name}",
+		Summary:     "Get a greeting",
+		Description: "Get a greeting for a person by name.",
+		Tags:        []string{"Greetings"},
+	}, func(ctx context.Context, input *struct{
+		Name string `path:"name" maxLength:"30" example:"world" doc:"Name to greet"`
+	}) (*GreetingOutput, error) {
+		resp := &GreetingOutput{}
+		resp.Body.Message = fmt.Sprintf("Hello, %s!", input.Name)
+		return resp, nil
+	})
+
+	// Start the server!
+	http.ListenAndServe("127.0.0.1:8888", router)
+}
+```
+
+Now restart the server and take a look at your updated docs!
+
 ## Review
 
 Congratulations! You just learned:
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index acfd731..ebe50d1 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -20,12 +20,6 @@ nav:
           - "Writing Tests": tutorial/writing-tests.md
           - "CLI Client": tutorial/cli-client.md
           - "Client SDKs": tutorial/client-sdks.md
-  - "How To Guides":
-      - "Custom Validation": how-to/custom-validation.md
-      - "Image Response": how-to/image-response.md
-      - "Graceful Shutdown": how-to/graceful-shutdown.md
-      - "Migrating From V1": how-to/migrate.md
-      - "OAuth 2.0 & JWT": how-to/oauth2-jwt.md
   - "Features":
       - "Features Overview": features/index.md
       - "The Server":
@@ -57,6 +51,12 @@ nav:
           - "Test Utilities": features/test-utilities.md
       - "Clients":
           - "CLI AutoConfig": features/cli-auto-config.md
+  - "How To Guides":
+      - "Custom Validation": how-to/custom-validation.md
+      - "Image Response": how-to/image-response.md
+      - "Graceful Shutdown": how-to/graceful-shutdown.md
+      - "Migrating From V1": how-to/migrate.md
+      - "OAuth 2.0 & JWT": how-to/oauth2-jwt.md
   - "Go Reference": "https://pkg.go.dev/github.com/danielgtaylor/huma/v2?tab=doc"
 theme:
   name: material
diff --git a/examples/go.sum b/examples/go.sum
index e045c54..f6f827c 100644
--- a/examples/go.sum
+++ b/examples/go.sum
@@ -138,15 +138,13 @@ github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5
 github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
 github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
 github.com/fxamacker/cbor/v2 v2.4.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
-github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE=
-github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
+github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA=
 github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
 github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec=
 github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
-github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA=
-github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
+github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
 github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
 github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@@ -161,7 +159,7 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
 github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
 github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
 github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
-github.com/go-playground/validator/v10 v10.17.0 h1:SmVVlfAOtlZncTxRuinDPomC2DkXJ4E5T9gDA0AIH74=
+github.com/go-playground/validator/v10 v10.18.0 h1:BvolUXjp4zuvkZ5YN5t7ebzbhlUtPsPm2S9NAZ5nl9U=
 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
 github.com/goccy/go-yaml v1.9.5/go.mod h1:U/jl18uSupI5rdI2jmuCswEA2htH9eXfferR3KfscvA=
 github.com/goccy/go-yaml v1.11.2 h1:joq77SxuyIs9zzxEjgyLBugMQ9NEgTWxXfz2wVqwAaQ=
@@ -238,6 +236,7 @@ github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLe
 github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
@@ -306,7 +305,7 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
-github.com/leodido/go-urn v1.3.0 h1:jX8FDLfW4ThVXctBNZ+3cIWnCSnrACDV73r76dy0aQQ=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
 github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
 github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
 github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
@@ -415,8 +414,8 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
 github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8=
 github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
@@ -471,7 +470,7 @@ golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm
 golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
+golang.org/x/crypto v0.20.0 h1:jmAMJJZXr5KiCw05dfYK9QnqaqKLYXijU23lsEdcQqg=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -550,7 +549,7 @@ golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy
 golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
+golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -649,8 +648,7 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
-golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
 golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
diff --git a/go.sum b/go.sum
index bdabcbf..4f27701 100644
--- a/go.sum
+++ b/go.sum
@@ -2,15 +2,12 @@ github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1
 github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
 github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
 github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
-github.com/bytedance/sonic v1.10.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE=
-github.com/bytedance/sonic v1.10.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
 github.com/bytedance/sonic v1.11.2 h1:ywfwo0a/3j9HR8wsYGWsIWl2mvRsI950HyoxiBERw5A=
 github.com/bytedance/sonic v1.11.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
 github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
 github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
 github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
 github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
-github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo=
 github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
 github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0=
 github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
@@ -27,8 +24,6 @@ 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/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg=
 github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ=
-github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE=
-github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
 github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA=
 github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
 github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
@@ -39,8 +34,6 @@ github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
 github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
 github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec=
 github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
-github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA=
-github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
 github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
 github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
 github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@@ -48,8 +41,6 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
 github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
 github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
 github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
-github.com/go-playground/validator/v10 v10.17.0 h1:SmVVlfAOtlZncTxRuinDPomC2DkXJ4E5T9gDA0AIH74=
-github.com/go-playground/validator/v10 v10.17.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
 github.com/go-playground/validator/v10 v10.18.0 h1:BvolUXjp4zuvkZ5YN5t7ebzbhlUtPsPm2S9NAZ5nl9U=
 github.com/go-playground/validator/v10 v10.18.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
 github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
@@ -69,13 +60,9 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
 github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
 github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
-github.com/klauspost/compress v1.17.5 h1:d4vBd+7CHydUqpFBgUEKkSdtSugf9YFmSkvUYPquI5E=
-github.com/klauspost/compress v1.17.5/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
 github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg=
 github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
 github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
-github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
-github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
 github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
 github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
 github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
@@ -87,8 +74,6 @@ github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zG
 github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8=
 github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
 github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
-github.com/leodido/go-urn v1.3.0 h1:jX8FDLfW4ThVXctBNZ+3cIWnCSnrACDV73r76dy0aQQ=
-github.com/leodido/go-urn v1.3.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
 github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
 github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
 github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@@ -111,8 +96,6 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
-github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
@@ -130,7 +113,6 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
@@ -142,8 +124,6 @@ github.com/uptrace/bunrouter v1.0.21 h1:HXarvX+N834sXyHpl+I/TuE11m19kLW/qG5u3YpH
 github.com/uptrace/bunrouter v1.0.21/go.mod h1:TwT7Bc0ztF2Z2q/ZzMuSVkcb/Ig/d3MQeP2cxn3e1hI=
 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
-github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
 github.com/valyala/fasthttp v1.52.0 h1:wqBQpxH71XW0e2g+Og4dzQM8pk34aFYlA1Ga8db7gU0=
 github.com/valyala/fasthttp v1.52.0/go.mod h1:hf5C4QnVMkNXMspnsUlfM3WitlgYflyhHYoKol/szxQ=
 github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
@@ -155,19 +135,13 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY
 golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
 golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
 golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
-golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
-golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
 golang.org/x/crypto v0.20.0 h1:jmAMJJZXr5KiCw05dfYK9QnqaqKLYXijU23lsEdcQqg=
 golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ=
-golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
-golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
 golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
 golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
-golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
 golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
diff --git a/huma.go b/huma.go
index 799e378..fa0165b 100644
--- a/huma.go
+++ b/huma.go
@@ -17,10 +17,13 @@ import (
 	"net"
 	"net/http"
 	"reflect"
+	"regexp"
 	"strconv"
 	"strings"
 	"sync"
 	"time"
+
+	"github.com/danielgtaylor/casing"
 )
 
 var errDeadlineUnsupported = fmt.Errorf("%w", http.ErrNotSupported)
@@ -520,8 +523,8 @@ func writeHeader(write func(string, string), info *headerInfo, f reflect.Value)
 // Register an operation handler for an API. The handler must be a function that
 // takes a context and a pointer to the input struct and returns a pointer to the
 // output struct and an error. The input struct must be a struct with fields
-// for the request path/query/header parameters and/or body. The output struct
-// must be a  struct with fields for the output headers and body of the
+// for the request path/query/header/cookie parameters and/or body. The output
+// struct must be a struct with fields for the output headers and body of the
 // operation, if any.
 //
 //	huma.Register(api, huma.Operation{
@@ -1327,3 +1330,140 @@ func AutoRegister(api API, server any) {
 		}
 	}
 }
+
+var reRemoveIDs = regexp.MustCompile(`\{([^}]+)\}`)
+
+// GenerateOperationID generates an operation ID from the method, path,
+// and response type. The operation ID is used to uniquely identify an
+// operation in the OpenAPI spec. The generated ID is kebab-cased and
+// includes the method and path, with any path parameters replaced by
+// their names.
+//
+// Examples:
+//
+//   - GET /things` -> `list-things
+//   - GET /things/{thing-id} -> get-things-by-thing-id
+//   - PUT /things/{thingId}/favorite -> put-things-by-thing-id-favorite
+//
+// This function can be overridden to provide custom operation IDs.
+var GenerateOperationID = func(method, path string, response any) string {
+	action := method
+	body, hasBody := deref(reflect.TypeOf(response)).FieldByName("Body")
+	if hasBody && method == http.MethodGet && deref(body.Type).Kind() == reflect.Slice {
+		// Special case: GET with a slice response body is a list operation.
+		action = "list"
+	}
+	return casing.Kebab(action + "-" + reRemoveIDs.ReplaceAllString(path, "by-$1"))
+}
+
+func convenience[I, O any](api API, method, path string, handler func(context.Context, *I) (*O, error)) {
+	var o *O
+	Register(api, Operation{
+		OperationID: GenerateOperationID(method, path, o),
+		Method:      method,
+		Path:        path,
+	}, handler)
+}
+
+// Get HTTP operation handler for an API. The handler must be a function that
+// takes a context and a pointer to the input struct and returns a pointer to the
+// output struct and an error. The input struct must be a struct with fields
+// for the request path/query/header/cookie parameters and/or body. The output
+// struct must be a struct with fields for the output headers and body of the
+// operation, if any.
+//
+//	huma.Get(api, "/things", func(ctx context.Context, input *struct{
+//		Body []Thing
+//	}) (*ListThingOutput, error) {
+//		// TODO: list things from DB...
+//		resp := &PostThingOutput{}
+//		resp.Body = []Thing{{ID: "1", Name: "Thing 1"}}
+//		return resp, nil
+//	})
+//
+// This is a convenience wrapper around `huma.Register`.
+func Get[I, O any](api API, path string, handler func(context.Context, *I) (*O, error)) {
+	convenience(api, http.MethodGet, path, handler)
+}
+
+// Post HTTP operation handler for an API. The handler must be a function that
+// takes a context and a pointer to the input struct and returns a pointer to the
+// output struct and an error. The input struct must be a struct with fields
+// for the request path/query/header/cookie parameters and/or body. The output
+// struct must be a struct with fields for the output headers and body of the
+// operation, if any.
+//
+//	huma.Post(api, "/things", func(ctx context.Context, input *struct{
+//		Body Thing
+//	}) (*PostThingOutput, error) {
+//		// TODO: save thing to DB...
+//		resp := &PostThingOutput{}
+//		resp.Location = "/things/" + input.Body.ID
+//		return resp, nil
+//	})
+//
+// This is a convenience wrapper around `huma.Register`.
+func Post[I, O any](api API, path string, handler func(context.Context, *I) (*O, error)) {
+	convenience(api, http.MethodPost, path, handler)
+}
+
+// Put HTTP operation handler for an API. The handler must be a function that
+// takes a context and a pointer to the input struct and returns a pointer to the
+// output struct and an error. The input struct must be a struct with fields
+// for the request path/query/header/cookie parameters and/or body. The output
+// struct must be a struct with fields for the output headers and body of the
+// operation, if any.
+//
+//	huma.Put(api, "/things/{thing-id}", func(ctx context.Context, input *struct{
+//		ID string `path:"thing-id"`
+//		Body Thing
+//	}) (*PutThingOutput, error) {
+//		// TODO: save thing to DB...
+//		resp := &PutThingOutput{}
+//		return resp, nil
+//	})
+//
+// This is a convenience wrapper around `huma.Register`.
+func Put[I, O any](api API, path string, handler func(context.Context, *I) (*O, error)) {
+	convenience(api, http.MethodPut, path, handler)
+}
+
+// Patch HTTP operation handler for an API. The handler must be a function that
+// takes a context and a pointer to the input struct and returns a pointer to the
+// output struct and an error. The input struct must be a struct with fields
+// for the request path/query/header/cookie parameters and/or body. The output
+// struct must be a struct with fields for the output headers and body of the
+// operation, if any.
+//
+//	huma.Patch(api, "/things/{thing-id}", func(ctx context.Context, input *struct{
+//		ID string `path:"thing-id"`
+//		Body ThingPatch
+//	}) (*PatchThingOutput, error) {
+//		// TODO: save thing to DB...
+//		resp := &PutThingOutput{}
+//		return resp, nil
+//	})
+//
+// This is a convenience wrapper around `huma.Register`.
+func Patch[I, O any](api API, path string, handler func(context.Context, *I) (*O, error)) {
+	convenience(api, http.MethodPatch, path, handler)
+}
+
+// Delete HTTP operation handler for an API. The handler must be a function that
+// takes a context and a pointer to the input struct and returns a pointer to the
+// output struct and an error. The input struct must be a struct with fields
+// for the request path/query/header/cookie parameters and/or body. The output
+// struct must be a struct with fields for the output headers and body of the
+// operation, if any.
+//
+//	huma.Delete(api, "/things/{thing-id}", func(ctx context.Context, input *struct{
+//		ID string `path:"thing-id"`
+//	}) (*struct{}, error) {
+//		// TODO: remove thing from DB...
+//		return nil, nil
+//	})
+//
+// This is a convenience wrapper around `huma.Register`.
+func Delete[I, O any](api API, path string, handler func(context.Context, *I) (*O, error)) {
+	convenience(api, http.MethodDelete, path, handler)
+}


Test patch:
diff --git a/docs/docs/tutorial/writing-tests.md b/docs/docs/tutorial/writing-tests.md
index 9646374..846d589 100644
--- a/docs/docs/tutorial/writing-tests.md
+++ b/docs/docs/tutorial/writing-tests.md
@@ -10,7 +10,7 @@ Huma provides a number of helpers for testing your API. The most important is th
 
 First, modify the service code to make it easier to test, by moving the operation registration code out of the `main` function:
 
-```go title="main.go" linenums="1" hl_lines="39 62 71"
+```go title="main.go" linenums="1" hl_lines="34 63 72"
 package main
 
 import (
@@ -28,11 +28,6 @@ type Options struct {
 	Port int `help:"Port to listen on" short:"p" default:"8888"`
 }
 
-// GreetingInput represents the greeting operation request.
-type GreetingInput struct {
-	Name string `path:"name" maxLength:"30" example:"world" doc:"Name to greet"`
-}
-
 // GreetingOutput represents the greeting operation response.
 type GreetingOutput struct {
 	Body struct {
@@ -53,20 +48,26 @@ func addRoutes(api huma.API) {
 	// Register GET /greeting/{name}
 	huma.Register(api, huma.Operation{
 		OperationID: "get-greeting",
-		Summary:     "Get a greeting",
 		Method:      http.MethodGet,
 		Path:        "/greeting/{name}",
-	}, func(ctx context.Context, input *GreetingInput) (*GreetingOutput, error) {
+		Summary:     "Get a greeting",
+		Description: "Get a greeting for a person by name.",
+		Tags:        []string{"Greetings"},
+	}, func(ctx context.Context, input *struct{
+		Name string `path:"name" maxLength:"30" example:"world" doc:"Name to greet"`
+	}) (*GreetingOutput, error) {
 		resp := &GreetingOutput{}
 		resp.Body.Message = fmt.Sprintf("Hello, %s!", input.Name)
 		return resp, nil
 	})
 
+	// Register POST /reviews
 	huma.Register(api, huma.Operation{
 		OperationID:   "post-review",
-		Summary:       "Post a review",
 		Method:        http.MethodPost,
 		Path:          "/reviews",
+		Summary:       "Post a review",
+		Tags:          []string{"Reviews"},
 		DefaultStatus: http.StatusCreated,
 	}, func(ctx context.Context, i *ReviewInput) (*struct{}, error) {
 		// TODO: save review in data store.
diff --git a/huma_test.go b/huma_test.go
index 88ad034..6feb725 100644
--- a/huma_test.go
+++ b/huma_test.go
@@ -1194,6 +1194,44 @@ func TestPointerDefaultPanics(t *testing.T) {
 	})
 }
 
+func TestConvenienceMethods(t *testing.T) {
+	_, api := humatest.New(t, huma.DefaultConfig("Test API", "1.0.0"))
+
+	path := "/things"
+	type Input struct {
+		Owner string `path:"owner"`
+		Repo  string `path:"repo"`
+	}
+
+	huma.Get(api, path, func(ctx context.Context, input *Input) (*struct {
+		Body []struct{}
+	}, error) {
+		return nil, nil
+	})
+	assert.Equal(t, "list-things", api.OpenAPI().Paths[path].Get.OperationID)
+
+	huma.Post(api, path, func(ctx context.Context, input *Input) (*struct{}, error) {
+		return nil, nil
+	})
+	assert.Equal(t, "post-things", api.OpenAPI().Paths[path].Post.OperationID)
+
+	path = path + "/{thing-id}"
+	huma.Put(api, path, func(ctx context.Context, input *Input) (*struct{}, error) {
+		return nil, nil
+	})
+	assert.Equal(t, "put-things-by-thing-id", api.OpenAPI().Paths[path].Put.OperationID)
+
+	huma.Patch(api, path, func(ctx context.Context, input *Input) (*struct{}, error) {
+		return nil, nil
+	})
+	assert.Equal(t, "patch-things-by-thing-id", api.OpenAPI().Paths[path].Patch.OperationID)
+
+	huma.Delete(api, path, func(ctx context.Context, input *Input) (*struct{}, error) {
+		return nil, nil
+	})
+	assert.Equal(t, "delete-things-by-thing-id", api.OpenAPI().Paths[path].Delete.OperationID)
+}
+
 // func BenchmarkSecondDecode(b *testing.B) {
 // 	//nolint: musttag
 // 	type MediumSized struct {