WW-5652 Add support for Java records and Optional serialization in the JSON plugin. (#1792)

* feat(json): support serialization of Java records in JSON processing

* feat(json): add support for serializing Optional values in JSON processing
This commit is contained in:
Sri lakshmi kanthan P
2026-07-20 13:00:52 +05:30
committed by GitHub
parent 963a81f43d
commit 60bc7b8d7a
4 changed files with 232 additions and 4 deletions
@@ -35,6 +35,7 @@ import java.beans.PropertyDescriptor;
import java.lang.reflect.Array;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.RecordComponent;
import java.text.CharacterIterator;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
@@ -57,6 +58,7 @@ import java.util.HashMap;
import java.util.Iterator;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.regex.Pattern;
@@ -160,6 +162,11 @@ public class StrutsJSONWriter implements JSONWriter {
return;
}
if (object instanceof Optional<?> optional) {
this.value(optional.orElse(null), method);
return;
}
if (this.stack.contains(object)) {
Class<?> clazz = object.getClass();
@@ -213,6 +220,8 @@ public class StrutsJSONWriter implements JSONWriter {
this.string(object);
} else if (object instanceof Enum<?> enumValue) {
this.enumeration(enumValue);
} else if (object.getClass().isRecord()) {
this.record(object);
} else {
processCustom(object, method);
}
@@ -305,6 +314,62 @@ public class StrutsJSONWriter implements JSONWriter {
this.add("}");
}
/**
* Serialize a Java record by iterating its record components.
*
* @param object the record instance
* @throws JSONException in case of error during serialize
*/
protected void record(Object object) throws JSONException {
this.add("{");
try {
boolean hasData = false;
for (RecordComponent component : object.getClass().getRecordComponents()) {
String name = component.getName();
Method accessor = component.getAccessor();
if (accessor.isAnnotationPresent(JSON.class)) {
JSONAnnotationFinder jsonFinder = new JSONAnnotationFinder(accessor).invoke();
if (!jsonFinder.shouldSerialize()) {
continue;
}
if (jsonFinder.getName() != null) {
name = jsonFinder.getName();
}
}
String expr = null;
if (this.buildExpr) {
expr = this.expandExpr(name);
if (this.shouldExcludeProperty(expr)) continue;
expr = this.setExprStack(expr);
}
Object value = accessor.invoke(object);
if (accessor.isAnnotationPresent(JSONFieldBridge.class)) {
value = getBridgedValue(accessor, value);
}
boolean propertyPrinted = this.add(name, value, accessor, hasData);
hasData = hasData || propertyPrinted;
if (this.buildExpr) {
this.setExprStack(expr);
}
}
} catch (Exception e) {
throw new JSONException(e);
}
this.add("}");
}
protected BeanInfo getBeanInfoIgnoreHierarchy(final Class<?> clazz) throws IntrospectionException {
BeanInfo beanInfo = BEAN_INFO_CACHE_IGNORE_HIERARCHY.get(clazz);
if (beanInfo != null) {
@@ -443,11 +508,15 @@ public class StrutsJSONWriter implements JSONWriter {
return false;
}
private static boolean isAbsent(Object value) {
return value == null || (value instanceof Optional<?> opt && opt.isEmpty());
}
/*
* Add name/value pair to buffer
*/
protected boolean add(String name, Object value, Method method, boolean hasData) throws JSONException {
if (excludeNullProperties && value == null) {
if (excludeNullProperties && isAbsent(value)) {
return false;
}
if (hasData) {
@@ -472,7 +541,7 @@ public class StrutsJSONWriter implements JSONWriter {
boolean hasData = false;
while (it.hasNext()) {
Map.Entry<?, ?> entry = (Map.Entry<?, ?>) it.next();
if (excludeNullProperties && entry.getValue() == null) {
if (excludeNullProperties && isAbsent(entry.getValue())) {
continue;
}
@@ -26,7 +26,7 @@ import java.lang.annotation.Target;
/*
* Annotation used to customize serialization
*/
@Target(ElementType.METHOD)
@Target({ElementType.METHOD, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface JSON {
String name() default "";
@@ -26,7 +26,7 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Target({ElementType.METHOD, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface JSONFieldBridge {
Class<? extends FieldBridge> impl() default StringBridge.class;
@@ -18,6 +18,7 @@
*/
package org.apache.struts2.json;
import org.apache.struts2.json.annotations.JSON;
import org.apache.struts2.json.annotations.JSONFieldBridge;
import org.apache.struts2.junit.util.TestUtils;
import org.junit.Test;
@@ -38,7 +39,9 @@ import java.util.Calendar;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TimeZone;
import java.util.regex.Pattern;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -315,4 +318,160 @@ public class StrutsJSONWriterTest {
assertTrue(json.contains("\"calendar\":\"2012-12-23T10:10:10\""));
}
@Test
public void testSerializeSimpleRecord() throws Exception {
record Person(String name, int age) {}
Person r = new Person("Alice", 30);
JSONWriter jsonWriter = new StrutsJSONWriter();
String json = jsonWriter.write(r);
assertEquals("{\"name\":\"Alice\",\"age\":30}", json);
}
@Test
public void testSerializeRecordWithNullField() throws Exception {
record RecordWithNullField(String name, String optional) {}
RecordWithNullField r = new RecordWithNullField("Bob", null);
JSONWriter jsonWriter = new StrutsJSONWriter();
String withNullJson = jsonWriter.write(r);
assertEquals("{\"name\":\"Bob\",\"optional\":null}", withNullJson);
String nonNullJson = jsonWriter.write(r, null, null, true);
assertEquals("{\"name\":\"Bob\"}", nonNullJson);
}
@Test
public void testSerializeNestedRecord() throws Exception {
record InnerRecord(String label) {}
record OuterRecord(String label, InnerRecord inner) {}
OuterRecord r = new OuterRecord("outer", new InnerRecord("inner"));
JSONWriter jsonWriter = new StrutsJSONWriter();
String json = jsonWriter.write(r);
assertEquals("{\"label\":\"outer\",\"inner\":{\"label\":\"inner\"}}", json);
}
@Test
public void testSerializeRecordSkipsComponentAnnotatedWithSerializeFalse() throws Exception {
record RecordWithAnnotatedComponent(@JSON(serialize = false) String secret, String visible) {}
RecordWithAnnotatedComponent r = new RecordWithAnnotatedComponent("topsecret", "hello");
JSONWriter jsonWriter = new StrutsJSONWriter();
String json = jsonWriter.write(r);
assertFalse("secret component should be excluded", json.contains("secret"));
assertTrue(json.contains("\"visible\":\"hello\""));
}
@Test
public void testSerializeRecordUsesRenamedComponentFromJsonAnnotation() throws Exception {
record RecordWithRenamedComponent(@JSON(name = "fullName") String name, int age) {}
RecordWithRenamedComponent r = new RecordWithRenamedComponent("Alice", 30);
JSONWriter jsonWriter = new StrutsJSONWriter();
String json = jsonWriter.write(r);
assertFalse("original key 'name' should not appear", json.contains("\"name\""));
assertTrue(json.contains("\"fullName\":\"Alice\""));
assertTrue(json.contains("\"age\":30"));
}
@Test
public void testSerializeRecordWithListField() throws Exception {
record RecordWithList(String title, List<String> tags) {}
RecordWithList r = new RecordWithList("news", List.of("java", "records"));
JSONWriter jsonWriter = new StrutsJSONWriter();
String json = jsonWriter.write(r);
assertEquals("{\"title\":\"news\",\"tags\":[\"java\",\"records\"]}", json);
}
@Test
public void testSerializeRecordIncludePropertyFilter() throws Exception {
record Person(String name, int age) {}
Person r = new Person("Alice", 30);
JSONWriter jsonWriter = new StrutsJSONWriter();
List<Pattern> include = List.of(Pattern.compile("name"));
String json = jsonWriter.write(r, null, include, false);
assertTrue(json.contains("\"name\""));
assertFalse(json.contains("\"age\""));
}
@Test
public void testSerializeRecordExcludePropertyFilter() throws Exception {
record Person(String name, int age) {}
Person r = new Person("Alice", 30);
JSONWriter jsonWriter = new StrutsJSONWriter();
List<Pattern> exclude = List.of(Pattern.compile("age"));
String json = jsonWriter.write(r, exclude, null, false);
assertTrue(json.contains("\"name\""));
assertFalse(json.contains("\"age\""));
}
@Test
public void testSerializeRecordWithFieldBridge() throws Exception {
record LinkRecord(@JSONFieldBridge URL homepage, String name) {}
URL url = URI.create("https://www.google.com").toURL();
LinkRecord r = new LinkRecord(url, "Struts");
JSONWriter jsonWriter = new StrutsJSONWriter();
String json = jsonWriter.write(r);
assertTrue(json.contains("\"homepage\":\"https:\\/\\/www.google.com\""));
assertTrue(json.contains("\"name\":\"Struts\""));
}
@Test
public void testSerializeOptionalPresent() throws Exception {
JSONWriter jsonWriter = new StrutsJSONWriter();
assertEquals("\"hello\"", jsonWriter.write(Optional.of("hello")));
}
@Test
public void testSerializeOptionalEmpty() throws Exception {
JSONWriter jsonWriter = new StrutsJSONWriter();
assertEquals("null", jsonWriter.write(Optional.empty()));
}
class BeanWithOptional {
private String field;
public Optional<String> getField() {
return Optional.ofNullable(field);
}
public void setField(String field) {
this.field = field;
}
}
@Test
public void testSerializeBeanWithOptionalWithNull() throws Exception {
BeanWithOptional bean = new BeanWithOptional();
bean.setField(null);
JSONWriter jsonWriter = new StrutsJSONWriter();
String json = jsonWriter.write(bean);
assertTrue(json.contains("\"field\":null"));
}
@Test
public void testSerializeBeanWithOptionalWithValue() throws Exception {
BeanWithOptional bean = new BeanWithOptional();
bean.setField("value");
JSONWriter jsonWriter = new StrutsJSONWriter();
String json = jsonWriter.write(bean);
assertTrue(json.contains("\"field\":\"value\""));
}
@Test
public void testMapWithOptionalEmptyValueExcludedWhenExcludeNullProperties() throws Exception {
Map<String, Object> map = new LinkedHashMap<>();
map.put("present", Optional.of("hello"));
map.put("absent", Optional.empty());
JSONWriter jsonWriter = new StrutsJSONWriter();
String json = jsonWriter.write(map, null, null, true);
assertEquals("{\"present\":\"hello\"}", json);
}
@Test
public void testMapWithOptionalEmptyValueIncludedAsNull() throws Exception {
Map<String, Object> map = new LinkedHashMap<>();
map.put("present", Optional.of("hello"));
map.put("absent", Optional.empty());
JSONWriter jsonWriter = new StrutsJSONWriter();
String json = jsonWriter.write(map, null, null, false);
assertEquals("{\"present\":\"hello\",\"absent\":null}", json);
}
}