From 8be6b98cde2ea97c4105f2fbd1c1ddf1c5459fee Mon Sep 17 00:00:00 2001 From: Mathias Geat Date: Tue, 3 Jun 2025 09:09:29 +0200 Subject: [PATCH 1/2] Add support to configure adding jackson @JsonPOJOBuilder annotations to generated builders --- options.md | 6 ++ pom.xml | 7 ++ .../recordbuilder/core/RecordBuilder.java | 2 + .../InternalRecordBuilderProcessor.java | 14 ++++ record-builder-test/pom.xml | 5 ++ .../recordbuilder/test/JacksonAnnotated.java | 45 ++++++++++++ .../test/TestJacksonAnnotations.java | 68 +++++++++++++++++++ 7 files changed, 147 insertions(+) create mode 100644 record-builder-test/src/main/java/io/soabase/recordbuilder/test/JacksonAnnotated.java create mode 100644 record-builder-test/src/test/java/io/soabase/recordbuilder/test/TestJacksonAnnotations.java diff --git a/options.md b/options.md index 9bb7689d..422c550d 100644 --- a/options.md +++ b/options.md @@ -54,6 +54,12 @@ The names used for generated methods, classes, etc. can be changed via the follo | `@RecordBuilder.Options(fileIndent = " ")` | Return the file indent to use. | | `@RecordBuilder.Options(prefixEnclosingClassNames = true/false)` | If the record is declared inside another class, the outer class's name will be prefixed to the builder name if this returns true. The default is `true`. | +## Jackson Support + +| option | details | +|--------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `@RecordBuilder.Options(addJacksonAnnotations = true/false)` | If true, builders will be annotated with `@JsonPOJOBuilder` Jackson annotations which can be used in combination with `@JsonDeserialize(builder = ...)`. See [TestJacksonAnnotations](./record-builder-test/src/test/java/io/soabase/recordbuilder/test/TestJacksonAnnotations.java) for an example. The default is `false`. | + ## Miscellaneous | option | details | diff --git a/pom.xml b/pom.xml index d58d237e..bca08f28 100644 --- a/pom.xml +++ b/pom.xml @@ -64,6 +64,7 @@ 6.2.0.Final 3.1.0 3.0.1-b09 + 2.19.0 0.7.0 1.0.0 1.18.42 @@ -167,6 +168,12 @@ ${hibernate-validator-version} + + com.fasterxml.jackson.core + jackson-databind + ${jackson-version} + + org.glassfish javax.el diff --git a/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilder.java b/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilder.java index 158ac369..e04f1c51 100644 --- a/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilder.java +++ b/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilder.java @@ -359,6 +359,8 @@ * @see #nullablePattern */ boolean defaultNotNull() default false; + + boolean addJacksonAnnotations() default false; } @Retention(RetentionPolicy.CLASS) diff --git a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalRecordBuilderProcessor.java b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalRecordBuilderProcessor.java index 98ffdb6d..24404fd3 100644 --- a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalRecordBuilderProcessor.java +++ b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalRecordBuilderProcessor.java @@ -89,6 +89,8 @@ class InternalRecordBuilderProcessor { builder.addAnnotation(recordBuilderGeneratedAnnotation); } + addJacksonAnnotations(); + if (!validateMethodNameConflicts(processingEnv, recordFacade.element())) { builderType = Optional.empty(); return; @@ -199,6 +201,18 @@ private void addVisibility(boolean builderIsInRecordPackage, Set modif } } + private void addJacksonAnnotations() { + if (!metaData.addJacksonAnnotations()) { + return; + } + + final var annotationSpec = AnnotationSpec + .builder(ClassName.get("com.fasterxml.jackson.databind.annotation", "JsonPOJOBuilder")) + .addMember("withPrefix", "$S", metaData.setterPrefix()).build(); + + builder.addAnnotation(annotationSpec); + } + private void addOnceOnlySupport() { if (recordComponents.isEmpty()) { return; diff --git a/record-builder-test/pom.xml b/record-builder-test/pom.xml index e9428681..23d061db 100644 --- a/record-builder-test/pom.xml +++ b/record-builder-test/pom.xml @@ -72,6 +72,11 @@ provided + + com.fasterxml.jackson.core + jackson-databind + + org.glassfish javax.el diff --git a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/JacksonAnnotated.java b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/JacksonAnnotated.java new file mode 100644 index 00000000..bcecb74c --- /dev/null +++ b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/JacksonAnnotated.java @@ -0,0 +1,45 @@ +/* + * Copyright 2019 The original author or authors + * + * 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 io.soabase.recordbuilder.test; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import io.soabase.recordbuilder.core.RecordBuilder; + +import java.util.Map; + +public interface JacksonAnnotated { + String name(); + + String type(); + + Map properties(); + + @RecordBuilder + @RecordBuilder.Options(addJacksonAnnotations = true, useImmutableCollections = true, prefixEnclosingClassNames = false) + @JsonDeserialize(builder = JacksonAnnotatedRecordBuilder.class) + record JacksonAnnotatedRecord(String name, @RecordBuilder.Initializer("DEFAULT_TYPE") String type, + Map properties) implements JacksonAnnotated { + public static final String DEFAULT_TYPE = "dummy"; + } + + @RecordBuilder + @RecordBuilder.Options(addJacksonAnnotations = true, useImmutableCollections = true, prefixEnclosingClassNames = false, setterPrefix = "set") + @JsonDeserialize(builder = JacksonAnnotatedRecordCustomSetterPrefixBuilder.class) + record JacksonAnnotatedRecordCustomSetterPrefix(String name, @RecordBuilder.Initializer("DEFAULT_TYPE") String type, + Map properties) implements JacksonAnnotated { + public static final String DEFAULT_TYPE = "dummy"; + } +} diff --git a/record-builder-test/src/test/java/io/soabase/recordbuilder/test/TestJacksonAnnotations.java b/record-builder-test/src/test/java/io/soabase/recordbuilder/test/TestJacksonAnnotations.java new file mode 100644 index 00000000..468e63da --- /dev/null +++ b/record-builder-test/src/test/java/io/soabase/recordbuilder/test/TestJacksonAnnotations.java @@ -0,0 +1,68 @@ +/* + * Copyright 2019 The original author or authors + * + * 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 io.soabase.recordbuilder.test; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import io.soabase.recordbuilder.test.JacksonAnnotated.JacksonAnnotatedRecord; +import io.soabase.recordbuilder.test.JacksonAnnotated.JacksonAnnotatedRecordCustomSetterPrefix; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Arrays; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +class TestJacksonAnnotations { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @ParameterizedTest + @MethodSource("recordBuilders") + void addsJsonPOJOBuilderAnnotation(Class type, String expectedPrefix) { + final var annotations = Arrays.stream(type.getAnnotations()).toList(); + assertThat(annotations).filteredOn(annotation -> annotation.annotationType().equals(JsonPOJOBuilder.class)) + .hasSize(1).first().asInstanceOf(InstanceOfAssertFactories.type(JsonPOJOBuilder.class)) + .satisfies(annotation -> { + assertThat(annotation.withPrefix()).isEqualTo(expectedPrefix); + }); + } + + static Stream recordBuilders() { + return Stream.of(arguments(JacksonAnnotatedRecordBuilder.class, ""), + arguments(JacksonAnnotatedRecordCustomSetterPrefixBuilder.class, "set")); + } + + @ParameterizedTest + @ValueSource(classes = { JacksonAnnotatedRecord.class, JacksonAnnotatedRecordCustomSetterPrefix.class }) + void deserializingModelInvokesBuilder(Class type) throws JsonProcessingException { + final var json = """ + { + "name" : "test" + } + """; + + final var model = objectMapper.readValue(json, type); + assertThat(model.name()).isEqualTo("test"); + assertThat(model.type()).isEqualTo("dummy"); // default value + assertThat(model.properties()).isNotNull().isEmpty(); // non-null initialized immutable collection + } +} From 408c108ababf9fc521e8d0e2db7d03a38e19eae3 Mon Sep 17 00:00:00 2001 From: Mathias Geat Date: Thu, 19 Feb 2026 21:00:12 +0100 Subject: [PATCH 2/2] Update Jackson support to support both Jackson 2 and 3 Introduces a dedicated Jackson option structure. JSONPojoBuilder support can be enabled with a boolean flag. Detects available jackson version on the classpath and adds the respective annotations when enabled. Jackson version can be set to AUTO (adds annotation for every found version) or to JACKSON_2 or JACKSON_3 (only adds annotations for defined version, fails the build when library is not on the classpath). --- options.md | 61 ++++++++- pom.xml | 10 +- .../recordbuilder/core/RecordBuilder.java | 48 ++++++- .../InternalRecordBuilderProcessor.java | 14 +- .../processor/JacksonSupport.java | 112 ++++++++++++++++ record-builder-test/pom.xml | 4 + .../recordbuilder/test/JacksonAnnotated.java | 26 +++- .../test/TestJacksonAnnotations.java | 121 ++++++++++++++++-- 8 files changed, 359 insertions(+), 37 deletions(-) create mode 100644 record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/JacksonSupport.java diff --git a/options.md b/options.md index 422c550d..22ed346d 100644 --- a/options.md +++ b/options.md @@ -54,12 +54,6 @@ The names used for generated methods, classes, etc. can be changed via the follo | `@RecordBuilder.Options(fileIndent = " ")` | Return the file indent to use. | | `@RecordBuilder.Options(prefixEnclosingClassNames = true/false)` | If the record is declared inside another class, the outer class's name will be prefixed to the builder name if this returns true. The default is `true`. | -## Jackson Support - -| option | details | -|--------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `@RecordBuilder.Options(addJacksonAnnotations = true/false)` | If true, builders will be annotated with `@JsonPOJOBuilder` Jackson annotations which can be used in combination with `@JsonDeserialize(builder = ...)`. See [TestJacksonAnnotations](./record-builder-test/src/test/java/io/soabase/recordbuilder/test/TestJacksonAnnotations.java) for an example. The default is `false`. | - ## Miscellaneous | option | details | @@ -122,3 +116,58 @@ Special handling for collections. See the project test classes for usage. | `@RecordBuilder.Options(useUnmodifiableCollections = true/false)` | Adds special handling for collection record components. The default is `false`. | | `@RecordBuilder.Options(allowNullableCollections = true/false)` | Adds special null handling for record collectioncomponents. The default is `false`. | | `@RecordBuilder.Options(addSingleItemCollectionBuilders = true/false)` | Adds special handling for record collectioncomponents. The default is `false`. | + +## Jackson Support + +RecordBuilder can automatically add Jackson annotations to generated builders, supporting both Jackson 2.x and 3.x. Configuration is done via the nested `@JacksonConfig` annotation. + +### Basic Example + +```java +@RecordBuilder +@RecordBuilder.Options( + jackson = @RecordBuilder.JacksonConfig(jsonPOJOBuilder = true) +) +@JsonDeserialize(builder = UserRecordBuilder.class) +record UserRecord(String name, int age) {} +``` + +### Configuration Options + +| option | details | +|--------|---------| +| `jackson = @JacksonConfig(...)` | Configures Jackson annotation support for the generated builder. By default, no Jackson annotations are added. | + +### JacksonConfig Properties + +| property | details | +|----------|---------| +| `jsonPOJOBuilder` | **boolean** (default: `false`) - When `true`, adds `@JsonPOJOBuilder` annotation to the generated builder. This annotation works with `@JsonDeserialize(builder = ...)` on the record. | +| `version` | **JacksonVersion** (default: `AUTO`) - Specifies which Jackson version to use:
• `AUTO` - Automatically detect Jackson version(s) on classpath and add all found annotations. If both Jackson 2.x and 3.x are present, annotations for both versions will be added.
• `JACKSON_2` - Only add Jackson 2.x annotations (`com.fasterxml.jackson.*`). Fails if Jackson 2.x is not found.
• `JACKSON_3` - Only add Jackson 3.x annotations (`tools.jackson.*`). Fails if Jackson 3.x is not found. | + +### Examples + +#### Auto-detect Jackson version (default) +```java +@RecordBuilder.Options(jackson = @RecordBuilder.JacksonConfig(jsonPOJOBuilder = true)) +``` + +#### Explicit Jackson 2.x +```java +@RecordBuilder.Options( + jackson = @RecordBuilder.JacksonConfig( + jsonPOJOBuilder = true, + version = JacksonVersion.JACKSON_2 + ) +) +``` + +#### With custom setter prefix +```java +@RecordBuilder.Options( + jackson = @RecordBuilder.JacksonConfig(jsonPOJOBuilder = true), + setterPrefix = "set" +) +``` + +See [TestJacksonAnnotations](./record-builder-test/src/test/java/io/soabase/recordbuilder/test/TestJacksonAnnotations.java) for complete examples. diff --git a/pom.xml b/pom.xml index bca08f28..0cd92b1f 100644 --- a/pom.xml +++ b/pom.xml @@ -64,7 +64,8 @@ 6.2.0.Final 3.1.0 3.0.1-b09 - 2.19.0 + 2.21.0 + 3.0.4 0.7.0 1.0.0 1.18.42 @@ -171,7 +172,12 @@ com.fasterxml.jackson.core jackson-databind - ${jackson-version} + ${jackson2-version} + + + tools.jackson.core + jackson-databind + ${jackson3-version} diff --git a/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilder.java b/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilder.java index e04f1c51..a092aacd 100644 --- a/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilder.java +++ b/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilder.java @@ -360,7 +360,30 @@ */ boolean defaultNotNull() default false; - boolean addJacksonAnnotations() default false; + /** + * Configuration for Jackson annotation support on generated builders. + * + * @see JacksonConfig + */ + JacksonConfig jackson() default @JacksonConfig; + } + + /** + * Configuration for Jackson annotation support on generated builders. + */ + @Retention(RetentionPolicy.CLASS) + @Target(ElementType.ANNOTATION_TYPE) + @interface JacksonConfig { + /** + * Add {@code @JsonPOJOBuilder} annotation to generated builder. This annotation works with + * {@code @JsonDeserialize(builder = ...)} on the record. + */ + boolean jsonPOJOBuilder() default false; + + /** + * Which Jackson version to use for annotations. + */ + JacksonVersion version() default JacksonVersion.AUTO; } @Retention(RetentionPolicy.CLASS) @@ -380,6 +403,29 @@ enum ConcreteSettersForOptionalMode { DISABLED, ENABLED, ENABLED_WITH_NULLABLE_ANNOTATION, } + /** + * Specifies which Jackson version(s) to use when generating builder annotations. + */ + enum JacksonVersion { + /** + * Automatically detect Jackson version(s) on classpath and add all found annotations. If both Jackson 2.x and + * 3.x are present, both annotations will be added. + */ + AUTO, + + /** + * Only add Jackson 2.x annotations (com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder). Fails if + * Jackson 2.x is not found on classpath. + */ + JACKSON_2, + + /** + * Only add Jackson 3.x annotations (tools.jackson.databind.annotation.JsonPOJOBuilder). Fails if Jackson 3.x is + * not found on classpath. + */ + JACKSON_3, + } + /** * Apply to record components to specify a field initializer for the generated builder */ diff --git a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalRecordBuilderProcessor.java b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalRecordBuilderProcessor.java index 24404fd3..e4a26a81 100644 --- a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalRecordBuilderProcessor.java +++ b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalRecordBuilderProcessor.java @@ -89,7 +89,7 @@ class InternalRecordBuilderProcessor { builder.addAnnotation(recordBuilderGeneratedAnnotation); } - addJacksonAnnotations(); + new JacksonSupport(processingEnv).addJacksonAnnotations(metaData, builder); if (!validateMethodNameConflicts(processingEnv, recordFacade.element())) { builderType = Optional.empty(); @@ -201,18 +201,6 @@ private void addVisibility(boolean builderIsInRecordPackage, Set modif } } - private void addJacksonAnnotations() { - if (!metaData.addJacksonAnnotations()) { - return; - } - - final var annotationSpec = AnnotationSpec - .builder(ClassName.get("com.fasterxml.jackson.databind.annotation", "JsonPOJOBuilder")) - .addMember("withPrefix", "$S", metaData.setterPrefix()).build(); - - builder.addAnnotation(annotationSpec); - } - private void addOnceOnlySupport() { if (recordComponents.isEmpty()) { return; diff --git a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/JacksonSupport.java b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/JacksonSupport.java new file mode 100644 index 00000000..868c04e5 --- /dev/null +++ b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/JacksonSupport.java @@ -0,0 +1,112 @@ +/* + * Copyright 2019 The original author or authors + * + * 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 io.soabase.recordbuilder.processor; + +import com.palantir.javapoet.AnnotationSpec; +import com.palantir.javapoet.ClassName; +import com.palantir.javapoet.TypeSpec; +import io.soabase.recordbuilder.core.RecordBuilder; + +import javax.annotation.processing.ProcessingEnvironment; + +import static javax.tools.Diagnostic.Kind.ERROR; + +class JacksonSupport { + private static final String JACKSON_2_ANNOTATION_PACKAGE = "com.fasterxml.jackson.databind.annotation"; + private static final String JACKSON_3_ANNOTATION_PACKAGE = "tools.jackson.databind.annotation"; + + private static final String JSON_POJO_BUILDER = "JsonPOJOBuilder"; + + private final ProcessingEnvironment processingEnv; + private final boolean jackson2Present; + private final boolean jackson3Present; + + JacksonSupport(ProcessingEnvironment processingEnv) { + this.processingEnv = processingEnv; + jackson2Present = isAnnotationClassPresent(JACKSON_2_ANNOTATION_PACKAGE, JSON_POJO_BUILDER); + jackson3Present = isAnnotationClassPresent(JACKSON_3_ANNOTATION_PACKAGE, JSON_POJO_BUILDER); + } + + private boolean isAnnotationClassPresent(String packageName, String className) { + return processingEnv.getElementUtils().getTypeElement(packageName + "." + className) != null; + } + + public void addJacksonAnnotations(RecordBuilder.Options metaData, TypeSpec.Builder builder) { + // return without further processing if no annotation is enabled + if (!anyJacksonAnnotationEnabled(metaData)) { + return; + } + + switch (metaData.jackson().version()) { + case AUTO -> { + if (!jackson2Present && !jackson3Present) { + processingEnv.getMessager().printMessage(ERROR, + "jackson.jsonPOJOBuilder is enabled but Jackson is not found on classpath. " + + "Add jackson-databind dependency or disable jsonPOJOBuilder."); + return; + } + + if (jackson2Present) { + addJacksonAnnotations(metaData, builder, JACKSON_2_ANNOTATION_PACKAGE); + } + + if (jackson3Present) { + addJacksonAnnotations(metaData, builder, JACKSON_3_ANNOTATION_PACKAGE); + } + } + + case JACKSON_2 -> { + if (!jackson2Present) { + processingEnv.getMessager().printMessage(ERROR, + "jackson.version is set to JACKSON_2 but Jackson 2.x is not found on classpath. " + + "Add jackson-databind 2.x dependency or change version to AUTO."); + return; + } + + addJacksonAnnotations(metaData, builder, JACKSON_2_ANNOTATION_PACKAGE); + } + + case JACKSON_3 -> { + if (!jackson3Present) { + processingEnv.getMessager().printMessage(ERROR, + "jackson.version is set to JACKSON_3 but Jackson 3.x is not found on classpath. " + + "Add jackson-databind 3.x dependency or change version to AUTO."); + return; + } + + addJacksonAnnotations(metaData, builder, JACKSON_3_ANNOTATION_PACKAGE); + } + } + } + + private boolean anyJacksonAnnotationEnabled(RecordBuilder.Options metaData) { + return metaData.jackson().jsonPOJOBuilder(); + } + + private void addJacksonAnnotations(RecordBuilder.Options metaData, TypeSpec.Builder builder, String packageName) { + if (metaData.jackson().jsonPOJOBuilder()) { + addJsonPOJOBuilderAnnotation(metaData, builder, packageName); + } + } + + private void addJsonPOJOBuilderAnnotation(RecordBuilder.Options metaData, TypeSpec.Builder builder, + String packageName) { + final var annotationSpec = AnnotationSpec.builder(ClassName.get(packageName, JSON_POJO_BUILDER)) + .addMember("withPrefix", "$S", metaData.setterPrefix()).build(); + + builder.addAnnotation(annotationSpec); + } +} diff --git a/record-builder-test/pom.xml b/record-builder-test/pom.xml index 23d061db..d4a07307 100644 --- a/record-builder-test/pom.xml +++ b/record-builder-test/pom.xml @@ -76,6 +76,10 @@ com.fasterxml.jackson.core jackson-databind + + tools.jackson.core + jackson-databind + org.glassfish diff --git a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/JacksonAnnotated.java b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/JacksonAnnotated.java index bcecb74c..a9788f8a 100644 --- a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/JacksonAnnotated.java +++ b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/JacksonAnnotated.java @@ -28,7 +28,7 @@ public interface JacksonAnnotated { Map properties(); @RecordBuilder - @RecordBuilder.Options(addJacksonAnnotations = true, useImmutableCollections = true, prefixEnclosingClassNames = false) + @RecordBuilder.Options(jackson = @RecordBuilder.JacksonConfig(jsonPOJOBuilder = true), useImmutableCollections = true, prefixEnclosingClassNames = false) @JsonDeserialize(builder = JacksonAnnotatedRecordBuilder.class) record JacksonAnnotatedRecord(String name, @RecordBuilder.Initializer("DEFAULT_TYPE") String type, Map properties) implements JacksonAnnotated { @@ -36,10 +36,32 @@ record JacksonAnnotatedRecord(String name, @RecordBuilder.Initializer("DEFAULT_T } @RecordBuilder - @RecordBuilder.Options(addJacksonAnnotations = true, useImmutableCollections = true, prefixEnclosingClassNames = false, setterPrefix = "set") + @RecordBuilder.Options(jackson = @RecordBuilder.JacksonConfig(jsonPOJOBuilder = true), useImmutableCollections = true, prefixEnclosingClassNames = false, setterPrefix = "set") @JsonDeserialize(builder = JacksonAnnotatedRecordCustomSetterPrefixBuilder.class) record JacksonAnnotatedRecordCustomSetterPrefix(String name, @RecordBuilder.Initializer("DEFAULT_TYPE") String type, Map properties) implements JacksonAnnotated { public static final String DEFAULT_TYPE = "dummy"; } + + @RecordBuilder + @RecordBuilder.Options(jackson = @RecordBuilder.JacksonConfig(jsonPOJOBuilder = true, version = RecordBuilder.JacksonVersion.JACKSON_2), useImmutableCollections = true, prefixEnclosingClassNames = false) + @JsonDeserialize(builder = JacksonAnnotatedRecordJackson2Builder.class) + record JacksonAnnotatedRecordJackson2(String name, @RecordBuilder.Initializer("DEFAULT_TYPE") String type, + Map properties) implements JacksonAnnotated { + public static final String DEFAULT_TYPE = "dummy"; + } + + @RecordBuilder + @RecordBuilder.Options(jackson = @RecordBuilder.JacksonConfig(jsonPOJOBuilder = true, version = RecordBuilder.JacksonVersion.JACKSON_3), useImmutableCollections = true, prefixEnclosingClassNames = false) + @tools.jackson.databind.annotation.JsonDeserialize(builder = JacksonAnnotatedRecordJackson3Builder.class) + record JacksonAnnotatedRecordJackson3(String name, @RecordBuilder.Initializer("DEFAULT_TYPE") String type, + Map properties) implements JacksonAnnotated { + public static final String DEFAULT_TYPE = "dummy"; + } + + @RecordBuilder + @RecordBuilder.Options(prefixEnclosingClassNames = false) + record JacksonAnnotatedRecordNoJackson(String name, String type, Map properties) + implements JacksonAnnotated { + } } diff --git a/record-builder-test/src/test/java/io/soabase/recordbuilder/test/TestJacksonAnnotations.java b/record-builder-test/src/test/java/io/soabase/recordbuilder/test/TestJacksonAnnotations.java index 468e63da..f0855845 100644 --- a/record-builder-test/src/test/java/io/soabase/recordbuilder/test/TestJacksonAnnotations.java +++ b/record-builder-test/src/test/java/io/soabase/recordbuilder/test/TestJacksonAnnotations.java @@ -20,7 +20,10 @@ import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; import io.soabase.recordbuilder.test.JacksonAnnotated.JacksonAnnotatedRecord; import io.soabase.recordbuilder.test.JacksonAnnotated.JacksonAnnotatedRecordCustomSetterPrefix; +import io.soabase.recordbuilder.test.JacksonAnnotated.JacksonAnnotatedRecordJackson2; +import io.soabase.recordbuilder.test.JacksonAnnotated.JacksonAnnotatedRecordJackson3; import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -33,36 +36,128 @@ import static org.junit.jupiter.params.provider.Arguments.arguments; class TestJacksonAnnotations { - private final ObjectMapper objectMapper = new ObjectMapper(); + private static final Class J2_POJO_BUILDER = JsonPOJOBuilder.class; + private static final Class J3_POJO_BUILDER = tools.jackson.databind.annotation.JsonPOJOBuilder.class; + + private final ObjectMapper jackson2ObjectMapper = new ObjectMapper(); + private final tools.jackson.databind.ObjectMapper jackson3ObjectMapper = new tools.jackson.databind.ObjectMapper(); + + // ------------------------------------------------------------------------- + // Jackson 2 annotation presence + // ------------------------------------------------------------------------- @ParameterizedTest - @MethodSource("recordBuilders") - void addsJsonPOJOBuilderAnnotation(Class type, String expectedPrefix) { + @MethodSource("jackson2RecordBuilders") + void addsJackson2JsonPOJOBuilderAnnotation(Class type, String expectedPrefix) { final var annotations = Arrays.stream(type.getAnnotations()).toList(); - assertThat(annotations).filteredOn(annotation -> annotation.annotationType().equals(JsonPOJOBuilder.class)) - .hasSize(1).first().asInstanceOf(InstanceOfAssertFactories.type(JsonPOJOBuilder.class)) - .satisfies(annotation -> { - assertThat(annotation.withPrefix()).isEqualTo(expectedPrefix); - }); + assertThat(annotations).filteredOn(annotation -> annotation.annotationType().equals(J2_POJO_BUILDER)).hasSize(1) + .first().asInstanceOf(InstanceOfAssertFactories.type(JsonPOJOBuilder.class)) + .satisfies(annotation -> assertThat(annotation.withPrefix()).isEqualTo(expectedPrefix)); } - static Stream recordBuilders() { + static Stream jackson2RecordBuilders() { return Stream.of(arguments(JacksonAnnotatedRecordBuilder.class, ""), - arguments(JacksonAnnotatedRecordCustomSetterPrefixBuilder.class, "set")); + arguments(JacksonAnnotatedRecordCustomSetterPrefixBuilder.class, "set"), + arguments(JacksonAnnotatedRecordJackson2Builder.class, "")); + } + + // ------------------------------------------------------------------------- + // Jackson 3 annotation presence + // ------------------------------------------------------------------------- + + @ParameterizedTest + @MethodSource("jackson3RecordBuilders") + void addsJackson3JsonPOJOBuilderAnnotation(Class type, String expectedPrefix) { + final var annotations = Arrays.stream(type.getAnnotations()).toList(); + assertThat(annotations).filteredOn(annotation -> annotation.annotationType().equals(J3_POJO_BUILDER)).hasSize(1) + .first() + .asInstanceOf(InstanceOfAssertFactories.type(tools.jackson.databind.annotation.JsonPOJOBuilder.class)) + .satisfies(annotation -> assertThat(annotation.withPrefix()).isEqualTo(expectedPrefix)); } + static Stream jackson3RecordBuilders() { + return Stream.of(arguments(JacksonAnnotatedRecordJackson3Builder.class, "")); + } + + // ------------------------------------------------------------------------- + // AUTO mode: both Jackson 2 and 3 annotations present + // ------------------------------------------------------------------------- + @ParameterizedTest - @ValueSource(classes = { JacksonAnnotatedRecord.class, JacksonAnnotatedRecordCustomSetterPrefix.class }) - void deserializingModelInvokesBuilder(Class type) throws JsonProcessingException { + @MethodSource("autoRecordBuilders") + void addsAnnotationsForBothVersionsInAutoMode(Class type) { + final var annotations = Arrays.stream(type.getAnnotations()).toList(); + assertThat(annotations).filteredOn(annotation -> annotation.annotationType().equals(J2_POJO_BUILDER)) + .hasSize(1); + assertThat(annotations).filteredOn(annotation -> annotation.annotationType().equals(J3_POJO_BUILDER)) + .hasSize(1); + } + + static Stream autoRecordBuilders() { + return Stream.of(arguments(JacksonAnnotatedRecordBuilder.class), + arguments(JacksonAnnotatedRecordCustomSetterPrefixBuilder.class)); + } + + // ------------------------------------------------------------------------- + // Version isolation: each explicit version only adds its own annotation + // ------------------------------------------------------------------------- + + @Test + void jackson2RecordDoesNotHaveJackson3Annotation() { + final var annotations = Arrays.stream(JacksonAnnotatedRecordJackson2Builder.class.getAnnotations()).toList(); + assertThat(annotations).noneMatch(annotation -> annotation.annotationType().equals(J3_POJO_BUILDER)); + } + + @Test + void jackson3RecordDoesNotHaveJackson2Annotation() { + final var annotations = Arrays.stream(JacksonAnnotatedRecordJackson3Builder.class.getAnnotations()).toList(); + assertThat(annotations).noneMatch(annotation -> annotation.annotationType().equals(J2_POJO_BUILDER)); + } + + // ------------------------------------------------------------------------- + // jsonPOJOBuilder = false → no annotation added + // ------------------------------------------------------------------------- + + @Test + void doesNotAddJsonPOJOBuilderAnnotationWhenDisabled() { + final var annotations = Arrays.stream(JacksonAnnotatedRecordNoJacksonBuilder.class.getAnnotations()).toList(); + assertThat(annotations).noneMatch(annotation -> annotation.annotationType().equals(J2_POJO_BUILDER)); + assertThat(annotations).noneMatch(annotation -> annotation.annotationType().equals(J3_POJO_BUILDER)); + } + + // ------------------------------------------------------------------------- + // Deserialization round-trips + // ------------------------------------------------------------------------- + + @ParameterizedTest + @ValueSource(classes = { JacksonAnnotatedRecord.class, JacksonAnnotatedRecordCustomSetterPrefix.class, + JacksonAnnotatedRecordJackson2.class }) + void deserializingWithJackson2InvokesBuilder(Class type) + throws JsonProcessingException { + final var json = """ + { + "name" : "test" + } + """; + + final var model = jackson2ObjectMapper.readValue(json, type); + assertThat(model.name()).isEqualTo("test"); + assertThat(model.type()).isEqualTo("dummy"); // default value + assertThat(model.properties()).isNotNull().isEmpty(); // non-null initialized immutable collection + } + + @Test + void deserializingWithJackson3InvokesBuilder() throws Exception { final var json = """ { "name" : "test" } """; - final var model = objectMapper.readValue(json, type); + final var model = jackson3ObjectMapper.readValue(json, JacksonAnnotatedRecordJackson3.class); assertThat(model.name()).isEqualTo("test"); assertThat(model.type()).isEqualTo("dummy"); // default value assertThat(model.properties()).isNotNull().isEmpty(); // non-null initialized immutable collection } + }