APIServiceFacadeImpl.java
/*
* Copyright 2026 Global Crop Diversity Trust
* Licensed under the Apache License, Version 2.0
* See LICENSE file in project root folder or http://www.apache.org/licenses/LICENSE-2.0
*/
package org.gringlobal.api.v2.facade.impl;
import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import org.genesys.blocks.model.EmptyModel;
import org.genesys.blocks.model.filters.EmptyModelFilter;
import org.genesys.blocks.util.TransactionHelper;
import org.gringlobal.api.FilteredPage;
import org.gringlobal.api.MultiOp;
import org.gringlobal.api.NestedEntityFullUpdate;
import org.gringlobal.api.exception.NotFoundElement;
import org.gringlobal.api.v2.facade.APIServiceFacade;
import org.gringlobal.api.v2.mapper.MapstructMapper;
import org.gringlobal.model.AuditedModel;
import org.gringlobal.model.DateVersionEntityId;
import org.gringlobal.service.CRUDService2;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.util.ReflectionUtils;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.Lists;
@Slf4j
public abstract class APIServiceFacadeImpl<S extends CRUDService2<T>, DTO, T extends EmptyModel> implements APIServiceFacade<DTO, T> {
protected static final int ENTITY_GENERIC_INDEX = 2;
private Type entityType = ((ParameterizedType) getClass().getGenericSuperclass()).getActualTypeArguments()[ENTITY_GENERIC_INDEX];
@Autowired
protected S service;
@Autowired
protected MapstructMapper mapper;
@Autowired
protected ObjectMapper objectMapper;
@Autowired
protected TransactionHelper transactionHelper;
// Every instance gets their own DTO updater
private final DtoUpdater dtoUpdater = new DtoUpdater();
/** Use {@code mapper.map(source)} in implemented methods */
protected abstract @NotNull @Valid T convert(DTO source);
/** Use {@code mapper.map(source)} in implemented methods */
protected abstract @NotNull @Valid DTO convert(T source);
protected APIServiceFacadeImpl() {
if (entityType instanceof Class) {
log.debug("{} is for {}", this.getClass(), entityType);
} else {
log.debug("{} is for type {} {}", this.getClass(), entityType.getClass(), entityType);
}
}
@Override
public String getServiceEntityName() {
return service.getServiceEntityName();
}
@Override
public T create(DTO source) {
return service.createFast(convert(source));
}
@Override
public MultiOp<T> create(List<DTO> inserts) {
return MultiOp.multiOp(mapper.map(inserts, this::convert), service::createFast);
}
@Override
public DTO get(long id) {
return convert(service.get(id));
}
@Override
public DTO get(T source) {
return convert(service.get(source));
}
@Override
public DTO get(DateVersionEntityId.EntityIdAndModifiedDate id) {
return convert(service.get(id));
}
@Override
public DTO get(long id, Instant modifiedDate) {
return convert(service.get(id, modifiedDate));
}
@Override
public List<DTO> get(List<T> source) {
return mapper.map(service.get(source), this::convert);
}
@Override
public Page<DTO> list(Pageable page) {
return mapper.map(service.list(page), this::convert);
}
@Override
public T update(DTO updated) {
return service.updateFast(convert(updated), service.get(convert(updated)));
}
@Override
public MultiOp<T> update(List<DTO> updates) {
// Uses per-row permission checks
return MultiOp.multiOp(mapper.map(updates, this::convert), service::updateFast);
}
@Override
public DTO remove(DTO entity) {
service.remove(convert(entity));
return entity;
}
@Override
public MultiOp<DTO> remove(List<DTO> deletes) {
// Uses per-row permission checks
return MultiOp.multiOp(deletes, this::remove);
}
@Override
public MultiOp<T> upsertMany(List<DTO> updates) {
return MultiOp.upsert(mapper.map(updates, this::convert), service::createFast, service::updateFast);
}
@Override
public final Page<DTO> map(Page<T> source) {
return new PageImpl<DTO>(get(source.getContent()), source.getPageable(), source.getTotalElements());
}
@Override
public final <F extends EmptyModelFilter<F, T, ?>> FilteredPage<DTO, F> map(FilteredPage<T, F> source) {
return new FilteredPage<DTO, F>(source.filterCode, source.filter, map(source.page));
}
@Override
public ResponseEntity<StreamingResponseBody> generateReport(String reportTemplate, Set<Long> entityIds, Locale locale,
HttpServletResponse response) throws Exception {
return service.generateReport(reportTemplate, entityIds, locale, response);
}
@Override
public MultiOp<DTO> bulkUpdate(@NotNull JsonNode updates, @NotNull @NotEmpty List<DateVersionEntityId.EntityIdAndModifiedDate> entities) {
if (updates.isNull() || updates.properties().isEmpty()) {
return new MultiOp<>();
}
List<MultiOp.MultiOpError> errors = new LinkedList<>();
List<T> success = new LinkedList<>();
Lists.partition(entities, 100).forEach(entitiesPart -> {
try {
Map<DateVersionEntityId.EntityIdAndModifiedDate, DTO> toUpdate = new LinkedHashMap<>();
Map<Long, DTO> idDtoMap = new LinkedHashMap<>();
List<T> loadedEntities = new LinkedList<>();
transactionHelper.executeInTransaction(true, () -> {
loadedEntities.addAll(service.getByIds(
entitiesPart.stream().map(DateVersionEntityId.EntityIdAndModifiedDate::getId).collect(Collectors.toList()))
);
idDtoMap.putAll(loadedEntities.stream().collect(Collectors.toMap(EmptyModel::getId, this::convert)));
return true;
});
for (DateVersionEntityId.EntityIdAndModifiedDate entityIdModifiedDate : entitiesPart) {
var loadedEntity = loadedEntities.stream().filter(loaded -> loaded.getId().equals(entityIdModifiedDate.id)).findFirst().orElse(null);
if (loadedEntity == null) {
errors.add(new MultiOp.MultiOpError(entities.indexOf(entityIdModifiedDate), new NotFoundElement("Entity not found: " + entityIdModifiedDate.id)));
} else if (((AuditedModel) loadedEntity).getModifiedDate() != null
&& !((AuditedModel) loadedEntity).getModifiedDate().equals(entityIdModifiedDate.modifiedDate)) {
log.warn("modifiedDate mismatch got={} want={}", ((AuditedModel) loadedEntity).getModifiedDate(), entityIdModifiedDate.modifiedDate);
errors.add(new MultiOp.MultiOpError(
entities.indexOf(entityIdModifiedDate),
new ObjectOptimisticLockingFailureException(loadedEntity.getClass(), loadedEntity.getId())
));
} else {
toUpdate.put(entityIdModifiedDate, idDtoMap.get(loadedEntity.getId()));
}
}
for (Map.Entry<DateVersionEntityId.EntityIdAndModifiedDate, DTO> entry : toUpdate.entrySet()) {
try {
var targetDTO = entry.getValue();
applyUpdates(targetDTO, updates, getServiceEntityName());
var updated = service.updateFast(convert(targetDTO));
success.add(updated);
} catch (Throwable t) {
errors.add(new MultiOp.MultiOpError(entities.indexOf(entry.getKey()), t));
}
}
} catch (Throwable err) {
log.error("Bulk update error: {}", err.getMessage(), err);
throw err;
}
});
return transactionHelper.executeInTransaction(true, () ->
new MultiOp<>(service.get(success).stream().map(this::convert).collect(Collectors.toList()), errors)
);
}
private void applyUpdates(Object target, JsonNode node, String path) throws Exception {
for (Map.Entry<String, JsonNode> entry : node.properties()) {
String fieldName = entry.getKey();
JsonNode value = entry.getValue();
if (fieldName.equals("_class")) {
continue;
}
Field field = dtoUpdater.findField(target.getClass(), fieldName);
String currentPath = path + "." + fieldName;
if (value.isObject()) {
Object nestedTarget = ReflectionUtils.getField(field, target);
if (nestedTarget == null) {
log.trace("{}.{} is null, must instantiate", target, currentPath);
var nestedFieldType = field.getType();
// Check if a nested field's type is a base class for subtypes
var subTypesAnnotation = nestedFieldType.getAnnotation(JsonSubTypes.class);
var nestedTypeIdentifier = value.get("_class");
if (subTypesAnnotation != null && nestedTypeIdentifier != null) {
var types = subTypesAnnotation.value();
for (JsonSubTypes.Type type : types) {
if (type.name().equals(nestedTypeIdentifier.asText())) {
nestedFieldType = type.value();
break;
}
}
}
nestedTarget = nestedFieldType.getDeclaredConstructor().newInstance();
ReflectionUtils.setField(field, target, nestedTarget);
}
// If the NestedEntityFullUpdate annotation is present, apply all given fields to the nested entity
// otherwise, try to set only the reference id
var nestedEntityId = value.get("id");
if (field.isAnnotationPresent(NestedEntityFullUpdate.class)) {
applyUpdates(nestedTarget, value, currentPath);
} else if (nestedEntityId != null) {
Field nestedIdField = dtoUpdater.findField(nestedTarget.getClass(), "id");
ReflectionUtils.setField(nestedIdField, nestedTarget, nestedEntityId.asLong());
}
// Ignore other types?
} else if (value.isNull()) {
if (field.getType().isPrimitive()) {
throw new IllegalArgumentException("Cannot set primitive field " + currentPath + " to null");
}
ReflectionUtils.setField(field, target, null);
} else {
var targetValue = objectMapper.convertValue(value, objectMapper.constructType(field.getGenericType()));
ReflectionUtils.setField(field, target, targetValue);
}
}
}
/**
* DtoUpdater uses caching
*/
private static class DtoUpdater {
private final Map<Class<?>, Map<String, Field>> classFields = new LinkedHashMap<>();
public Field findField(Class<?> targetClass, String fieldName) {
var classFieldsMap = classFields.computeIfAbsent(targetClass, (k) -> {
log.trace("First time visiting {}", k);
return new LinkedHashMap<>();
});
var field = classFieldsMap.get(fieldName);
if (field != null) {
log.trace("Re-visiting {}.{} of type {}", targetClass, fieldName);
return field;
} else if (classFieldsMap.containsKey(fieldName)) {
log.trace("Field {} was not found in earlier run, is null", fieldName);
throw new IllegalArgumentException("Field " + fieldName + " not found in " + targetClass.getName());
}
field = ReflectionUtils.findField(targetClass, fieldName);
if (field == null) {
log.debug("Field not found by simple name: {}.{}", targetClass, fieldName);
ReflectionUtils.doWithFields(targetClass, (f) -> {
var jsonName = f.getAnnotation(JsonProperty.class);
if (jsonName != null && jsonName.value() != null && Objects.equals(jsonName.value(), fieldName)) {
log.debug("Field {}.{} found by @JsonProperty({})", targetClass, f.getName(), fieldName);
ReflectionUtils.makeAccessible(f);
classFieldsMap.put(fieldName, f);
}
});
field = classFieldsMap.get(fieldName);
if (field == null) {
classFieldsMap.put(fieldName, null);
throw new IllegalArgumentException("Field " + fieldName + " not found in " + targetClass.getName());
} else {
return field;
}
} else {
log.trace("First time visiting {}.{} of type {}", targetClass, fieldName, field.getType());
ReflectionUtils.makeAccessible(field);
classFieldsMap.put(fieldName, field);
return field;
}
}
}
}