Repository: danielgtaylor/huma
Pull request #722 (2025-02-10 17:07:16)

feat: custom query parameter types
Hi! 

I need a way to handle null values in query parameters (and apparently I am not the only one #438 :slightly_smiling_face: ) so here is a PR that gives the possibility to wrap query parameter fields with custom types. 

It relies on implementing the `ParamWrapper` interface, which exposes as a `reflect.Value` the internal field that we want to use when parsing request parameters. 
`ParamReactor` (bad name, I know :see_no_evil: ) is another interface that can be implemented to specify a callback when a request parameter was parsed into our custom type.

```go
type ParamWrapper interface {
	Receiver() reflect.Value
}

type ParamReactor interface {
	OnParamSet(isSet bool, parsed any)
}
```

Example usage: 
```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
}
```

I believe it resolves validation issues we had with #438, but there might be other caveats I am not aware of. Also I am not 100% convinced with the proposed API, what do you think ? 

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/danielgtaylor/huma/pull/722?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+G.+Taylor) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 92.81%. Comparing base [(`f9ffb6a`)](https://app.codecov.io/gh/danielgtaylor/huma/commit/f9ffb6a65f9878c755e8c5f51655a93a2b5b6578?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+G.+Taylor) to head [(`4bbd9f3`)](https://app.codecov.io/gh/danielgtaylor/huma/commit/4bbd9f3aa009f78096d0bcddfd99ddfbbc72468d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+G.+Taylor).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #722      +/-   ##
==========================================
+ Coverage   92.79%   92.81%   +0.01%     
==========================================
  Files          22       22              
  Lines        5027     5037      +10     
==========================================
+ Hits         4665     4675      +10     
  Misses        312      312              
  Partials       50       50              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/danielgtaylor/huma/pull/722?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+G.+Taylor).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+G.+Taylor).


Patch:
diff --git a/autopatch/autopatch.go b/autopatch/autopatch.go
index 93c145c..c006527 100644
--- a/autopatch/autopatch.go
+++ b/autopatch/autopatch.go
@@ -181,10 +181,8 @@ func PatchResource(api huma.API, path *huma.PathItem) {
 			return
 		}
 
-		resourcePath := findRelativeResourcePath(ctx.URL().Path, put.Path)
-
 		// Perform the get!
-		origReq, err := http.NewRequest(http.MethodGet, resourcePath, nil)
+		origReq, err := http.NewRequest(http.MethodGet, ctx.URL().Path, nil)
 		if err != nil {
 			huma.WriteErr(api, ctx, http.StatusInternalServerError, "Unable to get resource", err)
 			return
@@ -283,7 +281,7 @@ func PatchResource(api huma.API, path *huma.PathItem) {
 		}
 
 		// Write the updated data back to the server!
-		putReq, err := http.NewRequest(http.MethodPut, resourcePath, bytes.NewReader(patched))
+		putReq, err := http.NewRequest(http.MethodPut, ctx.URL().Path, bytes.NewReader(patched))
 		if err != nil {
 			huma.WriteErr(api, ctx, http.StatusInternalServerError, "Unable to put modified resource", err)
 			return
@@ -402,32 +400,3 @@ func makeOptionalSchema(s *huma.Schema) *huma.Schema {
 
 	return optionalSchema
 }
-
-// This function help to find the relative path of the resource to patch
-// this allow to handle potential prefix in the path
-// for example if the requestPath is /api/v1/user/1 and the put path is /user/{id}
-// the function will return /user/1
-func findRelativeResourcePath(requestPath string, putPath string) string {
-	putPathParts := strings.Split(putPath, "/")
-	// if the path is not deep enough, we return the original path
-	if len(putPathParts) < 2 {
-		return requestPath
-	}
-	wantedPrefix := putPathParts[1]
-	workingPath := requestPath
-	for !strings.HasPrefix(workingPath, wantedPrefix) {
-		// we find the next /
-		slashIndex := strings.Index(workingPath, "/")
-		// if we don't have a / anymore, we return the original path
-		if slashIndex == -1 {
-			return requestPath
-		}
-		// we remove till the next /
-		workingPath = workingPath[slashIndex+1:]
-		// if we reach the end of the path, we return the original path
-		if workingPath == "" {
-			return requestPath
-		}
-	}
-	return "/" + workingPath
-}
diff --git a/docs/docs/features/request-inputs.md b/docs/docs/features/request-inputs.md
index 06b0160..599b05f 100644
--- a/docs/docs/features/request-inputs.md
+++ b/docs/docs/features/request-inputs.md
@@ -45,6 +45,43 @@ 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 91b3592..7084da9 100644
--- a/huma.go
+++ b/huma.go
@@ -102,6 +102,10 @@ 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
 		}
@@ -696,12 +700,21 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I)
 				return
 			}
 
-			pv, err := parseInto(ctx, f, value, nil, *p)
+			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)
 			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)
 			}
@@ -952,6 +965,38 @@ 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 {


Test patch:
diff --git a/autopatch/autopatch_test.go b/autopatch/autopatch_test.go
index 29129a6..06389ac 100644
--- a/autopatch/autopatch_test.go
+++ b/autopatch/autopatch_test.go
@@ -417,43 +417,3 @@ func TestMakeOptionalSchemaNestedSchemas(t *testing.T) {
 	assert.Empty(t, optionalNestedSchema.Required)
 	assert.Empty(t, optionalNestedSchema.Properties["nested"].Required)
 }
-
-type findRelativeResourcePathTest struct {
-	requestPath string
-	putPath     string
-	expected    string
-}
-
-func TestFindRelativeResourcePath(t *testing.T) {
-	tests := []findRelativeResourcePathTest{
-		{
-			requestPath: "/things/test",
-			putPath:     "/things/{id}",
-			expected:    "/things/test",
-		},
-		{
-			requestPath: "/api/things/test",
-			putPath:     "/things/{id}",
-			expected:    "/things/test",
-		},
-		{
-			requestPath: "/test",
-			putPath:     "/{id}",
-			expected:    "/test",
-		},
-		{
-			requestPath: "/api/v1/super/things/test",
-			putPath:     "/things/{id}",
-			expected:    "/things/test",
-		},
-		{
-			requestPath: "/api/v1/test",
-			putPath:     "{id}",
-			expected:    "/api/v1/test", // we check that we falback to the request path if unsupported
-		},
-	}
-
-	for _, test := range tests {
-		assert.Equal(t, test.expected, findRelativeResourcePath(test.requestPath, test.putPath))
-	}
-}
diff --git a/huma_test.go b/huma_test.go
index a30e87d..e055210 100644
--- a/huma_test.go
+++ b/huma_test.go
@@ -13,6 +13,7 @@ import (
 	"net/http/httputil"
 	"net/url"
 	"os"
+	"reflect"
 	"strings"
 	"testing"
 	"time"
@@ -86,6 +87,23 @@ 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
@@ -570,6 +588,23 @@ 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) {