Repository: danielgtaylor/huma
Pull request #612 (2024-10-16 23:39:55)

fix: make nullable arrays configurable
This is an alternative approach to #594. @lsdch would love to know what you think.

1. There is precedent in Huma to use global variables to control behavior. This adds another such value to set the default array nullability called `huma.DefaultArrayNullable`.
2. The default stays `true`, as this is more correct based on how Go marshals JSON. A `nil` slice will encode as `null` so we ask the user to opt-in to alternative behavior.
3. Updates docs to reflect this and adds a note about `nil` slices encoding as `null`.
4. Adds a few tests.

IMO this prevents another break, moving back to less correct/accurate behavior, and makes everything explicit. I'm not the biggest fan of global variables but it seems reasonable here instead of trying to plumb everything through the various types and interfaces. Thoughts?

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- **Documentation**
	- Updated request validation documentation to clarify nullable fields and rules for optional and required fields.
	- Enhanced examples for making fields nullable in Go structs.

- **New Features**
	- Introduced a global variable to control the default nullability of arrays, simplifying configuration.

- **Tests**
	- Added new test cases for nullable and non-nullable arrays, improving schema validation coverage.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Patch:
diff --git a/docs/docs/features/request-validation.md b/docs/docs/features/request-validation.md
index 1626dcc..74a1074 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 e451e23..69f0c30 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 {


Test patch:
diff --git a/schema_test.go b/schema_test.go
index bac7e46..e3e671f 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"`