Skip to content

Commit 3a43be6

Browse files
authored
Support setting fields as optional by default (#947)
1 parent a3c8b56 commit 3a43be6

File tree

4 files changed

+83
-17
lines changed

4 files changed

+83
-17
lines changed

api.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,11 @@ type Config struct {
215215
// for example if you need access to the path settings that may be changed
216216
// by the user after the defaults have been set.
217217
CreateHooks []func(Config) Config
218+
219+
// FieldsOptionalByDefault controls whether schema fields are treated as
220+
// optional by default. When false, fields are marked as required unless
221+
// they have the omitempty or omitzero tag.
222+
FieldsOptionalByDefault bool
218223
}
219224

220225
// API represents a Huma API wrapping a specific router.
@@ -410,6 +415,9 @@ func NewAPI(config Config, a Adapter) API {
410415
if config.Components.Schemas == nil {
411416
config.Components.Schemas = NewMapRegistry("#/components/schemas/", DefaultSchemaNamer)
412417
}
418+
if mr, ok := config.Components.Schemas.(*mapRegistry); ok {
419+
mr.fieldsOptionalByDefault = config.FieldsOptionalByDefault
420+
}
413421

414422
if config.DefaultFormat == "" && !config.NoFormatFallback {
415423
if config.Formats["application/json"].Marshal != nil {

huma_test.go

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3354,7 +3354,7 @@ func TestCustomValidationErrorStatus(t *testing.T) {
33543354
// })
33553355
// }
33563356

3357-
func globalHandler(ctx context.Context, input *struct {
3357+
func globalHandler(_ context.Context, input *struct {
33583358
Count int `query:"count"`
33593359
}) (*struct{ Body int }, error) {
33603360
return &struct{ Body int }{Body: input.Count * 3 / 2}, nil
@@ -3409,3 +3409,54 @@ func TestGenerateFuncsPanicWithDescriptiveMessage(t *testing.T) {
34093409
})
34103410

34113411
}
3412+
3413+
func TestFieldsOptionalByDefault(t *testing.T) {
3414+
type MyInput struct {
3415+
Body struct {
3416+
Name string `json:"name"`
3417+
Age int `json:"age" required:"true"`
3418+
}
3419+
}
3420+
3421+
// Default behavior.
3422+
{
3423+
config := huma.DefaultConfig("Test", "1.0.0")
3424+
config.FieldsOptionalByDefault = false
3425+
_, api := humatest.New(t, config)
3426+
3427+
huma.Post(api, "/test", func(ctx context.Context, input *MyInput) (*struct{}, error) {
3428+
return nil, nil
3429+
})
3430+
3431+
// Missing name should fail because it's required by default.
3432+
resp := api.Post("/test", map[string]any{
3433+
"age": 25,
3434+
})
3435+
assert.Equal(t, http.StatusUnprocessableEntity, resp.Code)
3436+
assert.Contains(t, resp.Body.String(), "required property name")
3437+
}
3438+
3439+
// Mark fields optional by default.
3440+
{
3441+
config := huma.DefaultConfig("Test", "1.0.0")
3442+
config.FieldsOptionalByDefault = true
3443+
_, api := humatest.New(t, config)
3444+
3445+
huma.Post(api, "/test", func(ctx context.Context, input *MyInput) (*struct{}, error) {
3446+
return nil, nil
3447+
})
3448+
3449+
// Missing name should pass because it's optional by default.
3450+
resp := api.Post("/test", map[string]any{
3451+
"age": 25,
3452+
})
3453+
assert.Equal(t, http.StatusNoContent, resp.Code)
3454+
3455+
// Missing age should still fail because it's explicitly marked as required.
3456+
resp = api.Post("/test", map[string]any{
3457+
"name": "John",
3458+
})
3459+
assert.Equal(t, http.StatusUnprocessableEntity, resp.Code)
3460+
assert.Contains(t, resp.Body.String(), "required property age")
3461+
}
3462+
}

registry.go

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type Registry interface {
2020
TypeFromRef(ref string) reflect.Type
2121
Map() map[string]*Schema
2222
RegisterTypeAlias(t reflect.Type, alias reflect.Type)
23+
FieldsOptionalByDefault() bool
2324
}
2425

2526
// DefaultSchemaNamer provides schema names for types. It uses the type name
@@ -60,12 +61,17 @@ func DefaultSchemaNamer(t reflect.Type, hint string) string {
6061
}
6162

6263
type mapRegistry struct {
63-
prefix string
64-
schemas map[string]*Schema
65-
types map[string]reflect.Type
66-
seen map[reflect.Type]bool
67-
namer func(reflect.Type, string) string
68-
aliases map[reflect.Type]reflect.Type
64+
prefix string
65+
schemas map[string]*Schema
66+
types map[string]reflect.Type
67+
seen map[reflect.Type]bool
68+
namer func(reflect.Type, string) string
69+
aliases map[reflect.Type]reflect.Type
70+
fieldsOptionalByDefault bool
71+
}
72+
73+
func (r *mapRegistry) FieldsOptionalByDefault() bool {
74+
return r.fieldsOptionalByDefault
6975
}
7076

7177
func (r *mapRegistry) Schema(t reflect.Type, allowRef bool, hint string) *Schema {
@@ -164,11 +170,12 @@ func (r *mapRegistry) RegisterTypeAlias(t reflect.Type, alias reflect.Type) {
164170
// returns references to them using the given prefix.
165171
func NewMapRegistry(prefix string, namer func(t reflect.Type, hint string) string) Registry {
166172
return &mapRegistry{
167-
prefix: prefix,
168-
schemas: map[string]*Schema{},
169-
types: map[string]reflect.Type{},
170-
seen: map[reflect.Type]bool{},
171-
aliases: map[reflect.Type]reflect.Type{},
172-
namer: namer,
173+
prefix: prefix,
174+
schemas: map[string]*Schema{},
175+
types: map[string]reflect.Type{},
176+
seen: map[reflect.Type]bool{},
177+
aliases: map[reflect.Type]reflect.Type{},
178+
namer: namer,
179+
fieldsOptionalByDefault: false,
173180
}
174181
}

schema.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -844,10 +844,10 @@ func schemaFromType(r Registry, t reflect.Type) *Schema {
844844
fieldSet[f.Name] = struct{}{}
845845

846846
// Controls whether the field is required or not. All fields start as
847-
// required, then can be made optional with the `omitempty` JSON tag,
848-
// `omitzero` JSON tag, or it can be overridden manually via the
849-
// `required` tag.
850-
fieldRequired := true
847+
// required (unless the registry says otherwise), then can be made
848+
// optional with the `omitempty` JSON tag, `omitzero` JSON tag, or it
849+
// can be overridden manually via the `required` tag.
850+
fieldRequired := !r.FieldsOptionalByDefault()
851851

852852
name := f.Name
853853
if j := f.Tag.Get("json"); j != "" {

0 commit comments

Comments
 (0)