diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemController.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemController.java index acf2fe40..71080a2c 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemController.java @@ -5,7 +5,9 @@ import net.hostsharing.hsadminng.hs.booking.generated.api.v1.api.HsBookingItemsA import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemInsertResource; import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemPatchResource; import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemResource; +import net.hostsharing.hsadminng.hs.booking.item.validators.BookingItemEntitySaveProcessor; import net.hostsharing.hsadminng.hs.booking.item.validators.HsBookingItemEntityValidatorRegistry; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealEntity; import net.hostsharing.hsadminng.mapper.KeyValueMap; import net.hostsharing.hsadminng.mapper.Mapper; import org.springframework.beans.factory.annotation.Autowired; @@ -48,7 +50,7 @@ public class HsBookingItemController implements HsBookingItemsApi { final var entities = bookingItemRepo.findAllByProjectUuid(projectUuid); - final var resources = mapper.mapList(entities, HsBookingItemResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); + final var resources = mapper.mapList(entities, HsBookingItemResource.class, RBAC_ENTITY_TO_RESOURCE_POSTMAPPER); return ResponseEntity.ok(resources); } @@ -62,16 +64,20 @@ public class HsBookingItemController implements HsBookingItemsApi { context.define(currentSubject, assumedRoles); final var entityToSave = mapper.map(body, HsBookingItemRbacEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER); - - final var saved = HsBookingItemEntityValidatorRegistry.validated(em, bookingItemRepo.save(entityToSave)); + final var mapped = new BookingItemEntitySaveProcessor(em, entityToSave) + .preprocessEntity() + .validateEntity() + .prepareForSave() + .save() + .validateContext() + .mapUsing(e -> mapper.map(e, HsBookingItemResource.class, ITEM_TO_RESOURCE_POSTMAPPER)) + .revampProperties(); final var uri = MvcUriComponentsBuilder.fromController(getClass()) .path("/api/hs/booking/items/{id}") - .buildAndExpand(saved.getUuid()) + .buildAndExpand(mapped.getUuid()) .toUri(); - final var mapped = mapper.map(saved, HsBookingItemResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); - return ResponseEntity.created(uri).body(mapped); } @@ -88,7 +94,7 @@ public class HsBookingItemController implements HsBookingItemsApi { result.ifPresent(entity -> em.detach(entity)); // prevent further LAZY-loading return result .map(bookingItemEntity -> ResponseEntity.ok( - mapper.map(bookingItemEntity, HsBookingItemResource.class, ENTITY_TO_RESOURCE_POSTMAPPER))) + mapper.map(bookingItemEntity, HsBookingItemResource.class, RBAC_ENTITY_TO_RESOURCE_POSTMAPPER))) .orElseGet(() -> ResponseEntity.notFound().build()); } @@ -121,18 +127,21 @@ public class HsBookingItemController implements HsBookingItemsApi { new HsBookingItemEntityPatcher(current).apply(body); final var saved = bookingItemRepo.save(HsBookingItemEntityValidatorRegistry.validated(em, current)); - final var mapped = mapper.map(saved, HsBookingItemResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); + final var mapped = mapper.map(saved, HsBookingItemResource.class, RBAC_ENTITY_TO_RESOURCE_POSTMAPPER); return ResponseEntity.ok(mapped); } - final BiConsumer ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> { + final BiConsumer ITEM_TO_RESOURCE_POSTMAPPER = (entity, resource) -> { resource.setValidFrom(entity.getValidity().lower()); if (entity.getValidity().hasUpperBound()) { resource.setValidTo(entity.getValidity().upper().minusDays(1)); } }; + final BiConsumer RBAC_ENTITY_TO_RESOURCE_POSTMAPPER = ITEM_TO_RESOURCE_POSTMAPPER::accept; + final BiConsumer RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> { + entity.setProject(em.getReference(HsBookingProjectRealEntity.class, resource.getProjectUuid())); entity.setValidity(toPostgresDateRange(LocalDate.now(), resource.getValidTo())); entity.putResources(KeyValueMap.from(resource.getResources())); }; diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/BookingItemEntitySaveProcessor.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/BookingItemEntitySaveProcessor.java new file mode 100644 index 00000000..77ce40ae --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/BookingItemEntitySaveProcessor.java @@ -0,0 +1,131 @@ +package net.hostsharing.hsadminng.hs.booking.item.validators; + +import net.hostsharing.hsadminng.errors.MultiValidationException; +import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemResource; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem; +import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; + +import jakarta.persistence.EntityManager; +import java.util.Arrays; +import java.util.Map; +import java.util.function.Function; +import java.util.regex.Pattern; + +// TODO.refa: introduce common base class with HsHostingAssetEntitySaveProcessor +/** + * Wraps the steps of the pararation, validation, mapping and revamp around saving of a HsBookingItem into a readable API. + */ +public class BookingItemEntitySaveProcessor { + + private final HsEntityValidator validator; + private String expectedStep = "preprocessEntity"; + private final EntityManager em; + private HsBookingItem entity; + private HsBookingItemResource resource; + + public BookingItemEntitySaveProcessor(final EntityManager em, final HsBookingItem entity) { + this.em = em; + this.entity = entity; + this.validator = HsBookingItemEntityValidatorRegistry.forType(entity.getType()); + } + + /// initial step allowing to set default values before any validations + public BookingItemEntitySaveProcessor preprocessEntity() { + step("preprocessEntity", "validateEntity"); + validator.preprocessEntity(entity); + return this; + } + + /// validates the entity itself including its properties + public BookingItemEntitySaveProcessor validateEntity() { + step("validateEntity", "prepareForSave"); + MultiValidationException.throwIfNotEmpty(validator.validateEntity(entity)); + return this; + } + + // TODO.impl: remove once the migration of legacy data is done + /// validates the entity itself including its properties, but ignoring some error messages for import of legacy data + public BookingItemEntitySaveProcessor validateEntityIgnoring(final String... ignoreRegExp) { + step("validateEntity", "prepareForSave"); + final var ignoreRegExpPatterns = Arrays.stream(ignoreRegExp).map(Pattern::compile).toList(); + MultiValidationException.throwIfNotEmpty( + validator.validateEntity(entity).stream() + .filter(error -> ignoreRegExpPatterns.stream().noneMatch(p -> p.matcher(error).matches() )) + .toList() + ); + return this; + } + + /// hashing passwords etc. + public BookingItemEntitySaveProcessor prepareForSave() { + step("prepareForSave", "save"); + validator.prepareProperties(em, entity); + return this; + } + + /** + * Saves the entity using the given `saveFunction`. + * + *

`validator.postPersist(em, entity)` is NOT called. + * If any postprocessing is necessary, the saveFunction has to implement this.

+ * @param saveFunction + * @return this + */ + public BookingItemEntitySaveProcessor saveUsing(final Function saveFunction) { + step("save", "validateContext"); + entity = saveFunction.apply(entity); + return this; + } + + /** + * Saves the using the `EntityManager`, but does NOT ever merge the entity. + * + *

`validator.postPersist(em, entity)` is called afterwards with the entity guaranteed to be flushed to the database.

+ * @return this + */ + public BookingItemEntitySaveProcessor save() { + return saveUsing(e -> { + if (!em.contains(entity)) { + em.persist(entity); + } + em.flush(); // makes RbacEntity available as RealEntity if needed + validator.postPersist(em, entity); + return entity; + }); + } + + /// validates the entity within it's parent and child hierarchy (e.g. totals validators and other limits) + public BookingItemEntitySaveProcessor validateContext() { + step("validateContext", "mapUsing"); + return HsEntityValidator.doWithEntityManager(em, () -> { + MultiValidationException.throwIfNotEmpty(validator.validateContext(entity)); + return this; + }); + } + + /// maps entity to JSON resource representation + public BookingItemEntitySaveProcessor mapUsing( + final Function mapFunction) { + step("mapUsing", "revampProperties"); + resource = mapFunction.apply(entity); + return this; + } + + /// removes write-only-properties and ads computed-properties + @SuppressWarnings("unchecked") + public HsBookingItemResource revampProperties() { + step("revampProperties", null); + final var revampedProps = validator.revampProperties(em, entity, (Map) resource.getResources()); + resource.setResources(revampedProps); + return resource; + } + + // Makes sure that the steps are called in the correct order. + // Could also be implemented using an interface per method, but that seems exaggerated. + private void step(final String current, final String next) { + if (!expectedStep.equals(current)) { + throw new IllegalStateException("expected " + expectedStep + " but got " + current); + } + expectedStep = next; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidatorRegistry.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidatorRegistry.java index 8bfe12fd..6567ae83 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidatorRegistry.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidatorRegistry.java @@ -48,10 +48,11 @@ public class HsBookingItemEntityValidatorRegistry { } public static List doValidate(final EntityManager em, final HsBookingItem bookingItem) { + final var bookingItemValidator = HsBookingItemEntityValidatorRegistry.forType(bookingItem.getType()); return HsEntityValidator.doWithEntityManager(em, () -> HsEntityValidator.sequentiallyValidate( - () -> HsBookingItemEntityValidatorRegistry.forType(bookingItem.getType()).validateEntity(bookingItem), - () -> HsBookingItemEntityValidatorRegistry.forType(bookingItem.getType()).validateContext(bookingItem)) + () -> bookingItemValidator.validateEntity(bookingItem), + () -> bookingItemValidator.validateContext(bookingItem)) ); } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/HsEntityValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/HsEntityValidator.java index b2fa8a02..68f2779f 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/validation/HsEntityValidator.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/HsEntityValidator.java @@ -130,7 +130,7 @@ public abstract class HsEntityValidator { } public Map revampProperties(final EntityManager em, final E entity, final Map config) { - final var copy = new HashMap<>(config); + final var copy = config != null ? new HashMap<>(config) : new HashMap(); stream(propertyValidators).forEach(p -> { if (p.isWriteOnly()) { copy.remove(p.propertyName); diff --git a/src/main/java/net/hostsharing/hsadminng/mapper/Mapper.java b/src/main/java/net/hostsharing/hsadminng/mapper/Mapper.java index 9fda5165..a8e25b4b 100644 --- a/src/main/java/net/hostsharing/hsadminng/mapper/Mapper.java +++ b/src/main/java/net/hostsharing/hsadminng/mapper/Mapper.java @@ -1,6 +1,8 @@ package net.hostsharing.hsadminng.mapper; import org.modelmapper.ModelMapper; +import org.modelmapper.convention.MatchingStrategies; +import org.modelmapper.spi.MatchingStrategy; import org.springframework.util.ReflectionUtils; import jakarta.persistence.EntityManager; @@ -8,11 +10,16 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.PersistenceContext; import jakarta.validation.ValidationException; import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.function.BiConsumer; import java.util.stream.Collectors; +import java.util.stream.Stream; +import static java.util.Arrays.stream; import static net.hostsharing.hsadminng.errors.DisplayAs.DisplayName; +import static org.modelmapper.convention.MatchingStrategies.STRICT; /** * A nicer API for ModelMapper. @@ -24,6 +31,8 @@ public class Mapper extends ModelMapper { public Mapper() { getConfiguration().setAmbiguityIgnored(true); +// getConfiguration().setMatchingStrategy(STRICT); +// getConfiguration().setDeepCopyEnabled(true); } public List mapList(final List source, final Class targetClass) { @@ -46,7 +55,7 @@ public class Mapper extends ModelMapper { @Override public D map(final Object source, final Class destinationType) { final var target = super.map(source, destinationType); - for (Field f : destinationType.getDeclaredFields()) { + for (Field f : getDeclaredFieldsIncludingSuperClasses(destinationType)) { if (f.getAnnotation(ManyToOne.class) == null) { continue; } @@ -69,6 +78,17 @@ public class Mapper extends ModelMapper { return target; } + private static Field[] getDeclaredFieldsIncludingSuperClasses(final Class destinationType) { + if (destinationType == null) { + return new Field[0]; + } + + return Stream.concat( + stream(destinationType.getDeclaredFields()), + stream(getDeclaredFieldsIncludingSuperClasses(destinationType.getSuperclass()))) + .toArray(Field[]::new); + } + private Object findEntityById(final Class entityClass, final Object subEntityUuid) { // using getReference would be more efficent, but results in very technical error messages final var entity = em.find(entityClass, subEntityUuid);