diff --git a/schema.go b/schema.go index f267b4de..18b7440e 100644 --- a/schema.go +++ b/schema.go @@ -243,6 +243,90 @@ func (s *Schema) MarshalJSON() ([]byte, error) { }, s.Extensions) } +// knownSchemaFields is the set of JSON keys that map to explicit Schema struct +// fields (as written by MarshalJSON). Any other key is an extension and is +// collected into Extensions during UnmarshalJSON, mirroring how MarshalJSON +// writes the Extensions map inline. +var knownSchemaFields = map[string]struct{}{ + "type": {}, "title": {}, "description": {}, "$ref": {}, "format": {}, + "contentMediaType": {}, "contentEncoding": {}, "default": {}, "examples": {}, + "items": {}, "additionalProperties": {}, "properties": {}, "enum": {}, + "const": {}, "minimum": {}, "exclusiveMinimum": {}, "maximum": {}, + "exclusiveMaximum": {}, "multipleOf": {}, "minLength": {}, "maxLength": {}, + "pattern": {}, "patternDescription": {}, "minItems": {}, "maxItems": {}, + "uniqueItems": {}, "required": {}, "dependentRequired": {}, + "minProperties": {}, "maxProperties": {}, "readOnly": {}, "writeOnly": {}, + "deprecated": {}, "oneOf": {}, "anyOf": {}, "allOf": {}, "not": {}, + "discriminator": {}, +} + +// UnmarshalJSON unmarshals JSON into the schema. It is the inverse of +// MarshalJSON so a schema produced by MarshalJSON round-trips back into an +// equivalent Schema: the `type` keyword may be a plain string or the nullable +// `[type, "null"]` array form, `$ref` is read into Ref, and any unknown keys +// (e.g. `x-` extensions) are collected into Extensions. +func (s *Schema) UnmarshalJSON(data []byte) error { + if strings.TrimSpace(string(data)) == "null" { + return nil + } + + // Alias avoids recursing into this method. `type` and `$ref` are pulled out + // separately: `type` because it may be a string or an array, and `$ref` + // because the struct field has no matching JSON tag. + type alias Schema + aux := struct { + Type json.RawMessage `json:"type"` + Ref string `json:"$ref"` + *alias + }{alias: (*alias)(s)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + s.Ref = aux.Ref + + if len(aux.Type) > 0 { + var single string + if err := json.Unmarshal(aux.Type, &single); err == nil { + s.Type = single + } else { + var multi []string + if err := json.Unmarshal(aux.Type, &multi); err != nil { + return err + } + s.Type = "" + for _, t := range multi { + if t == "null" { + s.Nullable = true + } else { + s.Type = t + } + } + } + } + + // Collect extension keys that don't map to a struct field. + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + for k, rv := range raw { + if _, ok := knownSchemaFields[k]; ok { + continue + } + var v any + if err := json.Unmarshal(rv, &v); err != nil { + return err + } + if s.Extensions == nil { + s.Extensions = map[string]any{} + } + s.Extensions[k] = v + } + + s.PrecomputeMessages() + return nil +} + // PrecomputeMessages tries to precompute as many validation error messages // as possible so that new strings aren't allocated during request validation. func (s *Schema) PrecomputeMessages() { diff --git a/schema_test.go b/schema_test.go index c0598fdc..8957f43c 100644 --- a/schema_test.go +++ b/schema_test.go @@ -1721,3 +1721,72 @@ func TestSchemaTransformer(t *testing.T) { updateSchema2 := huma.SchemaFromType(r, reflect.TypeFor[ExampleUpdateStruct]()) validateSchema(updateSchema2) } + +func TestSchemaUnmarshalJSON(t *testing.T) { + t.Run("nullable type round-trips", func(t *testing.T) { + minLen := 1 + orig := &huma.Schema{ + Type: huma.TypeString, + Nullable: true, + MinLength: &minLen, + } + data, err := json.Marshal(orig) + require.NoError(t, err) + assert.JSONEq(t, `{"type":["string","null"],"minLength":1}`, string(data)) + + var back huma.Schema + require.NoError(t, json.Unmarshal(data, &back)) + assert.Equal(t, huma.TypeString, back.Type) + assert.True(t, back.Nullable) + require.NotNil(t, back.MinLength) + assert.Equal(t, 1, *back.MinLength) + }) + + t.Run("plain string type", func(t *testing.T) { + var s huma.Schema + require.NoError(t, json.Unmarshal([]byte(`{"type":"integer"}`), &s)) + assert.Equal(t, huma.TypeInteger, s.Type) + assert.False(t, s.Nullable) + }) + + t.Run("ref and extensions", func(t *testing.T) { + data := []byte(`{"$ref":"#/components/schemas/Foo","description":"a foo","x-custom":42}`) + var s huma.Schema + require.NoError(t, json.Unmarshal(data, &s)) + assert.Equal(t, "#/components/schemas/Foo", s.Ref) + assert.Equal(t, "a foo", s.Description) + require.Contains(t, s.Extensions, "x-custom") + assert.EqualValues(t, 42, s.Extensions["x-custom"]) + }) + + t.Run("nested object round-trips", func(t *testing.T) { + orig := &huma.Schema{ + Type: huma.TypeObject, + Properties: map[string]*huma.Schema{ + "name": {Type: huma.TypeString, Nullable: true}, + }, + Required: []string{"name"}, + } + data, err := json.Marshal(orig) + require.NoError(t, err) + + var back huma.Schema + require.NoError(t, json.Unmarshal(data, &back)) + assert.Equal(t, huma.TypeObject, back.Type) + require.Contains(t, back.Properties, "name") + assert.Equal(t, huma.TypeString, back.Properties["name"].Type) + assert.True(t, back.Properties["name"].Nullable) + assert.Equal(t, []string{"name"}, back.Required) + }) + + t.Run("null literal is a no-op", func(t *testing.T) { + s := huma.Schema{Type: huma.TypeString} + require.NoError(t, json.Unmarshal([]byte(`null`), &s)) + assert.Equal(t, huma.TypeString, s.Type) + }) + + t.Run("invalid type value errors", func(t *testing.T) { + var s huma.Schema + assert.Error(t, json.Unmarshal([]byte(`{"type":123}`), &s)) + }) +}