Skip to content

Commit 0ec484b

Browse files
Add nested dot-notation field filtering to both Moshi and GSON formatters
Extends ?fields= to support paths like ?fields=status,items.id which filters inside nested objects and collections. Both MoshiStreamingMessageFormatter and JSONStreamingMessageFormatter now support the same filtering behavior for full parity. Each element in a List<Record> with 127 fields can be filtered down to 1 field during streaming serialization — no capture buffer, no post-processing. Key changes (both formatters): - Parse dot-notation into top-level + nested specs (two-phase filter) - Handle Collection, Map, and single POJO containers - Request-scoped field cache avoids repeated reflection in loops - setAccessible inside try block, catches SecurityException - Log injection prevention on field names in warn messages - One level of dot-notation supported (container.field); documented - Unused fallbackAdapter parameter removed from Moshi formatter New unit tests (5 nested + 3 flat = 8 new tests): - testNestedDotNotationKeepsOneFieldPerElement - testNestedDotNotationKeepsFiveFieldsPerElement - testNestedDotNotation126of127FieldsRemoved (>95% payload reduction) - testNestedDotNotationNonexistentSubField - testNoDotsPassesThrough (backward compatibility) Compatible with Axis2/C nested filtering deployed on penguin. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 384f166 commit 0ec484b

3 files changed

Lines changed: 604 additions & 12 deletions

File tree

modules/json/src/org/apache/axis2/json/streaming/JSONStreamingMessageFormatter.java

Lines changed: 190 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,15 @@
4444
import java.io.OutputStream;
4545
import java.io.OutputStreamWriter;
4646
import java.io.UnsupportedEncodingException;
47+
import java.lang.reflect.Field;
48+
import java.lang.reflect.Modifier;
4749
import java.lang.reflect.Type;
4850
import java.net.URL;
4951
import java.util.ArrayList;
5052
import java.util.Iterator;
53+
import java.util.LinkedHashSet;
54+
import java.util.List;
55+
import java.util.Set;
5156

