ShortFilterServiceImpl.java

/*
 * Copyright 2020 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.service.impl;

import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import lombok.extern.slf4j.Slf4j;
import org.genesys.blocks.model.filters.BasicModelFilter;
import org.genesys.blocks.model.filters.SuperModelFilter;
import org.gringlobal.api.exception.NotFoundElement;
import org.gringlobal.model.ShortFilter;
import org.gringlobal.persistence.ShortFilterRepository;
import org.gringlobal.service.ShortFilterService;
import org.hashids.Hashids;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.interceptor.KeyGenerator;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.databind.util.StdDateFormat;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;

/**
 * @author Maxym Borodenko
 */
@Service
@Slf4j
public class ShortFilterServiceImpl implements ShortFilterService, InitializingBean {

	public static final String CODE_PREFIX_V1 = "v1";
	public static final String CODE_PREFIX_V2 = "v2";
	public static final String CODE_PREFIX_CURRENT = CODE_PREFIX_V2;
	
	@Autowired
	private ShortFilterRepository shortFilterRepository;

	private final ObjectMapper mapper;
	private final ObjectMapper strictMapper;
	private final ObjectMapper mapperWithAllFields;

	@Value("${hashids.salt}")
	private String hashidsSalt;

	private Hashids hashids;


	public ShortFilterServiceImpl() {
		final var javaTimeModule = new JavaTimeModule();
		mapper = JsonMapper.builder()
			.addModule(javaTimeModule)
			// ignore Null and empty fields
			.serializationInclusion(JsonInclude.Include.NON_EMPTY)
			// can sort keys but cannot sort values
			.enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY)
			// upgrade to arrays
			.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY)
			// ignore outdated properties
			.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
			// format dates as string
			.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
			.defaultDateFormat(new StdDateFormat().withColonInTimeZone(true))
			.build();

		strictMapper = mapper.copy();
		strictMapper.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
	
		// don't ignore Null fields in this mapper
		mapperWithAllFields = mapper.copy().setSerializationInclusion(JsonInclude.Include.USE_DEFAULTS);
	}

	@Override
	public void afterPropertiesSet() throws Exception {
		// At least 4 chars for HashId
		hashids = new Hashids(this.hashidsSalt, 4);
	}

	@Override
	public <T extends SuperModelFilter<T, ?>> T readFilter(String json, Class<T> clazz) throws IOException {
		return BasicModelFilter.normalize(mapper.readValue(json, clazz));
	}

	@Override
	@SuppressWarnings(value = "unchecked")
	public <T extends SuperModelFilter<T, ?>> String normalizeFilter(final T filter) throws IOException {

		// Defaults
		SuperModelFilter<?, ?> defaultFilter = null;
		Map<String, Object> defaultFilterMap = null;
		try {
			defaultFilter = filter.getClass().getDeclaredConstructor().newInstance();
			defaultFilterMap = (Map<String, Object>) mapper.readValue(mapper.writeValueAsString(defaultFilter), Object.class);
		} catch (InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException | NoSuchMethodException | SecurityException e) {
			log.warn("Class {} cannot be instantiated.", filter.getClass().getSimpleName());
		}

		// get serialized filter without invalid values; with sorted keys but unsorted
		// values
		final String serializedFilter = mapper.writeValueAsString(BasicModelFilter.normalize(filter));

		// we have to deserialize filter object to be able to sort values
		final Object filterObject = mapper.readValue(serializedFilter, Object.class);

		
		if (defaultFilterMap == null || defaultFilterMap.size() == 0) {
			sortFilterValues(filterObject);
			return mapper.writeValueAsString(filterObject);
		}

		// make filter object as a map with keys and values
		final Map<String, Object> filterMap = (Map<String, Object>) filterObject;

		// add default properties with NULL values to current filter
		// if they aren't included after serialization
		defaultFilterMap.keySet().forEach(key -> filterMap.putIfAbsent(key, null));

		// get nice sorted filter
		sortFilterValues(filterMap);
		return mapperWithAllFields.writeValueAsString(filterMap);
	}

	@Override
	public <T extends SuperModelFilter<T, ?>> T normalizeFilter(T filter, Class<T> clazz) throws IOException {
		if (filter == null) {
			try {
				return clazz.getDeclaredConstructor().newInstance();
			} catch (InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException | NoSuchMethodException | SecurityException e) {
				log.warn("Class {} cannot be instantiated.", clazz.getSimpleName());
				throw new RuntimeException("Class cannot be instantiated", e);
			}
		} else {
			return strictMapper.readValue(normalizeFilter(filter), clazz);
		}
	}

	@SuppressWarnings(value = "unchecked")
	private void sortFilterValues(final Object filterObject) {
		if (filterObject == null || !(filterObject instanceof Map)) {
			return;
		}
		final Map<String, Object> map = (Map<String, Object>) filterObject;
		map.forEach((key, value) -> {
			if (value instanceof List) {
				try {
					Collections.sort((List) value);
				} catch (ClassCastException e) {
					// Ignore not Comparable
				}
			} else if (value instanceof Map) {
				sortFilterValues(value);
			}
		});
	}

	@Override
	@Transactional
	public <T extends SuperModelFilter<T, ?>> String getCode(final T filter) throws IOException {
		final String normalizedFilter = normalizeFilter(filter);

		ShortFilter shortFilter = loadByJSON(normalizedFilter);

		if (shortFilter != null) {
			// return existing shortFilter
			return shortFilter.getCode();
		}

		// store new filter
		final String shortName = generateFilterCode();
		shortFilter = shortFilterRepository.save(new ShortFilter(shortName, normalizedFilter));
		return shortFilter.getCode();
	}

	private String generateFilterCode() {
		return generateFilterCodeV2();
	}

