Skip to content

Converting Java objects into structured containers

Peter Nagy edited this page Nov 17, 2024 · 3 revisions

Last week I published a short article on the Java reflection. Today I would like to share a small Utility class which can convert a Java POJO into a structured container. This article will include examples of recursion, the composite and the visitor patterns.

Why is this good at all?

For example, we would like to create a general-purpose input parameter logging class that could be used like this:

@LoggedRestMethod(eventId = 1, subsystem = "my-service")
public ResponseURI createDocument(CreateDocumentParam request, String username, String password, String processID)
{
	...
}

And this would be the result:

2024-11-17 08:55:51.084 DEBUG --- [nio-8484-exec-6] h.p.s.s.r.LoggedRestMethodAspect         118: >>> | host: my-computer | system: my-service | eventId: 1 | event: createDocument | {"request":{"comment":"comment","content":{"bytes":"byte[] of length: 16 kB","fileName":"content.pdf"},"documentDate":"2024-10-01 02:00:00","documentTypeName":"my-document-type","keywords":[{"name":"my-keyword","value":"apple"}]},"username":"API-USER","password":"*** [hidden]","processID":"123"}  
2024-11-17 08:55:55.920 DEBUG --- [nio-8484-exec-6] h.p.s.c.t.Took                           137: c.i.o.r.MyController.createDocument() took 500 ms. Start time: 2024-11-17 08:55:20.408, end time: 2024-11-17 08:55:20.908

This appears in the log:

{
  "request": {
    "comment": "comment",
    "content": {
      "bytes": "byte[] of length: 16 kB",
      "fileName": "content.pdf"
    },
    "documentDate": "2024-10-01 02:00:00",
    "documentTypeName": "my-document-type",
    "keywords": [
      {
        "name":"my-keyword",
        "value": "apple"
      }
    ]
  },
  "username": "API-USER",
  "password": "*** [hidden]",
  "processID": "123"
}

As we can see in the example, the parameters of the createDocument() method are nicely logged, and the @LoggedRestMethod annotation takes care of everything for us. The result resembles JSON, but it’s not entirely JSON because the file content does not appear in the output. A 16 kB binary array would make the log completely unusable.

The second difference is that the password passed as a parameter only appears masked in the log.

How can this be achieved easily? For this, I created a class called Thing. It’s essentially a Bean, but I didn’t want to use the same name again.

We can observe the composite design pattern in work. This allows treating individual objects and compositions of objects uniformly, as they both implement the same interface. After the conversion of a POJO we will have a Thing object which contains the properties of the original object as a ValueMap. Beside this a Thing may be a collection or a terminal type, which we do not want to process any further.

The conversion is made using recursion in an elegant way.

@Getter
@Slf4j
@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
@EqualsAndHashCode
public abstract class Thing
{
    private final String name;

    public static Thing from(Object object)
    {
        return valueToThing(null, object, false);
    }

    public static Thing from(Object object, Boolean includePrivate)
    {
        return valueToThing(null, object, includePrivate);
    }

    public abstract void accept(ThingVisitor visitor);

    abstract boolean isEmpty();


    private static Value objectToValue(String name, Object object)
    {
        return new Value(name, object);
    }


    private static Thing valueToThing(String name, Object object, Boolean includePrivate)
    {
        if (object == null || ReflectionUtils.isTerminalType(object))
        {
            return objectToValue(name, object);
        }

        if (object instanceof Collection<?> list)
        {
            return convertCollection(name, list, includePrivate);
        }
        else if (object instanceof Map<?, ?> map)
        {
            return convertMap(name, map, includePrivate);
        }

        List<Property> properties = ReflectionUtils.propertiesOf(object.getClass(), includePrivate);
        if (properties.isEmpty())
        {
            // Enums come here
            return objectToValue(name, object);
        }
        ValueMap valueMap = new ValueMap(name);
        for (Property property : properties)
        {
            String propertyName = property.getName();
            try
            {
                Object propertyValue = property.get(object);
                valueMap.getProperties().put(propertyName, valueToThing(propertyName, propertyValue, includePrivate));
            }
            catch (IllegalAccessException | InvocationTargetException | RuntimeException e)
            {
                log.warn("Cannot process property {}.{}: {}", object.getClass().getName(), propertyName, e.getMessage());
            }
        }
        return valueMap;
    }


