Skip to content

Commit

Permalink
Merge pull request #722 from lsdch/custom-query-types
Browse files Browse the repository at this point in the history
feat: custom query parameter types
  • Loading branch information
danielgtaylor authored Feb 15, 2025
2 parents af68f0e + a928ac2 commit 151aa1e
Show file tree
Hide file tree
Showing 3 changed files with 118 additions and 1 deletion.
37 changes: 37 additions & 0 deletions docs/docs/features/request-inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
47 changes: 46 additions & 1 deletion huma.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions huma_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"net/http/httputil"
"net/url"
"os"
"reflect"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down

0 comments on commit 151aa1e

Please sign in to comment.