//	/**
//	 * Generate short name V1.
//	 *
//	 * @return the shortName
//	 * @deprecated Use {@link #generateFilterCodeV2()}
//	 */
//	@Deprecated
//	private String generateShortNameV1() {
//		for (int i = 0; i < 10; i++) {
//			final String code = CODE_PREFIX_V1.concat(UUID.randomUUID().toString().replace("-", ""));
//			// return shortName if unique
//			if (shortFilterRepository.findByCode(code) == null) {
//				return code;
//			}
//		}
//		throw new RuntimeException("Failed to generate a unique filters short name in several attempts");
//	}

	/**
	 * Generate short name V2.
	 *
	 * @return the shortName
	 */
	private String generateFilterCodeV2() {
		for (int i = 0; i < 10; i++) {
			String hashId = hashids.encode(System.currentTimeMillis());
			final String code = CODE_PREFIX_V2.concat(hashId);
			// return shortName if unique
			if (shortFilterRepository.findByCode(code) == null) {
				return code;
			} else {
				try {
					Thread.sleep(1);
				} catch (InterruptedException e) {
				}
			}
		}
		throw new RuntimeException("Failed to generate a unique filters short name in several attempts");
	}

	@Override
	@Transactional(readOnly = true)
	public ShortFilter loadByCode(final String code) {
		return shortFilterRepository.findByCode(code);
	}

	@Override
	@Transactional(readOnly = true)
	public ShortFilter loadByJSON(final String jsonFilters) {
		return shortFilterRepository.findFirstByJsonAndRedirectIsNullOrderByIdAsc(jsonFilters);
	}

	@Override
	@Transactional(readOnly = true)
	public <T extends SuperModelFilter<T, ?>> T filterByCode(String code, Class<T> clazz) throws IOException {
		ShortFilter shortFilter = shortFilterRepository.findByCode(code == null ? "" : code);

		if (shortFilter == null) {
			throw new NotFoundElement("No filter with " + code);
		}

		// handle redirects
		Set<String> codes = new HashSet<>();
		codes.add(shortFilter.getCode());
		while (shortFilter.getRedirect() != null) {
			if (codes.contains(shortFilter.getRedirect())) {
				log.warn("Cyclic shortFilter redirect for {}", shortFilter.getRedirect());
				return BasicModelFilter.normalize(strictMapper.readValue(shortFilter.getJson(), clazz));
			}
			shortFilter = shortFilterRepository.findByCode(shortFilter.getRedirect());
			codes.add(shortFilter.getCode());
		}

		return BasicModelFilter.normalize(strictMapper.readValue(shortFilter.getJson(), clazz));
	}


	@Override
	@Transactional
	public <T extends SuperModelFilter<T, ?>> FilterInfo<T> processFilter(final String filterCode, final T filter, Class<T> clazz) throws IOException {
		FilterInfo<T> processedFilter = new FilterInfo<>();
		if (filterCode != null) {
			try {
				processedFilter.filter = filterByCode(filterCode, clazz);
				processedFilter.filterCode = getCode(processedFilter.filter);

			} catch (JsonMappingException e) {
				log.warn("Stored filter does not match target {}", clazz);

				ShortFilter shortFilter = shortFilterRepository.findByCode(filterCode);
				// Reads recognized properties
				T updatedFilter = mapper.readValue(shortFilter.getJson(), clazz);

				processedFilter.filter = updatedFilter;
				processedFilter.filterCode = getCode(updatedFilter);
			}
		} else {
			processedFilter.filter = filter;
			processedFilter.filterCode = getCode(filter);
		}
		return processedFilter;
	}

	/**
	 * A Spring Cache key generator
	 */
	public static class KeyGen implements KeyGenerator, InitializingBean {
		@Autowired(required = false)
		private ShortFilterService sfs;

		@Override
		public void afterPropertiesSet() throws Exception {
			if (sfs == null) {
				log.error("ShortFilterService not set for cache key generator. This is not going to end well.");
			}
		}

		@Override
		public Object generate(@NotNull Object target, @NotNull Method method, @NotNull Object... params) {
			if (sfs == null) {
				throw new RuntimeException("ShortFilterService not set for cache key generator");
			}

			StringBuilder sb = new StringBuilder();
			for (Object p : params) {
				if (p instanceof BasicModelFilter) {
					BasicModelFilter filter = (BasicModelFilter) p;
					try {
						sb.append(sfs.getCode(filter));
					} catch (IOException e) {
						throw new RuntimeException(e);
					}
				} else if (p instanceof Pageable) {
					Pageable page = (Pageable) p;
					sb.append("-").append(page.getOffset());
					Sort sort = page.getSort();
					sort.forEach(s -> {
						sb.append("-").append(s.getProperty()).append("-").append(s.getDirection().ordinal());
					});
				}
			}
			// if (sb.length() > 0) {
			// System.err.println("Cachekey: " + sb);
			// }
			return sb.length() > 0 ? sb : null;
		}
	}

	/* (non-Javadoc)
	 * @see org.genesys.catalog.service.ShortFilterService#upgradeFilterCode(org.genesys.catalog.model.ShortFilter)
	 */
	@Override
	@Transactional
	public ShortFilter upgradeFilterCode(ShortFilter shortFilter) {
		assert(shortFilter.getJson() != null);
		assert(shortFilter.getCode() != null);
		
		// If filter code is current, ignore. 
		if (shortFilter.getCode().startsWith(CODE_PREFIX_CURRENT)) {
			return shortFilter;
		}
		
		// If there's a filter with CODE_PREFIX_CURRENT for the same JSON, ignore
		List<ShortFilter> existingShortFilters = shortFilterRepository.findByJson(shortFilter.getJson());
		ShortFilter filterWithCurrentCode = null;
		int needUpgrade = 0;
		for (ShortFilter existing: existingShortFilters) {
			if (existing.getCode().startsWith(CODE_PREFIX_CURRENT) && existing.getRedirect() == null) {
				filterWithCurrentCode = existing;
			} else {
				needUpgrade++;
			}
		}

		if (needUpgrade == 0) {
			return filterWithCurrentCode;
		}

		ShortFilter newFilter = filterWithCurrentCode != null ? filterWithCurrentCode : shortFilterRepository.save(new ShortFilter(generateFilterCode(), shortFilter.getJson()));

		for (ShortFilter existing: existingShortFilters) {
			if (! newFilter.getCode().equalsIgnoreCase(existing.getCode())) {
				existing.setRedirect(newFilter.getCode());
				shortFilterRepository.save(existing);
			}
		}

		return newFilter;
	}
}