5257
/**
5358
* Streaming JSON message formatter for Axis2.
@@ -210,7 +215,13 @@ private void writeElementResponse(MessageContext outMsgCtxt, JsonWriter jsonWrit
210215
* Because the JsonWriter is backed by a {@link FlushingOutputStream},
211216
* the HTTP transport receives chunks as serialization progresses —
212217
* the full response is never buffered in a single String or byte[].</p>
218+
*
219+
* <p>When {@link JsonConstant#FIELD_FILTER} is set on the MessageContext,
220+
* only the requested fields are serialized via reflection. Supports
221+
* dot-notation for one level of nesting ({@code container.field}).
222+
* This matches the Moshi formatter's filtering behavior.</p>
213223
*/
224+
@SuppressWarnings("unchecked")
214225
private void writeObjectResponse(MessageContext outMsgCtxt, JsonWriter jsonWriter,
215226
Object retObj) throws AxisFault {
216227
try {
@@ -220,8 +231,15 @@ private void writeObjectResponse(MessageContext outMsgCtxt, JsonWriter jsonWrite
220231

221232
jsonWriter.beginObject();
222233
jsonWriter.name(JsonConstant.RESPONSE);
223-
Type returnType = (Type) outMsgCtxt.getProperty(JsonConstant.RETURN_TYPE);
224-
gson.toJson(retObj, returnType, jsonWriter);
234+
235+
Object filterProp = outMsgCtxt.getProperty(JsonConstant.FIELD_FILTER);
236+
if (filterProp instanceof Set && !((Set<?>) filterProp).isEmpty()) {
237+
writeFilteredObjectGson(jsonWriter, retObj, (Set<String>) filterProp, gson);
238+
} else {
239+
Type returnType = (Type) outMsgCtxt.getProperty(JsonConstant.RETURN_TYPE);
240+
gson.toJson(retObj, returnType, jsonWriter);
241+
}
242+
225243
jsonWriter.endObject();
226244

227245
} catch (IOException e) {
@@ -231,6 +249,176 @@ private void writeObjectResponse(MessageContext outMsgCtxt, JsonWriter jsonWrite
231249
}
232250
}
233251

252+
/**
253+
* Serialize only the requested fields from the return object using GSON.
254+
* Supports dot-notation for one level of nesting ({@code container.field}).
255+
*
256+
* <p>Behavioral parity with
257+
* {@link MoshiStreamingMessageFormatter#writeFilteredObject} — same
258+
* two-phase approach (top-level prune, then nested prune), same
259+
* request-scoped field cache, same edge case handling.</p>
260+
*/
261+
private void writeFilteredObjectGson(JsonWriter jsonWriter, Object retObj,
262+
Set<String> allowedFields, Gson gson)
263+
throws IOException {
264+
265+
if (retObj == null) {
266+
jsonWriter.nullValue();
267+
return;
268+
}
269+
270+
// Parse dot-notation into top-level keeps + nested specs
271+
Set<String> topLevel = new LinkedHashSet<>();
272+
java.util.Map<String, Set<String>> nestedSpecs = new java.util.LinkedHashMap<>();
273+
274+
for (String fieldSpec : allowedFields) {
275+
int dot = fieldSpec.indexOf('.');
276+
if (dot > 0 && dot < fieldSpec.length() - 1) {
277+
String container = fieldSpec.substring(0, dot);
278+
String subField = fieldSpec.substring(dot + 1);
279+
topLevel.add(container);
280+
nestedSpecs.computeIfAbsent(container, k -> new LinkedHashSet<>())
281+
.add(subField);
282+
} else {
283+
topLevel.add(fieldSpec);
284+
}
285+
}
286+
287+
// Request-scoped cache for reflected field lists
288+
java.util.Map<Class<?>, List<Field>> fieldCache = new java.util.HashMap<>();
289+
290+
List<Field> allFields = fieldCache.computeIfAbsent(
291+
retObj.getClass(), JSONStreamingMessageFormatter::getAllFields);
292+
boolean prevSerializeNulls = jsonWriter.getSerializeNulls();
293+
jsonWriter.setSerializeNulls(true);
294+
try {
295+
jsonWriter.beginObject();
296+
297+
for (Field field : allFields) {
298+
if (!topLevel.contains(field.getName())) {
299+
continue;
300+
}
301+
302+
Object value;
303+
try {
304+
field.setAccessible(true);
305+
value = field.get(retObj);
306+
} catch (IllegalAccessException | SecurityException e) {
307+
log.warn("Cannot access field "
308+
+ field.getName().replaceAll("[\r\n]", "_")
309+
+ " for field filtering; skipping", e);
310+
continue;
311+
}
312+
313+
jsonWriter.name(field.getName());
314+
315+
Set<String> subFields = nestedSpecs.get(field.getName());
316+
if (subFields != null && value != null) {
317+
writeFilteredNestedGson(jsonWriter, value, subFields, gson, fieldCache);
318+
} else if (value == null) {
319+
jsonWriter.nullValue();
320+
} else {
321+
gson.toJson(value, field.getGenericType(), jsonWriter);
322+
}
323+
}
324+
325+
jsonWriter.endObject();
326+
} finally {
327+
jsonWriter.setSerializeNulls(prevSerializeNulls);
328+
}
329+
}
330+
331+
/**
332+
* Serialize a nested field (Collection, Map, or single POJO) with only
333+
* the specified sub-fields. GSON equivalent of the Moshi
334+
* {@code writeFilteredNested} method.
335+
*/
336+
private void writeFilteredNestedGson(JsonWriter jsonWriter, Object value,
337+
Set<String> subFields, Gson gson,
338+
java.util.Map<Class<?>, List<Field>> fieldCache)
339+
throws IOException {
340+
341+
if (value instanceof java.util.Collection) {
342+
jsonWriter.beginArray();
343+
for (Object element : (java.util.Collection<?>) value) {
344+
if (element == null) {
345+
jsonWriter.nullValue();
346+
} else {
347+
writeFilteredSingleObjectGson(jsonWriter, element, subFields,
348+
gson, fieldCache);
349+
}
350+
}
351+
jsonWriter.endArray();
352+
} else if (value instanceof java.util.Map) {
353+
jsonWriter.beginObject();
354+
for (java.util.Map.Entry<?, ?> entry : ((java.util.Map<?, ?>) value).entrySet()) {
355+
String key = String.valueOf(entry.getKey());
356+
if (subFields.contains(key)) {
357+
jsonWriter.name(key);
358+
gson.toJson(entry.getValue(), Object.class, jsonWriter);
359+
}
360+
}
361+
jsonWriter.endObject();
362+
} else if (value.getClass().getName().startsWith("java.lang.")) {
363+
gson.toJson(value, value.getClass(), jsonWriter);
364+
} else {
365+
writeFilteredSingleObjectGson(jsonWriter, value, subFields, gson, fieldCache);
366+
}
367+
}
368+
369+
/**
370+
* Serialize a single object with only the specified fields using GSON.
371+
* Inner loop of nested filtering — called once per collection element.
372+
*/
373+
private void writeFilteredSingleObjectGson(JsonWriter jsonWriter, Object obj,
374+
Set<String> allowedFields, Gson gson,
375+
java.util.Map<Class<?>, List<Field>> fieldCache)
376+
throws IOException {
377+
378+
List<Field> fields = fieldCache.computeIfAbsent(
379+
obj.getClass(), JSONStreamingMessageFormatter::getAllFields);
380+
jsonWriter.beginObject();
381+
for (Field field : fields) {
382+
if (!allowedFields.contains(field.getName())) {
383+
continue;
384+
}
385+
Object value;
386+
try {
387+
field.setAccessible(true);
388+
value = field.get(obj);
389+
} catch (IllegalAccessException | SecurityException e) {
390+
log.warn("Cannot access field "
391+
+ field.getDeclaringClass().getName().replaceAll("[\r\n]", "_")
392+
+ "." + field.getName().replaceAll("[\r\n]", "_")
393+
+ " for nested field filtering; skipping", e);
394+
continue;
395+
}
396+
jsonWriter.name(field.getName());
397+
if (value == null) {
398+
jsonWriter.nullValue();
399+
} else {
400+
gson.toJson(value, field.getGenericType(), jsonWriter);
401+
}
402+
}
403+
jsonWriter.endObject();
404+
}
405+
406+
/**
407+
* Collect all non-static, non-transient fields from the class hierarchy.
408+
*/
409+
private static List<Field> getAllFields(Class<?> clazz) {
410+
List<Field> result = new ArrayList<>();
411+
for (Class<?> c = clazz; c != null && c != Object.class; c = c.getSuperclass()) {
412+
for (Field field : c.getDeclaredFields()) {
413+
int mods = field.getModifiers();
414+
if (!Modifier.isStatic(mods) && !Modifier.isTransient(mods)) {
415+
result.add(field);
416+
}
417+
}
418+
}
419+
return result;
420+
}
421+
234422
/**
235423
* Read the flush interval from the service's configuration.
236424
* Falls back to {@link FlushingOutputStream#DEFAULT_FLUSH_INTERVAL}.

0 commit comments

Comments
 (0)