diff --git a/options.md b/options.md index 9bb7689..22ed346 100644 --- a/options.md +++ b/options.md @@ -116,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 d58d237..0cd92b1 100644 --- a/pom.xml +++ b/pom.xml @@ -64,6 +64,8 @@ 6.2.0.Final 3.1.0 3.0.1-b09 + 2.21.0 + 3.0.4 0.7.0 1.0.0 1.18.42 @@ -167,6 +169,17 @@ ${hibernate-validator-version} + + com.fasterxml.jackson.core + jackson-databind + ${jackson2-version} + + + tools.jackson.core + jackson-databind + ${jackson3-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 158ac36..a092aac 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,31 @@ * @see #nullablePattern */ boolean defaultNotNull() 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) @@ -378,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 98ffdb6..e4a26a8 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); } + new JacksonSupport(processingEnv).addJacksonAnnotations(metaData, builder); + if (!validateMethodNameConflicts(processingEnv, recordFacade.element())) { builderType = Optional.empty(); 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 0000000..868c04e --- /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 e942868..d4a0730 100644 --- a/record-builder-test/pom.xml +++ b/record-builder-test/pom.xml @@ -72,6 +72,15 @@ provided + + com.fasterxml.jackson.core + jackson-databind + + + tools.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 0000000..a9788f8 --- /dev/null +++ b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/JacksonAnnotated.java @@ -0,0 +1,67 @@ +/* + * 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(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 { + public static final String DEFAULT_TYPE = "dummy"; + } + + @RecordBuilder + @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 new file mode 100644 index 0000000..f085584 --- /dev/null +++ b/record-builder-test/src/test/java/io/soabase/recordbuilder/test/TestJacksonAnnotations.java @@ -0,0 +1,163 @@ +/* + * 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 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; +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 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("jackson2RecordBuilders") + void addsJackson2JsonPOJOBuilderAnnotation(Class type, String expectedPrefix) { + final var annotations = Arrays.stream(type.getAnnotations()).toList(); + 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 jackson2RecordBuilders() { + return Stream.of(arguments(JacksonAnnotatedRecordBuilder.class, ""), + 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 + @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 = 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 + } + +}