diff --git a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/AnnotationHelper.java b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/AnnotationHelper.java new file mode 100644 index 00000000..e1ac4032 --- /dev/null +++ b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/AnnotationHelper.java @@ -0,0 +1,134 @@ +/* + * 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; + +import com.fasterxml.classmate.members.ResolvedMember; +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +/** + * Helper class providing with standard mechanism to resolve annotations on annotated entities. + * + * @since 4.37.0 + */ +public final class AnnotationHelper { + + private AnnotationHelper() { + super(); + } + + /** + * Resolves the specified annotation on the given resolved member and resolve nested annotations. + * + * @param the generic type of the annotation + * @param member where to look for the specified annotation + * @param annotationClass the class of the annotation to look for + * @param metaAnnotationPredicate the predicate indicating nested annotations + * @return an empty entry if not found + */ + public static Optional resolveAnnotation( + ResolvedMember member, + Class annotationClass, + Predicate metaAnnotationPredicate + ) { + final A annotation = member.getAnnotations().get(annotationClass); + if (annotation != null) { + return Optional.of(annotation); + } + return resolveNestedAnnotations(StreamSupport.stream(member.getAnnotations().spliterator(), false), annotationClass, metaAnnotationPredicate); + } + + /** + * Select the instance of the specified annotation type from the given list. + * + *

Also considering meta annotations (i.e., annotations on annotations) if a meta annotation is + * deemed eligible according to the given {@code Predicate}.

+ * + * @param
the generic type of the annotation + * @param annotationList a list of annotations to look into + * @param annotationClass the class of the annotation to look for + * @param metaAnnotationPredicate the predicate indicating nested annotations + * @return an empty entry if not found + */ + public static Optional resolveAnnotation( + List annotationList, + Class annotationClass, + Predicate metaAnnotationPredicate + ) { + final Optional annotation = annotationList.stream().filter(annotationClass::isInstance).findFirst(); + if (annotation.isPresent()) { + return annotation.map(annotationClass::cast); + } + return resolveNestedAnnotations(annotationList.stream(), annotationClass, metaAnnotationPredicate); + } + + /** + * Select the instance of the specified annotation type from the given annotatedElement's annotations. + * + *

Also considering meta annotations (i.e., annotations on annotations) if a meta annotation is + * deemed eligible according to the given metaAnnotationPredicate.

+ * + * @param
the generic type of the annotation + * @param annotatedElement where to look for the specified annotation + * @param annotationClass the class of the annotation to look for + * @param metaAnnotationPredicate the predicate indicating meta annotations + * @return an empty entry if not found + */ + public static Optional resolveAnnotation( + AnnotatedElement annotatedElement, + Class annotationClass, + Predicate metaAnnotationPredicate + ) { + final A annotation = annotatedElement.getAnnotation(annotationClass); + if (annotation != null) { + return Optional.of(annotation); + } + return resolveNestedAnnotations(Arrays.stream(annotatedElement.getAnnotations()), annotationClass, metaAnnotationPredicate); + } + + private static Optional resolveNestedAnnotations( + Stream initialAnnotations, + Class annotationClass, + Predicate metaAnnotationPredicate + ) { + List annotations = extractAnnotationsFromMetaAnnotations(initialAnnotations, metaAnnotationPredicate); + while (!annotations.isEmpty()) { + final Optional directAnnotation = annotations.stream().filter(annotationClass::isInstance).findFirst(); + if (directAnnotation.isPresent()) { + return directAnnotation.map(annotationClass::cast); + } + annotations = extractAnnotationsFromMetaAnnotations(annotations.stream(), metaAnnotationPredicate); + } + return Optional.empty(); + } + + private static List extractAnnotationsFromMetaAnnotations( + Stream annotations, + Predicate metaAnnotationPredicate + ) { + return annotations.filter(metaAnnotationPredicate) + .flatMap(a -> Arrays.stream(a.annotationType().getAnnotations())) + .collect(Collectors.toList()); + } +} diff --git a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/TypeContext.java b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/TypeContext.java index e0cdc899..e0661432 100644 --- a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/TypeContext.java +++ b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/TypeContext.java @@ -276,20 +276,7 @@ public ResolvedType getContainerItemType(ResolvedType containerType) { */ public A getAnnotationFromList(Class annotationClass, List annotationList, Predicate considerOtherAnnotation) { - List annotations = annotationList; - while (!annotations.isEmpty()) { - Optional nestedAnnotation = annotations.stream() - .filter(annotationClass::isInstance) - .findFirst(); - if (nestedAnnotation.isPresent()) { - return nestedAnnotation.map(annotationClass::cast).get(); - } - annotations = annotations.stream() - .filter(considerOtherAnnotation) - .flatMap(otherAnnotation -> Stream.of(otherAnnotation.annotationType().getAnnotations())) - .collect(Collectors.toList()); - } - return null; + return AnnotationHelper.resolveAnnotation(annotationList, annotationClass, considerOtherAnnotation).orElse(null); } /** diff --git a/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/AnnotationHelperTest.java b/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/AnnotationHelperTest.java new file mode 100644 index 00000000..6409229d --- /dev/null +++ b/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/AnnotationHelperTest.java @@ -0,0 +1,122 @@ +package com.github.victools.jsonschema.generator; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.lang.annotation.Annotation; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.Arrays; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Stream; + +/** + * Unit test class dedicated to the validation of {@link AnnotationHelper}. + */ +class AnnotationHelperTest { + @Target({ElementType.ANNOTATION_TYPE, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + public @interface TargetAnnotation { + String value() default ""; + } + + @Target({ElementType.ANNOTATION_TYPE, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + public @interface MetaAnnotation {} + + @TargetAnnotation + private static class DirectlyAnnotatedClass { + } + + private static class NonAnnotatedClass { + } + + @UselessFirstComboAnnotation + @UselessSecondComboAnnotation + private static class AnnotatedClassWithUselessAnnotations { + + } + + @Target({ElementType.ANNOTATION_TYPE, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + @MetaAnnotation + private @interface UselessFirstComboAnnotation { + } + + @Target({ElementType.ANNOTATION_TYPE, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + @MetaAnnotation + private @interface UselessSecondComboAnnotation { + } + + @Target({ElementType.ANNOTATION_TYPE, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + @MetaAnnotation + @TargetAnnotation("first combo annotation value") + private @interface FirstComboAnnotation { + } + + @Target({ElementType.ANNOTATION_TYPE, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + @MetaAnnotation + @TargetAnnotation("second combo annotation value") + private @interface SecondComboAnnotation { + } + + @Target({ElementType.ANNOTATION_TYPE, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + @MetaAnnotation + @SecondComboAnnotation + private @interface ThirdComboAnnotation { + } + + @FirstComboAnnotation + @SecondComboAnnotation + private static class IndirectlyAnnotatedClass { + } + + @TargetAnnotation("direct value") + @FirstComboAnnotation + @SecondComboAnnotation + private static class BothDirectAndIndirectlyAnnotatedClass { + } + + @ThirdComboAnnotation + @FirstComboAnnotation + private static class BreadthFirstAnnotatedClass {} + + static Stream annotationLookupScenarios() { + return Stream.of( + Arguments.of(NonAnnotatedClass.class, Optional.empty()), + Arguments.of(AnnotatedClassWithUselessAnnotations.class, Optional.empty()), + Arguments.of(DirectlyAnnotatedClass.class, Optional.of("")), + Arguments.of(BothDirectAndIndirectlyAnnotatedClass.class, Optional.of("direct value")), + Arguments.of(IndirectlyAnnotatedClass.class, Optional.of("first combo annotation value")), + Arguments.of(BreadthFirstAnnotatedClass.class, Optional.of("first combo annotation value")) + ); + } + + @ParameterizedTest + @MethodSource("annotationLookupScenarios") + void resolveAnnotation_AnnotatedElement_respects_annotationLookupScenarios(Class annotatedClass, Optional expectedAnnotationValue) { + Optional value = AnnotationHelper.resolveAnnotation(annotatedClass, TargetAnnotation.class, metaAnnotationPredicate()).map(TargetAnnotation::value); + Assertions.assertEquals(expectedAnnotationValue, value); + } + + @ParameterizedTest + @MethodSource("annotationLookupScenarios") + void resolveAnnotation_List_respects_annotationLookupScenarios(Class annotatedClass, Optional expectedAnnotationValue) { + Optional value = AnnotationHelper.resolveAnnotation(Arrays.asList(annotatedClass.getAnnotations()), TargetAnnotation.class, metaAnnotationPredicate()).map(TargetAnnotation::value); + Assertions.assertEquals(expectedAnnotationValue, value); + } + + private static Predicate metaAnnotationPredicate() { + return (annotation) -> annotation.annotationType().isAnnotationPresent(MetaAnnotation.class); + } + +} \ No newline at end of file diff --git a/jsonschema-module-jackson/README.md b/jsonschema-module-jackson/README.md index cc11c7ec..231015ef 100644 --- a/jsonschema-module-jackson/README.md +++ b/jsonschema-module-jackson/README.md @@ -21,6 +21,7 @@ Module for the [jsonschema-generator](../jsonschema-generator) – deriving JSON 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`. +16. Support `com.fasterxml.jackson.annotation.JacksonAnnotationsInside` annotated combo annotations 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/AnnotationHelper.java b/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/AnnotationHelper.java new file mode 100644 index 00000000..31f83668 --- /dev/null +++ b/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/AnnotationHelper.java @@ -0,0 +1,68 @@ +/* + * 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.module.jackson; + +import com.fasterxml.classmate.members.ResolvedMember; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; + +final class AnnotationHelper { + + static final Predicate JACKSON_ANNOTATIONS_INSIDE_ANNOTATED_FILTER = (annotation) -> + annotation.annotationType().isAnnotationPresent(JacksonAnnotationsInside.class); + + private AnnotationHelper() { + super(); + } + + /** + * Resolves the specified annotation on the given resolved member and resolve indirect jackson annotations. + * + * @param the generic type of the annotation + * @param member where to look for the specified annotation + * @param annotationClass the class of the annotation to look for + * @return an empty entry if not found + */ + static Optional resolveAnnotation(ResolvedMember member, Class annotationClass) { + return com.github.victools.jsonschema.generator.AnnotationHelper.resolveAnnotation( + member, + annotationClass, + JACKSON_ANNOTATIONS_INSIDE_ANNOTATED_FILTER + ); + } + + /** + * Resolves the specified annotation on the given type and resolve indirect jackson annotations. + * + * @param the generic type of the annotation + * @param declaringType where to look for the specified annotation + * @param annotationClass the class of the annotation to look for + * @return an empty entry if not found + */ + static Optional resolveAnnotation(AnnotatedElement declaringType, Class annotationClass) { + return com.github.victools.jsonschema.generator.AnnotationHelper.resolveAnnotation( + declaringType, + annotationClass, + JACKSON_ANNOTATIONS_INSIDE_ANNOTATED_FILTER + ); + } + +} diff --git a/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/CustomEnumDefinitionProvider.java b/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/CustomEnumDefinitionProvider.java index 2ddbc0f7..e13c336f 100644 --- a/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/CustomEnumDefinitionProvider.java +++ b/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/CustomEnumDefinitionProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2020 VicTools. + * Copyright 2020-2024 VicTools. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -121,7 +121,7 @@ protected ResolvedMethod getJsonValueAnnotatedMethod(ResolvedType javaType, Sche ResolvedMethod[] memberMethods = context.getTypeContext().resolveWithMembers(javaType).getMemberMethods(); Set jsonValueAnnotatedMethods = Stream.of(memberMethods) .filter(method -> method.getArgumentCount() == 0) - .filter(method -> Optional.ofNullable(method.getAnnotations().get(JsonValue.class)).map(JsonValue::value).orElse(false)) + .filter(method -> AnnotationHelper.resolveAnnotation(method, JsonValue.class).map(JsonValue::value).orElse(false)) .collect(Collectors.toSet()); if (jsonValueAnnotatedMethods.size() == 1) { return jsonValueAnnotatedMethods.iterator().next(); @@ -141,14 +141,16 @@ protected List getSerializedValuesFromJsonProperty(ResolvedType javaType List serializedJsonValues = new ArrayList<>(enumConstants.length); for (Object enumConstant : enumConstants) { String enumValueName = ((Enum) enumConstant).name(); - JsonProperty annotation = javaType.getErasedType() - .getDeclaredField(enumValueName) - .getAnnotation(JsonProperty.class); - if (annotation == null) { + Optional annotation = AnnotationHelper.resolveAnnotation( + javaType.getErasedType().getDeclaredField(enumValueName), + JsonProperty.class + ); + if (!annotation.isPresent()) { // enum constant without @JsonProperty annotation return null; } - serializedJsonValues.add(JsonProperty.USE_DEFAULT_NAME.equals(annotation.value()) ? enumValueName : annotation.value()); + final String annotationValue = annotation.get().value(); + serializedJsonValues.add(JsonProperty.USE_DEFAULT_NAME.equals(annotationValue) ? enumValueName : annotationValue); } return serializedJsonValues; } catch (NoSuchFieldException | SecurityException ex) { 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 1d887605..214968cc 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 @@ -1,5 +1,5 @@ /* - * Copyright 2019 VicTools. + * Copyright 2019-2024 VicTools. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -183,11 +183,8 @@ protected String resolveDescription(MemberScope member) { */ protected String resolveDescriptionForType(TypeScope scope) { Class rawType = scope.getType().getErasedType(); - JsonClassDescription classAnnotation = rawType.getAnnotation(JsonClassDescription.class); - if (classAnnotation != null) { - return classAnnotation.value(); - } - return null; + Optional classAnnotation = AnnotationHelper.resolveAnnotation(rawType, JsonClassDescription.class); + return classAnnotation.map(JsonClassDescription::value).orElse(null); } /** @@ -201,7 +198,10 @@ protected String resolveDescriptionForType(TypeScope scope) { * @return alternative property name (or {@code null}) */ protected String getPropertyNameOverrideBasedOnJsonPropertyAnnotation(MemberScope member) { - JsonProperty annotation = member.getAnnotationConsideringFieldAndGetter(JsonProperty.class); + JsonProperty annotation = member.getAnnotationConsideringFieldAndGetter( + JsonProperty.class, + AnnotationHelper.JACKSON_ANNOTATIONS_INSIDE_ANNOTATED_FILTER + ); if (annotation != null) { String nameOverride = annotation.value(); // check for invalid overrides @@ -237,7 +237,7 @@ protected String getPropertyNameOverrideBasedOnJsonNamingAnnotation(FieldScope f * @return annotated naming strategy instance (or {@code null}) */ private PropertyNamingStrategy getAnnotatedNamingStrategy(Class declaringType) { - return Optional.ofNullable(declaringType.getAnnotation(JsonNaming.class)) + return AnnotationHelper.resolveAnnotation(declaringType, JsonNaming.class) .map(JsonNaming::value) .map(strategyType -> { try { @@ -274,11 +274,16 @@ protected final BeanDescription getBeanDescriptionForClass(ResolvedType targetTy * @return whether field should be excluded */ protected boolean shouldIgnoreField(FieldScope field) { - if (field.getAnnotationConsideringFieldAndGetterIfSupported(JsonBackReference.class) != null) { + if (field.getAnnotationConsideringFieldAndGetterIfSupported( + JsonBackReference.class, + AnnotationHelper.JACKSON_ANNOTATIONS_INSIDE_ANNOTATED_FILTER) != null) { return true; } // @since 4.32.0 - JsonUnwrapped unwrappedAnnotation = field.getAnnotationConsideringFieldAndGetterIfSupported(JsonUnwrapped.class); + JsonUnwrapped unwrappedAnnotation = field.getAnnotationConsideringFieldAndGetterIfSupported( + JsonUnwrapped.class, + AnnotationHelper.JACKSON_ANNOTATIONS_INSIDE_ANNOTATED_FILTER + ); if (unwrappedAnnotation != null && unwrappedAnnotation.enabled()) { // unwrapped properties should be ignored here, as they are included in their unwrapped form return true; @@ -309,11 +314,16 @@ protected boolean shouldIgnoreField(FieldScope field) { protected boolean shouldIgnoreMethod(MethodScope method) { FieldScope getterField = method.findGetterField(); if (getterField == null) { - if (method.getAnnotationConsideringFieldAndGetterIfSupported(JsonBackReference.class) != null) { + if (method.getAnnotationConsideringFieldAndGetterIfSupported( + JsonBackReference.class, + AnnotationHelper.JACKSON_ANNOTATIONS_INSIDE_ANNOTATED_FILTER) != null) { return true; } // @since 4.32.0 - JsonUnwrapped unwrappedAnnotation = method.getAnnotationConsideringFieldAndGetterIfSupported(JsonUnwrapped.class); + JsonUnwrapped unwrappedAnnotation = method.getAnnotationConsideringFieldAndGetterIfSupported( + JsonUnwrapped.class, + AnnotationHelper.JACKSON_ANNOTATIONS_INSIDE_ANNOTATED_FILTER + ); if (unwrappedAnnotation != null && unwrappedAnnotation.enabled()) { // unwrapped properties should be ignored here, as they are included in their unwrapped form return true; @@ -322,7 +332,9 @@ protected boolean shouldIgnoreMethod(MethodScope method) { return true; } return this.options.contains(JacksonOption.INCLUDE_ONLY_JSONPROPERTY_ANNOTATED_METHODS) - && method.getAnnotationConsideringFieldAndGetter(JsonProperty.class) == null; + && method.getAnnotationConsideringFieldAndGetter( + JsonProperty.class, + AnnotationHelper.JACKSON_ANNOTATIONS_INSIDE_ANNOTATED_FILTER) == null; } /** @@ -332,7 +344,10 @@ protected boolean shouldIgnoreMethod(MethodScope method) { * @return whether the field should be in the "required" list or not */ protected boolean getRequiredCheckBasedOnJsonPropertyAnnotation(MemberScope member) { - JsonProperty jsonProperty = member.getAnnotationConsideringFieldAndGetterIfSupported(JsonProperty.class) ; + JsonProperty jsonProperty = member.getAnnotationConsideringFieldAndGetterIfSupported( + JsonProperty.class, + AnnotationHelper.JACKSON_ANNOTATIONS_INSIDE_ANNOTATED_FILTER + ); return jsonProperty != null && jsonProperty.required(); } @@ -343,7 +358,10 @@ protected boolean getRequiredCheckBasedOnJsonPropertyAnnotation(MemberScope member) { - JsonProperty jsonProperty = member.getAnnotationConsideringFieldAndGetter(JsonProperty.class); + JsonProperty jsonProperty = member.getAnnotationConsideringFieldAndGetter( + JsonProperty.class, + AnnotationHelper.JACKSON_ANNOTATIONS_INSIDE_ANNOTATED_FILTER + ); return jsonProperty != null && jsonProperty.access() == JsonProperty.Access.READ_ONLY; } @@ -354,7 +372,10 @@ protected boolean getReadOnlyCheck(MemberScope member) { * @return whether the field should be marked as write-only */ protected boolean getWriteOnlyCheck(MemberScope member) { - JsonProperty jsonProperty = member.getAnnotationConsideringFieldAndGetter(JsonProperty.class); + JsonProperty jsonProperty = member.getAnnotationConsideringFieldAndGetter( + JsonProperty.class, + AnnotationHelper.JACKSON_ANNOTATIONS_INSIDE_ANNOTATED_FILTER + ); return jsonProperty != null && jsonProperty.access() == JsonProperty.Access.WRITE_ONLY; } } diff --git a/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/JsonIdentityReferenceDefinitionProvider.java b/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/JsonIdentityReferenceDefinitionProvider.java index 2c50fe31..3072d6cd 100644 --- a/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/JsonIdentityReferenceDefinitionProvider.java +++ b/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/JsonIdentityReferenceDefinitionProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2022 VicTools. + * Copyright 2022-2024 VicTools. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,6 +28,7 @@ import com.github.victools.jsonschema.generator.MemberScope; import com.github.victools.jsonschema.generator.SchemaGenerationContext; import com.github.victools.jsonschema.generator.TypeContext; +import java.util.Arrays; import java.util.Optional; import java.util.stream.Stream; @@ -73,8 +74,11 @@ public CustomPropertyDefinition provideCustomPropertySchemaDefinition(MemberScop * @return designated type of the applicable identity reference (may be empty) */ public Optional getIdentityReferenceType(ResolvedType javaType, TypeContext typeContext) { - JsonIdentityReference referenceAnnotation = javaType.getErasedType().getAnnotation(JsonIdentityReference.class); - return this.getIdentityReferenceType(referenceAnnotation, javaType, typeContext); + Optional referenceAnnotation = AnnotationHelper.resolveAnnotation( + javaType.getErasedType(), + JsonIdentityReference.class + ); + return this.getIdentityReferenceType(referenceAnnotation.orElse(null), javaType, typeContext); } /** @@ -86,9 +90,15 @@ public Optional getIdentityReferenceType(ResolvedType javaType, Ty * @return designated type of the applicable identity reference (may be empty) */ public Optional getIdentityReferenceType(MemberScope scope) { - JsonIdentityReference referenceAnnotation = scope.getContainerItemAnnotationConsideringFieldAndGetterIfSupported(JsonIdentityReference.class); + JsonIdentityReference referenceAnnotation = scope.getContainerItemAnnotationConsideringFieldAndGetterIfSupported( + JsonIdentityReference.class, + AnnotationHelper.JACKSON_ANNOTATIONS_INSIDE_ANNOTATED_FILTER + ); if (referenceAnnotation == null) { - referenceAnnotation = scope.getAnnotationConsideringFieldAndGetter(JsonIdentityReference.class); + referenceAnnotation = scope.getAnnotationConsideringFieldAndGetter( + JsonIdentityReference.class, + AnnotationHelper.JACKSON_ANNOTATIONS_INSIDE_ANNOTATED_FILTER + ); } return this.getIdentityReferenceType(referenceAnnotation, scope.getType(), scope.getContext()); } @@ -110,12 +120,20 @@ private Optional getIdentityReferenceType(JsonIdentityReference re return Optional.empty(); } // additionally, the type itself must have a @JsonIdentityInfo annotation - ResolvedType typeWithIdentityInfoAnnotation = typeContext.getTypeWithAnnotation(javaType, JsonIdentityInfo.class); + ResolvedType typeWithIdentityInfoAnnotation = typeContext.getTypeWithAnnotation( + javaType, + JsonIdentityInfo.class, + AnnotationHelper.JACKSON_ANNOTATIONS_INSIDE_ANNOTATED_FILTER + ); if (typeWithIdentityInfoAnnotation == null) { // otherwise, the @JsonIdentityReference annotation is simply ignored return Optional.empty(); } - JsonIdentityInfo identityInfoAnnotation = typeWithIdentityInfoAnnotation.getErasedType().getAnnotation(JsonIdentityInfo.class); + JsonIdentityInfo identityInfoAnnotation = typeContext.getAnnotationFromList( + JsonIdentityInfo.class, + Arrays.asList(typeWithIdentityInfoAnnotation.getErasedType().getAnnotations()), + AnnotationHelper.JACKSON_ANNOTATIONS_INSIDE_ANNOTATED_FILTER + ); // @JsonIdentityInfo annotation declares generator with specific identity type ResolvedType identityTypeFromGenerator = typeContext.getTypeParameterFor(typeContext.resolve(identityInfoAnnotation.generator()), ObjectIdGenerator.class, 0); diff --git a/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/JsonPropertySorter.java b/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/JsonPropertySorter.java index 3d6c79e5..0074f320 100644 --- a/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/JsonPropertySorter.java +++ b/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/JsonPropertySorter.java @@ -1,5 +1,5 @@ /* - * Copyright 2020 VicTools. + * Copyright 2020-2024 VicTools. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -98,7 +98,7 @@ protected int getPropertyIndex(MemberScope property) { * @return whether properties that are not specifically mentioned in a {@link JsonPropertyOrder} annotation should be sorted alphabetically */ protected boolean shouldSortPropertiesAlphabetically(Class declaringType) { - return Optional.ofNullable(declaringType.getAnnotation(JsonPropertyOrder.class)) + return AnnotationHelper.resolveAnnotation(declaringType, JsonPropertyOrder.class) .map(JsonPropertyOrder::alphabetic) .orElse(this.sortAlphabeticallyIfNotAnnotated); } @@ -110,7 +110,7 @@ protected boolean shouldSortPropertiesAlphabetically(Class declaringType) { * @return {@link JsonPropertyOrder#value()} or empty list */ private List getAnnotatedPropertyOrder(Class declaringType) { - return Optional.ofNullable(declaringType.getAnnotation(JsonPropertyOrder.class)) + return AnnotationHelper.resolveAnnotation(declaringType, JsonPropertyOrder.class) .map(JsonPropertyOrder::value) .filter(valueArray -> valueArray.length != 0) .map(Arrays::asList) diff --git a/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/JsonSubTypesResolver.java b/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/JsonSubTypesResolver.java index aba2eae0..cc292c6c 100644 --- a/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/JsonSubTypesResolver.java +++ b/jsonschema-module-jackson/src/main/java/com/github/victools/jsonschema/module/jackson/JsonSubTypesResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2020 VicTools. + * Copyright 2020-2024 VicTools. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -34,6 +34,8 @@ import com.github.victools.jsonschema.generator.TypeContext; import com.github.victools.jsonschema.generator.TypeScope; import com.github.victools.jsonschema.generator.impl.AttributeCollector; +import java.lang.annotation.Annotation; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; @@ -109,8 +111,8 @@ public List findSubtypes(ResolvedType declaredType, SchemaGenerati if (this.skipSubtypeResolution(declaredType, context.getTypeContext())) { return null; } - JsonSubTypes subtypesAnnotation = declaredType.getErasedType().getAnnotation(JsonSubTypes.class); - return this.lookUpSubtypesFromAnnotation(declaredType, subtypesAnnotation, context.getTypeContext()); + Optional subtypesAnnotation = AnnotationHelper.resolveAnnotation(declaredType.getErasedType(), JsonSubTypes.class); + return this.lookUpSubtypesFromAnnotation(declaredType, subtypesAnnotation.orElse(null), context.getTypeContext()); } /** @@ -123,7 +125,10 @@ public List findTargetTypeOverrides(MemberScope property) { if (this.skipSubtypeResolution(property)) { return null; } - JsonSubTypes subtypesAnnotation = property.getAnnotationConsideringFieldAndGetter(JsonSubTypes.class); + JsonSubTypes subtypesAnnotation = property.getAnnotationConsideringFieldAndGetter( + JsonSubTypes.class, + AnnotationHelper.JACKSON_ANNOTATIONS_INSIDE_ANNOTATED_FILTER + ); return this.lookUpSubtypesFromAnnotation(property.getType(), subtypesAnnotation, property.getContext()); } @@ -170,16 +175,30 @@ public CustomDefinition provideCustomSchemaDefinition(ResolvedType javaType, Sch // since 4.37.0: not for void methods return null; } - ResolvedType typeWithTypeInfo = context.getTypeContext().getTypeWithAnnotation(javaType, JsonTypeInfo.class); - if (typeWithTypeInfo == null || javaType.getErasedType().getAnnotation(JsonSubTypes.class) != null - || this.skipSubtypeResolution(javaType, context.getTypeContext())) { + final TypeContext typeContext = context.getTypeContext(); + ResolvedType typeWithTypeInfo = typeContext.getTypeWithAnnotation( + javaType, + JsonTypeInfo.class, + AnnotationHelper.JACKSON_ANNOTATIONS_INSIDE_ANNOTATED_FILTER + ); + if (typeWithTypeInfo == null || AnnotationHelper.resolveAnnotation(javaType.getErasedType(), JsonSubTypes.class).isPresent() + || this.skipSubtypeResolution(javaType, typeContext)) { // no @JsonTypeInfo annotation found or the given javaType is the super type, that should be replaced return null; } Class erasedTypeWithTypeInfo = typeWithTypeInfo.getErasedType(); - JsonTypeInfo typeInfoAnnotation = erasedTypeWithTypeInfo.getAnnotation(JsonTypeInfo.class); - JsonSubTypes subTypesAnnotation = erasedTypeWithTypeInfo.getAnnotation(JsonSubTypes.class); - TypeScope scope = context.getTypeContext().createTypeScope(javaType); + final List annotationsList = Arrays.asList(erasedTypeWithTypeInfo.getAnnotations()); + JsonTypeInfo typeInfoAnnotation = typeContext.getAnnotationFromList( + JsonTypeInfo.class, + annotationsList, + AnnotationHelper.JACKSON_ANNOTATIONS_INSIDE_ANNOTATED_FILTER + ); + JsonSubTypes subTypesAnnotation = typeContext.getAnnotationFromList( + JsonSubTypes.class, + annotationsList, + AnnotationHelper.JACKSON_ANNOTATIONS_INSIDE_ANNOTATED_FILTER + ); + TypeScope scope = typeContext.createTypeScope(javaType); ObjectNode definition = this.createSubtypeDefinition(scope, typeInfoAnnotation, subTypesAnnotation, context); if (definition == null) { return null; @@ -195,10 +214,14 @@ public CustomDefinition provideCustomSchemaDefinition(ResolvedType javaType, Sch * @return applicable custom per-property override schema definition (may be {@code null}) */ public CustomPropertyDefinition provideCustomPropertySchemaDefinition(MemberScope scope, SchemaGenerationContext context) { - if (this.skipSubtypeResolution(scope) || scope.getType().getErasedType().getDeclaredAnnotation(JsonSubTypes.class) != null) { + if (this.skipSubtypeResolution(scope) + || AnnotationHelper.resolveAnnotation(scope.getType().getErasedType(), JsonSubTypes.class).isPresent()) { return null; } - JsonTypeInfo typeInfoAnnotation = scope.getAnnotationConsideringFieldAndGetter(JsonTypeInfo.class); + JsonTypeInfo typeInfoAnnotation = scope.getAnnotationConsideringFieldAndGetter( + JsonTypeInfo.class, + AnnotationHelper.JACKSON_ANNOTATIONS_INSIDE_ANNOTATED_FILTER + ); if (typeInfoAnnotation == null) { // the normal per-type behaviour is not being overridden, i.e., no need for an inline custom property schema return null; @@ -210,7 +233,10 @@ public CustomPropertyDefinition provideCustomPropertySchemaDefinition(MemberScop .add(context.createStandardDefinitionReference(scope.getType(), this)); return new CustomPropertyDefinition(definition, CustomDefinition.AttributeInclusion.YES); } - JsonSubTypes subTypesAnnotation = scope.getAnnotationConsideringFieldAndGetter(JsonSubTypes.class); + JsonSubTypes subTypesAnnotation = scope.getAnnotationConsideringFieldAndGetter( + JsonSubTypes.class, + AnnotationHelper.JACKSON_ANNOTATIONS_INSIDE_ANNOTATED_FILTER + ); ObjectNode definition = this.createSubtypeDefinition(scope, typeInfoAnnotation, subTypesAnnotation, context); if (definition == null) { return null; @@ -269,7 +295,7 @@ private static Optional getNameFromSubTypeAnnotation(Class erasedTarg * @return simple class name, with declaring class's unqualified name as prefix for member classes */ private static Optional getNameFromTypeNameAnnotation(Class erasedTargetType) { - return Optional.ofNullable(erasedTargetType.getAnnotation(JsonTypeName.class)) + return AnnotationHelper.resolveAnnotation(erasedTargetType, JsonTypeName.class) .map(JsonTypeName::value) .filter(name -> !name.isEmpty()); } 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 index a825e811..7b5bd08b 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023 VicTools. + * Copyright 2023-2024 VicTools. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,7 +27,6 @@ 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.Arrays; import java.util.List; @@ -60,9 +59,9 @@ public CustomDefinition provideCustomSchemaDefinition(ResolvedType javaType, Sch // include each annotated member's type considering the optional prefix and/or suffix 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)) + .filter(Optional::isPresent) + .map(Optional::get) .forEachOrdered(allOf::add); return new CustomDefinition(definition); @@ -75,12 +74,7 @@ public CustomDefinition provideCustomSchemaDefinition(ResolvedType javaType, Sch * @return whether the given member has an {@code enabled} {@link JsonUnwrapped @JsonUnwrapped} annotation */ private boolean hasJsonUnwrappedAnnotation(ResolvedMember member) { - for (Annotation annotation : member.getAnnotations()) { - if (annotation instanceof JsonUnwrapped && ((JsonUnwrapped) annotation).enabled()) { - return true; - } - } - return false; + return AnnotationHelper.resolveAnnotation(member, JsonUnwrapped.class).filter(JsonUnwrapped::enabled).isPresent(); } /** @@ -90,13 +84,15 @@ private boolean hasJsonUnwrappedAnnotation(ResolvedMember member) { * @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; + private Optional createUnwrappedMemberSchema(ResolvedMember member, SchemaGenerationContext context) { + final Optional optAnnotation = AnnotationHelper.resolveAnnotation(member, JsonUnwrapped.class).filter(JsonUnwrapped::enabled); + return optAnnotation.map(annotation -> { + ObjectNode definition = context.createStandardDefinition(member.getType(), null); + if (!annotation.prefix().isEmpty() || !annotation.suffix().isEmpty()) { + this.applyPrefixAndSuffixToPropertyNames(definition, annotation.prefix(), annotation.suffix(), context); + } + return definition; + }); } /** diff --git a/jsonschema-module-jackson/src/test/java/com/github/victools/jsonschema/module/jackson/AnnotationHelperTest.java b/jsonschema-module-jackson/src/test/java/com/github/victools/jsonschema/module/jackson/AnnotationHelperTest.java new file mode 100644 index 00000000..84a7f2f2 --- /dev/null +++ b/jsonschema-module-jackson/src/test/java/com/github/victools/jsonschema/module/jackson/AnnotationHelperTest.java @@ -0,0 +1,119 @@ +package com.github.victools.jsonschema.module.jackson; + +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; +import com.fasterxml.jackson.annotation.JsonTypeName; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.Optional; + +/** + * Unit test class dedicated to the validation of {@link AnnotationHelper}. + */ +class AnnotationHelperTest { + + @JsonTypeName + private static class DirectlyAnnotatedClass { + } + + private static class NonAnnotatedClass { + } + + @UselessFirstComboAnnotation + @UselessSecondComboAnnotation + private static class AnnotatedClassWithUselessAnnotations { + + } + + @Target({ElementType.ANNOTATION_TYPE, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + @JacksonAnnotationsInside + private @interface UselessFirstComboAnnotation { + } + + @Target({ElementType.ANNOTATION_TYPE, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + @JacksonAnnotationsInside + private @interface UselessSecondComboAnnotation { + } + + @Target({ElementType.ANNOTATION_TYPE, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + @JacksonAnnotationsInside + @JsonTypeName("first combo annotation value") + private @interface FirstComboAnnotation { + } + + @Target({ElementType.ANNOTATION_TYPE, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + @JacksonAnnotationsInside + @JsonTypeName("second combo annotation value") + private @interface SecondComboAnnotation { + } + + @Target({ElementType.ANNOTATION_TYPE, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + @JacksonAnnotationsInside + @SecondComboAnnotation + private @interface ThirdComboAnnotation { + } + + @FirstComboAnnotation + @SecondComboAnnotation + private static class IndirectlyAnnotatedClass { + } + + @JsonTypeName("direct value") + @FirstComboAnnotation + @SecondComboAnnotation + private static class BothDirectAndIndirectlyAnnotatedClass { + } + + @ThirdComboAnnotation + @FirstComboAnnotation + private static class BreadthFirstAnnotatedClass {} + + @Test + void resolveAnnotation_returnsAnEmptyInstanceIfNotAnnotated() { + final Optional result = AnnotationHelper.resolveAnnotation(NonAnnotatedClass.class, JsonTypeName.class); + Assertions.assertFalse(result.isPresent()); + } + + @Test + void resolveAnnotation_returnsAnEmptyInstanceIfNotAnnotatedEvenIfThereAreComboAnnotations() { + final Optional result = AnnotationHelper.resolveAnnotation(AnnotatedClassWithUselessAnnotations.class, JsonTypeName.class); + Assertions.assertFalse(result.isPresent()); + } + + @Test + void resolveAnnotation_supportsDirectAnnotations() { + final Optional result = AnnotationHelper.resolveAnnotation(DirectlyAnnotatedClass.class, JsonTypeName.class); + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals(result.get().value(), ""); + } + + @Test + void resolveAnnotation_directAnnotationTakesPrecedence() { + final Optional result = AnnotationHelper.resolveAnnotation(BothDirectAndIndirectlyAnnotatedClass.class, JsonTypeName.class); + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals("direct value", result.get().value()); + } + + @Test + void resolveAnnotation_returnsFirstValueFound() { + final Optional result = AnnotationHelper.resolveAnnotation(IndirectlyAnnotatedClass.class, JsonTypeName.class); + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals("first combo annotation value", result.get().value()); + } + + @Test + void resolveAnnotation_usesABreadthFirstlookup() { + final Optional result = AnnotationHelper.resolveAnnotation(BreadthFirstAnnotatedClass.class, JsonTypeName.class); + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals("first combo annotation value", result.get().value()); + } +} \ No newline at end of file