diff --git a/docs/docs/features/request-validation.md b/docs/docs/features/request-validation.md index 1626dcce..74a10745 100644 --- a/docs/docs/features/request-validation.md +++ b/docs/docs/features/request-validation.md @@ -57,13 +57,14 @@ Huma tries to balance schema simplicity, usability, and broad compatibility with Fields being nullable is determined automatically but can be overridden as needed using the logic below: 1. Start with no fields as nullable -2. If a field is a pointer: +2. If a field is a pointer (including slices): 1. To a `boolean`, `integer`, `number`, `string`: it is nullable unless it has `omitempty`. - 2. To an `array`, `object`: it is **not** nullable, due to complexity and bad support for `anyOf`/`oneOf` in many tools. + 2. To an `array`: it is nullable if `huma.DefaultArrayNullable` is true. + 3. To an `object`: it is **not** nullable, due to complexity and bad support for `anyOf`/`oneOf` in many tools. 3. If a field has `nullable:"false"`, it is not nullable 4. If a field has `nullable:"true"`: - 1. To a `boolean`, `integer`, `number`, `string`: it is nullable - 2. To an `array`, `object`: **panic** saying this is not currently supported + 1. To a `boolean`, `integer`, `number`, `string`, `array`: it is nullable + 2. To an `object`: **panic** saying this is not currently supported 5. If a struct has a field `_` with `nullable: true`, the struct is nullable enabling users to opt-in for `object` without the `anyOf`/`oneOf` complication. Here are some examples: @@ -77,7 +78,7 @@ type MyStruct1 struct { } // Make a specific scalar field nullable. This is *not* supported for -// slices, maps, or structs. Structs *must* use the method above. +// maps or structs. Structs *must* use the method above. type MyStruct2 struct { Field1 *string `json:"field1"` Field2 string `json:"field2" nullable:"true"` @@ -86,6 +87,10 @@ type MyStruct2 struct { Nullable types will generate a type array like `"type": ["string", "null"]` which has broad compatibility and is easy to downgrade to OpenAPI 3.0. Also keep in mind you can always provide a [custom schema](./schema-customization.md) if the built-in features aren't exactly what you need. +!!! info "Note" + + Slices in Go marshal into JSON as `null` if the slice itself is `nil` rather than allocated but empty. This is why slices are nullable by default. See the [Go JSON package documentation](https://pkg.go.dev/encoding/json#Marshal) for more information. + ## Validation Tags The following additional tags are supported on model fields: diff --git a/schema.go b/schema.go index e451e23b..69f0c30c 100644 --- a/schema.go +++ b/schema.go @@ -22,6 +22,12 @@ import ( // ErrSchemaInvalid is sent when there is a problem building the schema. var ErrSchemaInvalid = errors.New("schema is invalid") +// DefaultArrayNullable controls whether arrays are nullable by default. Set +// this to `false` to make arrays non-nullable by default, but be aware that +// any `nil` slice will still encode as `null` in JSON. See also: +// https://pkg.go.dev/encoding/json#Marshal. +var DefaultArrayNullable = true + // JSON Schema type constants const ( TypeBoolean = "boolean" @@ -763,7 +769,7 @@ func schemaFromType(r Registry, t reflect.Type) *Schema { s.ContentEncoding = "base64" } else { s.Type = TypeArray - s.Nullable = true + s.Nullable = DefaultArrayNullable s.Items = r.Schema(t.Elem(), true, t.Name()+"Item") if t.Kind() == reflect.Array { diff --git a/schema_test.go b/schema_test.go index bac7e463..e3e671fd 100644 --- a/schema_test.go +++ b/schema_test.go @@ -722,6 +722,46 @@ func TestSchema(t *testing.T) { "required": ["int"] }`, }, + { + name: "field-nullable-array", + input: struct { + Int []int64 `json:"int" nullable:"true"` + }{}, + expected: `{ + "type": "object", + "additionalProperties": false, + "properties": { + "int": { + "type": ["array", "null"], + "items": { + "type": "integer", + "format": "int64" + } + } + }, + "required": ["int"] + }`, + }, + { + name: "field-non-nullable-array", + input: struct { + Int []int64 `json:"int" nullable:"false"` + }{}, + expected: `{ + "type": "object", + "additionalProperties": false, + "properties": { + "int": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + } + }, + "required": ["int"] + }`, + }, { name: "field-nullable-struct", input: struct { @@ -1319,6 +1359,22 @@ func TestMarshalDiscriminator(t *testing.T) { }`, string(b)) } +func TestSchemaArrayNotNullable(t *testing.T) { + huma.DefaultArrayNullable = false + defer func() { + huma.DefaultArrayNullable = true + }() + + type Value struct { + Field []string `json:"field"` + } + + r := huma.NewMapRegistry("#/components/schemas/", huma.DefaultSchemaNamer) + s := r.Schema(reflect.TypeOf(Value{}), false, "") + + assert.Equal(t, "array", s.Properties["field"].Type) +} + type BenchSub struct { Visible bool `json:"visible" default:"true"` Metrics []float64 `json:"metrics" maxItems:"31"`