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;
}
}