Skip to content

Commit

Permalink
feat: support custom field schemas; add omittable example
Browse files Browse the repository at this point in the history
  • Loading branch information
danielgtaylor committed Oct 20, 2023
1 parent f5a9652 commit 9f9caed
Show file tree
Hide file tree
Showing 6 changed files with 273 additions and 5 deletions.
85 changes: 85 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
108 changes: 108 additions & 0 deletions examples/omit/main.go
Original file line number Diff line number Diff line change
@@ -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()
}
7 changes: 2 additions & 5 deletions huma.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down Expand Up @@ -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...)
Expand Down
6 changes: 6 additions & 0 deletions registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down
59 changes: 59 additions & 0 deletions schema_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package huma

import (
"bytes"
"encoding/json"
"fmt"
"math/bits"
Expand Down Expand Up @@ -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"`
Expand Down

0 comments on commit 9f9caed

Please sign in to comment.