Repository: danielgtaylor/huma
Pull request #727 (2025-02-15 23:53:40)

fix: allow providing a custom request schema
This PR makes it possible to provide a custom request schema along with a body by preventing Huma from overwriting any existing schema for the default content type.


Patch:
diff --git a/docs/docs/features/request-inputs.md b/docs/docs/features/request-inputs.md
index 599b05f..06b0160 100644
--- a/docs/docs/features/request-inputs.md
+++ b/docs/docs/features/request-inputs.md
@@ -45,43 +45,6 @@ type MyInput struct {
 
 Then you can access e.g. `input.Session.Name` or `input.Session.Value`.
 
-### Custom wrapper types
-
-Request parameters can be parsed into custom wrapper types, by implementing the [`ParamWrapper`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#ParamWrapper) interface, which should give access to the wrapper field as a [`reflect.Value`](https://pkg.go.dev/reflect#Value).
-
-Interface [`ParamReactor`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#ParamReactor) may optionally be implemented to define a callback to execute after a request parameter was parsed.
-
-Example usage with a custom wrapper to handle null query parameters:
-
-```go
-type OptionalParam[T any] struct {
-	Value T
-	IsSet bool
-}
-
-// Define schema to use wrapped type
-func (o OptionalParam[T]) Schema(r huma.Registry) *huma.Schema {
-	return huma.SchemaFromType(r, reflect.TypeOf(o.Value))
-}
-
-// Expose wrapped value to receive parsed value from Huma
-// MUST have pointer receiver
-func (o *OptionalParam[T]) Receiver() reflect.Value {
-	return reflect.ValueOf(o).Elem().Field(0)
-}
-
-// React to request param being parsed to update internal state
-// MUST have pointer receiver
-func (o *OptionalParam[T]) OnParamSet(isSet bool, parsed any) {
-	o.IsSet = isSet
-}
-
-// Define request input with the wrapper type
-type MyRequestInput struct {
-    MaybeText OptionalParam[string] `query:"text"`
-}
-```
-
 ## Request Body
 
 The special struct field `Body` will be treated as the input request body and can refer to any other type or you can embed a struct or slice inline. If the body is a pointer, then it is optional. All doc & validation tags are allowed on the body in addition to these tags:
diff --git a/huma.go b/huma.go
index 7084da9..b2c6694 100644
--- a/huma.go
+++ b/huma.go
@@ -102,10 +102,6 @@ func findParams(registry Registry, op *Operation, t reflect.Type) *findResult[*p
 			Type: f.Type,
 		}
 
-		if reflect.PointerTo(f.Type).Implements(reflect.TypeFor[ParamWrapper]()) {
-			pfi.Type = reflect.New(f.Type).Interface().(ParamWrapper).Receiver().Type()
-		}
-
 		if def := f.Tag.Get("default"); def != "" {
 			pfi.Default = def
 		}
@@ -700,21 +696,12 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I)
 				return
 			}
 
-			var receiver = f
-			if f.Addr().Type().Implements(reflect.TypeFor[ParamWrapper]()) {
-				receiver = f.Addr().Interface().(ParamWrapper).Receiver()
-			}
-
-			pv, err := parseInto(ctx, receiver, value, nil, *p)
+			pv, err := parseInto(ctx, f, value, nil, *p)
 			if err != nil {
 				res.Add(pb, value, err.Error())
 				return
 			}
 
-			if f.Addr().Type().Implements(reflect.TypeFor[ParamReactor]()) {
-				f.Addr().Interface().(ParamReactor).OnParamSet(value != "", pv)
-			}
-
 			if !op.SkipValidateParams {
 				Validate(oapi.Components.Schemas, p.Schema, pb, ModeWriteToServer, pv, res)
 			}
@@ -965,38 +952,6 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I)
 	})))
 }
 
