Repository: danielgtaylor/huma
Pull request #156 (2023-10-27 20:59:24)

feat: split transform/marshal, reorg tests & dogfood humatest
This PR does the following things:

1. Split the transform functionality out of `API.Marshal(...)` so that marshaling no longer needs access to the full request context.
2. Better handle failures with transforming or marshaling responses after a response status code has already been sent on the wire.
3. Rename test package from `huma` → `huma_test` and dogfood using `humatest` now that there is no more circular dependency requiring yet another adapter implementation.


Patch:
diff --git a/api.go b/api.go
index 304aa82..5293113 100644
--- a/api.go
+++ b/api.go
@@ -116,10 +116,15 @@ type API interface {
 	// send an `accept` header, then JSON is used.
 	Negotiate(accept string) (string, error)
 
-	// Marshal marshals the given value into the given writer. The content type
-	// is used to determine which format to use. Use `Negotiate` to get the
-	// content type from an accept header. TODO: update
-	Marshal(ctx Context, respKey string, contentType string, v any) error
+	// Transform runs the API transformers on the given value. The `status` is
+	// the key in the operation's `Responses` map that corresponds to the
+	// response being sent (e.g. "200" for a 200 OK response).
+	Transform(ctx Context, status string, v any) (any, error)
+
+	// Marshal marshals the given value into the given writer. The
+	// content type is used to determine which format to use. Use `Negotiate` to
+	// get the content type from an accept header.
+	Marshal(w io.Writer, contentType string, v any) error
 
 	// Unmarshal unmarshals the given data into the given value. The content type
 	Unmarshal(contentType string, data []byte, v any) error
@@ -193,16 +198,18 @@ func (a *api) Negotiate(accept string) (string, error) {
 	return ct, nil
 }
 
-func (a *api) Marshal(ctx Context, respKey string, ct string, v any) error {
+func (a *api) Transform(ctx Context, status string, v any) (any, error) {
 	var err error
-
 	for _, t := range a.transformers {
-		v, err = t(ctx, respKey, v)
+		v, err = t(ctx, status, v)
 		if err != nil {
-			return err
+			return nil, err
 		}
 	}
+	return v, nil
+}
 
+func (a *api) Marshal(w io.Writer, ct string, v any) error {
 	f, ok := a.formats[ct]
 	if !ok {
 		start := strings.IndexRune(ct, '+') + 1
@@ -211,7 +218,7 @@ func (a *api) Marshal(ctx Context, respKey string, ct string, v any) error {
 	if !ok {
 		return fmt.Errorf("unknown content type: %s", ct)
 	}
-	return f.Marshal(ctx.BodyWriter(), v)
+	return f.Marshal(w, v)
 }
 
 func (a *api) UseMiddleware(middlewares ...func(ctx Context, next func(Context))) {
diff --git a/error.go b/error.go
index 2474719..5a85686 100644
--- a/error.go
+++ b/error.go
@@ -217,7 +217,11 @@ func WriteErr(api API, ctx Context, status int, msg string, errs ...error) error
 
 	ctx.SetHeader("Content-Type", ct)
 	ctx.SetStatus(status)
-	return api.Marshal(ctx, strconv.Itoa(status), ct, err)
+	tval, terr := api.Transform(ctx, strconv.Itoa(status), err)
+	if terr != nil {
+		return terr
+	}
+	return api.Marshal(ctx.BodyWriter(), ct, tval)
 }
 
 // Status304NotModified returns a 304. This is not really an error, but
diff --git a/huma.go b/huma.go
index f3d6c41..e60839e 100644
--- a/huma.go
+++ b/huma.go
@@ -356,6 +356,23 @@ var bufPool = sync.Pool{
 	},
 }
 
+// transformAndWrite is a utility function to transform and write a response.
+// It is best-effort as the status code and headers may have already been sent.
+func transformAndWrite(api API, ctx Context, status int, ct string, body any) {
+	// Try to transform and then marshal/write the response.
+	// Status code was already sent, so just log the error if something fails,
+	// and do our best to stuff it into the body of the response.
+	tval, terr := api.Transform(ctx, strconv.Itoa(status), body)
+	if terr != nil {
+		ctx.BodyWriter().Write([]byte("error transforming response"))
+		panic(fmt.Sprintf("error transforming response %+v for %s %s %d: %s\n", tval, ctx.Operation().Method, ctx.Operation().Path, status, terr.Error()))
+	}
+	if merr := api.Marshal(ctx.BodyWriter(), ct, tval); merr != nil {
+		ctx.BodyWriter().Write([]byte("error marshaling response"))
+		panic(fmt.Sprintf("error marshaling response %+v for %s %s %d: %s\n", tval, ctx.Operation().Method, ctx.Operation().Path, status, merr.Error()))
+	}
+}
+
 // 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
@@ -835,7 +852,7 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I)
 
 			ctx.SetStatus(status)
 			ctx.SetHeader("Content-Type", ct)
-			api.Marshal(ctx, strconv.Itoa(status), ct, err)
+			transformAndWrite(api, ctx, status, ct, err)
 			return
 		}
 
@@ -903,7 +920,7 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I)
 			}
 
 			ctx.SetStatus(status)
-			api.Marshal(ctx, strconv.Itoa(op.DefaultStatus), ct, body)
+			transformAndWrite(api, ctx, status, ct, body)
 		} else {
 			ctx.SetStatus(status)
 		}
@@ -915,9 +932,9 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I)
 // passed the API as the only argument. Since registration happens at
 // service startup, no errors are returned and methods should panic on error.
 //
-//	type ItemServer struct {}
+//	type ItemsHandler struct {}
 //
-//	func (s *ItemServer) RegisterListItems(api API) {
+//	func (s *ItemsHandler) RegisterListItems(api API) {
 //		huma.Register(api, huma.Operation{
 //			OperationID: "ListItems",
 //			Method: http.MethodGet,
@@ -926,9 +943,12 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I)
 //	}
 //
 //	func main() {
