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..70999cb 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 } @@ -265,7 +276,7 @@ func (r *Reflector) reflectTypeToSchemaWithID(defs Definitions, t reflect.Type) func (r *Reflector) reflectTypeToSchema(definitions Definitions, t reflect.Type) *Schema { // only try to reflect non-pointers - if t.Kind() == reflect.Ptr { + if t.Kind() == reflect.Pointer { return r.refOrReflectTypeToSchema(definitions, t.Elem()) } @@ -352,7 +363,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()) } @@ -468,7 +479,7 @@ 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 { + if t.Kind() == reflect.Pointer { t = t.Elem() } if t.Kind() != reflect.Struct { @@ -599,7 +610,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 +979,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 +1047,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 +1061,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..3c5240c 100644 --- a/reflect_test.go +++ b/reflect_test.go @@ -369,6 +369,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"},