JsonToFlatMapConverter.java

/*
 * Copyright 2019 Global Crop Diversity Trust
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.gringlobal.spring;

import java.lang.reflect.Field;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.BooleanNode;
import com.fasterxml.jackson.databind.node.MissingNode;
import com.fasterxml.jackson.databind.node.NullNode;
import com.fasterxml.jackson.databind.node.NumericNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.ValueNode;
import org.springframework.util.ReflectionUtils;

/**
 * Converts a JSON to a map of keys (full JSON paths) and values
 *
 * @author Maxym Borodenko
 * @author Matija Obreza
 */
@Slf4j
public class JsonToFlatMapConverter {

	private final Map<String, Object> map = new LinkedHashMap<>();
	private final Set<String> initialPathsOfArrays = new HashSet<>();
	private final List<String> excludedFields;
	private final Map<String, Map<Number, ObjectNode>> cachedObjects;

	private JsonToFlatMapConverter(List<String> excludedFields, Map<String, Map<Number, ObjectNode>> cachedObjects) {
		this.excludedFields = excludedFields;
		this.cachedObjects = cachedObjects;
	}

	public Set<String> getInitialPathsOfArrays() {
		return this.initialPathsOfArrays;
	}

	public Map<String, Object> getMap() {
		return this.map;
	}

	public static JsonToFlatMapConverter fromJson(Class<?> clazz, final JsonNode jsonNode, List<String> include, List<String> exclude, Map<String, Map<Number, ObjectNode>> cachedObjects) {
		JsonToFlatMapConverter jtm = new JsonToFlatMapConverter(exclude, cachedObjects);
		if (jsonNode == null) {
			return jtm;
		}

		if (include.isEmpty()) {
			jtm.readObject(jsonNode, "", clazz);
		} else {
			for (String field : include) {
				String point = Paths.get("/", field.split("\\.")).toString();
				JsonNode node = jsonNode.at(point);
				if (!node.isMissingNode()) {
					// Find type of property at node point
					Class<?> objectClazz = clazz;
					for (var prop : field.split("\\.")) {
						Field objectField = null;
						if (objectClazz != null) {
							objectField = ReflectionUtils.findField(objectClazz, prop);
						}
						if (objectField == null) {
							log.trace("Could not find field {} in {} for path={}", prop, objectClazz, field);
							objectClazz = null;
							break;
						}
						objectClazz = objectField.getType();
					}
					// Read node value
					jtm.readValue(node, field, objectClazz);
				}
			}
		}
		return jtm;
	}

	private void readObject(final JsonNode object, final String jsonPath, Class<?> clazz) {
		if (object == null) {
			log.trace("Object at path={} is null.", jsonPath);
			return;
		}
		Iterator<String> keysItr = object.fieldNames();
		while (keysItr.hasNext()) {
			String key = keysItr.next();

			String parentPath = StringUtils.isEmpty(jsonPath) ? key : jsonPath + "." + key;

			// MAYBE use cache for excluded paths instead of .anyMatch
			if (CollectionUtils.isNotEmpty(this.excludedFields) && this.excludedFields.stream().anyMatch(parentPath::startsWith))
				continue;

			Field objectField = null;
			if (clazz != null) {
				objectField = ReflectionUtils.findField(clazz, key);
			}
			readValue(object.get(key), parentPath, objectField == null ? null : objectField.getType());
		}
	}

	private void readArray(final ArrayNode array, final String jsonPath) {
		this.initialPathsOfArrays.add(jsonPath);
		for (int i = 0; i < array.size(); i++) {
			readValue(array.get(i), jsonPath + (i + 1), null);
		}
	}

	private void readValue(final Object value, final String jsonPath, Class<?> clazz) {
		if (value instanceof ArrayNode) {
			// TODO find type of element in collection
			readArray((ArrayNode) value, jsonPath);
		} else if (value instanceof ObjectNode) {
			cacheAndReadObject((ObjectNode) value, jsonPath, clazz);
		} else if (value instanceof ValueNode) {
			ValueNode valueNode = (ValueNode) value;
			if (valueNode == null || valueNode instanceof NullNode || valueNode instanceof MissingNode) {

			} else if (valueNode instanceof NumericNode) {
				readCachedObjectOrPutNumber((NumericNode)valueNode, jsonPath, clazz);
			} else if (valueNode instanceof BooleanNode) {
				this.map.put(jsonPath, valueNode.booleanValue() ? 1 : 0);
			} else {
				this.map.put(jsonPath, valueNode.asText());
			}
		}
	}

	private void readCachedObjectOrPutNumber(NumericNode value, String jsonPath, Class<?> clazz) {
		var cacheName = clazz == null ? jsonPath : clazz.getName();
		var cache = this.cachedObjects.get(cacheName);
		if (cache != null) {
			log.trace("Looking up cached object for path={} type={} key={}", jsonPath, clazz, value);
			ObjectNode cachedObject = this.cachedObjects.get(cacheName).get(value.numberValue());
			readObject(cachedObject, jsonPath, clazz);
		} else {
			this.map.put(jsonPath, value.numberValue());
		}
	}

	private void cacheAndReadObject(final ObjectNode object, final String jsonPath, Class<?> clazz) {
		if (object.get("id") != null && object.get("id").isNumber()) {
			log.trace("Caching object path={} type={} obj={}", jsonPath, clazz, object);
			var cacheName = clazz == null ? jsonPath : clazz.getName();
			var cache = this.cachedObjects.get(cacheName);
			if (cache != null) {
				cache.put(object.get("id").numberValue(), object);
			} else {
				Map<Number, ObjectNode> objectsByPath = new HashMap<>();
				objectsByPath.put(object.get("id").numberValue(), object);
				this.cachedObjects.put(cacheName, objectsByPath);
			}
		}
		readObject(object, jsonPath, clazz);
	}

}