ToggleService.java
package io.featureprobe.api.service;
import io.featureprobe.api.auth.TokenHelper;
import io.featureprobe.api.base.component.SpringBeanManager;
import io.featureprobe.api.base.db.Archived;
import io.featureprobe.api.base.enums.ChangeLogType;
import io.featureprobe.api.base.enums.ResourceType;
import io.featureprobe.api.base.enums.SketchStatusEnum;
import io.featureprobe.api.base.enums.ToggleReleaseStatusEnum;
import io.featureprobe.api.base.enums.VisitFilter;
import io.featureprobe.api.config.AppConfig;
import io.featureprobe.api.dao.entity.Environment;
import io.featureprobe.api.dao.entity.TargetingVersion;
import io.featureprobe.api.dao.entity.TrafficCache;
import io.featureprobe.api.dao.entity.Project;
import io.featureprobe.api.dao.entity.Tag;
import io.featureprobe.api.dao.entity.Targeting;
import io.featureprobe.api.dao.entity.TargetingSketch;
import io.featureprobe.api.dao.entity.Toggle;
import io.featureprobe.api.dao.entity.ToggleTagRelation;
import io.featureprobe.api.dao.exception.ResourceConflictException;
import io.featureprobe.api.dao.exception.ResourceNotFoundException;
import io.featureprobe.api.dao.exception.ResourceOverflowException;
import io.featureprobe.api.base.enums.TrafficCacheTypeEnum;
import io.featureprobe.api.dao.repository.TargetingVersionRepository;
import io.featureprobe.api.mapper.ToggleMapper;
import io.featureprobe.api.dao.repository.EnvironmentRepository;
import io.featureprobe.api.dao.repository.TrafficRepository;
import io.featureprobe.api.dao.repository.TrafficCacheRepository;
import io.featureprobe.api.dao.repository.ProjectRepository;
import io.featureprobe.api.dao.repository.TagRepository;
import io.featureprobe.api.dao.repository.TargetingRepository;
import io.featureprobe.api.dao.repository.TargetingSketchRepository;
import io.featureprobe.api.dao.repository.ToggleRepository;
import io.featureprobe.api.dao.repository.ToggleTagRepository;
import io.featureprobe.api.dao.utils.PageRequestUtil;
import io.featureprobe.api.dto.ToggleCreateRequest;
import io.featureprobe.api.dto.ToggleItemResponse;
import io.featureprobe.api.dto.ToggleResponse;
import io.featureprobe.api.dto.ToggleSearchRequest;
import io.featureprobe.api.dto.ToggleUpdateRequest;
import com.featureprobe.sdk.server.FPUser;
import com.featureprobe.sdk.server.FeatureProbe;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.persistence.criteria.Predicate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.Function;
import java.util.stream.Collectors;
@Slf4j
@Service
@AllArgsConstructor
public class ToggleService {
private AppConfig appConfig;
private ToggleRepository toggleRepository;
private TagRepository tagRepository;
private TargetingRepository targetingRepository;
private EnvironmentRepository environmentRepository;
private TrafficRepository trafficRepository;
private TargetingSketchRepository targetingSketchRepository;
private TrafficCacheRepository trafficCacheRepository;
private ToggleTagRepository toggleTagRepository;
private ChangeLogService changeLogService;
private ProjectRepository projectRepository;
private TargetingService targetingService;
private TargetingVersionRepository targetingVersionRepository;
@PersistenceContext
public EntityManager entityManager;
private static final String LIMITER_TOGGLE_KEY = "FeatureProbe_toggle_limiter";
@Transactional(rollbackFor = Exception.class)
public ToggleResponse create(String projectKey, ToggleCreateRequest createRequest) {
validateLimit(projectKey);
Toggle toggle = createToggle(projectKey, createRequest);
targetingService.createDefaultTargetingEntities(projectKey, toggle);
return ToggleMapper.INSTANCE.entityToResponse(toggle, appConfig.getToggleDeadline());
}
protected Toggle createToggle(String projectKey, ToggleCreateRequest createRequest) {
Toggle toggle = ToggleMapper.INSTANCE.requestToEntity(createRequest);
toggle.setProjectKey(projectKey);
setToggleTagRefs(toggle, createRequest.getTags());
return toggleRepository.save(toggle);
}
@Transactional(rollbackFor = Exception.class)
public ToggleResponse update(String projectKey, String toggleKey, ToggleUpdateRequest updateRequest) {
Toggle toggle = toggleRepository.findByProjectKeyAndKey(projectKey, toggleKey).orElseThrow(() ->
new ResourceNotFoundException(ResourceType.TOGGLE, toggleKey));
if (StringUtils.isNotBlank(updateRequest.getName()) &&
!StringUtils.equals(toggle.getName(), updateRequest.getName())) {
validateName(projectKey, updateRequest.getName());
}
ToggleMapper.INSTANCE.mapEntity(updateRequest, toggle);
if (CollectionUtils.isNotEmpty(updateRequest.getTags())) {
setToggleTagRefs(toggle, updateRequest.getTags());
}
toggleRepository.save(toggle);
return ToggleMapper.INSTANCE.entityToResponse(toggle, appConfig.getToggleDeadline());
}
@Transactional(rollbackFor = Exception.class)
@Archived
public ToggleResponse offline(String projectKey, String toggleKey) {
Project project = projectRepository.findByKey(projectKey).orElseThrow(() ->
new ResourceNotFoundException(ResourceType.PROJECT, projectKey));
Toggle toggle = toggleRepository.findByProjectKeyAndKeyAndArchived(projectKey, toggleKey, false)
.orElseThrow(() -> new ResourceNotFoundException(ResourceType.TOGGLE, toggleKey));
for (Environment environment : project.getEnvironments()) {
changeLogService.create(environment, ChangeLogType.CHANGE);
}
toggle.setArchived(true);
return ToggleMapper.INSTANCE.entityToResponse(toggleRepository.save(toggle), appConfig.getToggleDeadline());
}
@Transactional(rollbackFor = Exception.class)
@Archived
public ToggleResponse restore(String projectKey, String toggleKey) {
Project project = projectRepository.findByKey(projectKey).orElseThrow(() ->
new ResourceNotFoundException(ResourceType.PROJECT, projectKey));
Toggle toggle = toggleRepository.findByProjectKeyAndKeyAndArchived(projectKey, toggleKey, true)
.orElseThrow(() -> new ResourceNotFoundException(ResourceType.TOGGLE, toggleKey));
for (Environment environment : project.getEnvironments()) {
changeLogService.create(environment, ChangeLogType.CHANGE);
}
toggle.setArchived(false);
return ToggleMapper.INSTANCE.entityToResponse(toggleRepository.save(toggle), appConfig.getToggleDeadline());
}
@Archived
public Page<ToggleItemResponse> list(String projectKey, ToggleSearchRequest searchRequest) {
Page<Toggle> togglePage;
if (StringUtils.isNotBlank(searchRequest.getEnvironmentKey())) {
Environment environment = environmentRepository
.findByProjectKeyAndKeyAndArchived(projectKey, searchRequest.getEnvironmentKey(), false)
.orElseThrow(() ->
new ResourceNotFoundException(ResourceType.ENVIRONMENT, searchRequest.getEnvironmentKey()));
Set<String> toggleKeys = new TreeSet<>();
boolean isPrecondition = false;
if (Objects.nonNull(searchRequest.getDisabled())) {
isPrecondition = true;
Set<String> keys = queryToggleKeysByDisabled(projectKey, searchRequest.getEnvironmentKey(),
searchRequest.getDisabled());
retainAllKeys(toggleKeys, keys);
}
if (CollectionUtils.isNotEmpty(searchRequest.getTags())) {
isPrecondition = true;
Set<String> keys = queryToggleKeysByTags(searchRequest.getTags());
retainAllKeys(toggleKeys, keys);
}
if (Objects.nonNull(searchRequest.getVisitFilter())) {
isPrecondition = true;
Set<String> keys = queryToggleKeysByVisited(searchRequest.getVisitFilter(), projectKey, environment);
retainAllKeys(toggleKeys, keys);
}
if (CollectionUtils.isNotEmpty(searchRequest.getReleaseStatusList())) {
isPrecondition = true;
Set<String> keys = queryToggleKeysByReleaseStatus(projectKey, searchRequest.getEnvironmentKey(),
searchRequest.getReleaseStatusList());
retainAllKeys(toggleKeys, keys);
}
if (BooleanUtils.isTrue(searchRequest.getRelated())) {
isPrecondition = true;
Set<String> keys = queryToggleKeysByRelatedToMe(projectKey, environment.getKey());
retainAllKeys(toggleKeys, keys);
}
togglePage = compoundQuery(projectKey, searchRequest, toggleKeys, isPrecondition);
Set<String> keys = togglePage.getContent().stream().map(Toggle::getKey).collect(Collectors.toSet());
Map<String, Targeting> targetingMap = queryTargetingMap(projectKey, searchRequest.getEnvironmentKey(),
keys);
Map<String, TargetingSketch> targetingSketchMap = queryNewestTargetingSketchMap(projectKey,
searchRequest.getEnvironmentKey(), keys);
Map<String, TrafficCache> metricsCacheMap = queryTrafficCacheMap(projectKey,
searchRequest.getEnvironmentKey(), keys);
Map<String, Set<String>> tagMap = queryTagMap(keys);
return togglePage.map(item ->
entityToItemResponse(item, projectKey, searchRequest.getEnvironmentKey(),
targetingMap, targetingSketchMap, metricsCacheMap, tagMap));
} else {
togglePage = toggleRepository.findAllByProjectKeyAndArchived(projectKey, false,
PageRequestUtil.toPageable(searchRequest, Sort.Direction.DESC, "createdTime"));
return togglePage.map(item -> ToggleMapper.INSTANCE.entityToItemResponse(item,
appConfig.getToggleDeadline()));
}
}
protected Set<String> queryToggleKeysByRelatedToMe(String projectKey, String environmentKey) {
Specification specification = getRelatedToMeToggleKeySpecification(projectKey,
environmentKey);
Set<String> toggleKeys = targetingVersionRepository.findAll((Specification<TargetingVersion>) specification)
.stream()
.map(TargetingVersion::getToggleKey)
.collect(Collectors.toSet());
Specification<Toggle> withoutEnvironmentKeySpec = getRelatedToMeToggleKeySpecification(projectKey, null);
toggleKeys.addAll(
toggleRepository.findAll(withoutEnvironmentKeySpec).stream()
.map(Toggle::getKey)
.collect(Collectors.toSet()));
toggleKeys.addAll(targetingRepository.findAll((Specification<Targeting>) specification)
.stream()
.map(Targeting::getToggleKey)
.collect(Collectors.toSet()));
return toggleKeys;
}
private Specification getRelatedToMeToggleKeySpecification(String projectKey, String environmentKey) {
return (root, query, cb) -> {
Predicate p1 = cb.equal(root.get("projectKey"), projectKey);
if (StringUtils.isNotBlank(environmentKey)) {
Predicate p2 = cb.equal(root.get("environmentKey"), environmentKey);
cb.and(p1, p2);
} else {
cb.and(p1);
}
Predicate p3 = cb.equal(root.get("modifiedBy"), TokenHelper.getUserId());
Predicate p4 = cb.equal(root.get("createdBy"), TokenHelper.getUserId());
return query.where(cb.and(cb.or(p3, p4))).getRestriction();
};
}
private void validateLimit(String projectKey) {
long total = toggleRepository.countByProjectKey(projectKey);
FPUser user = new FPUser(String.valueOf(TokenHelper.getUserId()));
user.with("account", TokenHelper.getAccount());
double limitNum = SpringBeanManager.getBeanByType(FeatureProbe.class)
.numberValue(LIMITER_TOGGLE_KEY, user, -1);
if (limitNum > 0 && total >= limitNum) {
throw new ResourceOverflowException(ResourceType.TOGGLE);
}
}
private void validateName(String projectKey, String name) {
if (toggleRepository.existsByProjectKeyAndName(projectKey, name)) {
throw new ResourceConflictException(ResourceType.TOGGLE);
}
}
private void setToggleTagRefs(Toggle toggle, List<String> tagNames) {
Set<Tag> tags = tagRepository.findByProjectKeyAndNameIn(toggle.getProjectKey(), tagNames);
toggle.setTags(tags);
}
private Map<String, Set<String>> queryTagMap(Set<String> toggleKeys) {
List<ToggleTagRelation> toggleTags = toggleTagRepository.findByToggleKeyIn(toggleKeys);
Set<Long> tagIds = toggleTags.stream().map(ToggleTagRelation::getTagId).collect(Collectors.toSet());
List<Tag> tags = tagRepository.findAllById(tagIds);
Map<Long, String> tagMap = tags.stream().collect(Collectors.toMap(Tag::getId, Tag::getName));
Map<String, Set<String>> res = new HashMap<>(10);
toggleTags.stream().forEach(toggleTag -> {
if (res.containsKey(toggleTag.getToggleKey())) {
res.get(toggleTag.getToggleKey()).add(tagMap.get(toggleTag.getTagId()));
} else {
res.put(toggleTag.getToggleKey(), new HashSet<>(
Collections.singletonList(tagMap.get(toggleTag.getTagId()))));
}
});
return res;
}
private Map<String, TargetingSketch> queryNewestTargetingSketchMap(String projectKey, String environmentKey,
Set<String> toggleKeys) {
List<TargetingSketch> targetingSketches = targetingSketchRepository
.findByProjectKeyAndEnvironmentKeyAndStatusAndToggleKeyIn(projectKey, environmentKey,
SketchStatusEnum.PENDING, toggleKeys);
return targetingSketches.stream().collect(
Collectors.toMap(TargetingSketch::uniqueKey, Function.identity(), (x, y) -> x));
}
private Map<String, Targeting> queryTargetingMap(String projectKey, String environmentKey,
Set<String> toggleKeys) {
List<Targeting> targetingList = targetingRepository.findByProjectKeyAndEnvironmentKeyAndToggleKeyIn(projectKey,
environmentKey, toggleKeys);
return targetingList.stream().collect(Collectors.toMap(Targeting::uniqueKey, Function.identity(), (x, y) -> x));
}
private Page<Toggle> compoundQuery(String projectKey, ToggleSearchRequest searchRequest, Set<String> toggleKeys,
boolean isPrecondition) {
Specification<Toggle> resultSpec = (root, query, cb) -> {
List<Predicate> predicateListAnd = new ArrayList<>();
List<Predicate> predicateListOr = new ArrayList<>();
predicateListAnd.add(cb.equal(root.get("projectKey"), projectKey));
if (searchRequest.isArchived()) {
predicateListAnd.add(cb.equal(root.get("archived"), Boolean.TRUE));
} else {
predicateListAnd.add(cb.equal(root.get("archived"), Boolean.FALSE));
}
if (StringUtils.isNotBlank(searchRequest.getKeyword())) {
predicateListOr.add(cb.like(root.get("name"), "%" + searchRequest.getKeyword() + "%"));
predicateListOr.add(cb.like(root.get("key"), "%" + searchRequest.getKeyword() + "%"));
predicateListOr.add(cb.like(root.get("desc"), "%" + searchRequest.getKeyword() + "%"));
}
if (isPrecondition) {
predicateListAnd.add(root.get("key").in(toggleKeys));
}
if (!Objects.isNull(searchRequest.getPermanent())) {
predicateListAnd.add(cb.equal(root.get("permanent"), searchRequest.getPermanent()));
}
if (predicateListOr.size() > 0) {
return query.where(cb.and(predicateListAnd.toArray(new Predicate[predicateListAnd.size()])),
cb.or(predicateListOr.toArray(new Predicate[predicateListOr.size()]))).getRestriction();
}
return query.where(cb.and(predicateListAnd.toArray(new Predicate[predicateListAnd.size()])))
.getRestriction();
};
Pageable pageable = PageRequest.of(searchRequest.getPageIndex(), searchRequest.getPageSize(),
Sort.Direction.DESC, "createdTime");
return toggleRepository.findAll(resultSpec, pageable);
}
private void retainAllKeys(Set<String> keys, Set<String> targets) {
if (keys.isEmpty()) {
keys.addAll(targets);
} else {
keys.retainAll(targets);
}
}
private Set<String> queryToggleKeysByDisabled(String projectKey, String environmentKey, Boolean disabled) {
List<Targeting> targetingList = targetingRepository.findAllByProjectKeyAndEnvironmentKeyAndDisabled(projectKey,
environmentKey, disabled);
return targetingList.stream().map(Targeting::getToggleKey).collect(Collectors.toSet());
}
private Set<String> queryToggleKeysByReleaseStatus(String projectKey, String environmentKey,
List<ToggleReleaseStatusEnum> statusList) {
List<Targeting> targetingList = targetingRepository.findAllByProjectKeyAndEnvironmentKeyAndStatusIn(projectKey,
environmentKey, statusList);
return targetingList.stream().map(Targeting::getToggleKey).collect(Collectors.toSet());
}
private Set<String> queryToggleKeysByTags(List<String> tagNames) {
List<Tag> tags = tagRepository.findByNameIn(tagNames);
Set<Long> tagIds = tags.stream().map(Tag::getId).collect(Collectors.toSet());
List<ToggleTagRelation> toggleTags = toggleTagRepository.findByTagIdIn(tagIds);
return toggleTags.stream().map(ToggleTagRelation::getToggleKey).collect(Collectors.toSet());
}
private Set<String> queryToggleKeysByVisited(VisitFilter visitFilter, String projectKey, Environment environment) {
switch (visitFilter) {
case IN_WEEK_VISITED:
return weekVisitedToggleKeys(environment);
case OUT_WEEK_VISITED:
return lastVisitBeforeWeekToggleKeys(environment);
case NOT_VISITED:
return neverVisited(projectKey, environment);
default:
return new HashSet();
}
}
private Set<String> allVisitedToggleKeys(Environment environment) {
return trafficRepository.findAllAccessedToggleKey(environment.getServerSdkKey(), environment.getClientSdkKey());
}
private Set<String> weekVisitedToggleKeys(Environment environment) {
Date lastWeek = Date.from(LocalDateTime.now().minusDays(7).atZone(ZoneId.systemDefault()).toInstant());
return trafficRepository.findAllAccessedToggleKeyGreaterThanOrEqualToEndDate(
environment.getServerSdkKey(), environment.getClientSdkKey(), lastWeek);
}
private Set<String> lastVisitBeforeWeekToggleKeys(Environment environment) {
Set<String> allVisitedKeys = allVisitedToggleKeys(environment);
Set<String> weekVisitedKeys = weekVisitedToggleKeys(environment);
allVisitedKeys.removeAll(weekVisitedKeys);
return allVisitedKeys;
}
private Set<String> neverVisited(String projectKey, Environment environment) {
Set<String> allVisitedKeys = allVisitedToggleKeys(environment);
Specification<Toggle> notSpec = (root, query, cb) -> {
Predicate p1 = cb.equal(root.get("projectKey"), projectKey);
Predicate p2 = root.get("key").in(allVisitedKeys).not();
return query.where(cb.and(p1, p2)).getRestriction();
};
List<Toggle> toggles = toggleRepository.findAll(notSpec);
return toggles.stream().map(Toggle::getKey).collect(Collectors.toSet());
}
private ToggleItemResponse entityToItemResponse(Toggle toggle, String projectKey, String environmentKey,
Map<String, Targeting> targetingMap,
Map<String, TargetingSketch> targetingSketchMap,
Map<String, TrafficCache> metricsCacheMap,
Map<String, Set<String>> tagMap) {
ToggleItemResponse toggleItem = ToggleMapper.INSTANCE.entityToItemResponse(toggle,
appConfig.getToggleDeadline());
toggleItem.setTags(tagMap.get(toggle.getKey()));
toggleItem.setDisabled(targetingMap.get(uniqueKey(projectKey, environmentKey, toggle.getKey())).isDisabled());
toggleItem.setReleaseStatus(targetingMap.get(uniqueKey(projectKey, environmentKey, toggle.getKey()))
.getStatus());
if (ObjectUtils.isNotEmpty(metricsCacheMap.get(toggle.getKey()))) {
toggleItem.setVisitedTime(metricsCacheMap.get(toggle.getKey()).getStartDate());
}
TargetingSketch sketch = targetingSketchMap.get(uniqueKey(projectKey, environmentKey, toggle.getKey()));
if (ObjectUtils.isNotEmpty(sketch)) {
toggleItem.setLocked(locked(sketch));
toggleItem.setLockedBy(sketch.getCreatedBy().getAccount());
toggleItem.setLockedTime(sketch.getCreatedTime());
}
return toggleItem;
}
private String uniqueKey(String projectKey, String environmentKey, String toggleKey) {
return projectKey + "&" + environmentKey + "&" + toggleKey;
}
private boolean locked(TargetingSketch targetingSketch) {
return targetingSketch.getStatus() == SketchStatusEnum.PENDING;
}
private Map<String, TrafficCache> queryTrafficCacheMap(String projectKey, String environmentKey,
Set<String> toggleKeys) {
Environment environment = environmentRepository.findByProjectKeyAndKey(projectKey, environmentKey).get();
Specification<TrafficCache> spec = (root, query, cb) -> {
Predicate p0 = root.get("toggleKey").in(toggleKeys);
Predicate p1 = cb.equal(root.get("sdkKey"), environment.getServerSdkKey());
Predicate p2 = cb.equal(root.get("sdkKey"), environment.getClientSdkKey());
Predicate p3 = cb.equal(root.get("type"), TrafficCacheTypeEnum.EVALUATION);
return query.where(cb.and(p0, p3), cb.or(p1, p2)).getRestriction();
};
List<TrafficCache> trafficCaches = trafficCacheRepository.findAll(spec);
return trafficCaches.stream().collect(Collectors.toMap(TrafficCache::getToggleKey, Function.identity()));
}
@Archived
public ToggleResponse queryByKey(String projectKey, String toggleKey) {
Toggle toggle = toggleRepository.findByProjectKeyAndKey(projectKey, toggleKey).orElseThrow(() ->
new ResourceNotFoundException(ResourceType.TOGGLE, toggleKey));
return ToggleMapper.INSTANCE.entityToResponse(toggle, appConfig.getToggleDeadline());
}
}