-//		api := huma.NewAPI("My Service", "1.0.0")
-//		itemServer := &ItemServer{}
-//		huma.AutoRegister(api, itemServer)
+//		router := chi.NewMux()
+//		config := huma.DefaultConfig("My Service", "1.0.0")
+//		api := huma.NewExampleAPI(router, config)
+//
+//		itemsHandler := &ItemsHandler{}
+//		huma.AutoRegister(api, itemsHandler)
 //	}
 func AutoRegister(api API, server any) {
 	args := []reflect.Value{reflect.ValueOf(server), reflect.ValueOf(api)}


Test patch:
diff --git a/api_test.go b/api_test.go
index 5a64283..b4e3e4a 100644
--- a/api_test.go
+++ b/api_test.go
@@ -1,16 +1,69 @@
-package huma
+package huma_test
 
 import (
+	"net/http"
 	"testing"
 
+	"github.com/danielgtaylor/huma/v2"
+	"github.com/danielgtaylor/huma/v2/humatest"
 	"github.com/go-chi/chi/v5"
 	"github.com/stretchr/testify/assert"
 )
 
 func TestBlankConfig(t *testing.T) {
-	adapter := &testAdapter{chi.NewMux()}
+	adapter := humatest.NewAdapter(chi.NewMux())
 
 	assert.NotPanics(t, func() {
-		NewAPI(Config{}, adapter)
+		huma.NewAPI(huma.Config{}, adapter)
+	})
+}
+
+// ExampleAdapter_handle demonstrates how to use the adapter directly
+// instead of using the `huma.Register` convenience function to add a new
+// operation and handler to the API.
+//
+// Note that you are responsible for defining all of the operation details,
+// including the parameter and response definitions & schemas.
+func ExampleAdapter_handle() {
+	// Create an adapter for your chosen router.
+	adapter := NewExampleAdapter(chi.NewMux())
+
+	// Register an operation with a custom handler.
+	adapter.Handle(&huma.Operation{
+		OperationID: "example-operation",
+		Method:      "GET",
+		Path:        "/example/{name}",
+		Summary:     "Example operation",
+		Parameters: []*huma.Param{
+			{
+				Name:        "name",
+				In:          "path",
+				Description: "Name to return",
+				Required:    true,
+				Schema: &huma.Schema{
+					Type: "string",
+				},
+			},
+		},
+		Responses: map[string]*huma.Response{
+			"200": {
+				Description: "OK",
+				Content: map[string]*huma.MediaType{
+					"text/plain": {
+						Schema: &huma.Schema{
+							Type: "string",
+						},
+					},
+				},
+			},
+		},
+	}, func(ctx huma.Context) {
+		// Get the `name` path parameter.
+		name := ctx.Param("name")
+
+		// Set the response content type, status code, and body.
+		ctx.SetHeader("Content-Type", "text/plain; charset=utf-8")
+		ctx.SetStatus(http.StatusOK)
+		ctx.BodyWriter().Write([]byte("Hello, " + name))
 	})
 }
diff --git a/cli_test.go b/cli_test.go
index c909415..d5f094a 100644
--- a/cli_test.go
+++ b/cli_test.go
@@ -1,15 +1,69 @@
-package huma
+package huma_test
 
 import (
 	"bytes"
+	"context"
+	"fmt"
+	"log"
+	"net/http"
 	"syscall"
 	"testing"
 	"time"
 
+	"github.com/danielgtaylor/huma/v2"
+	"github.com/danielgtaylor/huma/v2/adapters/humachi"
+	"github.com/go-chi/chi/v5"
 	"github.com/spf13/cobra"
 	"github.com/stretchr/testify/assert"
 )
 
+func ExampleCLI() {
+	// 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"))
+
+		huma.Register(api, huma.Operation{
+			OperationID: "hello",
+			Method:      http.MethodGet,
+			Path:        "/hello",
+		}, func(ctx context.Context, input *struct{}) (*struct{}, error) {
+			// TODO: implement handler
+			return nil, nil
+		})
+
+		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 TestCLIPlain(t *testing.T) {
 	type Options struct {
 		Debug bool
@@ -20,7 +74,7 @@ func TestCLIPlain(t *testing.T) {
 		ingore bool
 	}
 
-	cli := NewCLI(func(hooks Hooks, options *Options) {
+	cli := huma.NewCLI(func(hooks huma.Hooks, options *Options) {
 		assert.Equal(t, true, options.Debug)
 		assert.Equal(t, "localhost", options.Host)
 		assert.Equal(t, 8001, options.Port)
@@ -46,7 +100,7 @@ func TestCLIAdvanced(t *testing.T) {
 		Port int    `doc:"Port to listen on." short:"p" default:"8000"`
 	}
 
-	cli := NewCLI(func(hooks Hooks, options *Options) {
+	cli := huma.NewCLI(func(hooks huma.Hooks, options *Options) {
 		assert.Equal(t, true, options.Debug)
 		assert.Equal(t, "localhost", options.Host)
 		assert.Equal(t, 8001, options.Port)
@@ -73,7 +127,7 @@ func TestCLIHelp(t *testing.T) {
 		Port  int
 	}
 
-	cli := NewCLI(func(hooks Hooks, options *Options) {
+	cli := huma.NewCLI(func(hooks huma.Hooks, options *Options) {
 		// Do nothing
 	})
 
@@ -91,14 +145,14 @@ func TestCLICommandWithOptions(t *testing.T) {
 		Debug bool
 	}
 
-	cli := NewCLI(func(hooks Hooks, options *Options) {
+	cli := huma.NewCLI(func(hooks huma.Hooks, options *Options) {
 		// Do nothing
 	})
 
 	wasSet := false
 	cli.Root().AddCommand(&cobra.Command{
 		Use: "custom",
-		Run: WithOptions(func(cmd *cobra.Command, args []string, options *Options) {
+		Run: huma.WithOptions(func(cmd *cobra.Command, args []string, options *Options) {
 			if options.Debug {
 				wasSet = true
 			}
@@ -116,7 +170,7 @@ func TestCLIShutdown(t *testing.T) {
 
 	started := false
 	stopping := make(chan bool, 1)
-	cli := NewCLI(func(hooks Hooks, options *Options) {
+	cli := huma.NewCLI(func(hooks huma.Hooks, options *Options) {
 		hooks.OnStart(func() {
 			started = true
 			<-stopping
@@ -142,7 +196,7 @@ func TestCLIBadType(t *testing.T) {
 	}
 
 	assert.Panics(t, func() {
-		NewCLI(func(hooks Hooks, options *Options) {})
+		huma.NewCLI(func(hooks huma.Hooks, options *Options) {})
 	})
 }
 
@@ -156,10 +210,10 @@ func TestCLIBadDefaults(t *testing.T) {
 	}
 
 	assert.Panics(t, func() {
-		NewCLI(func(hooks Hooks, options *OptionsBool) {})
+		huma.NewCLI(func(hooks huma.Hooks, options *OptionsBool) {})
 	})
 
 	assert.Panics(t, func() {
-		NewCLI(func(hooks Hooks, options *OptionsInt) {})
+		huma.NewCLI(func(hooks huma.Hooks, options *OptionsInt) {})
 	})
 }
diff --git a/error_test.go b/error_test.go
index b6c78d7..6d5de75 100644
--- a/error_test.go
+++ b/error_test.go
@@ -1,4 +1,4 @@
-package huma
+package huma_test
 
 import (
 	"fmt"
@@ -6,23 +6,24 @@ import (
 	"net/http/httptest"
 	"testing"
 
-	"github.com/go-chi/chi/v5"
+	"github.com/danielgtaylor/huma/v2"
+	"github.com/danielgtaylor/huma/v2/humatest"
 	"github.com/stretchr/testify/assert"
 )
 
 // Ensure the default error models satisfy these interfaces.
-var _ StatusError = (*ErrorModel)(nil)
-var _ ContentTypeFilter = (*ErrorModel)(nil)
-var _ ErrorDetailer = (*ErrorDetail)(nil)
+var _ huma.StatusError = (*huma.ErrorModel)(nil)
+var _ huma.ContentTypeFilter = (*huma.ErrorModel)(nil)
+var _ huma.ErrorDetailer = (*huma.ErrorDetail)(nil)
 
 func TestError(t *testing.T) {
-	err := &ErrorModel{
+	err := &huma.ErrorModel{
 		Status: 400,
 		Detail: "test err",
 	}
 
 	// Add some children.
-	err.Add(&ErrorDetail{
+	err.Add(&huma.ErrorDetail{
 		Message:  "test detail",
 		Location: "body.foo",
 		Value:    "bar",
@@ -44,29 +45,29 @@ func TestError(t *testing.T) {
 
 func TestErrorResponses(t *testing.T) {
 	// NotModified has a slightly different signature.
-	assert.Equal(t, 304, Status304NotModified().GetStatus())
+	assert.Equal(t, 304, huma.Status304NotModified().GetStatus())
 
 	for _, item := range []struct {
-		constructor func(msg string, errs ...error) StatusError
+		constructor func(msg string, errs ...error) huma.StatusError
 		expected    int
 	}{
-		{Error400BadRequest, 400},
-		{Error401Unauthorized, 401},
-		{Error403Forbidden, 403},
-		{Error404NotFound, 404},
-		{Error405MethodNotAllowed, 405},
-		{Error406NotAcceptable, 406},
-		{Error409Conflict, 409},
-		{Error410Gone, 410},
-		{Error412PreconditionFailed, 412},
-		{Error415UnsupportedMediaType, 415},
-		{Error422UnprocessableEntity, 422},
-		{Error429TooManyRequests, 429},
-		{Error500InternalServerError, 500},
-		{Error501NotImplemented, 501},
-		{Error502BadGateway, 502},
-		{Error503ServiceUnavailable, 503},
-		{Error504GatewayTimeout, 504},
+		{huma.Error400BadRequest, 400},
+		{huma.Error401Unauthorized, 401},
+		{huma.Error403Forbidden, 403},
+		{huma.Error404NotFound, 404},
+		{huma.Error405MethodNotAllowed, 405},
+		{huma.Error406NotAcceptable, 406},
+		{huma.Error409Conflict, 409},
+		{huma.Error410Gone, 410},
+		{huma.Error412PreconditionFailed, 412},
+		{huma.Error415UnsupportedMediaType, 415},
+		{huma.Error422UnprocessableEntity, 422},
+		{huma.Error429TooManyRequests, 429},
+		{huma.Error500InternalServerError, 500},
+		{huma.Error501NotImplemented, 501},
+		{huma.Error502BadGateway, 502},
+		{huma.Error503ServiceUnavailable, 503},
+		{huma.Error504GatewayTimeout, 504},
 	} {
 		err := item.constructor("test")
 		assert.Equal(t, item.expected, err.GetStatus())
@@ -74,12 +75,11 @@ func TestErrorResponses(t *testing.T) {
 }
 
 func TestNegotiateError(t *testing.T) {
-	r := chi.NewMux()
-	api := NewTestAdapter(r, Config{})
+	_, api := humatest.New(t, huma.Config{})
 
 	req, _ := http.NewRequest("GET", "/", nil)
 	resp := httptest.NewRecorder()
-	ctx := &testContext{nil, req, resp}
+	ctx := humatest.NewContext(nil, req, resp)
 
-	assert.Error(t, WriteErr(api, ctx, 400, "bad request"))
+	assert.Error(t, huma.WriteErr(api, ctx, 400, "bad request"))
 }
diff --git a/huma_test.go b/huma_test.go
index 83044d9..3d0d6c2 100644
--- a/huma_test.go
+++ b/huma_test.go
@@ -1,141 +1,85 @@
-package huma
+package huma_test
 
 import (
 	"context"
 	"encoding/json"
 	"fmt"
 	"io"
-	"mime/multipart"
 	"net/http"
 	"net/http/httptest"
-	"net/url"
 	"reflect"
 	"strings"
 	"testing"
 	"time"
 
-	"github.com/danielgtaylor/huma/v2/queryparam"
+	"github.com/danielgtaylor/huma/v2"
+	"github.com/danielgtaylor/huma/v2/adapters/humachi"
+	"github.com/danielgtaylor/huma/v2/humatest"
 	"github.com/go-chi/chi/v5"
 	"github.com/goccy/go-yaml"
 	"github.com/mitchellh/mapstructure"
 	"github.com/stretchr/testify/assert"
 )
 
-type testContext struct {
-	op *Operation
-	r  *http.Request
-	w  http.ResponseWriter
-}
-
-func (c *testContext) Operation() *Operation {
-	return c.op
-}
-
-func (c *testContext) Matched() string {
-	return chi.RouteContext(c.r.Context()).RoutePattern()
-}
-
-func (c *testContext) Context() context.Context {
-	return c.r.Context()
-}
-
-func (c *testContext) Method() string {
-	return c.r.Method
-}
+var NewExampleAdapter = humatest.NewAdapter
+var NewExampleAPI = humachi.New
 
-func (c *testContext) Host() string {
-	return c.r.Host
-}
-
-func (c *testContext) URL() url.URL {
-	return *c.r.URL
-}
-
-func (c *testContext) Param(name string) string {
-	return chi.URLParam(c.r, name)
-}
-
-func (c *testContext) Query(name string) string {
-	return queryparam.Get(c.r.URL.RawQuery, name)
-}
-
-func (c *testContext) Header(name string) string {
-	return c.r.Header.Get(name)
-}
+// Recoverer is a really simple recovery middleware we can use during tests.
+func Recoverer(next http.Handler) http.Handler {
+	fn := func(w http.ResponseWriter, r *http.Request) {
+		defer func() {
+			if rvr := recover(); rvr != nil {
+				w.WriteHeader(http.StatusInternalServerError)
+			}
+		}()
 
-func (c *testContext) EachHeader(cb func(name, value string)) {
-	for name, values := range c.r.Header {
-		for _, value := range values {
-			cb(name, value)
-		}
+		next.ServeHTTP(w, r)
 	}
-}
-
-func (c *testContext) Body() ([]byte, error) {
-	return io.ReadAll(c.r.Body)
-}
 
-func (c *testContext) BodyReader() io.Reader {
-	return c.r.Body
-}
-
-func (c *testContext) GetMultipartForm() (*multipart.Form, error) {
-	err := c.r.ParseMultipartForm(8 * 1024)
-	return c.r.MultipartForm, err
-}
-
-func (c *testContext) SetReadDeadline(deadline time.Time) error {
-	return http.NewResponseController(c.w).SetReadDeadline(deadline)
-}
-
-func (c *testContext) SetStatus(code int) {
-	c.w.WriteHeader(code)
-}
-
-func (c *testContext) AppendHeader(name string, value string) {
-	c.w.Header().Add(name, value)
-}
-
-func (c *testContext) SetHeader(name string, value string) {
-	c.w.Header().Set(name, value)
-}
-
-func (c *testContext) BodyWriter() io.Writer {
-	return c.w
-}
-
-type testAdapter struct {
-	router chi.Router
-}
-
-func (a *testAdapter) Handle(op *Operation, handler func(Context)) {
-	a.router.MethodFunc(op.Method, op.Path, func(w http.ResponseWriter, r *http.Request) {
-		handler(&testContext{op: op, r: r, w: w})
-	})
-}
-
-func (a *testAdapter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	a.router.ServeHTTP(w, r)
-}
-
-func NewTestAdapter(r chi.Router, config Config) API {
-	return NewAPI(config, &testAdapter{router: r})
+	return http.HandlerFunc(fn)
 }
 
 func TestFeatures(t *testing.T) {
 	for _, feature := range []struct {
-		Name     string
-		Register func(t *testing.T, api API)
-		Method   string
-		URL      string
-		Headers  map[string]string
-		Body     string
-		Assert   func(t *testing.T, resp *httptest.ResponseRecorder)
+		Name         string
+		Transformers []huma.Transformer
+		Register     func(t *testing.T, api huma.API)
+		Method       string
+		URL          string
+		Headers      map[string]string
+		Body         string
+		Assert       func(t *testing.T, resp *httptest.ResponseRecorder)
 	}{
+		{
+			Name: "middleware",
+			Register: func(t *testing.T, api huma.API) {
+				api.UseMiddleware(func(ctx huma.Context, next func(huma.Context)) {
+					// Just a do-nothing passthrough. Shows that chaining works.
+					next(ctx)
+				})
+				api.UseMiddleware(func(ctx huma.Context, next func(huma.Context)) {
+					// Return an error response, never calling the next handler.
+					ctx.SetStatus(299)
+				})
+				huma.Register(api, huma.Operation{
+					Method: http.MethodGet,
+					Path:   "/middleware",
+				}, func(ctx context.Context, input *struct{}) (*struct{}, error) {
+					// This should never be called because of the middleware.
+					return nil, nil
+				})
+			},
+			Method: http.MethodGet,
+			URL:    "/middleware",
+			Assert: func(t *testing.T, resp *httptest.ResponseRecorder) {
+				// We should get the error response from the middleware.
+				assert.Equal(t, 299, resp.Code)
+			},
+		},
 		{
 			Name: "params",
-			Register: func(t *testing.T, api API) {
-				Register(api, Operation{
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
 					Method: http.MethodGet,
 					Path:   "/test-params/{string}/{int}",
 				}, func(ctx context.Context, input *struct {
@@ -178,8 +122,8 @@ func TestFeatures(t *testing.T) {
 		},
 		{
 			Name: "params-error",
-			Register: func(t *testing.T, api API) {
-				Register(api, Operation{
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
 					Method: http.MethodGet,
 					Path:   "/test-params/{int}",
 				}, func(ctx context.Context, input *struct {
@@ -210,8 +154,8 @@ func TestFeatures(t *testing.T) {
 		},
 		{
 			Name: "request-body",
-			Register: func(t *testing.T, api API) {
-				Register(api, Operation{
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
 					Method: http.MethodPut,
 					Path:   "/body",
 				}, func(ctx context.Context, input *struct {
@@ -232,8 +176,8 @@ func TestFeatures(t *testing.T) {
 		},
 		{
 			Name: "request-body-required",
-			Register: func(t *testing.T, api API) {
-				Register(api, Operation{
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
 					Method: http.MethodPut,
 					Path:   "/body",
 				}, func(ctx context.Context, input *struct {
@@ -252,8 +196,8 @@ func TestFeatures(t *testing.T) {
 		},
 		{
 			Name: "request-ptr-body-required",
-			Register: func(t *testing.T, api API) {
-				Register(api, Operation{
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
 					Method: http.MethodPut,
 					Path:   "/body",
 				}, func(ctx context.Context, input *struct {
@@ -272,8 +216,8 @@ func TestFeatures(t *testing.T) {
 		},
 		{
 			Name: "request-body-too-large",
-			Register: func(t *testing.T, api API) {
-				Register(api, Operation{
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
 					Method:       http.MethodPut,
 					Path:         "/body",
 					MaxBodyBytes: 1,
@@ -294,8 +238,8 @@ func TestFeatures(t *testing.T) {
 		},
 		{
 			Name: "request-body-bad-json",
-			Register: func(t *testing.T, api API) {
-				Register(api, Operation{
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
 					Method: http.MethodPut,
 					Path:   "/body",
 				}, func(ctx context.Context, input *struct {
@@ -315,8 +259,8 @@ func TestFeatures(t *testing.T) {
 		},
 		{
 			Name: "request-body-file-upload",
-			Register: func(t *testing.T, api API) {
-				Register(api, Operation{
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
 					Method: http.MethodPut,
 					Path:   "/file",
 				}, func(ctx context.Context, input *struct {
@@ -337,12 +281,12 @@ func TestFeatures(t *testing.T) {
 		},
 		{
 			Name: "handler-error",
-			Register: func(t *testing.T, api API) {
-				Register(api, Operation{
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
 					Method: http.MethodGet,
 					Path:   "/error",
 				}, func(ctx context.Context, input *struct{}) (*struct{}, error) {
-					return nil, Error403Forbidden("nope")
+					return nil, huma.Error403Forbidden("nope")
 				})
 			},
 			Method: http.MethodGet,
@@ -353,7 +297,7 @@ func TestFeatures(t *testing.T) {
 		},
 		{
 			Name: "response-headers",
-			Register: func(t *testing.T, api API) {
+			Register: func(t *testing.T, api huma.API) {
 				type Resp struct {
 					Str   string    `header:"str"`
 					Int   int       `header:"int"`
@@ -363,7 +307,7 @@ func TestFeatures(t *testing.T) {
 					Date  time.Time `header:"date"`
 				}
 
-				Register(api, Operation{
+				huma.Register(api, huma.Operation{
 					Method: http.MethodGet,
 					Path:   "/response-headers",
 				}, func(ctx context.Context, input *struct{}) (*Resp, error) {
@@ -391,14 +335,14 @@ func TestFeatures(t *testing.T) {
 		},
 		{
 			Name: "response",
-			Register: func(t *testing.T, api API) {
+			Register: func(t *testing.T, api huma.API) {
 				type Resp struct {
 					Body struct {
 						Greeting string `json:"greeting"`
 					}
 				}
 
-				Register(api, Operation{
+				huma.Register(api, huma.Operation{
 					Method: http.MethodGet,
 					Path:   "/response",
 				}, func(ctx context.Context, input *struct{}) (*Resp, error) {
@@ -416,12 +360,12 @@ func TestFeatures(t *testing.T) {
 		},
 		{
 			Name: "response-raw",
-			Register: func(t *testing.T, api API) {
+			Register: func(t *testing.T, api huma.API) {
 				type Resp struct {
 					Body []byte
 				}
 
-				Register(api, Operation{
+				huma.Register(api, huma.Operation{
 					Method: http.MethodGet,
 					Path:   "/response-raw",
 				}, func(ctx context.Context, input *struct{}) (*Resp, error) {
@@ -437,13 +381,13 @@ func TestFeatures(t *testing.T) {
 		},
 		{
 			Name: "response-stream",
-			Register: func(t *testing.T, api API) {
-				Register(api, Operation{
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
 					Method: http.MethodGet,
 					Path:   "/stream",
-				}, func(ctx context.Context, input *struct{}) (*StreamResponse, error) {
-					return &StreamResponse{
-						Body: func(ctx Context) {
+				}, func(ctx context.Context, input *struct{}) (*huma.StreamResponse, error) {
+					return &huma.StreamResponse{
+						Body: func(ctx huma.Context) {
 							writer := ctx.BodyWriter()
 							writer.Write([]byte("hel"))
 							writer.Write([]byte("lo"))
@@ -458,14 +402,64 @@ func TestFeatures(t *testing.T) {
 				assert.Equal(t, `hello`, resp.Body.String())
 			},
 		},
+		{
+			Name: "response-transform-error",
+			Transformers: []huma.Transformer{
+				func(ctx huma.Context, status string, v any) (any, error) {
+					return nil, fmt.Errorf("whoops")
+				},
+			},
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
+					Method: http.MethodGet,
+					Path:   "/response",
+				}, func(ctx context.Context, input *struct{}) (*struct{ Body string }, error) {
+					return &struct{ Body string }{"foo"}, nil
+				})
+			},
+			Method: http.MethodGet,
+			URL:    "/response",
+			Assert: func(t *testing.T, resp *httptest.ResponseRecorder) {
+				// Since the handler completed, this returns a 204, however while
+				// writing the body there is an error, so that is written as a message
+				// into the body and dumped via a panic.
+				assert.Equal(t, http.StatusOK, resp.Code)
+				assert.Equal(t, `error transforming response`, resp.Body.String())
+			},
+		},
+		{
+			Name: "response-marshal-error",
+			Register: func(t *testing.T, api huma.API) {
+				type Resp struct {
+					Body struct {
+						Greeting any `json:"greeting"`
+					}
+				}
+
+				huma.Register(api, huma.Operation{
+					Method: http.MethodGet,
+					Path:   "/response",
+				}, func(ctx context.Context, input *struct{}) (*Resp, error) {
+					resp := &Resp{}
+					resp.Body.Greeting = func() {}
+					return resp, nil
+				})
+			},
+			Method: http.MethodGet,
+			URL:    "/response",
+			Assert: func(t *testing.T, resp *httptest.ResponseRecorder) {
+				assert.Equal(t, http.StatusOK, resp.Code)
+				assert.Equal(t, `error marshaling response`, resp.Body.String())
+			},
+		},
 		{
 			Name: "dynamic-status",
-			Register: func(t *testing.T, api API) {
+			Register: func(t *testing.T, api huma.API) {
 				type Resp struct {
 					Status int
 				}
 
-				Register(api, Operation{
+				huma.Register(api, huma.Operation{
 					Method: http.MethodGet,
 					Path:   "/status",
 				}, func(ctx context.Context, input *struct{}) (*Resp, error) {
@@ -485,8 +479,8 @@ func TestFeatures(t *testing.T) {
 			// includes the `$schema` field. It should be allowed to be passed
 			// to the new operation as input without modification.
 			Name: "round-trip-schema-field",
-			Register: func(t *testing.T, api API) {
-				Register(api, Operation{
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
 					Method: http.MethodPut,
 					Path:   "/round-trip",
 				}, func(ctx context.Context, input *struct {
@@ -503,22 +497,22 @@ func TestFeatures(t *testing.T) {
 		},
 		{
 			Name: "one-of input",
-			Register: func(t *testing.T, api API) {
+			Register: func(t *testing.T, api huma.API) {
 				// Step 1: create a custom schema
-				customSchema := &Schema{
-					OneOf: []*Schema{
+				customSchema := &huma.Schema{
+					OneOf: []*huma.Schema{
 						{
-							Type: TypeObject,
-							Properties: map[string]*Schema{
-								"foo": {Type: TypeString},
+							Type: huma.TypeObject,
+							Properties: map[string]*huma.Schema{
+								"foo": {Type: huma.TypeString},
 							},
 						},
 						{
-							Type: TypeArray,
-							Items: &Schema{
-								Type: TypeObject,
-								Properties: map[string]*Schema{
-									"foo": {Type: TypeString},
+							Type: huma.TypeArray,
+							Items: &huma.Schema{
+								Type: huma.TypeObject,
+								Properties: map[string]*huma.Schema{
+									"foo": {Type: huma.TypeString},
 								},
 							},
 						},
@@ -526,13 +520,13 @@ func TestFeatures(t *testing.T) {
 				}
 				customSchema.PrecomputeMessages()
 
-				Register(api, Operation{
+				huma.Register(api, huma.Operation{
 					Method: http.MethodPut,
 					Path:   "/one-of",
 					// Step 2: register an operation with a custom schema
-					RequestBody: &RequestBody{
+					RequestBody: &huma.RequestBody{
 						Required: true,
-						Content: map[string]*MediaType{
+						Content: map[string]*huma.MediaType{
 							"application/json": {
 								Schema: customSchema,
 							},
@@ -562,7 +556,12 @@ func TestFeatures(t *testing.T) {
 	} {
 		t.Run(feature.Name, func(t *testing.T) {
 			r := chi.NewRouter()
-			api := NewTestAdapter(r, DefaultConfig("Features Test API", "1.0.0"))
+			r.Use(Recoverer)
+			config := huma.DefaultConfig("Features Test API", "1.0.0")
+			if feature.Transformers != nil {
+				config.Transformers = append(config.Transformers, feature.Transformers...)
+			}
+			api := humatest.NewTestAPI(t, r, config)
 			feature.Register(t, api)
 
 			var body io.Reader = nil
@@ -587,8 +586,7 @@ func TestFeatures(t *testing.T) {
 }
 
 func TestOpenAPI(t *testing.T) {
-	r := chi.NewRouter()
-	api := NewTestAdapter(r, DefaultConfig("Features Test API", "1.0.0"))
+	r, api := humatest.New(t, huma.DefaultConfig("Features Test API", "1.0.0"))
 
 	type Resp struct {
 		Body struct {
@@ -596,7 +594,7 @@ func TestOpenAPI(t *testing.T) {
 		}
 	}
 
-	Register(api, Operation{
+	huma.Register(api, huma.Operation{
 		Method: http.MethodGet,
 		Path:   "/test",
 	}, func(ctx context.Context, input *struct{}) (*Resp, error) {
@@ -619,7 +617,7 @@ type ExhaustiveErrorsInputBody struct {
 	Count int    `json:"count" minimum:"1"`
 }
 
-func (b *ExhaustiveErrorsInputBody) Resolve(ctx Context) []error {
+func (b *ExhaustiveErrorsInputBody) Resolve(ctx huma.Context) []error {
 	return []error{fmt.Errorf("body resolver error")}
 }
 
@@ -628,8 +626,8 @@ type ExhaustiveErrorsInput struct {
 	Body ExhaustiveErrorsInputBody `json:"body"`
 }
 
-func (i *ExhaustiveErrorsInput) Resolve(ctx Context) []error {
-	return []error{&ErrorDetail{
+func (i *ExhaustiveErrorsInput) Resolve(ctx huma.Context) []error {
+	return []error{&huma.ErrorDetail{
 		Location: "path.id",
 		Message:  "input resolver error",
 		Value:    i.ID,
@@ -640,9 +638,8 @@ type ExhaustiveErrorsOutput struct {
 }
 
 func TestExhaustiveErrors(t *testing.T) {
-	r := chi.NewRouter()
-	app := NewTestAdapter(r, DefaultConfig("Test API", "1.0.0"))
-	Register(app, Operation{
+	r, app := humatest.New(t, huma.DefaultConfig("Test API", "1.0.0"))
+	huma.Register(app, huma.Operation{
 		OperationID: "test",
 		Method:      http.MethodPut,
 		Path:        "/errors/{id}",
@@ -688,15 +685,15 @@ type NestedResolversStruct struct {
 	Field2 string `json:"field2"`
 }
 
-func (b *NestedResolversStruct) Resolve(ctx Context, prefix *PathBuffer) []error {
-	return []error{&ErrorDetail{
+func (b *NestedResolversStruct) Resolve(ctx huma.Context, prefix *huma.PathBuffer) []error {
+	return []error{&huma.ErrorDetail{
 		Location: prefix.With("field2"),
 		Message:  "resolver error",
 		Value:    b.Field2,
 	}}
 }
 
-var _ ResolverWithPath = (*NestedResolversStruct)(nil)
+var _ huma.ResolverWithPath = (*NestedResolversStruct)(nil)
 
 type NestedResolversBody struct {
 	Field1 map[string][]NestedResolversStruct `json:"field1"`
@@ -707,9 +704,8 @@ type NestedResolverRequest struct {
 }
 
 func TestNestedResolverWithPath(t *testing.T) {
-	r := chi.NewRouter()
-	app := NewTestAdapter(r, DefaultConfig("Test API", "1.0.0"))
-	Register(app, Operation{
+	r, app := humatest.New(t, huma.DefaultConfig("Test API", "1.0.0"))
+	huma.Register(app, huma.Operation{
 		OperationID: "test",
 		Method:      http.MethodPut,
 		Path:        "/test",
@@ -727,14 +723,13 @@ func TestNestedResolverWithPath(t *testing.T) {
 
 type ResolverCustomStatus struct{}
 
-func (r *ResolverCustomStatus) Resolve(ctx Context) []error {
-	return []error{Error403Forbidden("nope")}
+func (r *ResolverCustomStatus) Resolve(ctx huma.Context) []error {
+	return []error{huma.Error403Forbidden("nope")}
 }
 
 func TestResolverCustomStatus(t *testing.T) {
-	r := chi.NewRouter()
-	app := NewTestAdapter(r, DefaultConfig("Test API", "1.0.0"))
-	Register(app, Operation{
+	r, app := humatest.New(t, huma.DefaultConfig("Test API", "1.0.0"))
+	huma.Register(app, huma.Operation{
 		OperationID: "test",
 		Method:      http.MethodPut,
 		Path:        "/test",
@@ -798,9 +793,9 @@ func BenchmarkSecondDecode(b *testing.B) {
 		]
 	}`)
 
-	pb := NewPathBuffer([]byte{}, 0)
-	res := &ValidateResult{}
-	registry := NewMapRegistry("#/components/schemas/", DefaultSchemaNamer)
+	pb := huma.NewPathBuffer([]byte{}, 0)
+	res := &huma.ValidateResult{}
+	registry := huma.NewMapRegistry("#/components/schemas/", huma.DefaultSchemaNamer)
 	fmt.Println("name", reflect.TypeOf(MediumSized{}).Name())
 	schema := registry.Schema(reflect.TypeOf(MediumSized{}), false, "")
 
@@ -812,7 +807,7 @@ func BenchmarkSecondDecode(b *testing.B) {
 				panic(err)
 			}
 
-			Validate(registry, schema, pb, ModeReadFromServer, tmp, res)
+			huma.Validate(registry, schema, pb, huma.ModeReadFromServer, tmp, res)
 
 			var out MediumSized
 			if err := json.Unmarshal(data, &out); err != nil {
@@ -829,7 +824,7 @@ func BenchmarkSecondDecode(b *testing.B) {
 				panic(err)
 			}
 
-			Validate(registry, schema, pb, ModeReadFromServer, tmp, res)
+			huma.Validate(registry, schema, pb, huma.ModeReadFromServer, tmp, res)
 
 			var out MediumSized
 			if err := mapstructure.Decode(tmp, &out); err != nil {
@@ -838,169 +833,3 @@ func BenchmarkSecondDecode(b *testing.B) {
 		}
 	})
 }
-
-// var jsonData = []byte(`[
-//   {
-//     "desired_state": "ON",
-//     "etag": "203f7a94",
-//     "id": "bvt3",
-//     "name": "BVT channel - CNN Plus 2",
-//     "org": "t2dev",
-//     "self": "https://api.istreamplanet.com/v2/t2dev/channels/bvt3",
-// 		"created": "2021-01-01T12:00:00Z",
-// 		"count": 18273,
-// 		"rating": 5.0,
-// 		"tags": ["one", "three"],
-//     "source": {
-//       "id": "stn-dd4j42ytxmajz6xz",
-//       "self": "https://api.istreamplanet.com/v2/t2dev/sources/stn-dd4j42ytxmajz6xz"
-//     }
-//   },
-//   {
-//     "desired_state": "ON",
-//     "etag": "WgY5zNTPn3ECf_TSPAgL9Y-E9doUaRxAdjukGsCt_sQ",
-//     "id": "bvt2",
-//     "name": "BVT channel - Hulu",
-//     "org": "t2dev",
-//     "self": "https://api.istreamplanet.com/v2/t2dev/channels/bvt2",
-// 		"created": "2023-01-01T12:01:00Z",
-// 		"count": 1,
-// 		"rating": 4.5,
-// 		"tags": ["two"],
-//     "source": {
-//       "id": "stn-yuqvm3hzowrv6rph",
-//       "self": "https://api.istreamplanet.com/v2/t2dev/sources/stn-yuqvm3hzowrv6rph"
-//     }
-//   },
-//   {
-//     "desired_state": "ON",
-//     "etag": "1GaleyULVhpmHJXCJPUGSeBM2YYAZGBYKVcR5sZu5U8",
-//     "id": "bvt1",
-//     "name": "BVT channel - Hulu",
-//     "org": "t2dev",
-//     "self": "https://api.istreamplanet.com/v2/t2dev/channels/bvt1",
-// 		"created": "2023-01-01T12:00:00Z",
-// 		"count": 57,
-// 		"rating": 3.5,
-// 		"tags": ["one", "two"],
-//     "source": {
-//       "id": "stn-fc6sqodptbz5keuy",
-//       "self": "https://api.istreamplanet.com/v2/t2dev/sources/stn-fc6sqodptbz5keuy"
-//     }
-//   }
-// ]`)
-
-// type Summary struct {
-// 	DesiredState string    `json:"desired_state"`
-// 	ETag         string    `json:"etag"`
-// 	ID           string    `json:"id"`
-// 	Name         string    `json:"name"`
-// 	Org          string    `json:"org"`
-// 	Self         string    `json:"self"`
-// 	Created      time.Time `json:"created"`
-// 	Count        int       `json:"count"`
-// 	Rating       float64   `json:"rating"`
-// 	Tags         []string  `json:"tags"`
-// 	Source       struct {
-// 		ID   string `json:"id"`
-// 		Self string `json:"self"`
-// 	} `json:"source"`
-// }
-
-// func BenchmarkMarshalStructJSON(b *testing.B) {
-// 	var summaries []Summary
-// 	if err := stdjson.Unmarshal(jsonData, &summaries); err != nil {
-// 		panic(err)
-// 	}
-
-// 	b.ResetTimer()
-// 	b.ReportAllocs()
-// 	for i := 0; i < b.N; i++ {
-// 		b, _ := stdjson.Marshal(summaries)
-// 		_ = b
-// 	}
-// }
-
-// func BenchmarkMarshalAnyJSON(b *testing.B) {
-// 	var summaries any
-// 	stdjson.Unmarshal(jsonData, &summaries)
-
-// 	b.ResetTimer()
-// 	b.ReportAllocs()
-// 	for i := 0; i < b.N; i++ {
-// 		b, _ := stdjson.Marshal(summaries)
-// 		_ = b
-// 	}
-// }
-
-// func BenchmarkUnmarshalStructJSON(b *testing.B) {
-// 	var summaries []Summary
-
-// 	b.ResetTimer()
-// 	b.ReportAllocs()
-// 	for i := 0; i < b.N; i++ {
-// 		summaries = nil
-// 		stdjson.Unmarshal(jsonData, &summaries)
-// 		_ = summaries
-// 	}
-// }
-
-// func BenchmarkUnmarshalAnyJSON(b *testing.B) {
-// 	var summaries any
-
-// 	b.ResetTimer()
-// 	b.ReportAllocs()
-// 	for i := 0; i < b.N; i++ {
-// 		summaries = nil
-// 		stdjson.Unmarshal(jsonData, &summaries)
-// 		_ = summaries
-// 	}
-// }
-
-// func BenchmarkMarshalStructJSONiter(b *testing.B) {
-// 	var summaries []Summary
-// 	json.Unmarshal(jsonData, &summaries)
-
-// 	b.ResetTimer()
-// 	b.ReportAllocs()
-// 	for i := 0; i < b.N; i++ {
-// 		b, _ := json.Marshal(summaries)
-// 		_ = b
-// 	}
-// }
-
-// func BenchmarkMarshalAnyJSONiter(b *testing.B) {
-// 	var summaries any
-// 	json.Unmarshal(jsonData, &summaries)
-
-// 	b.ResetTimer()
-// 	b.ReportAllocs()
-// 	for i := 0; i < b.N; i++ {
-// 		b, _ := json.Marshal(summaries)
-// 		_ = b
-// 	}
-// }
-
-// func BenchmarkUnmarshalStructJSONiter(b *testing.B) {
-// 	var summaries []Summary
-
-// 	b.ResetTimer()
-// 	b.ReportAllocs()
-// 	for i := 0; i < b.N; i++ {
-// 		summaries = nil
-// 		json.Unmarshal(jsonData, &summaries)
-// 		_ = summaries
-// 	}
-// }
-
-// func BenchmarkUnmarshalAnyJSONiter(b *testing.B) {
-// 	var summaries any
-
-// 	b.ResetTimer()
-// 	b.ReportAllocs()
-// 	for i := 0; i < b.N; i++ {
-// 		summaries = nil
-// 		json.Unmarshal(jsonData, &summaries)
-// 		_ = summaries
-// 	}
-// }
diff --git a/schema_test.go b/schema_test.go
index 693d491..e50dfb6 100644
--- a/schema_test.go
+++ b/schema_test.go
@@ -1,4 +1,4 @@
-package huma
+package huma_test
 
 import (
 	"bytes"
@@ -11,6 +11,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/danielgtaylor/huma/v2"
 	"github.com/stretchr/testify/assert"
 )
 
@@ -431,7 +432,7 @@ func TestSchema(t *testing.T) {
 
 	for _, c := range cases {
 		t.Run(c.name, func(t *testing.T) {
-			r := NewMapRegistry("#/components/schemas/", DefaultSchemaNamer)
+			r := huma.NewMapRegistry("#/components/schemas/", huma.DefaultSchemaNamer)
 
 			if c.panics != "" {
 				assert.PanicsWithError(t, c.panics, func() {
@@ -464,7 +465,7 @@ type RecursiveInput struct {
 }
 
 func TestSchemaOld(t *testing.T) {
-	r := NewMapRegistry("#/components/schemas/", DefaultSchemaNamer)
+	r := huma.NewMapRegistry("#/components/schemas/", huma.DefaultSchemaNamer)
 
 	s := r.Schema(reflect.TypeOf(GreetingInput{}), false, "")
 	// fmt.Printf("%+v\n", s)
@@ -475,9 +476,9 @@ func TestSchemaOld(t *testing.T) {
 	r.Schema(reflect.TypeOf(RecursiveInput{}), false, "")
 
 	s2 := r.Schema(reflect.TypeOf(TestInput{}), false, "")
-	pb := NewPathBuffer(make([]byte, 0, 128), 0)
-	res := ValidateResult{}
-	Validate(r, s2, pb, ModeReadFromServer, map[string]any{
+	pb := huma.NewPathBuffer(make([]byte, 0, 128), 0)
+	res := huma.ValidateResult{}
+	huma.Validate(r, s2, pb, huma.ModeReadFromServer, map[string]any{
 		"name": "foo",
 		"sub": map[string]any{
 			"num": 1.0,
@@ -494,7 +495,7 @@ func TestSchemaGenericNaming(t *testing.T) {
 		Value T `json:"value"`
 	}
 
-	r := NewMapRegistry("#/components/schemas/", DefaultSchemaNamer)
+	r := huma.NewMapRegistry("#/components/schemas/", huma.DefaultSchemaNamer)
 	s := r.Schema(reflect.TypeOf(SchemaGeneric[int]{}), true, "")
 
 	b, _ := json.Marshal(s)
@@ -521,7 +522,7 @@ func (o *OmittableNullable[T]) UnmarshalJSON(b []byte) error {
 	return nil
 }
 
-func (o OmittableNullable[T]) Schema(r Registry) *Schema {
+func (o OmittableNullable[T]) Schema(r huma.Registry) *huma.Schema {
 	return r.Schema(reflect.TypeOf(o.Value), true, "")
 }
 
@@ -533,7 +534,7 @@ func TestCustomUnmarshalType(t *testing.T) {
 	var o O
 
 	// Confirm the schema is generated properly, including field constraints.
-	r := NewMapRegistry("#/components/schemas/", DefaultSchemaNamer)
+	r := huma.NewMapRegistry("#/components/schemas/", huma.DefaultSchemaNamer)
 	s := r.Schema(reflect.TypeOf(o), false, "")
 	assert.Equal(t, "integer", s.Properties["field"].Type, s)
 	assert.Equal(t, Ptr(float64(10)), s.Properties["field"].Maximum, s)
@@ -577,7 +578,7 @@ type BenchStruct struct {
 }
 
 func BenchmarkSchema(b *testing.B) {
-	r := NewMapRegistry("#/components/schemas/", DefaultSchemaNamer)
+	r := huma.NewMapRegistry("#/components/schemas/", huma.DefaultSchemaNamer)
 
 	s2 := r.Schema(reflect.TypeOf(BenchStruct{}), false, "")
 
@@ -596,9 +597,9 @@ func BenchmarkSchema(b *testing.B) {
 			"metrics": []any{1.0, 2.0, 3.0},
 		},
 	}
-	pb := NewPathBuffer(make([]byte, 0, 128), 0)
-	res := ValidateResult{}
-	Validate(r, s2, pb, ModeReadFromServer, input, &res)
+	pb := huma.NewPathBuffer(make([]byte, 0, 128), 0)
+	res := huma.ValidateResult{}
+	huma.Validate(r, s2, pb, huma.ModeReadFromServer, input, &res)
 	assert.Empty(b, res.Errors)
 
 	b.ResetTimer()
@@ -606,7 +607,7 @@ func BenchmarkSchema(b *testing.B) {
 	for i := 0; i < b.N; i++ {
 		pb.Reset()
 		res.Reset()
-		Validate(r, s2, pb, ModeReadFromServer, input, &res)
+		huma.Validate(r, s2, pb, huma.ModeReadFromServer, input, &res)
 		if len(res.Errors) > 0 {
 			b.Fatal(res.Errors)
 		}
@@ -614,7 +615,7 @@ func BenchmarkSchema(b *testing.B) {
 }
 
 func BenchmarkSchemaErrors(b *testing.B) {
-	r := NewMapRegistry("#/components/schemas/", DefaultSchemaNamer)
+	r := huma.NewMapRegistry("#/components/schemas/", huma.DefaultSchemaNamer)
 
 	s2 := r.Schema(reflect.TypeOf(BenchStruct{}), false, "")
 
@@ -630,9 +631,9 @@ func BenchmarkSchemaErrors(b *testing.B) {
 			"unexpected": 2,
 		},
 	}
-	pb := NewPathBuffer(make([]byte, 0, 128), 0)
-	res := ValidateResult{}
-	Validate(r, s2, pb, ModeReadFromServer, input, &res)
+	pb := huma.NewPathBuffer(make([]byte, 0, 128), 0)
+	res := huma.ValidateResult{}
+	huma.Validate(r, s2, pb, huma.ModeReadFromServer, input, &res)
 	assert.NotEmpty(b, res.Errors)
 
 	b.ResetTimer()
@@ -640,7 +641,7 @@ func BenchmarkSchemaErrors(b *testing.B) {
 	for i := 0; i < b.N; i++ {
 		pb.Reset()
 		res.Reset()
-		Validate(r, s2, pb, ModeReadFromServer, input, &res)
+		huma.Validate(r, s2, pb, huma.ModeReadFromServer, input, &res)
 		if len(res.Errors) == 0 {
 			b.Fatal("expected error")
 		}
diff --git a/validate_test.go b/validate_test.go
index 15e575a..17d07f7 100644
--- a/validate_test.go
+++ b/validate_test.go
@@ -1,4 +1,4 @@
-package huma
+package huma_test
 
 import (
 	"encoding/json"
@@ -8,6 +8,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/danielgtaylor/huma/v2"
 	"github.com/stretchr/testify/assert"
 )
 
@@ -15,12 +16,20 @@ func Ptr[T any](v T) *T {
 	return &v
 }
 
+func mapTo[A, B any](s []A, f func(A) B) []B {
+	r := make([]B, len(s))
+	for i, v := range s {
+		r[i] = f(v)
+	}
+	return r
+}
+
 var validateTests = []struct {
 	name  string
 	typ   reflect.Type
-	s     *Schema
+	s     *huma.Schema
 	input any
-	mode  ValidateMode
+	mode  huma.ValidateMode
 	errs  []string
 	panic string
 }{
@@ -646,7 +655,7 @@ var validateTests = []struct {
 		typ: reflect.TypeOf(struct {
 			Value string `json:"value" readOnly:"true"`
 		}{}),
-		mode:  ModeWriteToServer,
+		mode:  huma.ModeWriteToServer,
 		input: map[string]any{"value": "whoops"},
 	},
 	{
@@ -654,7 +663,7 @@ var validateTests = []struct {
 		typ: reflect.TypeOf(struct {
 			Value string `json:"value" readOnly:"true"`
 		}{}),
-		mode:  ModeWriteToServer,
+		mode:  huma.ModeWriteToServer,
 		input: map[string]any{},
 	},
 	{
@@ -662,7 +671,7 @@ var validateTests = []struct {
 		typ: reflect.TypeOf(struct {
 			Value string `json:"value" readOnly:"true"`
 		}{}),
-		mode:  ModeReadFromServer,
+		mode:  huma.ModeReadFromServer,
 		input: map[string]any{},
 		errs:  []string{"expected required property value to be present"},
 	},
@@ -671,7 +680,7 @@ var validateTests = []struct {
 		typ: reflect.TypeOf(struct {
 			Value string `json:"value" writeOnly:"true"`
 		}{}),
-		mode:  ModeReadFromServer,
+		mode:  huma.ModeReadFromServer,
 		input: map[string]any{"value": "should not be set"},
 		errs:  []string{"write only property is non-zero"},
 	},
@@ -734,30 +743,30 @@ var validateTests = []struct {
 	},
 	{
 		name: "oneOf success bool",
-		s: &Schema{
-			OneOf: []*Schema{
-				{Type: TypeBoolean},
-				{Type: TypeString},
+		s: &huma.Schema{
+			OneOf: []*huma.Schema{
+				{Type: huma.TypeBoolean},
+				{Type: huma.TypeString},
 			},
 		},
 		input: true,
 	},
 	{
 		name: "oneOf success string",
-		s: &Schema{
-			OneOf: []*Schema{
-				{Type: TypeBoolean},
-				{Type: TypeString},
+		s: &huma.Schema{
+			OneOf: []*huma.Schema{
+				{Type: huma.TypeBoolean},
+				{Type: huma.TypeString},
 			},
 		},
 		input: "hello",
 	},
 	{
 		name: "oneOf fail zero",
-		s: &Schema{
-			OneOf: []*Schema{
-				{Type: TypeBoolean},
-				{Type: TypeString},
+		s: &huma.Schema{
+			OneOf: []*huma.Schema{
+				{Type: huma.TypeBoolean},
+				{Type: huma.TypeString},
 			},
 		},
 		input: 123,
@@ -765,10 +774,10 @@ var validateTests = []struct {
 	},
 	{
 		name: "oneOf fail multi",
-		s: &Schema{
-			OneOf: []*Schema{
-				{Type: TypeNumber, Minimum: Ptr(float64(5))},
-				{Type: TypeNumber, Maximum: Ptr(float64(10))},
+		s: &huma.Schema{
+			OneOf: []*huma.Schema{
+				{Type: huma.TypeNumber, Minimum: Ptr(float64(5))},
+				{Type: huma.TypeNumber, Maximum: Ptr(float64(10))},
 			},
 		},
 		input: 8,
@@ -776,20 +785,20 @@ var validateTests = []struct {
 	},
 	{
 		name: "anyOf success",
-		s: &Schema{
-			AnyOf: []*Schema{
-				{Type: TypeNumber, Minimum: Ptr(float64(5))},
-				{Type: TypeNumber, Maximum: Ptr(float64(10))},
+		s: &huma.Schema{
+			AnyOf: []*huma.Schema{
+				{Type: huma.TypeNumber, Minimum: Ptr(float64(5))},
+				{Type: huma.TypeNumber, Maximum: Ptr(float64(10))},
 			},
 		},
 		input: 8,
 	},
 	{
 		name: "anyOf fail",
-		s: &Schema{
-			AnyOf: []*Schema{
-				{Type: TypeNumber, Minimum: Ptr(float64(5))},
-				{Type: TypeNumber, Minimum: Ptr(float64(10))},
+		s: &huma.Schema{
+			AnyOf: []*huma.Schema{
+				{Type: huma.TypeNumber, Minimum: Ptr(float64(5))},
+				{Type: huma.TypeNumber, Minimum: Ptr(float64(10))},
 			},
 		},
 		input: 1,
@@ -797,20 +806,20 @@ var validateTests = []struct {
 	},
 	{
 		name: "allOf success",
-		s: &Schema{
-			AllOf: []*Schema{
-				{Type: TypeNumber, Minimum: Ptr(float64(5))},
-				{Type: TypeNumber, Maximum: Ptr(float64(10))},
+		s: &huma.Schema{
+			AllOf: []*huma.Schema{
+				{Type: huma.TypeNumber, Minimum: Ptr(float64(5))},
+				{Type: huma.TypeNumber, Maximum: Ptr(float64(10))},
 			},
 		},
 		input: 8,
 	},
 	{
 		name: "allOf fail",
-		s: &Schema{
-			AllOf: []*Schema{
-				{Type: TypeNumber, Minimum: Ptr(float64(5))},
-				{Type: TypeNumber, Maximum: Ptr(float64(10))},
+		s: &huma.Schema{
+			AllOf: []*huma.Schema{
+				{Type: huma.TypeNumber, Minimum: Ptr(float64(5))},
+				{Type: huma.TypeNumber, Maximum: Ptr(float64(10))},
 			},
 		},
 		input: 12,
@@ -818,15 +827,15 @@ var validateTests = []struct {
 	},
 	{
 		name: "not success",
-		s: &Schema{
-			Not: &Schema{Type: TypeNumber},
+		s: &huma.Schema{
+			Not: &huma.Schema{Type: huma.TypeNumber},
 		},
 		input: "hello",
 	},
 	{
 		name: "not fail",
-		s: &Schema{
-			Not: &Schema{Type: TypeNumber},
+		s: &huma.Schema{
+			Not: &huma.Schema{Type: huma.TypeNumber},
 		},
 		input: 5,
 		errs:  []string{"expected value to not match schema"},
@@ -834,14 +843,14 @@ var validateTests = []struct {
 }
 
 func TestValidate(t *testing.T) {
-	pb := NewPathBuffer([]byte(""), 0)
-	res := &ValidateResult{}
+	pb := huma.NewPathBuffer([]byte(""), 0)
+	res := &huma.ValidateResult{}
 
 	for _, test := range validateTests {
 		t.Run(test.name, func(t *testing.T) {
-			registry := NewMapRegistry("#/components/schemas/", DefaultSchemaNamer)
+			registry := huma.NewMapRegistry("#/components/schemas/", huma.DefaultSchemaNamer)
 
-			var s *Schema
+			var s *huma.Schema
 			if test.panic != "" {
 				assert.Panics(t, func() {
 					registry.Schema(test.typ, false, "TestInput")
@@ -859,11 +868,11 @@ func TestValidate(t *testing.T) {
 			pb.Reset()
 			res.Reset()
 
-			Validate(registry, s, pb, test.mode, test.input, res)
+			huma.Validate(registry, s, pb, test.mode, test.input, res)
 
 			if len(test.errs) > 0 {
 				errs := mapTo(res.Errors, func(e error) string {
-					return e.(*ErrorDetail).Message
+					return e.(*huma.ErrorDetail).Message
 				})
 				schemaJSON, _ := json.MarshalIndent(registry.Map(), "", "  ")
 				for _, err := range test.errs {
@@ -891,7 +900,7 @@ func ExampleModelValidator() {
 	json.Unmarshal([]byte(`{"name": "abcdefg", "age": 1}`), &val)
 
 	// Validate the unmarshaled data against the type and print errors.
-	validator := NewModelValidator()
+	validator := huma.NewModelValidator()
 	errs := validator.Validate(typ, val)
 	fmt.Println(errs)
 
@@ -904,12 +913,12 @@ func ExampleModelValidator() {
 	// []
 }
 
-var BenchValidatePB *PathBuffer
-var BenchValidateRes *ValidateResult
+var BenchValidatePB *huma.PathBuffer
+var BenchValidateRes *huma.ValidateResult
 
 func BenchmarkValidate(b *testing.B) {
-	pb := NewPathBuffer([]byte(""), 0)
-	res := &ValidateResult{}
+	pb := huma.NewPathBuffer([]byte(""), 0)
+	res := &huma.ValidateResult{}
 	BenchValidatePB = pb
 	BenchValidateRes = res
 
@@ -919,11 +928,11 @@ func BenchmarkValidate(b *testing.B) {
 		}
 
 		b.Run(strings.TrimSuffix(test.name, " success"), func(b *testing.B) {
-			registry := NewMapRegistry("#/components/schemas/", DefaultSchemaNamer)
+			registry := huma.NewMapRegistry("#/components/schemas/", huma.DefaultSchemaNamer)
 			s := registry.Schema(test.typ, false, "TestInput")
 
 			input := test.input
-			if s.Type == TypeObject && s.Properties["value"] != nil {
+			if s.Type == huma.TypeObject && s.Properties["value"] != nil {
 				s = s.Properties["value"]
 				input = input.(map[string]any)["value"]
 			}
@@ -933,7 +942,7 @@ func BenchmarkValidate(b *testing.B) {
 			for i := 0; i < b.N; i++ {
 				pb.Reset()
 				res.Reset()
-				Validate(registry, s, pb, test.mode, input, res)
+				huma.Validate(registry, s, pb, test.mode, input, res)
 			}
 		})
 	}