From 1961ef9a5cd2c9a8cc81b58e6026c7227805f761 Mon Sep 17 00:00:00 2001 From: Louis Duchemin Date: Tue, 1 Oct 2024 16:12:44 +0200 Subject: [PATCH 1/2] Revert "fix: nullable schemas for arrays/slices to match behavior of json.Marshal" This reverts commit 72a101dce9a18e0febfa040ca1d324699d0dfa60. --- schema.go | 1 - schema_test.go | 22 +++++++++++----------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/schema.go b/schema.go index b1e9b288..7c7376c3 100644 --- a/schema.go +++ b/schema.go @@ -762,7 +762,6 @@ func schemaFromType(r Registry, t reflect.Type) *Schema { s.ContentEncoding = "base64" } else { s.Type = TypeArray - s.Nullable = true 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..39405438 100644 --- a/schema_test.go +++ b/schema_test.go @@ -205,12 +205,12 @@ func TestSchema(t *testing.T) { { name: "array", input: [2]int{1, 2}, - expected: `{"type": ["array", "null"], "items": {"type": "integer", "format": "int64"}, "minItems": 2, "maxItems": 2}`, + expected: `{"type": "array", "items": {"type": "integer", "format": "int64"}, "minItems": 2, "maxItems": 2}`, }, { name: "slice", input: []int{1, 2, 3}, - expected: `{"type": ["array", "null"], "items": {"type": "integer", "format": "int64"}}`, + expected: `{"type": "array", "items": {"type": "integer", "format": "int64"}}`, }, { name: "map", @@ -286,7 +286,7 @@ func TestSchema(t *testing.T) { "type": "object", "properties": { "value": { - "type": ["array", "null"], + "type": "array", "minItems": 1, "maxItems": 10, "uniqueItems": true, @@ -344,7 +344,7 @@ func TestSchema(t *testing.T) { "type": "object", "properties": { "value": { - "type": ["array", "null"], + "type": "array", "items": { "type": "integer", "format": "int64", @@ -434,7 +434,7 @@ func TestSchema(t *testing.T) { "type": "object", "properties": { "value": { - "type": ["array", "null"], + "type": "array", "items": { "type": "string" }, @@ -454,7 +454,7 @@ func TestSchema(t *testing.T) { "type": "object", "properties": { "value": { - "type": ["array", "null"], + "type": "array", "items": { "type": "integer", "format": "int64" @@ -753,7 +753,7 @@ func TestSchema(t *testing.T) { }, "maxItems":1, "minItems":1, - "type":["array", "null"] + "type":"array" }, "byRef":{ "$ref":"#/components/schemas/RecursiveChildKey" @@ -771,7 +771,7 @@ func TestSchema(t *testing.T) { "items":{ "$ref":"#/components/schemas/RecursiveChildLoop" }, - "type":["array", "null"]} + "type":"array"} }, "required":["slice","array","map","byValue", "byRef"], "type":"object" @@ -893,7 +893,7 @@ func TestSchema(t *testing.T) { "additionalProperties":false, "properties":{ "values":{ - "type":["array", "null"], + "type":"array", "items":{ "type":"string", "minLength":1, @@ -1050,7 +1050,7 @@ func TestSchema(t *testing.T) { }, "maxItems":4, "minItems":4, - "type":["array", "null"] + "type":"array" } }, "required":["value"], @@ -1073,7 +1073,7 @@ func TestSchema(t *testing.T) { }, "maxItems":4, "minItems":4, - "type":["array", "null"] + "type":"array" } }, "required":["value"], From 0f0445632ce934e47f549716d5303e83254874cc Mon Sep 17 00:00:00 2001 From: Louis Duchemin Date: Wed, 2 Oct 2024 11:49:20 +0200 Subject: [PATCH 2/2] feat: allow configuration of registry to generate nullable schemas for arrays/slices --- api.go | 4 ++++ api_test.go | 10 ++++++++++ registry.go | 18 ++++++++++++++++++ schema.go | 1 + schema_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 73 insertions(+) diff --git a/api.go b/api.go index 60ac05e0..88106b15 100644 --- a/api.go +++ b/api.go @@ -191,6 +191,10 @@ type Config struct { CreateHooks []func(Config) Config } +func (c *Config) RegistryConfig() *RegistryConfig { + return c.Components.Schemas.Config() +} + // API represents a Huma API wrapping a specific router. type API interface { // Adapter returns the router adapter for this API, providing a generic diff --git a/api_test.go b/api_test.go index 87c9e186..ed103341 100644 --- a/api_test.go +++ b/api_test.go @@ -126,3 +126,13 @@ func TestChiRouterPrefix(t *testing.T) { assert.Equal(t, http.StatusOK, resp.Code) assert.Contains(t, resp.Body.String(), "/api/openapi.yaml") } + +func TestRegistryConfig(t *testing.T) { + cfg := huma.DefaultConfig("Test", "3.1") + + // Test default value + assert.False(t, cfg.Components.Schemas.Config().NullSlices) + + cfg.RegistryConfig().NullSlices = true + assert.True(t, cfg.Components.Schemas.Config().NullSlices) +} diff --git a/registry.go b/registry.go index 6ee69153..bc6da3fc 100644 --- a/registry.go +++ b/registry.go @@ -9,6 +9,15 @@ import ( "unicode/utf8" ) +// RegistryConfig contains configuration options to control how OpenAPI schemas +// are generated +type RegistryConfig struct { + // NullSlices specifies whether slices in request and response body should be + // marked as nullable in the generated OpenAPI spec to reflect the behavior + // of [encoding/json] package. + NullSlices bool +} + // Registry creates and stores schemas and their references, and supports // marshalling to JSON/YAML for use as an OpenAPI #/components/schemas object. // Behavior is implementation-dependent, but the design allows for recursive @@ -20,6 +29,7 @@ type Registry interface { TypeFromRef(ref string) reflect.Type Map() map[string]*Schema RegisterTypeAlias(t reflect.Type, alias reflect.Type) + Config() *RegistryConfig } // DefaultSchemaNamer provides schema names for types. It uses the type name @@ -66,6 +76,11 @@ type mapRegistry struct { seen map[reflect.Type]bool namer func(reflect.Type, string) string aliases map[reflect.Type]reflect.Type + config RegistryConfig +} + +func (r *mapRegistry) Config() *RegistryConfig { + return &r.config } func (r *mapRegistry) Schema(t reflect.Type, allowRef bool, hint string) *Schema { @@ -170,5 +185,8 @@ func NewMapRegistry(prefix string, namer func(t reflect.Type, hint string) strin seen: map[reflect.Type]bool{}, aliases: map[reflect.Type]reflect.Type{}, namer: namer, + config: RegistryConfig{ + NullSlices: false, + }, } } diff --git a/schema.go b/schema.go index 7c7376c3..93b97ae9 100644 --- a/schema.go +++ b/schema.go @@ -762,6 +762,7 @@ func schemaFromType(r Registry, t reflect.Type) *Schema { s.ContentEncoding = "base64" } else { s.Type = TypeArray + s.Nullable = r.Config().NullSlices 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 39405438..57fb25f0 100644 --- a/schema_test.go +++ b/schema_test.go @@ -106,11 +106,16 @@ func (c *TypedIntegerWithCustomLimits) TransformSchema(r huma.Registry, s *huma. func TestSchema(t *testing.T) { bitSize := strconv.Itoa(bits.UintSize) + nullSlicesCfg := huma.RegistryConfig{ + NullSlices: true, + } + cases := []struct { name string input any expected string panics string + config huma.RegistryConfig }{ { name: "bool", @@ -1095,12 +1100,47 @@ func TestSchema(t *testing.T) { "type":"object" }`, }, + { + name: "array-null-cfg", + config: nullSlicesCfg, + input: [2]int{1, 2}, + expected: `{"type": ["array", "null"], "items": {"type": "integer", "format": "int64"}, "minItems": 2, "maxItems": 2}`, + }, + { + name: "slice-null-cfg", + config: nullSlicesCfg, + input: []int{1, 2, 3}, + expected: `{"type": ["array", "null"], "items": {"type": "integer", "format": "int64"}}`, + }, + { + name: "field-array-null-cfg", + config: nullSlicesCfg, + input: struct { + Value []int `json:"value" minItems:"1" maxItems:"10" uniqueItems:"true"` + }{}, + expected: `{ + "type": "object", + "properties": { + "value": { + "type": ["array", "null"], + "minItems": 1, + "maxItems": 10, + "uniqueItems": true, + "items": {"type": "integer", "format": "int64"} + } + }, + "required": ["value"], + "additionalProperties": false + }`, + }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { r := huma.NewMapRegistry("#/components/schemas/", huma.DefaultSchemaNamer) + r.Config().NullSlices = c.config.NullSlices + if c.panics != "" { assert.PanicsWithError(t, c.panics, func() { r.Schema(reflect.TypeOf(c.input), false, "")