diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 1f24057..45bc7b0 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -18,4 +18,4 @@ jobs: - name: Lint uses: golangci/golangci-lint-action@v2 with: - version: v1.42 + version: v1.45 diff --git a/.golangci.yml b/.golangci.yml index df2c4fe..f1922d8 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -51,6 +51,7 @@ linters: - structcheck - stylecheck - exhaustive + - varnamelen linters-settings: govet: diff --git a/README.md b/README.md index 518cd7c..100ad0c 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,11 @@ This repository is a fork of the original [jsonschema](https://github.com/alecth - The original was stuck on the draft-04 version of JSON Schema, we've now moved to the latest JSON Schema Draft 2020-12. - Schema IDs are added automatically from the current Go package's URL in order to be unique, and can be disabled with the `Anonymous` option. - Support for the `FullyQualifyTypeName` option has been removed. If you have conflicts, you should use multiple schema files with different IDs, set the `DoNotReference` option to true to hide definitions completely, or add your own naming strategy using the `Namer` property. +- Support for `yaml` tags and related options has been dropped for the sake of simplification. There were a [few inconsistencies](https://github.com/invopop/jsonschema/pull/21) around this that have now been fixed. + +## Versions + +This project is still under v0 scheme, as per Go convention, breaking changes are likely. Please pin go modules to branches, and reach out if you think something can be improved. ## Example @@ -111,6 +116,12 @@ jsonschema.Reflect(&TestUser{}) } ``` +## YAML + +Support for `yaml` tags has now been removed. If you feel very strongly about this, we've opened a discussion to hear your comments: https://github.com/invopop/jsonschema/discussions/28 + +The recommended approach if you need to deal with YAML data is to first convert to JSON. The [invopop/yaml](https://github.com/invopop/yaml) library will make this trivial. + ## Configurable behaviour The behaviour of the schema generator can be altered with parameters when a `jsonschema.Reflector` @@ -175,65 +186,6 @@ will output: } ``` -### PreferYAMLSchema - -JSON schemas can also be used to validate YAML, however YAML frequently uses -different identifiers to JSON indicated by the `yaml:` tag. The `Reflector` will -by default prefer `json:` tags over `yaml:` tags (and only use the latter if the -former are not present). This behavior can be changed via the `PreferYAMLSchema` -flag, that will switch this behavior: `yaml:` tags will be preferred over -`json:` tags. - -With `PreferYAMLSchema: true`, the following struct: - -```go -type Person struct { - FirstName string `json:"FirstName" yaml:"first_name"` -} -``` - -would result in this schema: - -```json -{ - "$schema": "http://json-schema.org/draft/2020-12/schema", - "$ref": "#/$defs/TestYamlAndJson", - "$defs": { - "Person": { - "required": ["first_name"], - "properties": { - "first_name": { - "type": "string" - } - }, - "additionalProperties": false, - "type": "object" - } - } -} -``` - -whereas without the flag one obtains: - -```json -{ - "$schema": "http://json-schema.org/draft/2020-12/schema", - "$ref": "#/$defs/TestYamlAndJson", - "$defs": { - "Person": { - "required": ["FirstName"], - "properties": { - "first_name": { - "type": "string" - } - }, - "additionalProperties": false, - "type": "object" - } - } -} -``` - ### Using Go Comments Writing a good schema with descriptions inside tags can become cumbersome and tedious, especially if you already have some Go comments around your types and field definitions. If you'd like to take advantage of these existing comments, you can use the `AddGoComments(base, path string)` method that forms part of the reflector to parse your go files and automatically generate a dictionary of Go import paths, types, and fields, to individual comments. These will then be used automatically as description fields, and can be overridden with a manual definition if needed. @@ -296,7 +248,7 @@ In some situations, the keys actually used to write files are different from Go This is often the case when writing a configuration file to YAML or JSON from a Go struct, or when returning a JSON response for a Web API: APIs typically use snake_case, while Go uses PascalCase. -You can pass a `func(string) string` function to `Reflector`'s `KeyNamer` option to map Go field names to JSON key names and reflect the aforementionned transformations, without having to specify `json:"..."` on every struct field. +You can pass a `func(string) string` function to `Reflector`'s `KeyNamer` option to map Go field names to JSON key names and reflect the aforementioned transformations, without having to specify `json:"..."` on every struct field. For example, consider the following struct @@ -343,8 +295,7 @@ Will yield } ``` -As you can see, if a field name has a `json:""` or `yaml:""` tag set, the `key` argument to `KeyNamer` will have the value of that tag (if a field name has both, the value of `key` will respect [`PreferYAMLSchema`](#preferyamlschema)). - +As you can see, if a field name has a `json:""` tag set, the `key` argument to `KeyNamer` will have the value of that tag. ### Custom Type Definitions diff --git a/examples/nested/nested.go b/examples/nested/nested.go index 953c499..886c5cf 100644 --- a/examples/nested/nested.go +++ b/examples/nested/nested.go @@ -6,6 +6,12 @@ type Pet struct { Name string `json:"name" jsonschema:"title=Name"` } +// Pets is a collection of Pet objects. +type Pets []*Pet + +// NamedPets is a map of animal names to pets. +type NamedPets map[string]*Pet + type ( // Plant represents the plants the user might have and serves as a test // of structs inside a `type` set. diff --git a/examples/user.go b/examples/user.go index 283d01e..710f12e 100644 --- a/examples/user.go +++ b/examples/user.go @@ -15,8 +15,11 @@ type User struct { Tags map[string]interface{} `json:"tags,omitempty"` // An array of pets the user cares for. - Pets []*nested.Pet `json:"pets"` + Pets nested.Pets `json:"pets"` + + // Set of animal names to pets + NamedPets nested.NamedPets `json:"named_pets"` // Set of plants that the user likes - Plants []*nested.Plant `json:"plants" jsonschema:"title=Pants"` + Plants []*nested.Plant `json:"plants" jsonschema:"title=Plants"` } diff --git a/examples_test.go b/examples_test.go index d361635..08f6e53 100644 --- a/examples_test.go +++ b/examples_test.go @@ -21,7 +21,10 @@ type SampleUser struct { func ExampleReflect() { s := jsonschema.Reflect(&SampleUser{}) - data, _ := json.MarshalIndent(s, "", " ") + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + panic(err.Error()) + } fmt.Println(string(data)) // Output: // { diff --git a/fixtures/allow_additional_props.json b/fixtures/allow_additional_props.json index 17635c4..a196af9 100644 --- a/fixtures/allow_additional_props.json +++ b/fixtures/allow_additional_props.json @@ -3,6 +3,10 @@ "$id": "https://github.com/invopop/jsonschema/test-user", "$ref": "#/$defs/TestUser", "$defs": { + "Bytes": { + "type": "string", + "contentEncoding": "base64" + }, "GrandfatherType": { "properties": { "family_name": { @@ -14,14 +18,14 @@ "family_name" ] }, + "MapType": { + "type": "object" + }, "TestUser": { "properties": { "some_base_property": { "type": "integer" }, - "some_base_property_yaml": { - "type": "integer" - }, "grand": { "$ref": "#/$defs/GrandfatherType" }, @@ -31,6 +35,9 @@ "PublicNonExported": { "type": "integer" }, + "MapType": { + "$ref": "#/$defs/MapType" + }, "id": { "type": "integer" }, @@ -98,8 +105,7 @@ "contentEncoding": "base64" }, "photo2": { - "type": "string", - "contentEncoding": "base64" + "$ref": "#/$defs/Bytes" }, "feeling": { "oneOf": [ @@ -197,10 +203,10 @@ "type": "object", "required": [ "some_base_property", - "some_base_property_yaml", "grand", "SomeUntaggedBaseProperty", "PublicNonExported", + "MapType", "id", "name", "password", diff --git a/fixtures/array_type.json b/fixtures/array_type.json new file mode 100644 index 0000000..a354029 --- /dev/null +++ b/fixtures/array_type.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/invopop/jsonschema/array-type", + "$ref": "#/$defs/ArrayType", + "$defs": { + "ArrayType": { + "items": { + "type": "string" + }, + "type": "array" + } + } +} \ No newline at end of file diff --git a/fixtures/commas_in_pattern.json b/fixtures/commas_in_pattern.json index c67759c..d516d79 100644 --- a/fixtures/commas_in_pattern.json +++ b/fixtures/commas_in_pattern.json @@ -1,22 +1,22 @@ -{ +{ + "$schema": "http://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/invopop/jsonschema/pattern-test", "$ref": "#/$defs/PatternTest", - "$schema": "http://json-schema.org/draft/2020-12/schema", "$defs": { "PatternTest": { - "required": [ - "with_pattern" - ], "properties": { "with_pattern": { + "type": "string", "maxLength": 50, "minLength": 1, - "pattern": "[0-9]{1,4}", - "type": "string" + "pattern": "[0-9]{1,4}" } }, "additionalProperties": false, - "type": "object" + "type": "object", + "required": [ + "with_pattern" + ] } } } \ No newline at end of file diff --git a/fixtures/defaults_expanded_toplevel.json b/fixtures/defaults_expanded_toplevel.json index 93dc36c..894ece1 100644 --- a/fixtures/defaults_expanded_toplevel.json +++ b/fixtures/defaults_expanded_toplevel.json @@ -2,6 +2,10 @@ "$schema": "http://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/invopop/jsonschema/test-user", "$defs": { + "Bytes": { + "type": "string", + "contentEncoding": "base64" + }, "GrandfatherType": { "properties": { "family_name": { @@ -13,15 +17,15 @@ "required": [ "family_name" ] + }, + "MapType": { + "type": "object" } }, "properties": { "some_base_property": { "type": "integer" }, - "some_base_property_yaml": { - "type": "integer" - }, "grand": { "$ref": "#/$defs/GrandfatherType" }, @@ -31,6 +35,9 @@ "PublicNonExported": { "type": "integer" }, + "MapType": { + "$ref": "#/$defs/MapType" + }, "id": { "type": "integer" }, @@ -98,8 +105,7 @@ "contentEncoding": "base64" }, "photo2": { - "type": "string", - "contentEncoding": "base64" + "$ref": "#/$defs/Bytes" }, "feeling": { "oneOf": [ @@ -198,10 +204,10 @@ "type": "object", "required": [ "some_base_property", - "some_base_property_yaml", "grand", "SomeUntaggedBaseProperty", "PublicNonExported", + "MapType", "id", "name", "password", diff --git a/fixtures/go_comments.json b/fixtures/go_comments.json index 9865e93..4fc2312 100644 --- a/fixtures/go_comments.json +++ b/fixtures/go_comments.json @@ -3,6 +3,15 @@ "$id": "https://github.com/invopop/jsonschema/examples/user", "$ref": "#/$defs/User", "$defs": { + "NamedPets": { + "patternProperties": { + ".*": { + "$ref": "#/$defs/Pet" + } + }, + "type": "object", + "description": "NamedPets is a map of animal names to pets." + }, "Pet": { "properties": { "name": { @@ -18,6 +27,13 @@ ], "description": "Pet defines the user's fury friend." }, + "Pets": { + "items": { + "$ref": "#/$defs/Pet" + }, + "type": "array", + "description": "Pets is a collection of Pet objects." + }, "Plant": { "properties": { "variant": { @@ -62,18 +78,19 @@ "type": "object" }, "pets": { - "items": { - "$ref": "#/$defs/Pet" - }, - "type": "array", + "$ref": "#/$defs/Pets", "description": "An array of pets the user cares for." }, + "named_pets": { + "$ref": "#/$defs/NamedPets", + "description": "Set of animal names to pets" + }, "plants": { "items": { "$ref": "#/$defs/Plant" }, "type": "array", - "title": "Pants", + "title": "Plants", "description": "Set of plants that the user likes" } }, @@ -83,6 +100,7 @@ "id", "name", "pets", + "named_pets", "plants" ], "description": "User is used as a base to provide tests for comments." diff --git a/fixtures/ignore_type.json b/fixtures/ignore_type.json index ef93c9e..4e164ca 100644 --- a/fixtures/ignore_type.json +++ b/fixtures/ignore_type.json @@ -3,19 +3,23 @@ "$id": "https://github.com/invopop/jsonschema/test-user", "$ref": "#/$defs/TestUser", "$defs": { + "Bytes": { + "type": "string", + "contentEncoding": "base64" + }, "GrandfatherType": { "properties": {}, "additionalProperties": false, "type": "object" }, + "MapType": { + "type": "object" + }, "TestUser": { "properties": { "some_base_property": { "type": "integer" }, - "some_base_property_yaml": { - "type": "integer" - }, "grand": { "$ref": "#/$defs/GrandfatherType" }, @@ -25,6 +29,9 @@ "PublicNonExported": { "type": "integer" }, + "MapType": { + "$ref": "#/$defs/MapType" + }, "id": { "type": "integer" }, @@ -92,8 +99,7 @@ "contentEncoding": "base64" }, "photo2": { - "type": "string", - "contentEncoding": "base64" + "$ref": "#/$defs/Bytes" }, "feeling": { "oneOf": [ @@ -192,10 +198,10 @@ "type": "object", "required": [ "some_base_property", - "some_base_property_yaml", "grand", "SomeUntaggedBaseProperty", "PublicNonExported", + "MapType", "id", "name", "password", diff --git a/fixtures/disable_inlining_embedded.json b/fixtures/inlining_embedded.json similarity index 62% rename from fixtures/disable_inlining_embedded.json rename to fixtures/inlining_embedded.json index 0fd477f..cf12841 100644 --- a/fixtures/disable_inlining_embedded.json +++ b/fixtures/inlining_embedded.json @@ -1,20 +1,28 @@ { "$schema": "http://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/invopop/jsonschema/outer", - "properties": { - "inner": { + "$id": "https://github.com/invopop/jsonschema/outer-named", + "$defs": { + "Inner": { "properties": { - "foo": { + "Foo": { "type": "string" } }, "additionalProperties": false, "type": "object", "required": [ - "foo" + "Foo" ] } }, + "properties": { + "text": { + "type": "string" + }, + "inner": { + "$ref": "#/$defs/Inner" + } + }, "additionalProperties": false, "type": "object", "required": [ diff --git a/fixtures/disable_inlining_embedded_anchored.json b/fixtures/inlining_embedded_anchored.json similarity index 61% rename from fixtures/disable_inlining_embedded_anchored.json rename to fixtures/inlining_embedded_anchored.json index d242719..2b39433 100644 --- a/fixtures/disable_inlining_embedded_anchored.json +++ b/fixtures/inlining_embedded_anchored.json @@ -1,22 +1,30 @@ { "$schema": "http://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/invopop/jsonschema/outer", - "$anchor": "Outer", - "properties": { - "inner": { + "$id": "https://github.com/invopop/jsonschema/outer-named", + "$anchor": "OuterNamed", + "$defs": { + "Inner": { "$anchor": "Inner", "properties": { - "foo": { + "Foo": { "type": "string" } }, "additionalProperties": false, "type": "object", "required": [ - "foo" + "Foo" ] } }, + "properties": { + "text": { + "type": "string" + }, + "inner": { + "$ref": "#/$defs/Inner" + } + }, "additionalProperties": false, "type": "object", "required": [ diff --git a/fixtures/inlining_inheritance.json b/fixtures/inlining_inheritance.json new file mode 100644 index 0000000..f97fa2d --- /dev/null +++ b/fixtures/inlining_inheritance.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/invopop/jsonschema/outer", + "properties": { + "TextNamed": { + "type": "string" + }, + "Text": { + "type": "string" + }, + "Foo": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "TextNamed", + "Foo" + ] +} \ No newline at end of file diff --git a/fixtures/keynamed.json b/fixtures/keynamed.json index edd2961..d59412b 100644 --- a/fixtures/keynamed.json +++ b/fixtures/keynamed.json @@ -49,4 +49,4 @@ ] } } -} +} \ No newline at end of file diff --git a/fixtures/map_type.json b/fixtures/map_type.json new file mode 100644 index 0000000..dba13a2 --- /dev/null +++ b/fixtures/map_type.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/invopop/jsonschema/map-type", + "$ref": "#/$defs/MapType", + "$defs": { + "MapType": { + "type": "object" + } + } +} \ No newline at end of file diff --git a/fixtures/no_reference.json b/fixtures/no_reference.json index a5d60a8..498fb44 100644 --- a/fixtures/no_reference.json +++ b/fixtures/no_reference.json @@ -5,9 +5,6 @@ "some_base_property": { "type": "integer" }, - "some_base_property_yaml": { - "type": "integer" - }, "grand": { "properties": { "family_name": { @@ -26,6 +23,9 @@ "PublicNonExported": { "type": "integer" }, + "MapType": { + "type": "object" + }, "id": { "type": "integer" }, @@ -193,10 +193,10 @@ "type": "object", "required": [ "some_base_property", - "some_base_property_yaml", "grand", "SomeUntaggedBaseProperty", "PublicNonExported", + "MapType", "id", "name", "password", diff --git a/fixtures/no_reference_anchor.json b/fixtures/no_reference_anchor.json index 1bc397d..7428959 100644 --- a/fixtures/no_reference_anchor.json +++ b/fixtures/no_reference_anchor.json @@ -6,9 +6,6 @@ "some_base_property": { "type": "integer" }, - "some_base_property_yaml": { - "type": "integer" - }, "grand": { "$anchor": "GrandfatherType", "properties": { @@ -28,6 +25,9 @@ "PublicNonExported": { "type": "integer" }, + "MapType": { + "type": "object" + }, "id": { "type": "integer" }, @@ -195,10 +195,10 @@ "type": "object", "required": [ "some_base_property", - "some_base_property_yaml", "grand", "SomeUntaggedBaseProperty", "PublicNonExported", + "MapType", "id", "name", "password", diff --git a/fixtures/required_from_jsontags.json b/fixtures/required_from_jsontags.json index 58dccf8..6c5b3d0 100644 --- a/fixtures/required_from_jsontags.json +++ b/fixtures/required_from_jsontags.json @@ -3,6 +3,10 @@ "$id": "https://github.com/invopop/jsonschema/test-user", "$ref": "#/$defs/TestUser", "$defs": { + "Bytes": { + "type": "string", + "contentEncoding": "base64" + }, "GrandfatherType": { "properties": { "family_name": { @@ -15,14 +19,14 @@ "family_name" ] }, + "MapType": { + "type": "object" + }, "TestUser": { "properties": { "some_base_property": { "type": "integer" }, - "some_base_property_yaml": { - "type": "integer" - }, "grand": { "$ref": "#/$defs/GrandfatherType" }, @@ -32,6 +36,9 @@ "PublicNonExported": { "type": "integer" }, + "MapType": { + "$ref": "#/$defs/MapType" + }, "id": { "type": "integer" }, @@ -99,8 +106,7 @@ "contentEncoding": "base64" }, "photo2": { - "type": "string", - "contentEncoding": "base64" + "$ref": "#/$defs/Bytes" }, "feeling": { "oneOf": [ diff --git a/fixtures/test_yaml_and_json2.json b/fixtures/test_description_override.json similarity index 54% rename from fixtures/test_yaml_and_json2.json rename to fixtures/test_description_override.json index 48497e8..c3cb419 100644 --- a/fixtures/test_yaml_and_json2.json +++ b/fixtures/test_description_override.json @@ -1,9 +1,9 @@ { - "$schema": "http://json-schema.org/draft-04/schema#", - "$ref": "#/definitions/TestYamlAndJson2", - "definitions": { - "TestYamlAndJson2": { - "required": ["FirstName", "LastName", "age"], + "$schema": "http://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/invopop/jsonschema/test-description-override", + "$ref": "#/$defs/TestDescriptionOverride", + "$defs": { + "TestDescriptionOverride": { "properties": { "FirstName": { "type": "string", @@ -17,13 +17,18 @@ "type": "integer", "description": "test4" }, - "MiddleName": { + "middle_name": { "type": "string", "description": "test5" } }, "additionalProperties": false, - "type": "object" + "type": "object", + "required": [ + "FirstName", + "LastName", + "age" + ] } } -} +} \ No newline at end of file diff --git a/fixtures/test_user.json b/fixtures/test_user.json index 4bb7e9e..10e62a2 100644 --- a/fixtures/test_user.json +++ b/fixtures/test_user.json @@ -3,6 +3,10 @@ "$id": "https://github.com/invopop/jsonschema/test-user", "$ref": "#/$defs/TestUser", "$defs": { + "Bytes": { + "type": "string", + "contentEncoding": "base64" + }, "GrandfatherType": { "properties": { "family_name": { @@ -15,14 +19,14 @@ "family_name" ] }, + "MapType": { + "type": "object" + }, "TestUser": { "properties": { "some_base_property": { "type": "integer" }, - "some_base_property_yaml": { - "type": "integer" - }, "grand": { "$ref": "#/$defs/GrandfatherType" }, @@ -32,6 +36,9 @@ "PublicNonExported": { "type": "integer" }, + "MapType": { + "$ref": "#/$defs/MapType" + }, "id": { "type": "integer" }, @@ -99,8 +106,7 @@ "contentEncoding": "base64" }, "photo2": { - "type": "string", - "contentEncoding": "base64" + "$ref": "#/$defs/Bytes" }, "feeling": { "oneOf": [ @@ -199,10 +205,10 @@ "type": "object", "required": [ "some_base_property", - "some_base_property_yaml", "grand", "SomeUntaggedBaseProperty", "PublicNonExported", + "MapType", "id", "name", "password", diff --git a/fixtures/test_user_assign_anchor.json b/fixtures/test_user_assign_anchor.json index 4a22f85..5c1b86f 100644 --- a/fixtures/test_user_assign_anchor.json +++ b/fixtures/test_user_assign_anchor.json @@ -3,6 +3,10 @@ "$id": "https://github.com/invopop/jsonschema/test-user", "$ref": "#/$defs/TestUser", "$defs": { + "Bytes": { + "type": "string", + "contentEncoding": "base64" + }, "GrandfatherType": { "$anchor": "GrandfatherType", "properties": { @@ -16,15 +20,15 @@ "family_name" ] }, + "MapType": { + "type": "object" + }, "TestUser": { "$anchor": "TestUser", "properties": { "some_base_property": { "type": "integer" }, - "some_base_property_yaml": { - "type": "integer" - }, "grand": { "$ref": "#/$defs/GrandfatherType" }, @@ -34,6 +38,9 @@ "PublicNonExported": { "type": "integer" }, + "MapType": { + "$ref": "#/$defs/MapType" + }, "id": { "type": "integer" }, @@ -101,8 +108,7 @@ "contentEncoding": "base64" }, "photo2": { - "type": "string", - "contentEncoding": "base64" + "$ref": "#/$defs/Bytes" }, "feeling": { "oneOf": [ @@ -201,10 +207,10 @@ "type": "object", "required": [ "some_base_property", - "some_base_property_yaml", "grand", "SomeUntaggedBaseProperty", "PublicNonExported", + "MapType", "id", "name", "password", diff --git a/fixtures/test_yaml_and_json.json b/fixtures/test_yaml_and_json.json deleted file mode 100644 index f65dbea..0000000 --- a/fixtures/test_yaml_and_json.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/invopop/jsonschema/test-yaml-and-json", - "$ref": "#/$defs/TestYamlAndJson", - "$defs": { - "TestYamlAndJson": { - "properties": { - "FirstName": { - "type": "string" - }, - "LastName": { - "type": "string" - }, - "age": { - "type": "integer" - }, - "MiddleName": { - "type": "string" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "FirstName", - "LastName", - "age" - ] - } - } -} \ No newline at end of file diff --git a/fixtures/yaml_inline_embed.json b/fixtures/yaml_inline_embed.json deleted file mode 100644 index cef5d52..0000000 --- a/fixtures/yaml_inline_embed.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/invopop/jsonschema/test-yaml-inline", - "$ref": "#/$defs/TestYamlInline", - "$defs": { - "TestYamlInline": { - "properties": { - "foo": { - "type": "string" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "foo" - ] - } - } -} \ No newline at end of file diff --git a/reflect.go b/reflect.go index 36dcab0..e333e38 100644 --- a/reflect.go +++ b/reflect.go @@ -167,24 +167,13 @@ type Reflector struct { // default of requiring any key *not* tagged with `json:,omitempty`. RequiredFromJSONSchemaTags bool - // YAMLEmbeddedStructs will cause the Reflector to generate a schema that does - // not inline embedded structs. This should be enabled if the JSON schemas are - // used with yaml.Marshal/Unmarshal. - YAMLEmbeddedStructs bool - - // Prefer yaml: tags over json: tags to generate the schema even if json: tags - // are present - PreferYAMLSchema 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. DoNotReference bool // ExpandedStruct when true will include the reflected type's definition in the - // root as opposed to a definition with a reference. Using a reference in the root - // is useful as it allows us to maintain the struct's original name, but it is - // not common practice. + // root as opposed to a definition with a reference. ExpandedStruct bool // IgnoredTypes defines a slice of types that should be ignored in the schema, @@ -205,8 +194,8 @@ type Reflector struct { Namer func(reflect.Type) string // KeyNamer allows customizing of key names. - // The default is to use the key's name as is, or the json (or yaml) tag if present. - // If a json or yaml tag is present, KeyNamer will receive the tag's name as an argument, not the original key name. + // The default is to use the key's name as is, or the json tag if present. + // If a json tag is present, KeyNamer will receive the tag's name as an argument, not the original key name. KeyNamer func(string) string // AdditionalFields allows adding structfields for a given type @@ -317,8 +306,8 @@ func (r *Reflector) refOrReflectTypeToSchema(definitions Definitions, t reflect. } // Already added to definitions? - if _, ok := definitions[r.typeName(t)]; ok && !r.DoNotReference { - return r.refDefinition(definitions, t) + if def := r.refDefinition(definitions, t); def != nil { + return def } return r.reflectTypeToSchemaWithID(definitions, t) @@ -338,23 +327,32 @@ 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 { + return r.refOrReflectTypeToSchema(definitions, t.Elem()) + } + + // Do any pre-definitions exist? if r.Mapper != nil { if t := r.Mapper(t); t != nil { return t } } - if rt := r.reflectCustomSchema(definitions, t); rt != nil { return rt } + // Prepare a base to which details can be added + st := new(Schema) + // jsonpb will marshal protobuf enum options as either strings or integers. // It will unmarshal either. if t.Implements(protoEnumType) { - return &Schema{OneOf: []*Schema{ + st.OneOf = []*Schema{ {Type: "string"}, {Type: "integer"}, - }} + } + return st } // Defined format types for JSON Schema Validation @@ -362,87 +360,47 @@ func (r *Reflector) reflectTypeToSchema(definitions Definitions, t reflect.Type) // TODO email RFC section 7.3.2, hostname RFC section 7.3.3, uriref RFC section 7.3.7 if t == ipType { // TODO differentiate ipv4 and ipv6 RFC section 7.3.4, 7.3.5 - return &Schema{Type: "string", Format: "ipv4"} // ipv4 RFC section 7.3.4 + st.Type = "string" + st.Format = "ipv4" + return st } switch t.Kind() { case reflect.Struct: - switch t { - case timeType: // date-time RFC section 7.3.1 - return &Schema{Type: "string", Format: "date-time"} - case uriType: // uri RFC section 7.3.6 - return &Schema{Type: "string", Format: "uri"} - default: - return r.reflectOrRefStruct(definitions, t) - } - - case reflect.Map: - switch t.Key().Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - rt := &Schema{ - Type: "object", - PatternProperties: map[string]*Schema{ - "^[0-9]+$": r.refOrReflectTypeToSchema(definitions, t.Elem()), - }, - AdditionalProperties: FalseSchema, - } - return rt - } - - var rt *Schema - if t.Elem().Kind() == reflect.Interface { - rt = &Schema{ - Type: "object", - } - } else { - rt = &Schema{ - Type: "object", - PatternProperties: map[string]*Schema{ - ".*": r.refOrReflectTypeToSchema(definitions, t.Elem()), - }, - } - } - return rt + r.reflectStruct(definitions, t, st) case reflect.Slice, reflect.Array: - returnType := &Schema{} - if t == rawMessageType { - return &Schema{} - } - if t.Kind() == reflect.Array { - returnType.MinItems = t.Len() - returnType.MaxItems = returnType.MinItems - } - if t.Kind() == reflect.Slice && t.Elem() == byteSliceType.Elem() { - returnType.Type = "string" - // NOTE: ContentMediaType is not set here - returnType.ContentEncoding = "base64" - return returnType - } - returnType.Type = "array" - returnType.Items = r.refOrReflectTypeToSchema(definitions, t.Elem()) - return returnType + r.reflectSliceOrArray(definitions, t, st) + + case reflect.Map: + r.reflectMap(definitions, t, st) case reflect.Interface: - return &Schema{} // empty + // empty case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return &Schema{Type: "integer"} + st.Type = "integer" case reflect.Float32, reflect.Float64: - return &Schema{Type: "number"} + st.Type = "number" case reflect.Bool: - return &Schema{Type: "boolean"} + st.Type = "boolean" case reflect.String: - return &Schema{Type: "string"} + st.Type = "string" - case reflect.Ptr: - return r.refOrReflectTypeToSchema(definitions, t.Elem()) + default: + panic("unsupported type " + t.String()) + } + + // Always try to reference the definition which may have just been created + if def := r.refDefinition(definitions, t); def != nil { + return def } - panic("unsupported type " + t.String()) + + return st } func (r *Reflector) reflectCustomSchema(definitions Definitions, t reflect.Type) *Schema { @@ -455,29 +413,78 @@ func (r *Reflector) reflectCustomSchema(definitions Definitions, t reflect.Type) o := v.Interface().(customSchemaImpl) st := o.JSONSchema() r.addDefinition(definitions, t, st) - if r.DoNotReference { - return st - } else { - return r.refDefinition(definitions, t) + if ref := r.refDefinition(definitions, t); ref != nil { + return ref } + return st } return nil } -func (r *Reflector) reflectOrRefStruct(definitions Definitions, t reflect.Type) *Schema { - st := new(Schema) - r.addDefinition(definitions, t, st) // makes sure we have a re-usable reference already - r.reflectStruct(definitions, t, st) - if r.DoNotReference { - return st +func (r *Reflector) reflectSliceOrArray(definitions Definitions, t reflect.Type, st *Schema) { + if t == rawMessageType { + return + } + + r.addDefinition(definitions, t, st) + + if st.Description == "" { + st.Description = r.lookupComment(t, "") + } + + if t.Kind() == reflect.Array { + st.MinItems = t.Len() + st.MaxItems = st.MinItems + } + if t.Kind() == reflect.Slice && t.Elem() == byteSliceType.Elem() { + st.Type = "string" + // NOTE: ContentMediaType is not set here + st.ContentEncoding = "base64" } else { - return r.refDefinition(definitions, t) + st.Type = "array" + st.Items = r.refOrReflectTypeToSchema(definitions, t.Elem()) + } +} + +func (r *Reflector) reflectMap(definitions Definitions, t reflect.Type, st *Schema) { + r.addDefinition(definitions, t, st) + + st.Type = "object" + if st.Description == "" { + st.Description = r.lookupComment(t, "") + } + + switch t.Key().Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + st.PatternProperties = map[string]*Schema{ + "^[0-9]+$": r.refOrReflectTypeToSchema(definitions, t.Elem()), + } + st.AdditionalProperties = FalseSchema + return + } + if t.Elem().Kind() != reflect.Interface { + st.PatternProperties = map[string]*Schema{ + ".*": r.refOrReflectTypeToSchema(definitions, t.Elem()), + } } } // Reflects a struct to a JSON Schema type. func (r *Reflector) reflectStruct(definitions Definitions, t reflect.Type, s *Schema) { + // Handle special types + switch t { + case timeType: // date-time RFC section 7.3.1 + s.Type = "string" + s.Format = "date-time" + return + case uriType: // uri RFC section 7.3.6 + s.Type = "string" + s.Format = "uri" + return + } + + r.addDefinition(definitions, t, s) s.Type = "object" s.Properties = orderedmap.New() s.Description = r.lookupComment(t, "") @@ -581,12 +588,24 @@ func (r *Reflector) lookupComment(t reflect.Type, name string) string { // addDefinition will append the provided schema. If needed, an ID and anchor will also be added. func (r *Reflector) addDefinition(definitions Definitions, t reflect.Type, s *Schema) { name := r.typeName(t) + if name == "" { + return + } definitions[name] = s } // refDefinition will provide a schema with a reference to an existing definition. -func (r *Reflector) refDefinition(_ Definitions, t reflect.Type) *Schema { +func (r *Reflector) refDefinition(definitions Definitions, t reflect.Type) *Schema { + if r.DoNotReference { + return nil + } name := r.typeName(t) + if name == "" { + return nil + } + if _, ok := definitions[name]; !ok { + return nil + } return &Schema{ Ref: "#/$defs/" + name, } @@ -892,15 +911,6 @@ func nullableFromJSONSchemaTags(tags []string) bool { return false } -func inlineYAMLTags(tags []string) bool { - for _, tag := range tags { - if tag == "inline" { - return true - } - } - return false -} - func ignoredByJSONTags(tags []string) bool { return tags[0] == "-" } @@ -910,65 +920,45 @@ func ignoredByJSONSchemaTags(tags []string) bool { } func (r *Reflector) reflectFieldName(f reflect.StructField) (string, bool, bool, bool) { - jsonTags, exist := f.Tag.Lookup("json") - yamlTags, yamlExist := f.Tag.Lookup("yaml") - if !exist || r.PreferYAMLSchema { - jsonTags = yamlTags - exist = yamlExist - } - - jsonTagsList := strings.Split(jsonTags, ",") - yamlTagsList := strings.Split(yamlTags, ",") + jsonTagString, _ := f.Tag.Lookup("json") + jsonTags := strings.Split(jsonTagString, ",") - if ignoredByJSONTags(jsonTagsList) { + if ignoredByJSONTags(jsonTags) { return "", false, false, false } - jsonSchemaTags := strings.Split(f.Tag.Get("jsonschema"), ",") - if ignoredByJSONSchemaTags(jsonSchemaTags) { + schemaTags := strings.Split(f.Tag.Get("jsonschema"), ",") + if ignoredByJSONSchemaTags(schemaTags) { return "", false, false, false } - name := f.Name - required := requiredFromJSONTags(jsonTagsList) - + required := requiredFromJSONTags(jsonTags) if r.RequiredFromJSONSchemaTags { - required = requiredFromJSONSchemaTags(jsonSchemaTags) + required = requiredFromJSONSchemaTags(schemaTags) } - nullable := nullableFromJSONSchemaTags(jsonSchemaTags) - - if jsonTagsList[0] != "" { - name = jsonTagsList[0] - } + nullable := nullableFromJSONSchemaTags(schemaTags) - // field not anonymous and not export has no export name - if !f.Anonymous && f.PkgPath != "" { - name = "" - } - - embed := false - - // field anonymous but without json tag should be inherited by current type - if f.Anonymous && !exist { - if !r.YAMLEmbeddedStructs { - name = "" - embed = true - } else { - name = strings.ToLower(name) + if f.Anonymous && jsonTags[0] == "" { + // As per JSON Marshal rules, anonymous structs are inherited + if f.Type.Kind() == reflect.Struct { + return "", true, false, false } } - if yamlExist && inlineYAMLTags(yamlTagsList) { - name = "" - embed = true + // Try to determine the name from the different combos + name := f.Name + if jsonTags[0] != "" { + name = jsonTags[0] } - - if r.KeyNamer != nil { + if !f.Anonymous && f.PkgPath != "" { + // field not anonymous and not export has no export name + name = "" + } else if r.KeyNamer != nil { name = r.KeyNamer(name) } - return name, embed, required, nullable + return name, false, required, nullable } // UnmarshalJSON is used to parse a schema object or boolean. diff --git a/reflect_test.go b/reflect_test.go index d103cc6..ab71dcb 100644 --- a/reflect_test.go +++ b/reflect_test.go @@ -29,8 +29,7 @@ type GrandfatherType struct { } type SomeBaseType struct { - SomeBaseProperty int `json:"some_base_property"` - SomeBasePropertyYaml int `yaml:"some_base_property_yaml"` + SomeBaseProperty int `json:"some_base_property"` // The jsonschema required tag is nonsensical for private and ignored properties. // Their presence here tests that the fields *will not* be required in the output // schema, even if they are tagged required. @@ -45,6 +44,8 @@ type SomeBaseType struct { type MapType map[string]interface{} +type ArrayType []string + type nonExported struct { PublicNonExported int privateNonExported int @@ -144,10 +145,21 @@ type ChildOneOf struct { Child4 string `json:"child4" jsonschema:"oneof_required=group1"` } +type Text string + +type TextNamed string + type Outer struct { + TextNamed + Text `json:",omitempty"` Inner } +type OuterNamed struct { + Text `json:"text,omitempty"` + Inner `json:"inner"` +} + type Inner struct { Foo string `yaml:"foo"` } @@ -161,17 +173,6 @@ type TestNullable struct { Child1 string `json:"child1" jsonschema:"nullable"` } -type TestYamlInline struct { - Inlined Inner `yaml:",inline"` -} - -type TestYamlAndJson struct { - FirstName string `json:"FirstName" yaml:"first_name"` - LastName string `json:"LastName"` - Age uint `yaml:"age"` - MiddleName string `yaml:"middle_name,omitempty" json:"MiddleName,omitempty"` -} - type CompactDate struct { Year int Month int @@ -190,14 +191,14 @@ func (CompactDate) JSONSchema() *Schema { } } -type TestYamlAndJson2 struct { - FirstName string `json:"FirstName" yaml:"first_name"` +type TestDescriptionOverride struct { + FirstName string `json:"FirstName"` LastName string `json:"LastName"` - Age uint `yaml:"age"` - MiddleName string `yaml:"middle_name,omitempty" json:"MiddleName,omitempty"` + Age uint `json:"age"` + MiddleName string `json:"middle_name,omitempty"` } -func (TestYamlAndJson2) GetFieldDocString(fieldName string) string { +func (TestDescriptionOverride) GetFieldDocString(fieldName string) string { switch fieldName { case "FirstName": return "test2" @@ -365,12 +366,11 @@ func TestSchemaGeneration(t *testing.T) { return EmptyID }, }, "fixtures/lookup_expanded.json"}, - {&Outer{}, &Reflector{ExpandedStruct: true, DoNotReference: true, YAMLEmbeddedStructs: true}, "fixtures/disable_inlining_embedded.json"}, - {&Outer{}, &Reflector{ExpandedStruct: true, DoNotReference: true, YAMLEmbeddedStructs: true, AssignAnchor: true}, "fixtures/disable_inlining_embedded_anchored.json"}, + {&Outer{}, &Reflector{ExpandedStruct: true}, "fixtures/inlining_inheritance.json"}, + {&OuterNamed{}, &Reflector{ExpandedStruct: true}, "fixtures/inlining_embedded.json"}, + {&OuterNamed{}, &Reflector{ExpandedStruct: true, AssignAnchor: true}, "fixtures/inlining_embedded_anchored.json"}, {&MinValue{}, &Reflector{}, "fixtures/schema_with_minimum.json"}, {&TestNullable{}, &Reflector{}, "fixtures/nullable.json"}, - {&TestYamlInline{}, &Reflector{YAMLEmbeddedStructs: true}, "fixtures/yaml_inline_embed.json"}, - {&TestYamlInline{}, &Reflector{}, "fixtures/yaml_inline_embed.json"}, {&GrandfatherType{}, &Reflector{ AdditionalFields: func(r reflect.Type) []reflect.StructField { return []reflect.StructField{ @@ -383,9 +383,7 @@ func TestSchemaGeneration(t *testing.T) { } }, }, "fixtures/custom_additional.json"}, - {&TestYamlAndJson{}, &Reflector{PreferYAMLSchema: true}, "fixtures/test_yaml_and_json_prefer_yaml.json"}, - {&TestYamlAndJson{}, &Reflector{}, "fixtures/test_yaml_and_json.json"}, - // {&TestYamlAndJson2{}, &Reflector{}, "fixtures/test_yaml_and_json2.json"}, + {&TestDescriptionOverride{}, &Reflector{}, "fixtures/test_description_override.json"}, {&CompactDate{}, &Reflector{}, "fixtures/compact_date.json"}, {&CustomSliceOuter{}, &Reflector{}, "fixtures/custom_slice_type.json"}, {&CustomMapOuter{}, &Reflector{}, "fixtures/custom_map_type.json"}, @@ -416,6 +414,8 @@ func TestSchemaGeneration(t *testing.T) { return "unknown case" }, }, "fixtures/keynamed.json"}, + {MapType{}, &Reflector{}, "fixtures/map_type.json"}, + {ArrayType{}, &Reflector{}, "fixtures/array_type.json"}, } for _, tt := range tests { @@ -447,7 +447,7 @@ func compareSchemaOutput(t *testing.T, f string, r *Reflector, obj interface{}) require.NoError(t, err) actualSchema := r.Reflect(obj) - actualJSON, _ := json.MarshalIndent(actualSchema, "", " ") + actualJSON, _ := json.MarshalIndent(actualSchema, "", " ") //nolint:errchkjson if *updateFixtures { _ = ioutil.WriteFile(f, actualJSON, 0600)