-// ParamWrapper is an interface that can be implemented by a wrapping type
-// to expose a field into which request parameters may be parsed.
-// Must have pointer receiver.
-// Example:
-//
-//	type OptionalParam[T any] struct {
-//		Value T
-//		IsSet bool
-//	}
-//	func (o *OptionalParam[T]) Receiver() reflect.Value {
-//		return reflect.ValueOf(o).Elem().Field(0)
-//	}
-type ParamWrapper interface {
-	Receiver() reflect.Value
-}
-
-// ParamReactor is an interface that can be implemented to react to request
-// parameters being set on the field. Must have pointer receiver.
-// Intended to be combined with ParamWrapper interface.
-//
-// First argument is a boolean indicating if the parameter was set in the request.
-// Second argument is the parsed value from Huma.
-//
-// Example:
-//
-//	func (o *OptionalParam[T]) OnParamSet(isSet bool, parsed any) {
-//		 o.IsSet = isSet
-//	}
-type ParamReactor interface {
-	OnParamSet(isSet bool, parsed any)
-}
-
 // initResponses initializes Responses if it was unset.
 func initResponses(op *Operation) {
 	if op.Responses == nil {
@@ -1069,16 +1024,17 @@ func setRequestBodyFromBody(op *Operation, registry Registry, fBody reflect.Stru
 	if c := fBody.Tag.Get("contentType"); c != "" {
 		contentType = c
 	}
-	hint := getHint(inputType, fBody.Name, op.OperationID+"Request")
-	if nameHint := fBody.Tag.Get("nameHint"); nameHint != "" {
-		hint = nameHint
-	}
-	s := SchemaFromField(registry, fBody, hint)
 	if op.RequestBody.Content[contentType] == nil {
 		op.RequestBody.Content[contentType] = &MediaType{}
 	}
-	op.RequestBody.Content[contentType].Schema = s
-
+	if op.RequestBody.Content[contentType].Schema == nil {
+		hint := getHint(inputType, fBody.Name, op.OperationID+"Request")
+		if nameHint := fBody.Tag.Get("nameHint"); nameHint != "" {
+			hint = nameHint
+		}
+		s := SchemaFromField(registry, fBody, hint)
+		op.RequestBody.Content[contentType].Schema = s
+	}
 }
 
 type rawBodyType int


Test patch:
diff --git a/huma_test.go b/huma_test.go
index e055210..c5d40c4 100644
--- a/huma_test.go
+++ b/huma_test.go
@@ -13,7 +13,6 @@ import (
 	"net/http/httputil"
 	"net/url"
 	"os"
-	"reflect"
 	"strings"
 	"testing"
 	"time"
@@ -87,23 +86,6 @@ func (m *MyTextUnmarshaler) UnmarshalText(text []byte) error {
 	return nil
 }
 
-type OptionalParam[T any] struct {
-	Value T
-	IsSet bool
-}
-
-func (o OptionalParam[T]) Schema(r huma.Registry) *huma.Schema {
-	return huma.SchemaFromType(r, reflect.TypeOf(o.Value))
-}
-
-func (o *OptionalParam[T]) Receiver() reflect.Value {
-	return reflect.ValueOf(o).Elem().Field(0)
-}
-
-func (o *OptionalParam[T]) OnParamSet(isSet bool, parsed any) {
-	o.IsSet = isSet
-}
-
 func TestFeatures(t *testing.T) {
 	for _, feature := range []struct {
 		Name         string
@@ -588,23 +570,6 @@ func TestFeatures(t *testing.T) {
 			Method: http.MethodGet,
 			URL:    "/test",
 		},
-		{
-			Name: "parse-with-param-receiver",
-			Register: func(t *testing.T, api huma.API) {
-				huma.Register(api, huma.Operation{
-					Method: http.MethodGet,
-					Path:   "/test",
-				}, func(ctx context.Context, i *struct {
-					Param OptionalParam[int] `query:"param"`
-				}) (*struct{}, error) {
-					assert.Equal(t, 42, i.Param.Value)
-					assert.True(t, i.Param.IsSet)
-					return nil, nil
-				})
-			},
-			URL:    "/test?param=42",
-			Method: http.MethodGet,
-		},
 		{
 			Name: "request-body",
 			Register: func(t *testing.T, api huma.API) {
@@ -690,11 +655,6 @@ func TestFeatures(t *testing.T) {
 		{
 			Name: "request-body-examples",
 			Register: func(t *testing.T, api huma.API) {
-				schema := &huma.Schema{
-					Type:       huma.TypeObject,
-					Properties: map[string]*huma.Schema{"name": {Type: huma.TypeString}},
-				}
-
 				huma.Register(api, huma.Operation{
 					Method: http.MethodPut,
 					Path:   "/body",
@@ -702,7 +662,6 @@ func TestFeatures(t *testing.T) {
 						Description: "A description",
 						Content: map[string]*huma.MediaType{
 							"application/json": {
-								Schema: schema,
 								Examples: map[string]*huma.Example{
 									"Example 1": {
 										Summary: "Example summary",
@@ -924,6 +883,40 @@ func TestFeatures(t *testing.T) {
 			URL:    "/body",
 			Body:   `{"name": "Name"}`,
 		},
+		{
+			Name: "request-body-custom-schema",
+			Register: func(t *testing.T, api huma.API) {
+				api.OpenAPI().Components.Schemas.Map()["Dummy"] = &huma.Schema{
+					Type: huma.TypeObject,
+					Properties: map[string]*huma.Schema{
+						"name": {Type: huma.TypeString},
+					},
+				}
+				huma.Register(api, huma.Operation{
+					Method: http.MethodPut,
+					Path:   "/body",
+					RequestBody: &huma.RequestBody{
+						Content: map[string]*huma.MediaType{
+							"application/json": {
+								Schema: &huma.Schema{
+									Ref: "#/components/schemas/Dummy",
+								},
+							},
+						},
+					},
+				}, func(ctx context.Context, input *struct {
+					Body struct {
+						Name string `json:"name"`
+					}
+				}) (*struct{}, error) {
+					return nil, nil
+				})
+				assert.Equal(t, "#/components/schemas/Dummy", api.OpenAPI().Paths["/body"].Put.RequestBody.Content["application/json"].Schema.Ref)
+			},
+			Method: http.MethodPut,
+			URL:    "/body",
+			Body:   `{"name": "Name"}`,
+		},
 		{
 			Name: "request-body-embed-struct",
 			Register: func(t *testing.T, api huma.API) {