Repository: danielgtaylor/huma
Pull request #123 (2023-09-08 05:11:41)

docs: improve docs & examples
Various documentation & example updates to make usage in an IDE nicer and show how various features get used.


Patch:
diff --git a/api.go b/api.go
index a940489..c176e49 100644
--- a/api.go
+++ b/api.go
@@ -143,15 +143,15 @@ type api struct {
 	transformers []Transformer
 }
 
-func (r *api) Adapter() Adapter {
-	return r.adapter
+func (a *api) Adapter() Adapter {
+	return a.adapter
 }
 
-func (r *api) OpenAPI() *OpenAPI {
-	return r.config.OpenAPI
+func (a *api) OpenAPI() *OpenAPI {
+	return a.config.OpenAPI
 }
 
-func (r *api) Unmarshal(contentType string, data []byte, v any) error {
+func (a *api) Unmarshal(contentType string, data []byte, v any) error {
 	// Handle e.g. `application/json; charset=utf-8` or `my/format+json`
 	start := strings.IndexRune(contentType, '+') + 1
 	end := strings.IndexRune(contentType, ';')
@@ -163,19 +163,19 @@ func (r *api) Unmarshal(contentType string, data []byte, v any) error {
 		// Default to assume JSON since this is an API.
 		ct = "application/json"
 	}
-	f, ok := r.formats[ct]
+	f, ok := a.formats[ct]
 	if !ok {
 		return fmt.Errorf("unknown content type: %s", contentType)
 	}
 	return f.Unmarshal(data, v)
 }
 
-func (r *api) Negotiate(accept string) (string, error) {
-	ct := negotiation.SelectQValueFast(accept, r.formatKeys)
-	if ct == "" {
-		ct = r.formatKeys[0]
+func (a *api) Negotiate(accept string) (string, error) {
+	ct := negotiation.SelectQValueFast(accept, a.formatKeys)
+	if ct == "" && a.formatKeys != nil {
+		ct = a.formatKeys[0]
 	}
-	if _, ok := r.formats[ct]; !ok {
+	if _, ok := a.formats[ct]; !ok {
 		return ct, fmt.Errorf("unknown content type: %s", ct)
 	}
 	return ct, nil
diff --git a/cli.go b/cli.go
index fc9a36a..013fe26 100644
--- a/cli.go
+++ b/cli.go
@@ -52,6 +52,13 @@ type contextKey string
 var optionsKey contextKey = "huma/cli/options"
 
 // WithOptions is a helper for custom commands that need to access the options.
+//
+//	cli.Root().AddCommand(&cobra.Command{
+//		Use: "my-custom-command",
+//		Run: huma.WithOptions(func(cmd *cobra.Command, args []string, opts *Options) {
+//			fmt.Println("Hello " + opts.Name)
+//		}),
+//	})
 func WithOptions[Options any](f func(cmd *cobra.Command, args []string, options *Options)) func(*cobra.Command, []string) {
 	return func(cmd *cobra.Command, s []string) {
 		var options *Options = cmd.Context().Value(optionsKey).(*Options)
@@ -183,8 +190,43 @@ func (c *cli[O]) setupOptions(flags *pflag.FlagSet, t reflect.Type, path []int)
 
 // NewCLI creates a new CLI. The `onParsed` callback is called after the command
 // options have been parsed and the options struct has been populated. You
-// should set up a `cli.OnStart` callback to start the server with your chosen
-// router.
+// should set up a `hooks.OnStart` callback to start the server with your
+// chosen router.
+//
+//	// First, define your input options.
+//	type Options struct {
+//		Debug bool   `doc:"Enable debug logging"`
+//		Host  string `doc:"Hostname to listen on."`
+//		Port  int    `doc:"Port to listen on." short:"p" default:"8888"`
+//	}
+//
+//	// Then, create the CLI.
+//	cli := huma.NewCLI(func(hooks huma.Hooks, opts *Options) {
+//		fmt.Printf("Options are debug:%v host:%v port%v\n",
+//			opts.Debug, opts.Host, opts.Port)
+//
+//		// Set up the router & API
+//		router := chi.NewRouter()
+//		api := humachi.New(router, huma.DefaultConfig("My API", "1.0.0"))
+//		srv := &http.Server{
+//			Addr: fmt.Sprintf("%s:%d", opts.Host, opts.Port),
+//			Handler: router,
+//			// TODO: Set up timeouts!
+//		}
+//
+//		hooks.OnStart(func() {
+//			if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+//				log.Fatalf("listen: %s\n", err)
+//			}
+//		})
+//
+//		hooks.OnStop(func() {
+//			srv.Shutdown(context.Background())
+//		})
+//	})
+//
+//	// Run the thing!
+//	cli.Run()
 func NewCLI[O any](onParsed func(Hooks, *O)) CLI {
 	c := &cli[O]{
 		root: &cobra.Command{
diff --git a/defaults.go b/defaults.go
index 5416c6e..15b5441 100644
--- a/defaults.go
+++ b/defaults.go
@@ -8,7 +8,13 @@ import (
 )
 
 // DefaultJSONFormat is the default JSON formatter that can be set in the API's
-// `Config.Formats` map.
+// `Config.Formats` map. This is used by the `DefaultConfig` function.
+//
+//	config := huma.Config{}
+//	config.Formats = map[string]huma.Format{
+//		"application/json": huma.DefaultJSONFormat,
+//		"json":             huma.DefaultJSONFormat,
+//	}
 var DefaultJSONFormat = Format{
 	Marshal: func(w io.Writer, v any) error {
 		return json.NewEncoder(w).Encode(v)
@@ -29,7 +35,13 @@ var cborEncMode, _ = cbor.EncOptions{
 }.EncMode()
 
 // DefaultCBORFormat is the default CBOR formatter that can be set in the API's
-// `Config.Formats` map.
+// `Config.Formats` map. This is used by the `DefaultConfig` function.
+//
+//	config := huma.Config{}
+//	config.Formats = map[string]huma.Format{
+//		"application/cbor": huma.DefaultCBORFormat,
+//		"cbor":             huma.DefaultCBORFormat,
+//	}
 var DefaultCBORFormat = Format{
 	Marshal: func(w io.Writer, v any) error {
 		return cborEncMode.NewEncoder(w).Encode(v)
@@ -37,6 +49,19 @@ var DefaultCBORFormat = Format{
 	Unmarshal: cbor.Unmarshal,
 }
 
+// DefaultConfig returns a default configuration for a new API. It is a good
+// starting point for creating your own configuration. It supports JSON and
+// CBOR formats out of the box. The registry uses references for structs and
+// a link transformer is included to add `$schema` fields and links into
+// responses. The `/openapi.[json|yaml]`, `/docs`, and `/schemas` paths are
+// set up to serve the OpenAPI spec, docs UI, and schemas respectively.
+//
+//	// Create and customize the config (if desired).
+//	config := huma.DefaultConfig("My API", "1.0.0")
+//
+//	// Create the API using the config.
+//	router := chi.NewMux()
+//	api := humachi.New(router, config)
 func DefaultConfig(title, version string) Config {
 	schemaPrefix := "#/components/schemas/"
 	schemasPath := "/schemas"
diff --git a/error.go b/error.go
index 49ddd8f..2474719 100644
--- a/error.go
+++ b/error.go
@@ -6,7 +6,8 @@ import (
 	"strconv"
 )
 
-// ErrorDetailer returns error details for responses & debugging.
+// ErrorDetailer returns error details for responses & debugging. This enables
+// the use of custom error types. See `NewError` for more details.
 type ErrorDetailer interface {
 	ErrorDetail() *ErrorDetail
 }
@@ -16,10 +17,10 @@ type ErrorDetail struct {
 	// Message is a human-readable explanation of the error.
 	Message string `json:"message,omitempty" doc:"Error message text"`
 
-	// Location is a path-like string indicating where the error occured.
+	// Location is a path-like string indicating where the error occurred.
 	// It typically begins with `path`, `query`, `header`, or `body`. Example:
 	// `body.items[3].tags` or `path.thing-id`.
-	Location string `json:"location,omitempty" doc:"Where the error occured, e.g. 'body.items[3].tags' or 'path.thing-id'"`
+	Location string `json:"location,omitempty" doc:"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'"`
 
 	// Value is the value at the given location, echoed back to the client
 	// to help with debugging. This can be useful for e.g. validating that
@@ -28,7 +29,9 @@ type ErrorDetail struct {
 	Value any `json:"value,omitempty" doc:"The value at the given location"`
 }
 
-// Error returns the error message / satisfies the `error` interface.
+// Error returns the error message / satisfies the `error` interface. If a
+// location and value are set, they will be included in the error message,
+// otherwise just the message is returned.
 func (e *ErrorDetail) Error() string {
 	if e.Location == "" && e.Value == nil {
 		return e.Message
@@ -41,14 +44,35 @@ func (e *ErrorDetail) ErrorDetail() *ErrorDetail {
 	return e
 }
 
-// ErrorModel defines a basic error message model.
+// ErrorModel defines a basic error message model based on RFC 7807 Problem
+// Details for HTTP APIs (https://datatracker.ietf.org/doc/html/rfc7807). It
+// is augmented with an `errors` field of `huma.ErrorDetail` objects that
+// can help provide exhaustive & descriptive errors.
+//
+//	err := &huma.ErrorModel{
+//		Title: http.StatusText(http.StatusBadRequest),
+//		Status http.StatusBadRequest,
+//		Detail: "Validation failed",
+//		Errors: []*huma.ErrorDetail{
+//			&huma.ErrorDetail{
+//				Message: "expected required property id to be present",
+//				Location: "body.friends[0]",
+//				Value: nil,
+//			},
+//			&huma.ErrorDetail{
+//				Message: "expected boolean",
+//				Location: "body.friends[1].active",
+//				Value: 5,
+//			},
+//		},
+//	}
 type ErrorModel struct {
 	// Type is a URI to get more information about the error type.
 	Type string `json:"type,omitempty" format:"uri" default:"about:blank" example:"https://example.com/errors/example" doc:"A URI reference to human-readable documentation for the error."`
 
 	// Title provides a short static summary of the problem. Huma will default this
 	// to the HTTP response status code text if not present.
-	Title string `json:"title,omitempty" example:"Bad Request" doc:"A short, human-readable summary of the problem type. This value should not change between occurances of the error."`
+	Title string `json:"title,omitempty" example:"Bad Request" doc:"A short, human-readable summary of the problem type. This value should not change between occurrences of the error."`
 
 	// Status provides the HTTP status code for client convenience. Huma will
 	// default this to the response status code if unset. This SHOULD match the
@@ -58,18 +82,29 @@ type ErrorModel struct {
 	// Detail is an explanation specific to this error occurrence.
 	Detail string `json:"detail,omitempty" example:"Property foo is required but is missing." doc:"A human-readable explanation specific to this occurrence of the problem."`
 
-	// Instance is a URI to get more info about this error occurence.
-	Instance string `json:"instance,omitempty" format:"uri" example:"https://example.com/error-log/abc123" doc:"A URI reference that identifies the specific occurence of the problem."`
+	// Instance is a URI to get more info about this error occurrence.
+	Instance string `json:"instance,omitempty" format:"uri" example:"https://example.com/error-log/abc123" doc:"A URI reference that identifies the specific occurrence of the problem."`
 
 	// Errors provides an optional mechanism of passing additional error details
 	// as a list.
 	Errors []*ErrorDetail `json:"errors,omitempty" doc:"Optional list of individual error details"`
 }
 
+// Error satisfies the `error` interface. It returns the error's detail field.
 func (e *ErrorModel) Error() string {
 	return e.Detail
 }
 
+// Add an error to the `Errors` slice. If passed a struct that satisfies the
+// `huma.ErrorDetailer` interface, then it is used, otherwise the error
+// string is used as the error detail message.
+//
+//	err := &ErrorModel{ /* ... */ }
+//	err.Add(&huma.ErrorDetail{
+//		Message: "expected boolean",
+//		Location: "body.friends[1].active",
+//		Value: 5
+//	})
 func (e *ErrorModel) Add(err error) {
 	if converted, ok := err.(ErrorDetailer); ok {
 		e.Errors = append(e.Errors, converted.ErrorDetail())
@@ -79,10 +114,15 @@ func (e *ErrorModel) Add(err error) {
 	e.Errors = append(e.Errors, &ErrorDetail{Message: err.Error()})
 }
 
+// GetStatus returns the HTTP status that should be returned to the client
+// for this error.
 func (e *ErrorModel) GetStatus() int {
 	return e.Status
 }
 
+// ContentType provides a filter to adjust response content types. This is
+// used to ensure e.g. `application/problem+json` content types defined in
+// RFC 7807 Problem Details for HTTP APIs are used in responses to clients.
 func (e *ErrorModel) ContentType(ct string) string {
 	if ct == "application/json" {
 		return "application/problem+json"
@@ -102,16 +142,47 @@ type ContentTypeFilter interface {
 }
 
 // StatusError is an error that has an HTTP status code. When returned from
-// an operation handler, this sets the response status code.
+// an operation handler, this sets the response status code before sending it
+// to the client.
 type StatusError interface {
 	GetStatus() int
 	Error() string
 }
 
 // NewError creates a new instance of an error model with the given status code,
-// message, and errors. If the error implements the `ErrorDetailer` interface,
-// the error details will be used. Otherwise, the error message will be used.
-// Replace this function to use your own error type.
+// message, and optional error details. If the error details implement the
+// `ErrorDetailer` interface, the error details will be used. Otherwise, the
+// error string will be used as the message. This function is used by all the
+// error response utility functions, like `huma.Error400BadRequest`.
+//
+// Replace this function to use your own error type. Example:
+//
+//	type MyDetail struct {
+//		Message string	`json:"message"`
+//		Location string	`json:"location"`
+//	}
+//
+//	type MyError struct {
+//		status  int
+//		Message string	`json:"message"`
+//		Errors  []error	`json:"errors"`
+//	}
+//
+//	func (e *MyError) Error() string {
+//		return e.Message
+//	}
+//
+//	func (e *MyError) GetStatus() int {
+//		return e.status
+//	}
+//
+//	huma.NewError = func(status int, msg string, errs ...error) StatusError {
+//		return &MyError{
+//			status:  status,
+//			Message: msg,
+//			Errors:  errs,
+//		}
+//	}
 var NewError = func(status int, msg string, errs ...error) StatusError {
 	details := make([]*ErrorDetail, len(errs))
 	for i := 0; i < len(errs); i++ {
@@ -132,17 +203,21 @@ var NewError = func(status int, msg string, errs ...error) StatusError {
 // WriteErr writes an error response with the given context, using the
 // configured error type and with the given status code and message. It is
 // marshaled using the API's content negotiation methods.
-func WriteErr(api API, ctx Context, status int, msg string, errs ...error) {
+func WriteErr(api API, ctx Context, status int, msg string, errs ...error) error {
 	var err any = NewError(status, msg, errs...)
 
-	ct, _ := api.Negotiate(ctx.Header("Accept"))
+	ct, negotiateErr := api.Negotiate(ctx.Header("Accept"))
+	if negotiateErr != nil {
+		return negotiateErr
+	}
+
 	if ctf, ok := err.(ContentTypeFilter); ok {
 		ct = ctf.ContentType(ct)
 	}
 
 	ctx.SetHeader("Content-Type", ct)
 	ctx.SetStatus(status)
-	api.Marshal(ctx, strconv.Itoa(status), ct, err)
+	return api.Marshal(ctx, strconv.Itoa(status), ct, err)
 }
 
 // Status304NotModified returns a 304. This is not really an error, but
diff --git a/huma.go b/huma.go
index 1ae579a..7c20f30 100644
--- a/huma.go
+++ b/huma.go
@@ -22,7 +22,10 @@ var bodyCallbackType = reflect.TypeOf(func(Context) {})
 
 // SetReadDeadline is a utility to set the read deadline on a response writer,
 // if possible. If not, it will not incur any allocations (unlike the stdlib
-// `http.ResponseController`).
+// `http.ResponseController`). This is mostly a convenience function for
+// adapters so they can be more efficient.
+//
+//	huma.SetReadDeadline(w, time.Now().Add(5*time.Second))
 func SetReadDeadline(w http.ResponseWriter, deadline time.Time) error {
 	for {
 		switch t := w.(type) {
@@ -39,6 +42,26 @@ func SetReadDeadline(w http.ResponseWriter, deadline time.Time) error {
 // StreamResponse is a response that streams data to the client. The body
 // function will be called once the response headers have been written and
 // the body writer is ready to be written to.
+//
+//	func handler(ctx context.Context, input *struct{}) (*huma.StreamResponse, error) {
+//		return &huma.StreamResponse{
+//			Body: func(ctx huma.Context) {
+//				ctx.SetHeader("Content-Type", "text/my-type")
+//
+//				// Write some data to the stream.
+//				writer := ctx.BodyWriter()
+//				writer.Write([]byte("Hello "))
+//
+//				// Flush the stream to the client.
+//				if f, ok := writer.(http.Flusher); ok {
+//					f.Flush()
+//				}
+//
+//				// Write some more...
+//				writer.Write([]byte("world!"))
+//			}
+//		}
+//	}
 type StreamResponse struct {
 	Body func(ctx Context)
 }
diff --git a/schema.go b/schema.go
index 41d0f97..7d886ca 100644
--- a/schema.go
+++ b/schema.go
@@ -46,6 +46,16 @@ func deref(t reflect.Type) reflect.Type {
 // with your own custom properties. It supports a subset of the full JSON Schema
 // spec, designed specifically for use with Go structs and to enable fast zero
 // or near-zero allocation happy-path validation for incoming requests.
+//
+// Typically you will use a registry and `huma.SchemaFromType` to generate
+// schemas for your types. You can then use `huma.Validate` to validate
+// incoming requests.
+//
+//	// Create a registry and register a type.
+//	registry := huma.NewMapRegistry("#/prefix", huma.DefaultSchemaNamer)
+//	schema := huma.SchemaFromType(registry, reflect.TypeOf(MyType{}))
+//
+// Note that the registry may create references for your types.
 type Schema struct {
 	Type                 string             `yaml:"type,omitempty"`
 	Title                string             `yaml:"title,omitempty"`
@@ -100,6 +110,8 @@ type Schema struct {
 	msgRequired         map[string]string `yaml:"-"`
 }
 
+// PrecomputeMessages tries to precompute as many validation error messages
+// as possible so that new strings aren't allocated during request validation.
 func (s *Schema) PrecomputeMessages() {
 	s.msgEnum = "expected value to be one of \"" + strings.Join(mapTo(s.Enum, func(v any) string {
 		return fmt.Sprintf("%v", v)
@@ -152,6 +164,8 @@ func (s *Schema) PrecomputeMessages() {
 	}
 }
 
+// MarshalJSON marshals the schema into JSON, respecting the `Extensions` map
+// to marshal extensions inline.
 func (s *Schema) MarshalJSON() ([]byte, error) {
 	return yaml.MarshalWithOptions(s, yaml.JSON())
 }
@@ -247,6 +261,12 @@ func jsonTag(f reflect.StructField, name string, multi bool) any {
 	return nil
 }
 
+// SchemaFromField generates a schema for a given struct field. If the field
+// is a struct (or slice/map of structs) then the registry is used to
+// potentially get a reference to that type.
+//
+// This is used by `huma.SchemaFromType` when it encounters a struct, and
+// is used to generate schemas for path/query/header parameters.
 func SchemaFromField(registry Registry, parent reflect.Type, f reflect.StructField) *Schema {
 	parentName := ""
 	if parent != nil {
@@ -362,6 +382,14 @@ func getFields(typ reflect.Type) []fieldInfo {
 	return fields
 }
 
+// SchemaFromType returns a schema for a given type, using the registry to
+// possibly create references for nested structs. The schema that is returned
+// can then be passed to `huma.Validate` to efficiently validate incoming
+// requests.
+//
+//	// Create a registry and register a type.
+//	registry := huma.NewMapRegistry("#/prefix", huma.DefaultSchemaNamer)
+//	schema := huma.SchemaFromType(registry, reflect.TypeOf(MyType{}))
 func SchemaFromType(r Registry, t reflect.Type) *Schema {
 	s := Schema{}
 	t = deref(t)
diff --git a/transforms.go b/transforms.go
index 8e2a4a9..9f6460d 100644
--- a/transforms.go
+++ b/transforms.go
@@ -13,6 +13,12 @@ type schemaField struct {
 	Schema string `json:"$schema"`
 }
 
+// SchemaLinkTransformer is a transform that adds a `$schema` field to the
+// response (if it is a struct) and a Link header pointing to the JSON
+// Schema that describes the response structure. This is useful for clients
+// to understand the structure of the response and enables things like
+// as-you-type validation & completion of HTTP resources in editors like
+// VSCode.
 type SchemaLinkTransformer struct {
 	prefix      string
 	schemasPath string
@@ -23,6 +29,12 @@ type SchemaLinkTransformer struct {
 	}
 }
 
+// NewSchemaLinkTransformer creates a new transformer that will add a `$schema`
+// field to the response (if it is a struct) and a Link header pointing to the
+// JSON Schema that describes the response structure. This is useful for clients
+// to understand the structure of the response and enables things like
+// as-you-type validation & completion of HTTP resources in editors like
+// VSCode.
 func NewSchemaLinkTransformer(prefix, schemasPath string) *SchemaLinkTransformer {
 	return &SchemaLinkTransformer{
 		prefix:      prefix,
@@ -35,6 +47,9 @@ func NewSchemaLinkTransformer(prefix, schemasPath string) *SchemaLinkTransformer
 	}
 }
 
+// OnAddOperation is triggered whenever a new operation is added to the API,
+// enabling this transformer to precompute & cache information about the
+// response and schema.
 func (t *SchemaLinkTransformer) OnAddOperation(oapi *OpenAPI, op *Operation) {
 	// Update registry to be able to get the type from a schema ref.
 	// Register the type in t.types with the generated ref
@@ -86,6 +101,8 @@ func (t *SchemaLinkTransformer) OnAddOperation(oapi *OpenAPI, op *Operation) {
 	}
 }
 
+// Transform is called for every response to add the `$schema` field and/or
+// the Link header pointing to the JSON Schema.
 func (t *SchemaLinkTransformer) Transform(ctx Context, status string, v any) (any, error) {
 	if v == nil {
 		return v, nil
diff --git a/validate.go b/validate.go
index 6798fc5..f481bef 100644
--- a/validate.go
+++ b/validate.go
@@ -16,6 +16,9 @@ import (
 	"golang.org/x/net/idna"
 )
 
+// ValidateMode describes the direction of validation (server -> client or
+// client -> server). It impacts things like how read-only or write-only fields
+// are handled.
 type ValidateMode int
 
 const (
@@ -147,11 +150,14 @@ func NewPathBuffer(buf []byte, offset int) *PathBuffer {
 	return &PathBuffer{buf: buf, off: offset}
 }
 
-// ValidateResult tracks validation errors.
+// ValidateResult tracks validation errors. It is safe to use for multiple
+// validations as long as `Reset()` is called between uses.
 type ValidateResult struct {
 	Errors []error
 }
 
+// Add an error to the validation result at the given path and with the
+// given value.
 func (r *ValidateResult) Add(path *PathBuffer, v any, msg string) {
 	r.Errors = append(r.Errors, &ErrorDetail{
 		Message:  msg,
@@ -160,6 +166,8 @@ func (r *ValidateResult) Add(path *PathBuffer, v any, msg string) {
 	})
 }
 
+// Addf adds an error to the validation result at the given path and with
+// the given value, allowing for fmt.Printf-style formatting.
 func (r *ValidateResult) Addf(path *PathBuffer, v any, format string, args ...any) {
 	r.Errors = append(r.Errors, &ErrorDetail{
 		Message:  fmt.Sprintf(format, args...),
@@ -168,6 +176,7 @@ func (r *ValidateResult) Addf(path *PathBuffer, v any, format string, args ...an
 	})
 }
 
+// Reset the validation error so it can be used again.
 func (r *ValidateResult) Reset() {
 	r.Errors = r.Errors[:0]
 }
@@ -257,6 +266,18 @@ func validateFormat(path *PathBuffer, str string, s *Schema, res *ValidateResult
 // result object. If successful, `res.Errors` will be empty. It is suggested
 // to use a `sync.Pool` to reuse the PathBuffer and ValidateResult objects,
 // making sure to call `Reset()` on them before returning them to the pool.
+//
+//	registry := huma.NewMapRegistry("#/prefix", huma.DefaultSchemaNamer)
+//	schema := huma.SchemaFromType(registry, reflect.TypeOf(MyType{}))
+//	pb := huma.NewPathBuffer([]byte(""), 0)
+//	res := &huma.ValidateResult{}
+//
+//	var value any
+//	json.Unmarshal([]byte(`{"foo": "bar"}`), &v)
+//	huma.Validate(registry, schema, pb, huma.ModeWriteToServer, value, res)
+//	for _, err := range res.Errors {
+//		fmt.Println(err.Error())
+//	}
 func Validate(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, v any, res *ValidateResult) {
 	// Get the actual schema if this is a reference.
 	for s.Ref != "" {
diff --git a/wrk.lua b/wrk.lua
index ab78440..1f83bb2 100644
--- a/wrk.lua
+++ b/wrk.lua
@@ -1,3 +1,2 @@
-wrk.method = "POST"
-wrk.body   = "{\"suffix\": \"!\"}"
-wrk.headers["Content-Type"] = "application/json"
+wrk.method = "GET"
+-- wrk.headers["Content-Type"] = "application/json"


Test patch:
diff --git a/error_test.go b/error_test.go
index 3c1b95e..35f57dd 100644
--- a/error_test.go
+++ b/error_test.go
@@ -2,14 +2,18 @@ package huma
 
 import (
 	"fmt"
+	"net/http"
+	"net/http/httptest"
 	"testing"
 
+	"github.com/go-chi/chi"
 	"github.com/stretchr/testify/assert"
 )
 
-// Ensure the default error model satisfies these interfaces.
+// Ensure the default error models satisfy these interfaces.
 var _ StatusError = (*ErrorModel)(nil)
 var _ ContentTypeFilter = (*ErrorModel)(nil)
+var _ ErrorDetailer = (*ErrorDetail)(nil)
 
 func TestError(t *testing.T) {
 	err := &ErrorModel{
@@ -68,3 +72,14 @@ func TestErrorResponses(t *testing.T) {
 		assert.Equal(t, item.expected, err.GetStatus())
 	}
 }
+
+func TestNegotiateError(t *testing.T) {
+	r := chi.NewMux()
+	api := NewTestAdapter(r, Config{})
+
+	req, _ := http.NewRequest("GET", "/", nil)
+	resp := httptest.NewRecorder()
+	ctx := &testContext{nil, req, resp}
+
+	assert.Error(t, WriteErr(api, ctx, 400, "bad request"))
+}