Merge pull request #777 from KyleAure/615-record-jsonbadapater test: `@JsonbTypeInfo` name collision
diff --git a/src/main/java/org/eclipse/yasson/internal/properties/MessageKeys.java b/src/main/java/org/eclipse/yasson/internal/properties/MessageKeys.java index fde557a..2171f8b 100644 --- a/src/main/java/org/eclipse/yasson/internal/properties/MessageKeys.java +++ b/src/main/java/org/eclipse/yasson/internal/properties/MessageKeys.java
@@ -285,6 +285,10 @@ */ PROPERTY_NAME_CLASH("propertyNameClash"), /** + * A {@code @JsonbTypeInfo} discriminator key collides with a property name on the implementing class. + */ + TYPE_INFO_KEY_CLASH("typeInfoKeyClash"), + /** * {@link Date} is not supported I-Json is enabled. */ SQL_DATE_IJSON_ERROR("sqlDateIJsonError"),
diff --git a/src/main/java/org/eclipse/yasson/internal/serializer/SerializationModelCreator.java b/src/main/java/org/eclipse/yasson/internal/serializer/SerializationModelCreator.java index 9aa29a7..c7870b7 100644 --- a/src/main/java/org/eclipse/yasson/internal/serializer/SerializationModelCreator.java +++ b/src/main/java/org/eclipse/yasson/internal/serializer/SerializationModelCreator.java
@@ -237,7 +237,10 @@ } for (PropertyModel propertyModel : classModel.getSortedProperties()) { if (propertySerializers.containsKey(propertyModel.getWriteName())) { - throw new JsonbException("CHANGE naming conflict!"); + throw new JsonbException(Messages.getMessage(MessageKeys.TYPE_INFO_KEY_CLASH, + typeInheritanceConfiguration.getFieldName(), + propertyModel.getWriteName(), + classModel.getType().getName())); } } }
diff --git a/src/main/resources/yasson-messages.properties b/src/main/resources/yasson-messages.properties index 244df20..59b7d23 100644 --- a/src/main/resources/yasson-messages.properties +++ b/src/main/resources/yasson-messages.properties
@@ -81,6 +81,7 @@ jsonbTransientWithOtherAnnotations=JsonbTransient annotation cannot be used with other jsonb annotations on the same property. nonParametrizedType=Type: {0} is not a parametrized type. propertyNameClash=Property {0} clashes with property {1} by read or write name in class {2}. +typeInfoKeyClash=@JsonbTypeInfo discriminator key ''{0}'' conflicts with property ''{1}'' in class {2}. Rename the property or change the @JsonbTypeInfo key. sqlDateIJsonError=java.sql.Date is not supported in STRICT_IJSON mode. recursiveReference=Recursive reference has been found in {0}. datatypeFactoryCreationFailed=An error occurred while DatatypeFactory creation.
diff --git a/src/test/java/org/eclipse/yasson/customization/polymorphism/AnnotationPolymorphismTest.java b/src/test/java/org/eclipse/yasson/customization/polymorphism/AnnotationPolymorphismTest.java index bf6ae1b..d5eb27a 100644 --- a/src/test/java/org/eclipse/yasson/customization/polymorphism/AnnotationPolymorphismTest.java +++ b/src/test/java/org/eclipse/yasson/customization/polymorphism/AnnotationPolymorphismTest.java
@@ -22,6 +22,8 @@ import jakarta.json.bind.annotation.JsonbTypeInfo; import org.eclipse.yasson.Jsonbs; +import org.eclipse.yasson.internal.properties.MessageKeys; +import org.eclipse.yasson.internal.properties.Messages; import org.junit.jupiter.api.Test; import static org.hamcrest.CoreMatchers.instanceOf; @@ -145,4 +147,38 @@ } + /** + * When a class has a field annotated {@code @JsonbProperty("reason")} and its + * declaring interface carries {@code @JsonbTypeInfo(key = "reason", ...)}, the + * type-discriminator key and the property name are identical. Serialisation must + * throw a {@link JsonbException} rather than silently emit a duplicate JSON key. + */ + @Test + public void testTypeInfoKeyClashWithPropertyNameThrowsOnSerialisation() { + ReasonEvent event = new ReasonEvent("some-value"); + String expectedMessage = Messages.getMessage(MessageKeys.TYPE_INFO_KEY_CLASH, + "reason", "reason", ReasonEvent.class.getName()); + JsonbException ex = assertThrows(JsonbException.class, () -> Jsonbs.defaultJsonb.toJson(event)); + assertThat(ex.getMessage(), is(expectedMessage)); + } + + @JsonbTypeInfo( + key = "reason", + value = { + @JsonbSubtype(alias = "event", type = ReasonEvent.class) + } + ) + public interface ReasonHolder { + } + + public static final class ReasonEvent implements ReasonHolder { + + @JsonbProperty("reason") + public String reason; + + public ReasonEvent(String reason) { + this.reason = reason; + } + } + }
diff --git a/src/test/java/org/eclipse/yasson/records/NotificationModel.java b/src/test/java/org/eclipse/yasson/records/NotificationModel.java new file mode 100644 index 0000000..ade9e02 --- /dev/null +++ b/src/test/java/org/eclipse/yasson/records/NotificationModel.java
@@ -0,0 +1,111 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, + * or the Eclipse Distribution License v. 1.0 which is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: EPL-2.0 OR BSD-3-Clause + */ + +package org.eclipse.yasson.records; + +import jakarta.json.bind.adapter.JsonbAdapter; +import jakarta.json.bind.annotation.JsonbProperty; +import jakarta.json.bind.annotation.JsonbSubtype; +import jakarta.json.bind.annotation.JsonbTypeAdapter; +import jakarta.json.bind.annotation.JsonbTypeInfo; + +/** + * Model types for the issue #615 reproducer. + * <p> + * The bug: when a sealed interface carries {@code @JsonbTypeInfo} and one of its + * abstract methods is annotated with {@code @JsonbTypeAdapter}, that adapter was + * silently ignored for implementing records, leaving the field {@code null} after + * deserialization. + */ +public final class NotificationModel { + + private NotificationModel() {} + + // ------------------------------------------------------------------ + // Reason enum + // ------------------------------------------------------------------ + + public enum NotificationReason { + MENTION("mention"), + FOLLOW("follow"); + + private final String value; + + NotificationReason(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + public static NotificationReason fromString(String s) { + for (NotificationReason r : values()) { + if (r.value.equals(s)) { + return r; + } + } + throw new IllegalArgumentException("Unknown reason: " + s); + } + } + + // ------------------------------------------------------------------ + // Adapter + // ------------------------------------------------------------------ + + public static class NotificationReasonAdapter implements JsonbAdapter<NotificationReason, String> { + + @Override + public String adaptToJson(NotificationReason obj) throws Exception { + return obj.getValue(); + } + + @Override + public NotificationReason adaptFromJson(String obj) throws Exception { + return NotificationReason.fromString(obj); + } + } + + // ------------------------------------------------------------------ + // Sealed interface with @JsonbTypeInfo + @JsonbTypeAdapter on reason() + // ------------------------------------------------------------------ + + @JsonbTypeInfo( + key = "@reason", + value = { + @JsonbSubtype(alias = "mention", type = MentionNotification.class), + @JsonbSubtype(alias = "follow", type = FollowNotification.class) + } + ) + public sealed interface Notification permits MentionNotification, FollowNotification { + + @JsonbTypeAdapter(NotificationReasonAdapter.class) + @JsonbProperty("reason") + NotificationReason reason(); + } + + // ------------------------------------------------------------------ + // Record subtypes + // ------------------------------------------------------------------ + + public record MentionNotification( + @JsonbProperty("uri") String uri, + @JsonbTypeAdapter(NotificationReasonAdapter.class) + @JsonbProperty("reason") NotificationReason reason + ) implements Notification {} + + public record FollowNotification( + @JsonbProperty("handle") String handle, + @JsonbTypeAdapter(NotificationReasonAdapter.class) + @JsonbProperty("reason") NotificationReason reason + ) implements Notification {} +}
diff --git a/src/test/java/org/eclipse/yasson/records/RecordTest.java b/src/test/java/org/eclipse/yasson/records/RecordTest.java index e8998b4..edaee35 100644 --- a/src/test/java/org/eclipse/yasson/records/RecordTest.java +++ b/src/test/java/org/eclipse/yasson/records/RecordTest.java
@@ -14,6 +14,9 @@ package org.eclipse.yasson.records; import jakarta.json.bind.JsonbException; +import jakarta.json.bind.annotation.JsonbProperty; +import jakarta.json.bind.annotation.JsonbSubtype; +import jakarta.json.bind.annotation.JsonbTypeInfo; import org.eclipse.yasson.Jsonbs; import org.eclipse.yasson.TestTypeToken; @@ -267,4 +270,65 @@ assertThat(result.items().get(1).required(), is("c")); assertThat(result.items().get(1).optional(), is((String) null)); } + + // ----------------------------------------------------------------- + // Issue #615 — @JsonbTypeAdapter on record components with @JsonbTypeInfo + // ----------------------------------------------------------------- + + /** + * The original issue reported that {@code @JsonbTypeAdapter} on a record component was + * silently ignored during (de)serialisation when the declaring interface also carried + * {@code @JsonbTypeInfo}; that was resolved by + * https://github.com/eclipse-ee4j/yasson/pull/770 + */ + @Test + public void testNotificationMentionSerializesReasonViaAdapter() { + NotificationModel.MentionNotification mention = + new NotificationModel.MentionNotification("https://example.com/post/1", + NotificationModel.NotificationReason.MENTION); + String json = Jsonbs.defaultJsonb.toJson(mention, NotificationModel.MentionNotification.class); + assertThat(json, is("{\"@reason\":\"mention\",\"reason\":\"mention\",\"uri\":\"https://example.com/post/1\"}")); + } + + @Test + public void testNotificationMentionDeserializesReasonViaAdapter() { + String json = "{\"@reason\":\"mention\",\"reason\":\"mention\",\"uri\":\"https://example.com/post/1\"}"; + NotificationModel.Notification notification = + Jsonbs.defaultJsonb.fromJson(json, NotificationModel.Notification.class); + assertThat(notification, instanceOf(NotificationModel.MentionNotification.class)); + NotificationModel.MentionNotification mention = (NotificationModel.MentionNotification) notification; + assertThat(mention.uri(), is("https://example.com/post/1")); + assertThat(mention.reason(), is(NotificationModel.NotificationReason.MENTION)); + } + + /** + * Same clash as {@code testTypeInfoKeyClashWithPropertyNameThrowsOnSerialisation} in + * {@link org.eclipse.yasson.customization.polymorphism.AnnotationPolymorphismTest}, + * but using a record component instead of a plain class field. + * <p> + * The {@code @JsonbTypeInfo(key = "reason")} discriminator and the record component + * {@code @JsonbProperty("reason")} map to the same JSON key. Serialisation must + * throw a {@link JsonbException} with a descriptive message rather than silently + * emitting a duplicate key. + */ + @Test + public void testRecordTypeInfoKeyClashWithComponentNameThrowsOnSerialisation() { + ReasonRecord record = new ReasonRecord("some-value"); + String expectedMessage = Messages.getMessage(MessageKeys.TYPE_INFO_KEY_CLASH, + "reason", "reason", ReasonRecord.class.getName()); + JsonbException ex = assertThrows(JsonbException.class, () -> Jsonbs.defaultJsonb.toJson(record)); + assertThat(ex.getMessage(), is(expectedMessage)); + } + + @JsonbTypeInfo( + key = "reason", + value = { + @JsonbSubtype(alias = "event", type = ReasonRecord.class) + } + ) + public interface ReasonHolder { + } + + public record ReasonRecord(@JsonbProperty("reason") String reason) implements ReasonHolder { + } }