    private static ValueList convertCollection(String name, Collection<?> collection, Boolean includePrivate)
    {
        ValueList valueList = new ValueList(name);
        for (Object item : collection)
        {
            valueList.getElements().add(valueToThing(name, item, includePrivate));
        }
        return valueList;
    }


    private static ValueMap convertMap(String name, Map<?, ?> map, Boolean includePrivate)
    {
        ValueMap valueMap = new ValueMap(name);
        for (Map.Entry<?, ?> entry : map.entrySet())
        {
            String propertyName = entry.getKey().toString();
            valueMap.getProperties().put(propertyName, valueToThing(propertyName, entry.getValue(), includePrivate));
        }
        return valueMap;
    }
}


@Getter
@JsonSerialize(using = ValueSerializer.class)
@ToString
@EqualsAndHashCode(callSuper = true)
public class Value extends Thing
{
    private final Object value;

    public Value(String name, Object value)
    {
        super(name);
        this.value = value;
    }

    public void accept(ThingVisitor visitor)
    {
        visitor.visit(this);
    }

    @Override
    boolean isEmpty()
    {
        return value == null;
    }
}


@Getter
@JsonSerialize(using = ValueListSerializer.class)
@ToString
@EqualsAndHashCode(callSuper = true)
public class ValueList extends Thing
{
    private final List<Thing> elements = new ArrayList<>();

    protected ValueList(String name)
    {
        super(name);
    }

    @Override
    public void accept(ThingVisitor visitor)
    {
        visitor.visit(this);
    }

    @Override
    boolean isEmpty()
    {
        return elements.isEmpty();
    }
}


@Getter
@Slf4j
@JsonSerialize(using = ValueMapSerializer.class)
@ToString
@EqualsAndHashCode(callSuper = true)
public class ValueMap extends Thing
{
    private final Map<String, Thing> properties = new LinkedHashMap<>();

    protected ValueMap(String name)
    {
        super(name);
    }


    @Override
    public void accept(ThingVisitor visitor)
    {
        visitor.visit(this);
    }


    @Override
    public boolean isEmpty()
    {
        return properties.isEmpty();
    }
}

The good thing is that if we convert the Thing object to JSON, we get a JSON representation identical to the original object.

Now let's see, how we can print the Thing object converting binary objects into a more handy string. Here comes the visitor pattern into the play. The visitor pattern enables adding new operations to existing object structures without modifying their classes, by separating the operation logic into a visitor object.

public interface ThingVisitor
{
    void visit(Value value);
    void visit(ValueMap valueMap);
    void visit(ValueList valueList);
}

@RequiredArgsConstructor
public class PrinterVisitor implements ThingVisitor
{
    public static final String PASSWORD = "password";

    @Builder
    @Getter
    public static class Options
    {
        @Builder.Default
        private boolean prettyPrint = false;
        @Builder.Default
        private boolean hidePasswords = true;
        @Builder.Default
        private boolean ignoreNulls = true;
        @Builder.Default
        private int maxStringLength = 100;
    }

    private final StringBuilder jsonBuilder = new StringBuilder();
    private int indentLevel = 0;
    private final Options options;

    protected void indent()
    {
        if (options.prettyPrint)
        {
            jsonBuilder.append("  ".repeat(indentLevel));
        }
    }

    protected void newLine()
    {
        if (options.prettyPrint)
        {
            jsonBuilder.append("\n");
        }
    }

    public String getJson()
    {
        return jsonBuilder.toString();
    }

