diff --git a/CHANGELOG.md b/CHANGELOG.md index c7a34e64..93d430f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### `jsonschema-module-jackson` +#### Added +- elevate nested properties to the parent type where members are annotated with `@JsonUnwrapped` + ### `jsonschema-module-swagger-2` #### Added - consider `@Schema(additionalProperties = ...)` attribute (only values `TRUE` and `FALSE`), when it is annotated on a type (not on a member) diff --git a/jsonschema-module-jackson/README.md b/jsonschema-module-jackson/README.md index 1798be94..cc11c7ec 100644 --- a/jsonschema-module-jackson/README.md +++ b/jsonschema-module-jackson/README.md @@ -20,6 +20,7 @@ Module for the [jsonschema-generator](../jsonschema-generator) – deriving JSON 12. Consider `@JsonProperty.access` for marking a field/method as `readOnly` or `writeOnly` 13. Optionally: ignore all methods but those with a `@JsonProperty` annotation, if the `JacksonOption.INCLUDE_ONLY_JSONPROPERTY_ANNOTATED_METHODS` was provided (i.e. this is an "opt-in"). 14. Optionally: respect `@JsonIdentityReference(alwaysAsId=true)` annotation if there is a corresponding `@JsonIdentityInfo` annotation on the type and the `JacksonOption.JSONIDENTITY_REFERENCE_ALWAYS_AS_ID` as provided (i.e., this is an "opt-in") +15. Elevate nested properties to the parent type where members are annotated with `@JsonUnwrapped`. Schema attributes derived from validation annotations on getter methods are also applied to their associated fields. diff --git a/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/JacksonModule.java b/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/JacksonModule.java index a698d6dc..0a4852eb 100644 --- a/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/JacksonModule.java +++ b/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/JacksonModule.java @@ -22,6 +22,7 @@ import com.fasterxml.jackson.annotation.JsonClassDescription; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.fasterxml.jackson.annotation.JsonUnwrapped; import com.fasterxml.jackson.databind.BeanDescription; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.PropertyNamingStrategy; @@ -126,6 +127,8 @@ public void applyToConfigBuilder(SchemaGeneratorConfigBuilder builder) { methodConfigPart.withCustomDefinitionProvider(subtypeResolver::provideCustomPropertySchemaDefinition); } } + + generalConfigPart.withCustomDefinitionProvider(new JsonUnwrappedDefinitionProvider()); } /** @@ -268,6 +271,12 @@ protected boolean shouldIgnoreField(FieldScope field) { if (field.getAnnotationConsideringFieldAndGetterIfSupported(JsonBackReference.class) != null) { return true; } + // @since 4.32.0 + JsonUnwrapped unwrappedAnnotation = field.getAnnotationConsideringFieldAndGetterIfSupported(JsonUnwrapped.class); + if (unwrappedAnnotation != null && unwrappedAnnotation.enabled()) { + // unwrapped properties should be ignored here, as they are included in their unwrapped form + return true; + } // instead of re-creating the various ways a property may be included/excluded in jackson: just use its built-in introspection HierarchicType topMostHierarchyType = field.getDeclaringTypeMembers().allTypesAndOverrides().get(0); BeanDescription beanDescription = this.getBeanDescriptionForClass(topMostHierarchyType.getType()); @@ -293,10 +302,17 @@ protected boolean shouldIgnoreField(FieldScope field) { */ protected boolean shouldIgnoreMethod(MethodScope method) { FieldScope getterField = method.findGetterField(); - if (getterField != null && this.shouldIgnoreField(getterField)) { - return true; - } - if (getterField == null && method.getAnnotationConsideringFieldAndGetterIfSupported(JsonBackReference.class) != null) { + if (getterField == null) { + if (method.getAnnotationConsideringFieldAndGetterIfSupported(JsonBackReference.class) != null) { + return true; + } + // @since 4.32.0 + JsonUnwrapped unwrappedAnnotation = method.getAnnotationConsideringFieldAndGetterIfSupported(JsonUnwrapped.class); + if (unwrappedAnnotation != null && unwrappedAnnotation.enabled()) { + // unwrapped properties should be ignored here, as they are included in their unwrapped form + return true; + } + } else if (this.shouldIgnoreField(getterField)) { return true; } return this.options.contains(JacksonOption.INCLUDE_ONLY_JSONPROPERTY_ANNOTATED_METHODS) diff --git a/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/JsonUnwrappedDefinitionProvider.java b/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/JsonUnwrappedDefinitionProvider.java new file mode 100644 index 00000000..f8ea7c51 --- /dev/null +++ b/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/JsonUnwrappedDefinitionProvider.java @@ -0,0 +1,122 @@ +/* + * Copyright 2023 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.module.jackson; + +import com.fasterxml.classmate.ResolvedType; +import com.fasterxml.classmate.ResolvedTypeWithMembers; +import com.fasterxml.classmate.members.RawMember; +import com.fasterxml.classmate.members.ResolvedMember; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.victools.jsonschema.generator.CustomDefinition; +import com.github.victools.jsonschema.generator.CustomDefinitionProviderV2; +import com.github.victools.jsonschema.generator.SchemaGenerationContext; +import com.github.victools.jsonschema.generator.SchemaKeyword; +import java.lang.annotation.Annotation; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +/** + * Definition provider handling the integration of properties with the {@link JsonUnwrapped} annotation. + * + * @since 4.32.0 + */ +public class JsonUnwrappedDefinitionProvider implements CustomDefinitionProviderV2 { + + @Override + public CustomDefinition provideCustomSchemaDefinition(ResolvedType javaType, SchemaGenerationContext context) { + if (javaType.getMemberFields().stream().noneMatch(this::hasJsonUnwrappedAnnotation) + && javaType.getMemberMethods().stream().noneMatch(this::hasJsonUnwrappedAnnotation)) { + // no need for custom handling here, if no relevant annotation is present + return null; + } + // include the target type itself (assuming the annotated members are being ignored then) + ObjectNode definition = context.createStandardDefinition(javaType, this); + ArrayNode allOf = definition.withArray(context.getKeyword(SchemaKeyword.TAG_ALLOF)); + // include each annotated member's type considering the optional prefix and/or suffix + ResolvedTypeWithMembers typeWithMembers = context.getTypeContext().resolveWithMembers(javaType); + + Stream.concat(Stream.of(typeWithMembers.getMemberFields()), Stream.of(typeWithMembers.getMemberMethods())) + .filter(member -> Optional.ofNullable(member.getAnnotations().get(JsonUnwrapped.class)) + .filter(JsonUnwrapped::enabled).isPresent()) + .map(member -> this.createUnwrappedMemberSchema(member, context)) + .forEachOrdered(allOf::add); + + return new CustomDefinition(definition); + } + + /** + * Check whether the given field/method's type should be "unwrapped", i.e., elevating their properties to this member's type. + * + * @param member field/method to check + * @return whether the given member has an {@code enabled} {@link JsonUnwrapped @JsonUnwrapped} annotation + */ + private boolean hasJsonUnwrappedAnnotation(RawMember member) { + for (Annotation annotation : member.getAnnotations()) { + if (annotation instanceof JsonUnwrapped && ((JsonUnwrapped) annotation).enabled()) { + return true; + } + } + return false; + } + + /** + * Create a schema representing an unwrapped member's type. Contained properties may get a certain prefix and/or suffix applied to their names. + * + * @param member field/method of which to unwrap the associated type + * @param context generation context + * @return created schema + */ + private ObjectNode createUnwrappedMemberSchema(ResolvedMember member, SchemaGenerationContext context) { + ObjectNode definition = context.createStandardDefinition(member.getType(), null); + JsonUnwrapped annotation = member.getAnnotations().get(JsonUnwrapped.class); + if (!annotation.prefix().isEmpty() || !annotation.suffix().isEmpty()) { + this.applyPrefixAndSuffixToPropertyNames(definition, annotation.prefix(), annotation.suffix(), context); + } + return definition; + } + + /** + * Rename the properties defined in the given schema by prepending the given suffix and appending the given suffix. + * + * @param definition schema in which to alter contained properties' names + * @param prefix prefix to prepend to all contained properties' names (may be an empty string) + * @param suffix suffix to append to all contained properties' names (may be an empty string) + * @param context generation context + */ + private void applyPrefixAndSuffixToPropertyNames(JsonNode definition, String prefix, String suffix, SchemaGenerationContext context) { + JsonNode properties = definition.get(context.getKeyword(SchemaKeyword.TAG_PROPERTIES)); + if (properties instanceof ObjectNode && !properties.isEmpty()) { + List fieldNames = new ArrayList<>(); + properties.fieldNames().forEachRemaining(fieldNames::add); + for (String fieldName : fieldNames) { + JsonNode propertySchema = ((ObjectNode) properties).remove(fieldName); + ((ObjectNode) properties).set(prefix + fieldName + suffix, propertySchema); + } + } + JsonNode allOf = definition.get(context.getKeyword(SchemaKeyword.TAG_ALLOF)); + if (allOf instanceof ArrayNode) { + // this only considers inlined parts and not any to-be-referenced subschema + allOf.forEach(allOfEntry -> this.applyPrefixAndSuffixToPropertyNames(allOfEntry, prefix, suffix, context)); + } + // keeping it simple for now (version 4.32.0) and not considering all potential nested properties + } +} diff --git a/jsonschema-module-jackson/src/test/java/com/github/victools/jsonschema/module/jackson/IntegrationTest.java b/jsonschema-module-jackson/src/test/java/com/github/victools/jsonschema/module/jackson/IntegrationTest.java index 860349fd..d58d730e 100644 --- a/jsonschema-module-jackson/src/test/java/com/github/victools/jsonschema/module/jackson/IntegrationTest.java +++ b/jsonschema-module-jackson/src/test/java/com/github/victools/jsonschema/module/jackson/IntegrationTest.java @@ -23,6 +23,7 @@ import com.fasterxml.jackson.annotation.JsonPropertyDescription; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonUnwrapped; import com.fasterxml.jackson.annotation.JsonValue; import com.fasterxml.jackson.annotation.ObjectIdGenerators; import com.fasterxml.jackson.databind.JsonNode; @@ -97,6 +98,9 @@ static class TestClass { public BaseType interfaceWithDeclaredSubtypes; + @JsonUnwrapped + public TypeToBeUnwrapped typeToBeUnwrapped; + public String ignoredUnannotatedMethod() { return "nothing"; } @@ -151,4 +155,8 @@ static class SubType1 implements BaseType { static class SubType2 implements BaseType { public BaseType recursiveBaseReference; } + + static class TypeToBeUnwrapped { + public String unwrappedProperty; + } } diff --git a/jsonschema-module-jackson/src/test/java/com/github/victools/jsonschema/module/jackson/JacksonModuleTest.java b/jsonschema-module-jackson/src/test/java/com/github/victools/jsonschema/module/jackson/JacksonModuleTest.java index 2e8b1987..8b147f2d 100644 --- a/jsonschema-module-jackson/src/test/java/com/github/victools/jsonschema/module/jackson/JacksonModuleTest.java +++ b/jsonschema-module-jackson/src/test/java/com/github/victools/jsonschema/module/jackson/JacksonModuleTest.java @@ -65,12 +65,11 @@ public void setUp() { public void testApplyToConfigBuilder() { new JacksonModule().applyToConfigBuilder(this.configBuilder); - this.verifyCommonConfigurations(true); + this.verifyCommonConfigurations(true, 1); Mockito.verify(this.typesInGeneralConfigPart).withSubtypeResolver(Mockito.any()); Mockito.verify(this.fieldConfigPart).withTargetTypeOverridesResolver(Mockito.any()); Mockito.verify(this.methodConfigPart).withTargetTypeOverridesResolver(Mockito.any()); - Mockito.verify(this.typesInGeneralConfigPart).withCustomDefinitionProvider(Mockito.any()); Mockito.verify(this.fieldConfigPart).withCustomDefinitionProvider(Mockito.any()); Mockito.verify(this.methodConfigPart).withCustomDefinitionProvider(Mockito.any()); @@ -82,7 +81,7 @@ public void testApplyToConfigBuilderWithRespectJsonPropertyOrderOption() { new JacksonModule(JacksonOption.RESPECT_JSONPROPERTY_ORDER, JacksonOption.SKIP_SUBTYPE_LOOKUP, JacksonOption.IGNORE_TYPE_INFO_TRANSFORM) .applyToConfigBuilder(this.configBuilder); - this.verifyCommonConfigurations(true); + this.verifyCommonConfigurations(true, 0); Mockito.verify(this.typesInGeneralConfigPart).withPropertySorter(Mockito.any(JsonPropertySorter.class)); @@ -94,7 +93,7 @@ public void testApplyToConfigBuilderWithoutOptionalFeatures() { new JacksonModule(JacksonOption.IGNORE_PROPERTY_NAMING_STRATEGY, JacksonOption.SKIP_SUBTYPE_LOOKUP, JacksonOption.IGNORE_TYPE_INFO_TRANSFORM) .applyToConfigBuilder(this.configBuilder); - this.verifyCommonConfigurations(false); + this.verifyCommonConfigurations(false, 0); Mockito.verifyNoMoreInteractions(this.configBuilder, this.fieldConfigPart, this.methodConfigPart, this.typesInGeneralConfigPart); } @@ -104,9 +103,8 @@ public void testApplyToConfigBuilderWithSkipSubtypeLookupOption() { new JacksonModule(JacksonOption.SKIP_SUBTYPE_LOOKUP) .applyToConfigBuilder(this.configBuilder); - this.verifyCommonConfigurations(true); + this.verifyCommonConfigurations(true, 1); - Mockito.verify(this.typesInGeneralConfigPart).withCustomDefinitionProvider(Mockito.any()); Mockito.verify(this.fieldConfigPart).withCustomDefinitionProvider(Mockito.any()); Mockito.verify(this.methodConfigPart).withCustomDefinitionProvider(Mockito.any()); @@ -118,7 +116,7 @@ public void testApplyToConfigBuilderWithIgnoreTypeInfoTranformOption() { new JacksonModule(JacksonOption.IGNORE_TYPE_INFO_TRANSFORM) .applyToConfigBuilder(this.configBuilder); - this.verifyCommonConfigurations(true); + this.verifyCommonConfigurations(true, 0); Mockito.verify(this.typesInGeneralConfigPart).withSubtypeResolver(Mockito.any()); Mockito.verify(this.fieldConfigPart).withTargetTypeOverridesResolver(Mockito.any()); @@ -141,10 +139,9 @@ public void testApplyToConfigBuilderWithEnumOptions(JacksonOption[] options) { new JacksonModule(options) .applyToConfigBuilder(this.configBuilder); - this.verifyCommonConfigurations(true); + this.verifyCommonConfigurations(true, 2); Mockito.verify(this.typesInGeneralConfigPart).withSubtypeResolver(Mockito.any()); - Mockito.verify(this.typesInGeneralConfigPart, Mockito.times(2)).withCustomDefinitionProvider(Mockito.any()); Mockito.verify(this.fieldConfigPart).withTargetTypeOverridesResolver(Mockito.any()); Mockito.verify(this.fieldConfigPart).withCustomDefinitionProvider(Mockito.any()); Mockito.verify(this.methodConfigPart).withTargetTypeOverridesResolver(Mockito.any()); @@ -158,10 +155,9 @@ public void testApplyToConfigBuilderWithIdentityReferenceOption() { new JacksonModule(JacksonOption.JSONIDENTITY_REFERENCE_ALWAYS_AS_ID) .applyToConfigBuilder(this.configBuilder); - this.verifyCommonConfigurations(true); + this.verifyCommonConfigurations(true, 2); Mockito.verify(this.typesInGeneralConfigPart).withSubtypeResolver(Mockito.any()); - Mockito.verify(this.typesInGeneralConfigPart, Mockito.times(2)).withCustomDefinitionProvider(Mockito.any()); Mockito.verify(this.fieldConfigPart).withTargetTypeOverridesResolver(Mockito.any()); Mockito.verify(this.fieldConfigPart, Mockito.times(2)).withCustomDefinitionProvider(Mockito.any()); Mockito.verify(this.methodConfigPart).withTargetTypeOverridesResolver(Mockito.any()); @@ -170,7 +166,7 @@ public void testApplyToConfigBuilderWithIdentityReferenceOption() { Mockito.verifyNoMoreInteractions(this.configBuilder, this.fieldConfigPart, this.methodConfigPart, this.typesInGeneralConfigPart); } - private void verifyCommonConfigurations(boolean considerNamingStrategy) { + private void verifyCommonConfigurations(boolean considerNamingStrategy, int additionalCustomTypeDefinitions) { Mockito.verify(this.configBuilder).getObjectMapper(); Mockito.verify(this.configBuilder).forFields(); Mockito.verify(this.configBuilder).forMethods(); @@ -189,6 +185,8 @@ private void verifyCommonConfigurations(boolean considerNamingStrategy) { Mockito.verify(this.methodConfigPart).withWriteOnlyCheck(Mockito.any()); Mockito.verify(this.typesInGeneralConfigPart).withDescriptionResolver(Mockito.any()); + Mockito.verify(this.typesInGeneralConfigPart, Mockito.times(1 + additionalCustomTypeDefinitions)) + .withCustomDefinitionProvider(Mockito.any()); } static Stream parametersForTestPropertyNameOverride() { diff --git a/jsonschema-module-jackson/src/test/resources/com/github/victools/jsonschema/module/jackson/integration-test-result.json b/jsonschema-module-jackson/src/test/resources/com/github/victools/jsonschema/module/jackson/integration-test-result.json index a730f519..0ae36e1c 100644 --- a/jsonschema-module-jackson/src/test/resources/com/github/victools/jsonschema/module/jackson/integration-test-result.json +++ b/jsonschema-module-jackson/src/test/resources/com/github/victools/jsonschema/module/jackson/integration-test-result.json @@ -75,6 +75,9 @@ "type": "string" } } + }, + "unwrappedProperty": { + "type": "string" } }, "description": "test description" diff --git a/slate-docs/source/includes/_jackson-module.md b/slate-docs/source/includes/_jackson-module.md index e8cb5a54..9a01f37d 100644 --- a/slate-docs/source/includes/_jackson-module.md +++ b/slate-docs/source/includes/_jackson-module.md @@ -31,6 +31,7 @@ SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(Sc 12. Consider `@JsonProperty.access` for marking a field/method as `readOnly` or `writeOnly` 13. Optionally: ignore all methods but those with a `@JsonProperty` annotation, if the `JacksonOption.INCLUDE_ONLY_JSONPROPERTY_ANNOTATED_METHODS` was provided (i.e. this is an "opt-in"). 14. Optionally: respect `@JsonIdentityReference(alwaysAsId=true)` annotation if there is a corresponding `@JsonIdentityInfo` annotation on the type and the `JacksonOption.JSONIDENTITY_REFERENCE_ALWAYS_AS_ID` as provided (i.e., this is an "opt-in") +15. Elevate nested properties to the parent type where members are annotated with `@JsonUnwrapped`. Schema attributes derived from annotations on getter methods are also applied to their associated fields.