diff --git a/CHANGELOG.md b/CHANGELOG.md index 48cc2880..1252f199 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### `jsonschema-generator` +#### Added +- new `Option.ACCEPT_SINGLE_VALUE_AS_ARRAY` to support Jackson `DeserializationFeature` of the same name, i.e., when an array type is declared, an instance of a single item should also be accepted by the schema + #### Changed - consider `Boolean` values as valid in `const`/`enum` (i.e., no longer ignore them) diff --git a/jsonschema-examples/src/main/java/com/github/victools/jsonschema/examples/SingleArrayItemExample.java b/jsonschema-examples/src/main/java/com/github/victools/jsonschema/examples/SingleArrayItemExample.java index 55c7a319..52befe5e 100644 --- a/jsonschema-examples/src/main/java/com/github/victools/jsonschema/examples/SingleArrayItemExample.java +++ b/jsonschema-examples/src/main/java/com/github/victools/jsonschema/examples/SingleArrayItemExample.java @@ -19,6 +19,7 @@ import com.fasterxml.classmate.ResolvedType; import com.fasterxml.jackson.databind.node.ObjectNode; import com.github.victools.jsonschema.generator.MemberScope; +import com.github.victools.jsonschema.generator.Option; import com.github.victools.jsonschema.generator.OptionPreset; import com.github.victools.jsonschema.generator.SchemaGenerationContext; import com.github.victools.jsonschema.generator.SchemaGenerator; @@ -42,22 +43,13 @@ public class SingleArrayItemExample implements SchemaGenerationExampleInterface @Override public ObjectNode generateSchema() { SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON); - configBuilder.forFields() - .withTargetTypeOverridesResolver(this::acceptSingleValueAsArray); + configBuilder.with(Option.ACCEPT_SINGLE_VALUE_AS_ARRAY); SchemaGeneratorConfig config = configBuilder.build(); SchemaGenerator generator = new SchemaGenerator(config); return generator.generateSchema(Example.class); } - private List acceptSingleValueAsArray(MemberScope scope) { - if (scope.isContainerType() && !scope.isFakeContainerItemScope()) { - return Arrays.asList(scope.getContainerItemType(), scope.getType()); - } - return null; - } - static class Example { - @NotNull public List someArray; } diff --git a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/Option.java b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/Option.java index cef08dee..096c59cf 100644 --- a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/Option.java +++ b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/Option.java @@ -26,6 +26,7 @@ import com.github.victools.jsonschema.generator.impl.module.MethodExclusionModule; import com.github.victools.jsonschema.generator.impl.module.SimpleTypeModule; import com.github.victools.jsonschema.generator.impl.module.SimplifiedOptionalModule; +import com.github.victools.jsonschema.generator.impl.module.SingleValueAsArrayModule; import java.util.Collections; import java.util.Set; import java.util.function.Supplier; @@ -222,6 +223,14 @@ public enum Option { * @since 4.11.0 */ MAP_VALUES_AS_ADDITIONAL_PROPERTIES(AdditionalPropertiesModule::forMapValues, null), + /** + * Whether each property with a container/{@link java.util.Collection Collection} type should also allow for a single collection item to be + * provided instead of an array. This corresponds to the Jackson + * {@link com.fasterxml.jackson.databind.DeserializationFeature#ACCEPT_SINGLE_VALUE_AS_ARRAY ACCEPT_SINGLE_VALUE_AS_ARRAY} feature. + * + * @since 4.36.0 + */ + ACCEPT_SINGLE_VALUE_AS_ARRAY(SingleValueAsArrayModule::new, null), /** * Whether allowed values should always be included in an {@link SchemaKeyword#TAG_ENUM "enum"} keyword. If there is exactly one allowed value, it * will otherwise be represented by a {@link SchemaKeyword#TAG_CONST "const"} keyword instead. diff --git a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/module/SingleValueAsArrayModule.java b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/module/SingleValueAsArrayModule.java new file mode 100644 index 00000000..c1d02f99 --- /dev/null +++ b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/module/SingleValueAsArrayModule.java @@ -0,0 +1,53 @@ +/* + * Copyright 2024 VicTools. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.github.victools.jsonschema.generator.impl.module; + +import com.fasterxml.classmate.ResolvedType; +import com.github.victools.jsonschema.generator.MemberScope; +import com.github.victools.jsonschema.generator.Module; +import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder; +import java.util.Arrays; +import java.util.List; + +/** + * Default module being included if {@code Option.ACCEPT_SINGLE_VALUE_AS_ARRAY} is enabled. + * + * @since 4.36.0 + */ +public class SingleValueAsArrayModule implements Module { + + /** + * Allow a container's item type as single instance alternative to an array, or return null for non-containers. + * + * @param scope targeted field/method + * @return collection containing both the container's item type and the container type as such, or null + */ + private static List acceptSingleValueAsArray(MemberScope scope) { + if (scope.isContainerType() && !scope.isFakeContainerItemScope()) { + return Arrays.asList(scope.getContainerItemType(), scope.getType()); + } + return null; + } + + @Override + public void applyToConfigBuilder(SchemaGeneratorConfigBuilder builder) { + builder.forFields() + .withTargetTypeOverridesResolver(SingleValueAsArrayModule::acceptSingleValueAsArray); + builder.forMethods() + .withTargetTypeOverridesResolver(SingleValueAsArrayModule::acceptSingleValueAsArray); + } +} diff --git a/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/SchemaGeneratorComplexTypesTest.java b/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/SchemaGeneratorComplexTypesTest.java index d84ca6ab..25e95470 100644 --- a/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/SchemaGeneratorComplexTypesTest.java +++ b/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/SchemaGeneratorComplexTypesTest.java @@ -117,7 +117,8 @@ static Stream parametersForTestGenerateSchema() { .withAnchorResolver(scope -> scope.isContainerType() ? null : "#anchor") .withPropertySorter((_prop1, _prop2) -> 0), "for type in general: "); - Module methodModule = configBuilder -> populateConfigPart(configBuilder.with(Option.FIELDS_DERIVED_FROM_ARGUMENTFREE_METHODS) + Module methodModule = configBuilder -> populateConfigPart(configBuilder.with(Option.FIELDS_DERIVED_FROM_ARGUMENTFREE_METHODS, + Option.ACCEPT_SINGLE_VALUE_AS_ARRAY) .forMethods(), "looked-up from method: "); Module fieldModule = configBuilder -> populateConfigPart(configBuilder.with(Option.INLINE_ALL_SCHEMAS).forFields(), "looked-up from field: "); Module enumToStringModule = configBuilder -> configBuilder.with(Option.FLATTENED_ENUMS_FROM_TOSTRING); diff --git a/jsonschema-generator/src/test/resources/com/github/victools/jsonschema/generator/testclass1-JAVA_OBJECT-methodattributes.json b/jsonschema-generator/src/test/resources/com/github/victools/jsonschema/generator/testclass1-JAVA_OBJECT-methodattributes.json index 4f61dbf6..4323275d 100644 --- a/jsonschema-generator/src/test/resources/com/github/victools/jsonschema/generator/testclass1-JAVA_OBJECT-methodattributes.json +++ b/jsonschema-generator/src/test/resources/com/github/victools/jsonschema/generator/testclass1-JAVA_OBJECT-methodattributes.json @@ -6,10 +6,16 @@ "const": 5 }, "genericArray": { - "type": "array", - "items": { - "type": "string" - } + "anyOf": [ + { + "type": "string" + }, { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "genericValue": { "type": ["string", "null"], diff --git a/jsonschema-generator/src/test/resources/com/github/victools/jsonschema/generator/testclass3-JAVA_OBJECT-methodattributes.json b/jsonschema-generator/src/test/resources/com/github/victools/jsonschema/generator/testclass3-JAVA_OBJECT-methodattributes.json index 2543047e..5aee3349 100644 --- a/jsonschema-generator/src/test/resources/com/github/victools/jsonschema/generator/testclass3-JAVA_OBJECT-methodattributes.json +++ b/jsonschema-generator/src/test/resources/com/github/victools/jsonschema/generator/testclass3-JAVA_OBJECT-methodattributes.json @@ -1,5 +1,20 @@ { "definitions": { + "LazyStringSupplier": { + "type": "object", + "properties": { + "get()": { + "type": ["string", "null"], + "title": "String", + "description": "looked-up from method: String", + "const": "constant string value", + "minLength": 1, + "maxLength": 256, + "format": "date", + "pattern": "^.{1,256}$" + } + } + }, "Optional(Integer)": { "type": "object", "properties": { @@ -74,22 +89,37 @@ ] }, "values()": { - "title": "RoundingMode[]", - "description": "looked-up from method: RoundingMode[]", - "minItems": 2, - "maxItems": 100, - "uniqueItems": false, - "type": ["array", "null"], - "items": { - "allOf": [ - { - "$ref": "#/definitions/RoundingMode-nullable" - }, { - "title": "RoundingMode", - "description": "looked-up from method: RoundingMode" + "anyOf": [ + { + "type": "null" + }, { + "allOf": [ + { + "$ref": "#/definitions/RoundingMode" + }, { + "title": "RoundingMode", + "description": "looked-up from method: RoundingMode" + } + ] + }, { + "title": "RoundingMode[]", + "description": "looked-up from method: RoundingMode[]", + "minItems": 2, + "maxItems": 100, + "uniqueItems": false, + "type": "array", + "items": { + "allOf": [ + { + "$ref": "#/definitions/RoundingMode-nullable" + }, { + "title": "RoundingMode", + "description": "looked-up from method: RoundingMode" + } + ] } - ] - } + } + ] } } }, @@ -110,10 +140,16 @@ "const": 5 }, "genericArray": { - "type": "array", - "items": { - "type": "string" - } + "anyOf": [ + { + "type": "string" + }, { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "genericValue": { "type": ["string", "null"], @@ -138,14 +174,20 @@ "calculateSomething(Number, Number)": false } }, - "TestClass2(Long)-nullable": { - "type": ["object", "null"], + "TestClass2(Long)": { + "type": "object", "properties": { "genericArray": { - "type": "array", - "items": { - "type": "integer" - } + "anyOf": [ + { + "type": "integer" + }, { + "type": "array", + "items": { + "type": "integer" + } + } + ] }, "genericValue": { "type": ["integer", "null"], @@ -161,14 +203,29 @@ } } }, + "TestClass2(Long)-nullable": { + "anyOf": [ + { + "type": "null" + }, { + "$ref": "#/definitions/TestClass2(Long)" + } + ] + }, "TestClass2(String)": { "type": "object", "properties": { "genericArray": { - "type": "array", - "items": { - "type": "string" - } + "anyOf": [ + { + "type": "string" + }, { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "genericValue": { "type": ["string", "null"], @@ -201,10 +258,16 @@ "type": ["object", "null"], "properties": { "genericArray": { - "type": "array", - "items": { - "$ref": "#/definitions/TestClass2(String)" - } + "anyOf": [ + { + "$ref": "#/definitions/TestClass2(String)" + }, { + "type": "array", + "items": { + "$ref": "#/definitions/TestClass2(String)" + } + } + ] }, "genericValue": { "anyOf": [ @@ -235,31 +298,31 @@ } }, "listOfOptionalS": { - "type": "array", - "items": { - "$ref": "#/definitions/Optional(Integer)" - } + "anyOf": [ + { + "$ref": "#/definitions/Optional(Integer)" + }, { + "type": "array", + "items": { + "$ref": "#/definitions/Optional(Integer)" + } + } + ] }, "optionalS": { "$ref": "#/definitions/Optional(Integer)" }, "setOfStringSupplier": { - "type": "array", - "items": { - "type": "object", - "properties": { - "get()": { - "type": ["string", "null"], - "title": "String", - "description": "looked-up from method: String", - "const": "constant string value", - "minLength": 1, - "maxLength": 256, - "format": "date", - "pattern": "^.{1,256}$" + "anyOf": [ + { + "$ref": "#/definitions/LazyStringSupplier" + }, { + "type": "array", + "items": { + "$ref": "#/definitions/LazyStringSupplier" } } - } + ] }, "supplierS": { "type": "object", @@ -290,39 +353,70 @@ "type": ["object", "null"], "properties": { "genericArray": { - "type": "array", - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/TestClass1" + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/definitions/TestClass1" + } + }, { + "type": "array", + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/TestClass1" + } + } } - } + ] }, "genericValue": { - "title": "TestClass1[]", - "description": "looked-up from method: TestClass1[]", - "minItems": 2, - "maxItems": 100, - "uniqueItems": false, - "type": ["array", "null"], - "items": { - "anyOf": [ - { - "type": "null" - }, { - "$ref": "#/definitions/TestClass1" - } - ], - "title": "TestClass1", - "description": "looked-up from method: TestClass1", - "additionalProperties": false, - "patternProperties": { - "^generic.+$": { - "type": "string" + "anyOf": [ + { + "type": "null" + }, { + "allOf": [ + { + "$ref": "#/definitions/TestClass1" + }, { + "title": "TestClass1", + "description": "looked-up from method: TestClass1", + "additionalProperties": false, + "patternProperties": { + "^generic.+$": { + "type": "string" + } + }, + "type": ["object", "null"] + } + ] + }, { + "title": "TestClass1[]", + "description": "looked-up from method: TestClass1[]", + "minItems": 2, + "maxItems": 100, + "uniqueItems": false, + "type": "array", + "items": { + "anyOf": [ + { + "type": "null" + }, { + "$ref": "#/definitions/TestClass1" + } + ], + "title": "TestClass1", + "description": "looked-up from method: TestClass1", + "additionalProperties": false, + "patternProperties": { + "^generic.+$": { + "type": "string" + } + }, + "type": ["object", "null"] } - }, - "type": ["object", "null"] - } + } + ] } }, "title": "TestClass2", @@ -355,29 +449,51 @@ ] }, "nestedLongList": { - "title": "List>", - "description": "looked-up from method: List>", - "minItems": 2, - "maxItems": 100, - "uniqueItems": false, - "type": ["array", "null"], - "items": { - "allOf": [ - { - "$ref": "#/definitions/TestClass2(Long)-nullable" - }, { - "title": "TestClass2", - "description": "looked-up from method: TestClass2", - "additionalProperties": false, - "patternProperties": { - "^generic.+$": { - "type": "integer" + "anyOf": [ + { + "type": "null" + }, { + "allOf": [ + { + "$ref": "#/definitions/TestClass2(Long)" + }, { + "title": "TestClass2", + "description": "looked-up from method: TestClass2", + "additionalProperties": false, + "patternProperties": { + "^generic.+$": { + "type": "integer" + } + }, + "type": ["object", "null"] + } + ] + }, { + "title": "List>", + "description": "looked-up from method: List>", + "minItems": 2, + "maxItems": 100, + "uniqueItems": false, + "type": "array", + "items": { + "allOf": [ + { + "$ref": "#/definitions/TestClass2(Long)-nullable" + }, { + "title": "TestClass2", + "description": "looked-up from method: TestClass2", + "additionalProperties": false, + "patternProperties": { + "^generic.+$": { + "type": "integer" + } + }, + "type": ["object", "null"] } - }, - "type": ["object", "null"] + ] } - ] - } + } + ] } } } diff --git a/slate-docs/source/includes/_main-generator-options.md b/slate-docs/source/includes/_main-generator-options.md index 1c44b677..c99bfbeb 100644 --- a/slate-docs/source/includes/_main-generator-options.md +++ b/slate-docs/source/includes/_main-generator-options.md @@ -260,23 +260,31 @@ configBuilder.without( 27 + Option.ACCEPT_SINGLE_VALUE_AS_ARRAY + + + Including an anyOf for every field/method declaring a container type. The anyOf then allows for either the array as declared or just a single item type instead. + A container type will be represented by an array with the declared item type. + + + 28 Option.ENUM_KEYWORD_FOR_SINGLE_VALUES Using the enum keyword for allowed values, even if there is only one. In case of a single allowed value, use the const keyword instead of enum. + #Behavior if includedBehavior if excluded - 28 + 29 Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT Setting the additionalProperties attribute in all object schemas to false by default unless some configuration specifically says something else. Omitting the additionalProperties attribute in all object schemas by default (thereby allowing any additional properties) unless some configuration specifically says something else. - #Behavior if includedBehavior if excluded - 29 + 30 Option.DEFINITIONS_FOR_ALL_OBJECTS @@ -284,7 +292,7 @@ configBuilder.without( Only include those entries in the $defs/definitions for object types that are referenced more than once and which are not explicitly declared as "inline" via a custom definition. - 30 + 31 Option.DEFINITION_FOR_MAIN_SCHEMA @@ -292,24 +300,24 @@ configBuilder.without( Define the main/target type "inline". - 31 + 32 Option.DEFINITIONS_FOR_MEMBER_SUPERTYPES For a member (field/method), having a declared type for which subtypes are being detected, include a single definition with any collected member attributes assigned directly. Any subtypes are only being handled as generic types, i.e., outside of the member context. That means, certain relevant annotations may be ignored (e.g. a jackson @JsonTypeInfo override on a single member would not be correctly reflected in the produced schema). For a member (field/method), having a declared type for which subtypes are being detected, include a list of definittions: one for each subtype in the given member's context. This allows independently interpreting contextual information (e.g., member annotations) for each subtype. + #Behavior if includedBehavior if excluded - 32 + 33 Option.INLINE_ALL_SCHEMAS Do not include any $defs/definitions but rather define all sub-schemas "inline" – however, this results in an exception being thrown if the given type contains any kind of circular reference. Depending on whether DEFINITIONS_FOR_ALL_OBJECTS is included or excluded. - #Behavior if includedBehavior if excluded - 33 + 34 Option.INLINE_NULLABLE_SCHEMAS @@ -317,7 +325,7 @@ configBuilder.without( Depending on whether DEFINITIONS_FOR_ALL_OBJECTS is included or excluded. - 34 + 35 Option.PLAIN_DEFINITION_KEYS @@ -325,15 +333,16 @@ configBuilder.without( Ensure that the keys for any $defs/definitions are URI compatible (as expected by the JSON Schema specification). - 35 + 36 Option.ALLOF_CLEANUP_AT_THE_END + #Behavior if includedBehavior if excluded At the very end of the schema generation reduce allOf wrappers where it is possible without overwriting any attributes – this also affects the results from custom definitions. Do not attempt to reduce allOf wrappers but preserve them as they were generated regardless of them being necessary or not. - 36 + 37 Option.STRICT_TYPE_INFO @@ -350,7 +359,7 @@ Below, you can find the lists of Options included/excluded in the r * "P_J" = PLAIN_JSON | # | Standard `Option` | F_D | J_O | P_J | -|----| -------------------------------------------- | -- | --- | --- | +|----|----------------------------------------------| -- | --- | --- | | 1 | `SCHEMA_VERSION_INDICATOR` | ⬜️ | ⬜️ | ✅ | | 2 | `ADDITIONAL_FIXED_TYPES` | ⬜️ | ⬜️ | ✅ | | 3 | `STANDARD_FORMATS` | ⬜ | ⬜️ | ✅ | @@ -377,13 +386,14 @@ Below, you can find the lists of Options included/excluded in the r | 24 | `NULLABLE_ARRAY_ITEMS_ALLOWED` | ⬜️ | ⬜️ | ⬜️ | | 25 | `FIELDS_DERIVED_FROM_ARGUMENTFREE_METHODS` | ⬜️ | ⬜️ | ⬜️ | | 26 | `MAP_VALUES_AS_ADDITIONAL_PROPERTIES` | ⬜️ | ⬜️ | ⬜️ | -| 27 | `ENUM_KEYWORD_FOR_SINGLE_VALUES` | ⬜️ | ⬜️ | ⬜️ | -| 28 | `FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT` | ⬜️ | ⬜️ | ⬜️ | -| 29 | `DEFINITIONS_FOR_ALL_OBJECTS` | ⬜️ | ⬜️ | ⬜️ | -| 30 | `DEFINITION_FOR_MAIN_SCHEMA` | ⬜️ | ⬜️ | ⬜️ | -| 31 | `DEFINITIONS_FOR_MEMBER_SUPERTYPES` | ⬜️ | ⬜️ | ⬜️ | -| 32 | `INLINE_ALL_SCHEMAS` | ⬜️ | ⬜️ | ⬜️ | -| 33 | `INLINE_NULLABLE_SCHEMAS` | ⬜️ | ⬜️ | ⬜️ | -| 34 | `PLAIN_DEFINITION_KEYS` | ⬜️ | ⬜️ | ⬜️ | -| 35 | `ALLOF_CLEANUP_AT_THE_END` | ✅ | ✅ | ✅ | -| 36 | `STRICT_TYPE_INFO` | ⬜️ | ⬜️ | ⬜️ | +| 27 | `ACCEPT_SINGLE_VALUE_AS_ARRAY` | ⬜️ | ⬜️ | ⬜️ | +| 28 | `ENUM_KEYWORD_FOR_SINGLE_VALUES` | ⬜️ | ⬜️ | ⬜️ | +| 29 | `FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT` | ⬜️ | ⬜️ | ⬜️ | +| 30 | `DEFINITIONS_FOR_ALL_OBJECTS` | ⬜️ | ⬜️ | ⬜️ | +| 31 | `DEFINITION_FOR_MAIN_SCHEMA` | ⬜️ | ⬜️ | ⬜️ | +| 32 | `DEFINITIONS_FOR_MEMBER_SUPERTYPES` | ⬜️ | ⬜️ | ⬜️ | +| 33 | `INLINE_ALL_SCHEMAS` | ⬜️ | ⬜️ | ⬜️ | +| 34 | `INLINE_NULLABLE_SCHEMAS` | ⬜️ | ⬜️ | ⬜️ | +| 35 | `PLAIN_DEFINITION_KEYS` | ⬜️ | ⬜️ | ⬜️ | +| 36 | `ALLOF_CLEANUP_AT_THE_END` | ✅ | ✅ | ✅ | +| 37 | `STRICT_TYPE_INFO` | ⬜️ | ⬜️ | ⬜️ |