From 7eb1a33a28e698397df8f2483c4bb987cd7a7701 Mon Sep 17 00:00:00 2001 From: srilakshmikanthanp Date: Thu, 16 Jul 2026 21:26:27 +0530 Subject: [PATCH 1/2] feat(json): support serialization of Java records in JSON processing --- .../apache/struts2/json/StrutsJSONWriter.java | 59 ++++++++++++ .../apache/struts2/json/annotations/JSON.java | 2 +- .../json/annotations/JSONFieldBridge.java | 2 +- .../struts2/json/StrutsJSONWriterTest.java | 96 +++++++++++++++++++ 4 files changed, 157 insertions(+), 2 deletions(-) diff --git a/plugins/json/src/main/java/org/apache/struts2/json/StrutsJSONWriter.java b/plugins/json/src/main/java/org/apache/struts2/json/StrutsJSONWriter.java index b7f540b0b3..e8c170cf78 100644 --- a/plugins/json/src/main/java/org/apache/struts2/json/StrutsJSONWriter.java +++ b/plugins/json/src/main/java/org/apache/struts2/json/StrutsJSONWriter.java @@ -35,6 +35,7 @@ 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; @@ -213,6 +214,8 @@ protected void process(Object object, Method method) throws JSONException { 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 +308,62 @@ protected void bean(Object object) throws JSONException { 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) { diff --git a/plugins/json/src/main/java/org/apache/struts2/json/annotations/JSON.java b/plugins/json/src/main/java/org/apache/struts2/json/annotations/JSON.java index 9b40f6f578..f9ea7d7ee5 100644 --- a/plugins/json/src/main/java/org/apache/struts2/json/annotations/JSON.java +++ b/plugins/json/src/main/java/org/apache/struts2/json/annotations/JSON.java @@ -26,7 +26,7 @@ /* * Annotation used to customize serialization */ -@Target(ElementType.METHOD) +@Target({ElementType.METHOD, ElementType.RECORD_COMPONENT}) @Retention(RetentionPolicy.RUNTIME) public @interface JSON { String name() default ""; diff --git a/plugins/json/src/main/java/org/apache/struts2/json/annotations/JSONFieldBridge.java b/plugins/json/src/main/java/org/apache/struts2/json/annotations/JSONFieldBridge.java index 799edd76b2..68a83d1c0c 100644 --- a/plugins/json/src/main/java/org/apache/struts2/json/annotations/JSONFieldBridge.java +++ b/plugins/json/src/main/java/org/apache/struts2/json/annotations/JSONFieldBridge.java @@ -26,7 +26,7 @@ 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 impl() default StringBridge.class; diff --git a/plugins/json/src/test/java/org/apache/struts2/json/StrutsJSONWriterTest.java b/plugins/json/src/test/java/org/apache/struts2/json/StrutsJSONWriterTest.java index acc52dcaa8..5be8146cf2 100644 --- a/plugins/json/src/test/java/org/apache/struts2/json/StrutsJSONWriterTest.java +++ b/plugins/json/src/test/java/org/apache/struts2/json/StrutsJSONWriterTest.java @@ -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; @@ -39,6 +40,7 @@ import java.util.List; import java.util.Map; import java.util.TimeZone; +import java.util.regex.Pattern; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -315,4 +317,98 @@ public void testSerializeCalendar() throws Exception { 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 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 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 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\"")); + } + } From 4acf34700e1abc6e8c6dc4677fa8623ff34d8fb8 Mon Sep 17 00:00:00 2001 From: srilakshmikanthanp Date: Thu, 16 Jul 2026 21:26:27 +0530 Subject: [PATCH 2/2] feat(json): add support for serializing Optional values in JSON processing --- .../apache/struts2/json/StrutsJSONWriter.java | 14 ++++- .../struts2/json/StrutsJSONWriterTest.java | 63 +++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/plugins/json/src/main/java/org/apache/struts2/json/StrutsJSONWriter.java b/plugins/json/src/main/java/org/apache/struts2/json/StrutsJSONWriter.java index e8c170cf78..fd6fe14aaf 100644 --- a/plugins/json/src/main/java/org/apache/struts2/json/StrutsJSONWriter.java +++ b/plugins/json/src/main/java/org/apache/struts2/json/StrutsJSONWriter.java @@ -58,6 +58,7 @@ 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; @@ -161,6 +162,11 @@ protected void value(Object object, Method method) throws JSONException { return; } + if (object instanceof Optional optional) { + this.value(optional.orElse(null), method); + return; + } + if (this.stack.contains(object)) { Class clazz = object.getClass(); @@ -502,11 +508,15 @@ protected boolean shouldExcludeProperty(String expr) { 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) { @@ -531,7 +541,7 @@ protected void map(Map map, Method method) throws JSONException { boolean hasData = false; while (it.hasNext()) { Map.Entry entry = (Map.Entry) it.next(); - if (excludeNullProperties && entry.getValue() == null) { + if (excludeNullProperties && isAbsent(entry.getValue())) { continue; } diff --git a/plugins/json/src/test/java/org/apache/struts2/json/StrutsJSONWriterTest.java b/plugins/json/src/test/java/org/apache/struts2/json/StrutsJSONWriterTest.java index 5be8146cf2..2b37f6ca40 100644 --- a/plugins/json/src/test/java/org/apache/struts2/json/StrutsJSONWriterTest.java +++ b/plugins/json/src/test/java/org/apache/struts2/json/StrutsJSONWriterTest.java @@ -39,6 +39,7 @@ 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; @@ -411,4 +412,66 @@ record LinkRecord(@JSONFieldBridge URL homepage, String name) {} 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 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 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 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); + } + }