Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
69 changes: 69 additions & 0 deletions schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})
}