diff --git a/README.md b/README.md index 1a68a09..080dac9 100644 --- a/README.md +++ b/README.md @@ -371,3 +371,19 @@ The resulting schema generated for this struct would look like: } } ``` + +### Nullability + +Nullability in JSON schema is expressed via +[`oneOf`](https://json-schema.org/draft/2020-12/json-schema-core#section-10.2.1.3) choice +between original schema & `{"type":"null"}`. + +By default, only the struct fields that are explicitly annotated with `jsonschema:"nullable"` tag are marked nullable. +However, when setting `NullableFromType` to `true`, `jsonschema:"nullable"` is ignored & +the following Go reflect types are marked as nullable instead: + +- `reflect.Pointer` +- `reflect.UnsafePointer` +- `reflect.Map` +- `reflect.Slice` +- `reflect.Interface` diff --git a/fixtures/nullable_from_type_disabled.json b/fixtures/nullable_from_type_disabled.json new file mode 100644 index 0000000..d1739ae --- /dev/null +++ b/fixtures/nullable_from_type_disabled.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/invopop/jsonschema/nullable-with-odd-fields", + "$ref": "#/$defs/NullableWithOddFields", + "$defs": { + "NullableWithOddFields": { + "properties": { + "simple_map": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "map_with_nullable_values": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "int_slice": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "pointer_to_int_slice": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "int_slice_with_nullable_values": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "pointer_to_int_slice_with_nullable_values": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "chan": true, + "unsafe_pointer": true, + "reader": true + }, + "additionalProperties": false, + "type": "object", + "required": [ + "simple_map", + "map_with_nullable_values", + "int_slice", + "pointer_to_int_slice", + "int_slice_with_nullable_values", + "pointer_to_int_slice_with_nullable_values", + "chan", + "unsafe_pointer", + "reader" + ] + } + } +} \ No newline at end of file diff --git a/fixtures/nullable_from_type_enabled.json b/fixtures/nullable_from_type_enabled.json new file mode 100644 index 0000000..13928b9 --- /dev/null +++ b/fixtures/nullable_from_type_enabled.json @@ -0,0 +1,140 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/invopop/jsonschema/nullable-with-odd-fields", + "$ref": "#/$defs/NullableWithOddFields", + "$defs": { + "NullableWithOddFields": { + "properties": { + "simple_map": { + "oneOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ] + }, + "map_with_nullable_values": { + "oneOf": [ + { + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "type": "object" + }, + { + "type": "null" + } + ] + }, + "int_slice": { + "oneOf": [ + { + "items": { + "type": "integer" + }, + "type": "array" + }, + { + "type": "null" + } + ] + }, + "pointer_to_int_slice": { + "oneOf": [ + { + "items": { + "type": "integer" + }, + "type": "array" + }, + { + "type": "null" + } + ] + }, + "int_slice_with_nullable_values": { + "oneOf": [ + { + "items": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "type": "array" + }, + { + "type": "null" + } + ] + }, + "pointer_to_int_slice_with_nullable_values": { + "oneOf": [ + { + "items": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "type": "array" + }, + { + "type": "null" + } + ] + }, + "chan": true, + "unsafe_pointer": { + "oneOf": [ + true, + { + "type": "null" + } + ] + }, + "reader": { + "oneOf": [ + true, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "simple_map", + "map_with_nullable_values", + "int_slice", + "pointer_to_int_slice", + "int_slice_with_nullable_values", + "pointer_to_int_slice_with_nullable_values", + "chan", + "unsafe_pointer", + "reader" + ] + } + } +} \ No newline at end of file diff --git a/fixtures/test_user_nullable_from_type.json b/fixtures/test_user_nullable_from_type.json new file mode 100644 index 0000000..70b179d --- /dev/null +++ b/fixtures/test_user_nullable_from_type.json @@ -0,0 +1,315 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/invopop/jsonschema/test-user", + "$ref": "#/$defs/TestUser", + "$defs": { + "Bytes": { + "type": "string", + "contentEncoding": "base64" + }, + "GrandfatherType": { + "properties": { + "family_name": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "family_name" + ] + }, + "MapType": { + "type": "object" + }, + "TestUser": { + "properties": { + "id": { + "type": "integer" + }, + "some_base_property": { + "type": "integer" + }, + "grand": { + "$ref": "#/$defs/GrandfatherType" + }, + "SomeUntaggedBaseProperty": { + "type": "boolean" + }, + "PublicNonExported": { + "type": "integer" + }, + "MapType": { + "oneOf": [ + { + "$ref": "#/$defs/MapType" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string", + "maxLength": 20, + "minLength": 1, + "pattern": ".*", + "title": "the name", + "description": "this is a property", + "default": "alex", + "readOnly": true, + "examples": [ + "joe", + "lucy" + ] + }, + "password": { + "type": "string", + "writeOnly": true + }, + "friends": { + "oneOf": [ + { + "items": { + "type": "integer" + }, + "type": "array", + "description": "list of IDs, omitted when empty" + }, + { + "type": "null" + } + ] + }, + "tags": { + "oneOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ] + }, + "options": { + "oneOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, + "TestFlag": { + "type": "boolean" + }, + "TestFlagFalse": { + "type": "boolean", + "default": false + }, + "TestFlagTrue": { + "type": "boolean", + "default": true + }, + "birth_date": { + "type": "string", + "format": "date-time" + }, + "website": { + "type": "string", + "format": "uri" + }, + "network_address": { + "oneOf": [ + { + "type": "string", + "format": "ipv4" + }, + { + "type": "null" + } + ] + }, + "photo": { + "oneOf": [ + { + "type": "string", + "contentEncoding": "base64" + }, + { + "type": "null" + } + ] + }, + "photo2": { + "oneOf": [ + { + "$ref": "#/$defs/Bytes" + }, + { + "type": "null" + } + ] + }, + "feeling": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "age": { + "type": "integer", + "maximum": 120, + "exclusiveMaximum": 121, + "minimum": 18, + "exclusiveMinimum": 17 + }, + "email": { + "type": "string", + "format": "email" + }, + "uuid": { + "type": "string", + "format": "uuid" + }, + "Baz": { + "type": "string", + "foo": [ + "bar", + "bar1" + ], + "hello": "world" + }, + "bool_extra": { + "type": "string", + "isFalse": false, + "isTrue": true + }, + "color": { + "type": "string", + "enum": [ + "red", + "green", + "blue" + ] + }, + "rank": { + "type": "integer", + "enum": [ + 1, + 2, + 3 + ] + }, + "mult": { + "type": "number", + "enum": [ + 1.0, + 1.5, + 2.0 + ] + }, + "roles": { + "oneOf": [ + { + "items": { + "type": "string", + "enum": [ + "admin", + "moderator", + "user" + ] + }, + "type": "array" + }, + { + "type": "null" + } + ] + }, + "priorities": { + "oneOf": [ + { + "items": { + "type": "integer", + "enum": [ + -1, + 0, + 1 + ] + }, + "type": "array" + }, + { + "type": "null" + } + ] + }, + "offsets": { + "oneOf": [ + { + "items": { + "type": "number", + "enum": [ + 1.570796, + 3.141592, + 6.283185 + ] + }, + "type": "array" + }, + { + "type": "null" + } + ] + }, + "anything": { + "oneOf": [ + true, + { + "type": "null" + } + ] + }, + "raw": { + "oneOf": [ + true, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "some_base_property", + "grand", + "SomeUntaggedBaseProperty", + "PublicNonExported", + "MapType", + "name", + "password", + "TestFlag", + "photo", + "photo2", + "age", + "email", + "uuid", + "Baz", + "color", + "roles", + "raw" + ] + } + } +} \ No newline at end of file diff --git a/reflect.go b/reflect.go index 3249c8c..ae9e80d 100644 --- a/reflect.go +++ b/reflect.go @@ -106,6 +106,17 @@ type Reflector struct { // default of requiring any key *not* tagged with `json:,omitempty`. RequiredFromJSONSchemaTags bool + // NullableFromType will cause the Reflector to determine nullability based on the field type + // as opposed to the default behavior to honor `jsonschema:"nullable"` tag. + // The following reflect.Kind types can be safely parsed as nil in Go + // & will be marked nullable if NullableFromType=true: + // - reflect.Pointer + // - reflect.UnsafePointer + // - reflect.Map + // - reflect.Slice + // - reflect.Interface + NullableFromType bool + // Do not reference definitions. This will remove the top-level $defs map and // instead cause the entire structure of types to be output in one tree. The // list of type definitions (`$defs`) will not be included. @@ -167,7 +178,7 @@ func (r *Reflector) Reflect(v any) *Schema { // ReflectFromType generates root schema func (r *Reflector) ReflectFromType(t reflect.Type) *Schema { - if t.Kind() == reflect.Ptr { + if t.Kind() == reflect.Pointer { t = t.Elem() // re-assign from pointer } @@ -263,9 +274,15 @@ func (r *Reflector) reflectTypeToSchemaWithID(defs Definitions, t reflect.Type) return s } -func (r *Reflector) reflectTypeToSchema(definitions Definitions, t reflect.Type) *Schema { +func (r *Reflector) reflectTypeToSchema(definitions Definitions, t reflect.Type) (res *Schema) { + defer func() { + if r.NullableFromType && isNullable(t.Kind()) { + res.MakeNullable() + } + }() + // only try to reflect non-pointers - if t.Kind() == reflect.Ptr { + if t.Kind() == reflect.Pointer { return r.refOrReflectTypeToSchema(definitions, t.Elem()) } @@ -321,7 +338,7 @@ func (r *Reflector) reflectTypeToSchema(definitions Definitions, t reflect.Type) case reflect.Map: r.reflectMap(definitions, t, st) - case reflect.Interface: + case reflect.Interface, reflect.Chan, reflect.UnsafePointer: // empty case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, @@ -352,7 +369,7 @@ func (r *Reflector) reflectTypeToSchema(definitions Definitions, t reflect.Type) } func (r *Reflector) reflectCustomSchema(definitions Definitions, t reflect.Type) *Schema { - if t.Kind() == reflect.Ptr { + if t.Kind() == reflect.Pointer { return r.reflectCustomSchema(definitions, t.Elem()) } @@ -424,9 +441,10 @@ func (r *Reflector) reflectMap(definitions Definitions, t reflect.Type, st *Sche } st.AdditionalProperties = FalseSchema return - } - if t.Elem().Kind() != reflect.Interface { - st.AdditionalProperties = r.refOrReflectTypeToSchema(definitions, t.Elem()) + default: + if t.Elem().Kind() != reflect.Interface { + st.AdditionalProperties = r.refOrReflectTypeToSchema(definitions, t.Elem()) + } } } @@ -468,8 +486,12 @@ func (r *Reflector) reflectStruct(definitions Definitions, t reflect.Type, s *Sc } func (r *Reflector) reflectStructFields(st *Schema, definitions Definitions, t reflect.Type) { - if t.Kind() == reflect.Ptr { - t = t.Elem() + if t.Kind() == reflect.Pointer { + r.reflectStructFields(st, definitions, t.Elem()) + if r.NullableFromType { + st.MakeNullable() + } + return } if t.Kind() != reflect.Struct { return @@ -511,23 +533,20 @@ func (r *Reflector) reflectStructFields(st *Schema, definitions Definitions, t r property = r.refOrReflectTypeToSchema(definitions, f.Type) } - property.structKeywordsFromTags(f, st, name) - if property.Description == "" { - property.Description = r.lookupComment(t, f.Name) + unwrapped := property + if r.NullableFromType { + unwrapped = unwrapped.UnwrapNullable() + } + unwrapped.structKeywordsFromTags(f, st, name) + if unwrapped.Description == "" { + unwrapped.Description = r.lookupComment(t, f.Name) } if getFieldDocString != nil { - property.Description = getFieldDocString(f.Name) + unwrapped.Description = getFieldDocString(f.Name) } - if nullable { - property = &Schema{ - OneOf: []*Schema{ - property, - { - Type: "null", - }, - }, - } + if nullable && !r.NullableFromType { // we already set the nullability if r.NullableFromType is set + property.MakeNullable() } st.Properties.Set(name, property) @@ -599,7 +618,7 @@ func (r *Reflector) refDefinition(definitions Definitions, t reflect.Type) *Sche func (r *Reflector) lookupID(t reflect.Type) ID { if r.Lookup != nil { - if t.Kind() == reflect.Ptr { + if t.Kind() == reflect.Pointer { t = t.Elem() } return r.Lookup(t) @@ -968,6 +987,19 @@ func nullableFromJSONSchemaTags(tags []string) bool { return false } +func isNullable(k reflect.Kind) bool { + switch k { + case reflect.Pointer, + reflect.UnsafePointer, + reflect.Map, + reflect.Slice, + reflect.Interface: + return true + default: + return false + } +} + func ignoredByJSONTags(tags []string) bool { return tags[0] == "-" } @@ -1023,7 +1055,12 @@ func (r *Reflector) reflectFieldName(f reflect.StructField) (string, bool, bool, } requiredFromJSONSchemaTags(schemaTags, &required) - nullable := nullableFromJSONSchemaTags(schemaTags) + var nullable bool + if r.NullableFromType { + nullable = isNullable(f.Type.Kind()) + } else { + nullable = nullableFromJSONSchemaTags(schemaTags) + } if f.Anonymous && jsonTags[0] == "" { // As per JSON Marshal rules, anonymous structs are inherited @@ -1032,7 +1069,7 @@ func (r *Reflector) reflectFieldName(f reflect.StructField) (string, bool, bool, } // As per JSON Marshal rules, anonymous pointer to structs are inherited - if f.Type.Kind() == reflect.Ptr && f.Type.Elem().Kind() == reflect.Struct { + if f.Type.Kind() == reflect.Pointer && f.Type.Elem().Kind() == reflect.Struct { return "", true, false, false } } diff --git a/reflect_test.go b/reflect_test.go index 94b6018..abe914a 100644 --- a/reflect_test.go +++ b/reflect_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "flag" "fmt" + "io" "net" "net/url" "os" @@ -12,6 +13,7 @@ import ( "strings" "testing" "time" + "unsafe" "github.com/invopop/jsonschema/examples" @@ -362,6 +364,27 @@ func TestReflectFromType(t *testing.T) { assert.Empty(t, s.ID) } +type NullableWithOddFields struct { + SimpleMap map[string]string `json:"simple_map"` + MapWithNullableValues map[string]*string `json:"map_with_nullable_values"` + + IntSlice []int `json:"int_slice"` + PointerToIntSlice *[]int `json:"pointer_to_int_slice"` + IntSliceWithNullableValues []*int `json:"int_slice_with_nullable_values"` + PointerToIntSliceWithNullableValues *[]*int `json:"pointer_to_int_slice_with_nullable_values"` + + Chan chan int `json:"chan"` + UnsafePointer unsafe.Pointer `json:"unsafe_pointer"` + Reader io.Reader `json:"reader"` +} + +func TestNullableFromType(t *testing.T) { + r := &Reflector{} + compareSchemaOutput(t, "fixtures/nullable_from_type_disabled.json", r, &NullableWithOddFields{}) + r.NullableFromType = true + compareSchemaOutput(t, "fixtures/nullable_from_type_enabled.json", r, &NullableWithOddFields{}) +} + func TestSchemaGeneration(t *testing.T) { tests := []struct { typ any @@ -369,6 +392,7 @@ func TestSchemaGeneration(t *testing.T) { fixture string }{ {&TestUser{}, &Reflector{}, "fixtures/test_user.json"}, + {&TestUser{}, &Reflector{NullableFromType: true}, "fixtures/test_user_nullable_from_type.json"}, {&UserWithAnchor{}, &Reflector{}, "fixtures/user_with_anchor.json"}, {&TestUser{}, &Reflector{AssignAnchor: true}, "fixtures/test_user_assign_anchor.json"}, {&TestUser{}, &Reflector{AllowAdditionalProperties: true}, "fixtures/allow_additional_props.json"}, diff --git a/schema.go b/schema.go index 2d914b8..2761bff 100644 --- a/schema.go +++ b/schema.go @@ -88,6 +88,31 @@ var ( FalseSchema = &Schema{boolean: &[]bool{false}[0]} ) +// MakeNullable will replace the schema that either matches the schema or `null` value: +// The resulting schema is wrapped via `oneOf` feature. +// This will be performed if the schema isn't already nullable (as `oneOf` is used). +// +// {"oneOf":[s,{"type":"null"}]} +func (t *Schema) MakeNullable() { + if !t.IsNullable() { + sc := *t + *t = Schema{OneOf: []*Schema{&sc, {Type: "null"}}} + } +} + +// IsNullable will test if the Schema is nullable in terms of MakeNullable +func (t *Schema) IsNullable() bool { + return len(t.OneOf) == 2 && t.OneOf[1].Type == "null" +} + +// UnwrapNullable will return the non-nullable schema part if the schema is nullable. +func (t *Schema) UnwrapNullable() *Schema { + if t.IsNullable() { + return t.OneOf[0] + } + return t +} + // Definitions hold schema definitions. // http://json-schema.org/latest/json-schema-validation.html#rfc.section.5.26 // RFC draft-wright-json-schema-validation-00, section 5.26