diff --git a/README.md b/README.md index 3c272d46..d3068d72 100644 --- a/README.md +++ b/README.md @@ -647,6 +647,91 @@ Also take a look at [`http.ResponseController`](https://pkg.go.dev/net/http#Resp > :whale: The `sse` package provides a helper for streaming Server-Sent Events (SSE) responses that is easier to use than the above example! +### Generated Schema Customization + +Schemas that are generated for input/output bodies can be customized in a couple of different ways. First, when registering your operation you can provide your own request and/or response schemas if you want to override the entire body. The automatic generation only applies when you have not provided your own schema in the OpenAPI. + +Second, this can be done on a per-field basis by making a struct that implements a special interface to get a schema, allowing you to e.g. encapsulate additional functionality within that field. This is the interface: + +```go +// SchemaProvider is an interface that can be implemented by types to provide +// a custom schema for themselves, overriding the built-in schema generation. +// This can be used by custom types with their own special serialization rules. +type SchemaProvider interface { + Schema(r huma.Registry) *huma.Schema +} +``` + +The `huma.Registry` is passed to you and can be used to get schemas or refs for any embedded structs. Here is an example, where we want to know if a field was omitted vs. null vs. a value when sent as part of a request body. First we start by defininig the custom generic struct: + +```go +// OmittableNullable is a field which can be omitted from the input, +// set to `null`, or set to a value. Each state is tracked and can +// be checked for in handling code. +type OmittableNullable[T any] struct { + Sent bool + Null bool + Value T +} + +// UnmarshalJSON unmarshals this value from JSON input. +func (o *OmittableNullable[T]) UnmarshalJSON(b []byte) error { + if len(b) > 0 { + o.Sent = true + if bytes.Equal(b, []byte("null")) { + o.Null = true + return nil + } + return json.Unmarshal(b, &o.Value) + } + return nil +} + +// Schema returns a schema representing this value on the wire. +// It returns the schema of the contained type. +func (o OmittableNullable[T]) Schema(r huma.Registry) *huma.Schema { + return r.Schema(reflect.TypeOf(o.Value), true, "") +} +``` + +This is how it can be used in an operation: + +```go +type MyResponse struct { + Body struct { + Message string `json:"message"` + } +} + +huma.Register(api, huma.Operation{ + OperationID: "omittable", + Method: http.MethodPost, + Path: "/omittable", + Summary: "Omittable / nullable example", +}, func(ctx context.Context, input *struct { + // Making the body a pointer makes it optional, as it may be `nil`. + Body *struct { + Name OmittableNullable[string] `json:"name,omitempty" maxLength:"10"` + } +}) (*MyResponse, error) { + resp := &MyResponse{} + if input.Body == nil { + resp.Body.Message = "Body was not sent" + } else if !input.Body.Name.Sent { + resp.Body.Message = "Name was omitted from the request" + } else if input.Body.Name.Null { + resp.Body.Message = "Name was set to null" + } else { + resp.Body.Message = "Name was set to: " + input.Body.Name.Value + } + return resp, nil +}) +``` + +If you go to view the generated docs, you will see that the type of the `name` field is `string` and that it is optional, with a max length of 10, indicating that the custom schema was correctly used in place of one generated for the `OmittableNullable[string]` struct. + +See https://github.com/danielgtaylor/huma/blob/main/examples/omit/main.go for a full example along with how to call it. This just scratches the surface of what's possible with custom schemas for fields. + ### Exhaustive Errors Errors use [RFC 7807](https://tools.ietf.org/html/rfc7807) and return a structure that looks like: diff --git a/examples/omit/main.go b/examples/omit/main.go new file mode 100644 index 00000000..b75a5319 --- /dev/null +++ b/examples/omit/main.go @@ -0,0 +1,108 @@ +// This example shows how to handle omittable/nullable fields and an optional +// body in JSON input. Try the following requests: +// +// # Omit the body +// restish post :8888/omittable +// +// # Send a null body +// echo '{}' | restish post :8888/omittable +// +// # Send a body with a null name +// restish post :8888/omittable name: null +// +// # Send a body with a name +// restish post :8888/omittable name: Kari +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "reflect" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humachi" + "github.com/go-chi/chi/v5" +) + +// Options for the CLI. +type Options struct { + Port int `help:"Port to listen on" default:"8888"` +} + +// OmittableNullable is a field which can be omitted from the input, +// set to `null`, or set to a value. Each state is tracked and can +// be checked for in handling code. +type OmittableNullable[T any] struct { + Sent bool + Null bool + Value T +} + +// UnmarshalJSON unmarshals this value from JSON input. +func (o *OmittableNullable[T]) UnmarshalJSON(b []byte) error { + if len(b) > 0 { + o.Sent = true + if bytes.Equal(b, []byte("null")) { + o.Null = true + return nil + } + return json.Unmarshal(b, &o.Value) + } + return nil +} + +// Schema returns a schema representing this value on the wire. +// It returns the schema of the contained type. +func (o OmittableNullable[T]) Schema(r huma.Registry) *huma.Schema { + return r.Schema(reflect.TypeOf(o.Value), true, "") +} + +type MyResponse struct { + Body struct { + Message string `json:"message"` + } +} + +func main() { + // Create a CLI app which takes a port option. + cli := huma.NewCLI(func(hooks huma.Hooks, options *Options) { + // Create a new router & API + router := chi.NewMux() + api := humachi.New(router, huma.DefaultConfig("My API", "1.0.0")) + + huma.Register(api, huma.Operation{ + OperationID: "omittable", + Method: http.MethodPost, + Path: "/omittable", + Summary: "Omittable / nullable example", + }, func(ctx context.Context, input *struct { + // Making the body a pointer makes it optional, as it may be `nil`. + Body *struct { + Name OmittableNullable[string] `json:"name,omitempty" maxLength:"10"` + } + }) (*MyResponse, error) { + resp := &MyResponse{} + if input.Body == nil { + resp.Body.Message = "Body was not sent" + } else if !input.Body.Name.Sent { + resp.Body.Message = "Name was omitted from the request" + } else if input.Body.Name.Null { + resp.Body.Message = "Name was set to null" + } else { + resp.Body.Message = "Name was set to: " + input.Body.Name.Value + } + return resp, nil + }) + + // Tell the CLI how to start your router. + hooks.OnStart(func() { + http.ListenAndServe(fmt.Sprintf(":%d", options.Port), router) + }) + }) + + // Run the CLI. When passed no commands, it starts the server. + cli.Run() +} diff --git a/huma.go b/huma.go index 5998e70b..a21b3f2f 100644 --- a/huma.go +++ b/huma.go @@ -374,6 +374,7 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) if f, ok := inputType.FieldByName("Body"); ok { inputBodyIndex = f.Index[0] op.RequestBody = &RequestBody{ + Required: f.Type.Kind() != reflect.Ptr && f.Type.Kind() != reflect.Interface, Content: map[string]*MediaType{ "application/json": { Schema: registry.Schema(f.Type, true, getHint(inputType, f.Name, op.OperationID+"Request")), @@ -686,11 +687,7 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) } if len(body) == 0 { - kind := reflect.Slice // []byte by default for raw body - if inputBodyIndex != -1 { - kind = v.Field(inputBodyIndex).Kind() - } - if kind != reflect.Ptr && kind != reflect.Interface { + if op.RequestBody != nil && op.RequestBody.Required { buf.Reset() bufPool.Put(buf) WriteErr(api, ctx, http.StatusBadRequest, "request body is required", res.Errors...) diff --git a/registry.go b/registry.go index 9773be95..87ed262d 100644 --- a/registry.go +++ b/registry.go @@ -55,6 +55,12 @@ func (r *mapRegistry) Schema(t reflect.Type, allowRef bool, hint string) *Schema getsRef = false } + v := reflect.New(t).Interface() + if _, ok := v.(SchemaProvider); ok { + // Special case: type provides its own schema + getsRef = false + } + name := r.namer(t, hint) if getsRef { diff --git a/schema.go b/schema.go index 31823090..ebb93d6e 100644 --- a/schema.go +++ b/schema.go @@ -403,6 +403,13 @@ func getFields(typ reflect.Type) []fieldInfo { return fields } +// SchemaProvider is an interface that can be implemented by types to provide +// a custom schema for themselves, overriding the built-in schema generation. +// This can be used by custom types with their own special serialization rules. +type SchemaProvider interface { + Schema(r Registry) *Schema +} + // 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 @@ -412,6 +419,12 @@ func getFields(typ reflect.Type) []fieldInfo { // registry := huma.NewMapRegistry("#/prefix", huma.DefaultSchemaNamer) // schema := huma.SchemaFromType(registry, reflect.TypeOf(MyType{})) func SchemaFromType(r Registry, t reflect.Type) *Schema { + v := reflect.New(t).Interface() + if sp, ok := v.(SchemaProvider); ok { + // Special case: type provides its own schema. Do not try to generate. + return sp.Schema(r) + } + s := Schema{} t = deref(t) diff --git a/schema_test.go b/schema_test.go index 4ccac7d9..6b0368e5 100644 --- a/schema_test.go +++ b/schema_test.go @@ -1,6 +1,7 @@ package huma import ( + "bytes" "encoding/json" "fmt" "math/bits" @@ -472,6 +473,64 @@ func TestSchemaGenericNaming(t *testing.T) { }`, string(b)) } +type OmittableNullable[T any] struct { + Sent bool + Null bool + Value T +} + +func (o *OmittableNullable[T]) UnmarshalJSON(b []byte) error { + if len(b) > 0 { + o.Sent = true + if bytes.Equal(b, []byte("null")) { + o.Null = true + return nil + } + return json.Unmarshal(b, &o.Value) + } + return nil +} + +func (o OmittableNullable[T]) Schema(r Registry) *Schema { + return r.Schema(reflect.TypeOf(o.Value), true, "") +} + +func TestCustomUnmarshalType(t *testing.T) { + type O struct { + Field OmittableNullable[int] `json:"field" maximum:"10"` + } + + var o O + + // Confirm the schema is generated properly, including field constraints. + r := NewMapRegistry("#/components/schemas/", 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) + + // Confirm the field works as expected when loading JSON. + o = O{} + err := json.Unmarshal([]byte(`{"field": 123}`), &o) + assert.NoError(t, err) + assert.True(t, o.Field.Sent) + assert.False(t, o.Field.Null) + assert.Equal(t, 123, o.Field.Value) + + o = O{} + err = json.Unmarshal([]byte(`{"field": null}`), &o) + assert.NoError(t, err) + assert.True(t, o.Field.Sent) + assert.True(t, o.Field.Null) + assert.Equal(t, 0, o.Field.Value) + + o = O{} + err = json.Unmarshal([]byte(`{}`), &o) + assert.NoError(t, err) + assert.False(t, o.Field.Sent) + assert.False(t, o.Field.Null) + assert.Equal(t, 0, o.Field.Value) +} + type BenchSub struct { Visible bool `json:"visible" default:"true"` Metrics []float64 `json:"metrics" maxItems:"31"`