TablesJacksonSerializer.java

// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package com.azure.data.tables.implementation;

import com.azure.core.util.logging.ClientLogger;
import com.azure.core.util.serializer.JacksonAdapter;
import com.azure.core.util.serializer.SerializerEncoding;
import com.azure.data.tables.implementation.models.TableEntityQueryResponse;
import com.fasterxml.jackson.databind.JsonNode;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

/**
 * Serializer for Tables responses.
 */
public class TablesJacksonSerializer extends JacksonAdapter {
    private final ClientLogger logger = new ClientLogger(TablesJacksonSerializer.class);

    @Override
    public void serialize(Object object, SerializerEncoding encoding, OutputStream outputStream) throws IOException {
        outputStream.write(serializeToBytes(object, encoding));
    }

    @Override
    public String serialize(Object object, SerializerEncoding encoding) throws IOException {
        return new String(serializeToBytes(object, encoding), StandardCharsets.UTF_8);
    }

    @Override
    public byte[] serializeToBytes(Object object, SerializerEncoding encoding) throws IOException {
        if (object instanceof Map) {
            return super.serializeToBytes(insertTypeProperties(object), encoding);
        } else {
            return super.serializeToBytes(object, encoding);
        }
    }

    @SuppressWarnings("unchecked")
    private Map<String, Object> insertTypeProperties(Object o) {
        Map<String, Object> map = (Map<String, Object>) o;
        Map<String, Object> result = new HashMap<>();

        for (Map.Entry<String, Object> entry : map.entrySet()) {
            String propertyName = entry.getKey();
            Object propertyValue = entry.getValue();

            // Skip entries with null values
            if (propertyValue == null) {
                continue;
            }

            if (propertyValue instanceof Long) {
                // Long values must be represented as a JSON string with a type annotation
                result.put(propertyName, String.valueOf(propertyValue));
            } else {
                result.put(propertyName, propertyValue);
            }

            if (TablesConstants.METADATA_KEYS.contains(propertyName)
                || propertyName.endsWith(TablesConstants.ODATA_TYPE_KEY_SUFFIX)) {
                continue;
            }

            EntityDataModelType typeToTag = EntityDataModelType.forClass(propertyValue.getClass());
            if (typeToTag == null) {
                continue;
            }

            // Use putIfAbsent to avoid overwriting a user's custom OData type annotation
            result.putIfAbsent(propertyName + TablesConstants.ODATA_TYPE_KEY_SUFFIX, typeToTag.getEdmType());
        }

        return result;
    }

    @Override
    public <U> U deserialize(String value, Type type, SerializerEncoding serializerEncoding) throws IOException {
        return deserialize(new ByteArrayInputStream(value.getBytes(StandardCharsets.UTF_8)), type, serializerEncoding);
    }

    @SuppressWarnings("unchecked")
    @Override
    public <U> U deserialize(InputStream inputStream, Type type, SerializerEncoding serializerEncoding)
        throws IOException {
        if (inputStream != null && type == TableEntityQueryResponse.class) {
            return deserializeTableEntityQueryResponse(super.serializer().readTree(inputStream));
        } else if (inputStream != null && shouldGetEntityFieldsAsMap(type)) {
            return (U) getEntityFieldsAsMap(super.serializer().readTree(inputStream));
        } else {
            return super.deserialize(inputStream, type, serializerEncoding);
        }
    }

    @Override
    public <U> U deserialize(byte[] bytes, Type type, SerializerEncoding encoding) throws IOException {
        if (bytes == null || bytes.length == 0) {
            return super.deserialize(bytes, type, encoding);
        } else {
            return deserialize(new ByteArrayInputStream(bytes), type, encoding);
        }
    }

    private static boolean shouldGetEntityFieldsAsMap(Type type) {
        return type instanceof ParameterizedType
            && ((ParameterizedType) type).getRawType() == Map.class;
    }

    @SuppressWarnings("unchecked")
    private <U> U deserializeTableEntityQueryResponse(JsonNode node) throws IOException {
        String odataMetadata = null;
        List<Map<String, Object>> values = new ArrayList<>();

        for (Iterator<Map.Entry<String, JsonNode>> it = node.fields(); it.hasNext();) {
            final Map.Entry<String, JsonNode> entry = it.next();
            final String fieldName = entry.getKey();
            final JsonNode childNode = entry.getValue();

            if (fieldName.equals(TablesConstants.ODATA_METADATA_KEY)) {
                odataMetadata = childNode.asText();
            } else if ("value".equals(fieldName) && childNode.isArray()) {
                // This is a multiple-entity response.
                for (JsonNode childEntry : childNode) {
                    values.add(getEntityFieldsAsMap(childEntry));
                }
            } else {
                // This is not a multiple-entity response.
                throw logger.logExceptionAsError(new IllegalStateException(
                    "Unexpected response format. Response containing a 'value' array must not contain other properties."
                ));
            }
        }

        return (U) new TableEntityQueryResponse()
            .setOdataMetadata(odataMetadata)
            .setValue(values);
    }

    private Map<String, Object> getEntityFieldsAsMap(JsonNode node) throws IOException {
        Map<String, Object> result = new HashMap<>();

        for (Iterator<String> it = node.fieldNames(); it.hasNext();) {
            String fieldName = it.next();

            if (!fieldName.equals(TablesConstants.ODATA_METADATA_KEY)) {
                result.put(fieldName, getEntityFieldAsObject(node, fieldName));
            }
        }

        return result;
    }

    private Object getEntityFieldAsObject(JsonNode parentNode, String fieldName) throws IOException {
        JsonNode valueNode = parentNode.get(fieldName);
        if (TablesConstants.TIMESTAMP_KEY.equals(fieldName)) {
            return EntityDataModelType.DATE_TIME.deserialize(valueNode.asText());
        }

        if (TablesConstants.METADATA_KEYS.contains(fieldName)
            || fieldName.endsWith(TablesConstants.ODATA_TYPE_KEY_SUFFIX)) {
            return serializer().treeToValue(valueNode, Object.class);
        }

        JsonNode typeNode = parentNode.get(fieldName + TablesConstants.ODATA_TYPE_KEY_SUFFIX);
        if (typeNode == null) {
            return serializer().treeToValue(valueNode, Object.class);
        }

        String typeString = typeNode.asText();
        EntityDataModelType type = EntityDataModelType.fromString(typeString);
        if (type == null) {
            logger.warning("'{}' value has unknown OData type {}", fieldName, typeString);
            return serializer().treeToValue(valueNode, Object.class);
        }

        try {
            return type.deserialize(valueNode.asText());
        } catch (Exception e) {
            throw logger.logExceptionAsError(new IllegalArgumentException(String.format(
                "'%s' value is not a valid %s.", fieldName, type.getEdmType()), e));
        }
    }
}