Skip to content

Commit

Permalink
feat: Support jackson @JsonUnwrapped annotation (#362)
Browse files Browse the repository at this point in the history
  • Loading branch information
CarstenWickner authored Jun 12, 2023
1 parent 5965fdd commit baea9b6
Show file tree
Hide file tree
Showing 8 changed files with 169 additions and 16 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions jsonschema-module-jackson/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -126,6 +127,8 @@ public void applyToConfigBuilder(SchemaGeneratorConfigBuilder builder) {
methodConfigPart.withCustomDefinitionProvider(subtypeResolver::provideCustomPropertySchemaDefinition);
}
}

generalConfigPart.withCustomDefinitionProvider(new JsonUnwrappedDefinitionProvider());
}

/**
Expand Down Expand Up @@ -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());
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -97,6 +98,9 @@ static class TestClass {

public BaseType interfaceWithDeclaredSubtypes;

@JsonUnwrapped
public TypeToBeUnwrapped typeToBeUnwrapped;

public String ignoredUnannotatedMethod() {
return "nothing";
}
Expand Down Expand Up @@ -151,4 +155,8 @@ static class SubType1 implements BaseType {
static class SubType2 implements BaseType {
public BaseType recursiveBaseReference;
}

static class TypeToBeUnwrapped {
public String unwrappedProperty;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand All @@ -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));

Expand All @@ -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);
}
Expand All @@ -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());

Expand All @@ -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());
Expand All @@ -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());
Expand All @@ -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());
Expand All @@ -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();
Expand All @@ -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<Arguments> parametersForTestPropertyNameOverride() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@
"type": "string"
}
}
},
"unwrappedProperty": {
"type": "string"
}
},
"description": "test description"
Expand Down
1 change: 1 addition & 0 deletions slate-docs/source/includes/_jackson-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down

0 comments on commit baea9b6

Please sign in to comment.