    @Override
    public void visit(Value value)
    {
        if (value.isEmpty())
        {
            jsonBuilder.append("null");
            return;
        }

        String convertedValue = convertProperty(value.getName(), value.getValue());
        String escaped = convertedValue
                .replace("\\", "\\\\")
                .replace("\"", "\\\"")
                .replace("\n", "\\n")
                .replace("\r\n", "\\r\\n")
                .replace("\t", "\\t");
        jsonBuilder.append("\"").append(escaped).append("\"");
    }


    // To override for other special conversions
    protected String convertProperty(String name, Object value)
    {
        if (value == null)
        {
            return "null";
        }

        if (value instanceof byte[] bytes)
        {
            return formatByteArray(bytes);
        }
        else if (value instanceof XMLGregorianCalendar xmlGregorianCalendar)
        {
            return formatXmlGregorianCalendar(xmlGregorianCalendar);
        }
        else if (value instanceof InputStream inputStream)
        {
            return formatInputStream(inputStream);
        }
        else if (value instanceof Date date)
        {
            return LocalDateTimeUtils.format(date);
        }
        else if (value instanceof OffsetDateTime offsetDateTime)
        {
            return LocalDateTimeUtils.format(offsetDateTime);
        }

        // if this is a password
        if (options.hidePasswords && StringUtils.contains(name, PASSWORD))
        {
            return "*** [hidden]";
        }

        // if string is too long
        if (value instanceof String stringValue && stringValue.length() > options.getMaxStringLength())
        {
            return formatLongString(stringValue, options.getMaxStringLength());
        }

        return value.toString();
    }


    protected String formatByteArray(byte[] bytes)
    {
        return MessageFormat.format("byte[] of length: {0}", FileUtils.byteCountToDisplaySize(bytes.length));
    }


    protected String formatXmlGregorianCalendar(XMLGregorianCalendar xmlGregorianCalendar)
    {
        OffsetDateTime offsetDateTime = xmlGregorianCalendar.toGregorianCalendar().getTime().toInstant().atOffset(OffsetDateTime.now().getOffset());
        return offsetDateTime.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME);
    }


    protected String formatLongString(String longString, int maxLength)
    {
        return MessageFormat.format("String of size {0} beginning with: {1}", longString.length(), StringUtils.abbreviate(longString, maxLength));
    }


    protected String formatInputStream(InputStream inputStream)
    {
        try
        {
            return MessageFormat.format("{0} of size {1}", inputStream.getClass().getName(), inputStream.available());
        }
        catch (IOException e)
        {
            return MessageFormat.format("{0} of unknown size", inputStream.getClass().getName());
        }
    }


    @Override
    public void visit(ValueList valueList)
    {
        jsonBuilder.append("[");
        newLine();
        indentLevel++;

        int elementCount = 0;
        for (Thing element : valueList.getElements())
        {
            indent();
            element.accept(this);

            elementCount++;
            if (elementCount < valueList.getElements().size())
            {
                jsonBuilder.append(",");
            }
            newLine();
        }

        indentLevel--;
        indent();
        jsonBuilder.append("]");
    }

    @Override
    public void visit(ValueMap valueMap)
    {
        jsonBuilder.append("{");
        newLine();
        indentLevel++;

        int entryCount = 0;
        List<Map.Entry<String, Thing>> entryList = optionallyFilterNulls(valueMap);
        for (Map.Entry<String, Thing> entry : entryList)
        {
            String key = entry.getKey();
            Thing property = entry.getValue();
            indent();
            jsonBuilder.append("\"").append(key).append("\":");

            property.accept(this);

            entryCount++;
            if (entryCount < entryList.size())
            {
                jsonBuilder.append(",");
            }
            newLine();
        }

        indentLevel--;
        indent();
        jsonBuilder.append("}");
    }


    private List<Map.Entry<String, Thing>> optionallyFilterNulls(ValueMap valueMap)
    {
        if (options.ignoreNulls)
        {
            return valueMap.getProperties().entrySet().stream().filter(i -> !i.getValue().isEmpty()).toList();
        }

        return valueMap.getProperties().entrySet().stream().toList();
    }
}

These classes can be found in spvitamin-core.

Clone this wiki locally