refactoring for implicit creation of dependend hosting-assets (#108)

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: #108
Reviewed-by: Timotheus Pokorra <timotheus.pokorra@hostsharing.net>
This commit is contained in:
Michael Hoennig 2024-09-26 10:51:27 +02:00
parent d949604d70
commit cb4aecb9c8
47 changed files with 474 additions and 139 deletions

View File

@ -550,12 +550,37 @@ Dependency versions can be automatically upgraded to the latest available versio
gw useLatestVersions
```
Afterwards, `gw check` is automatically started.
Afterward, `gw check` is automatically started.
Please only commit+push to master if the check run shows no errors.
More infos, e.g. on blacklists see on the [project's website](https://github.com/patrikerdes/gradle-use-latest-versions-plugin).
## Biggest Flaws in our Architecture
### The RBAC System is too Complicated
Now, where we have a better experience with what we really need from the RBAC system, we have learned
that and creates too many (grant- and role-) rows and too even tables which could be avoided completely.
The basic idea is always to always have a fixed set of ordered role-types which apply for all DB-tables under RBAC,
e.g. OWNER>ADMIN>AGENT\[>PROXY?\]>TENENT>REFERRER.
Grants between these for the same DB-row would be implicit by order comparision.
This way we would get rid of all explicit grants within the same DB-row
and would not need the `rbac.role` table anymore.
We would also reduce the depth of the expensive recursive CTE-query.
This has to be explored further.
For now, we just keep it in mind and
### The Mapper is Error-Prone
Where `org.modelmapper.ModelMapper` reduces bloat-code a lot and has some nice features about recursive data-structure mappings,
it often causes strange errors which are hard to fix.
E.g. the uuid of the target main object is often taken from an uuid of a sub-subject.
(For now, use `StrictMapper` to avoid this, for the case it happens.)
## How To ...
### How to Configure .pgpass for the Default PostgreSQL Database?

View File

@ -9,15 +9,25 @@ import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface DisplayAs {
class DisplayName {
public static String of(final Class<?> clazz) {
final var displayNameAnnot = clazz.getAnnotation(DisplayAs.class);
final var displayNameAnnot = getDisplayNameAnnotation(clazz);
return displayNameAnnot != null ? displayNameAnnot.value() : clazz.getSimpleName();
}
public static String of(@NotNull final Object instance) {
return of(instance.getClass());
}
private static DisplayAs getDisplayNameAnnotation(final Class<?> clazz) {
if (clazz == null) {
return null;
}
final var annot = clazz.getAnnotation(DisplayAs.class);
return annot != null ? annot : getDisplayNameAnnotation(clazz.getSuperclass());
}
}
String value() default "";

View File

@ -5,17 +5,18 @@ 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 net.hostsharing.hsadminng.mapper.StrictMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
@ -30,13 +31,13 @@ public class HsBookingItemController implements HsBookingItemsApi {
private Context context;
@Autowired
private Mapper mapper;
private StrictMapper mapper;
@Autowired
private HsBookingItemRbacRepository bookingItemRepo;
@PersistenceContext
private EntityManager em;
@Autowired
private EntityManagerWrapper em;
@Override
@Transactional(readOnly = true)
@ -48,7 +49,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,15 +63,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);
}
@ -87,7 +93,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());
}
@ -120,18 +126,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<HsBookingItemRbacEntity, HsBookingItemResource> ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> {
final BiConsumer<HsBookingItem, HsBookingItemResource> ITEM_TO_RESOURCE_POSTMAPPER = (entity, resource) -> {
resource.setValidFrom(entity.getValidity().lower());
if (entity.getValidity().hasUpperBound()) {
resource.setValidTo(entity.getValidity().upper().minusDays(1));
}
};
final BiConsumer<HsBookingItemRbacEntity, HsBookingItemResource> RBAC_ENTITY_TO_RESOURCE_POSTMAPPER = ITEM_TO_RESOURCE_POSTMAPPER::accept;
final BiConsumer<HsBookingItemInsertResource, HsBookingItemRbacEntity> RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
entity.setProject(em.find(HsBookingProjectRealEntity.class, resource.getProjectUuid()));
entity.setValidity(toPostgresDateRange(LocalDate.now(), resource.getValidTo()));
entity.putResources(KeyValueMap.from(resource.getResources()));
};

View File

@ -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<HsBookingItem> 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`.
*
* <p>`validator.postPersist(em, entity)` is NOT called.
* If any postprocessing is necessary, the saveFunction has to implement this.</p>
* @param saveFunction
* @return this
*/
public BookingItemEntitySaveProcessor saveUsing(final Function<HsBookingItem, HsBookingItem> saveFunction) {
step("save", "validateContext");
entity = saveFunction.apply(entity);
return this;
}
/**
* Saves the using the `EntityManager`, but does NOT ever merge the entity.
*
* <p>`validator.postPersist(em, entity)` is called afterwards with the entity guaranteed to be flushed to the database.</p>
* @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<HsBookingItem, HsBookingItemResource> 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<String, Object>) 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;
}
}

View File

@ -48,10 +48,11 @@ public class HsBookingItemEntityValidatorRegistry {
}
public static List<String> 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))
);
}

View File

@ -1,3 +1,4 @@
package net.hostsharing.hsadminng.hs.booking.item.validators;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem;
@ -15,6 +16,9 @@ class HsDomainSetupBookingItemValidator extends HsBookingItemEntityValidator {
public static final String FQDN_REGEX = "^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,12}";
public static final String DOMAIN_NAME_PROPERTY_NAME = "domainName";
public static final String TARGET_UNIX_USER_PROPERTY_NAME = "targetUnixUser";
public static final String WEBSPACE_NAME_REGEX = "[a-z][a-z0-9]{2}[0-9]{2}";
public static final String TARGET_UNIX_USER_NAME_REGEX = "^"+WEBSPACE_NAME_REGEX+"$|^"+WEBSPACE_NAME_REGEX+"-[a-z0-9\\._-]+$";
public static final String VERIFICATION_CODE_PROPERTY_NAME = "verificationCode";
HsDomainSetupBookingItemValidator() {
@ -24,6 +28,12 @@ class HsDomainSetupBookingItemValidator extends HsBookingItemEntityValidator {
.matchesRegEx(FQDN_REGEX).describedAs("is not a (non-top-level) fully qualified domain name")
.notMatchesRegEx(REGISTRAR_LEVEL_DOMAINS).describedAs("is a forbidden registrar-level domain name")
.required(),
// TODO.legacy: remove the following property once we give up legacy compatibility
stringProperty(TARGET_UNIX_USER_PROPERTY_NAME).writeOnce()
.maxLength(253)
.matchesRegEx(TARGET_UNIX_USER_NAME_REGEX).describedAs("is not a valid unix-user name")
.writeOnce()
.required(),
stringProperty(VERIFICATION_CODE_PROPERTY_NAME)
.minLength(12)
.maxLength(64)

View File

@ -6,7 +6,7 @@ import net.hostsharing.hsadminng.hs.booking.generated.api.v1.api.HsBookingProjec
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingProjectInsertResource;
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingProjectPatchResource;
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingProjectResource;
import net.hostsharing.hsadminng.mapper.Mapper;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
@ -25,7 +25,7 @@ public class HsBookingProjectController implements HsBookingProjectsApi {
private Context context;
@Autowired
private Mapper mapper;
private StandardMapper mapper;
@Autowired
private HsBookingProjectRbacRepository bookingProjectRepo;

View File

@ -11,7 +11,7 @@ import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAsse
import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetResource;
import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetTypeResource;
import net.hostsharing.hsadminng.mapper.KeyValueMap;
import net.hostsharing.hsadminng.mapper.Mapper;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
@ -35,7 +35,7 @@ public class HsHostingAssetController implements HsHostingAssetsApi {
private Context context;
@Autowired
private Mapper mapper;
private StandardMapper mapper;
@Autowired
private HsHostingAssetRbacRepository rbacAssetRepo;

View File

@ -4,7 +4,7 @@ import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeBankAccountsApi;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeBankAccountInsertResource;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeBankAccountResource;
import net.hostsharing.hsadminng.mapper.Mapper;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import org.iban4j.BicUtil;
import org.iban4j.IbanUtil;
import org.springframework.beans.factory.annotation.Autowired;
@ -24,7 +24,7 @@ public class HsOfficeBankAccountController implements HsOfficeBankAccountsApi {
private Context context;
@Autowired
private Mapper mapper;
private StandardMapper mapper;
@Autowired
private HsOfficeBankAccountRepository bankAccountRepo;

View File

@ -1,6 +1,6 @@
package net.hostsharing.hsadminng.hs.office.contact;
import net.hostsharing.hsadminng.mapper.Mapper;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeContactsApi;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeContactInsertResource;
@ -26,7 +26,7 @@ public class HsOfficeContactController implements HsOfficeContactsApi {
private Context context;
@Autowired
private Mapper mapper;
private StandardMapper mapper;
@Autowired
private HsOfficeContactRbacRepository contactRepo;

View File

@ -4,7 +4,7 @@ import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeCoopAssetsApi;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.*;
import net.hostsharing.hsadminng.errors.MultiValidationException;
import net.hostsharing.hsadminng.mapper.Mapper;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.format.annotation.DateTimeFormat.ISO;
@ -29,7 +29,7 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse
private Context context;
@Autowired
private Mapper mapper;
private StandardMapper mapper;
@Autowired
private HsOfficeCoopAssetsTransactionRepository coopAssetsTransactionRepo;

View File

@ -6,7 +6,7 @@ import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeCoopShar
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopSharesTransactionInsertResource;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopSharesTransactionResource;
import net.hostsharing.hsadminng.errors.MultiValidationException;
import net.hostsharing.hsadminng.mapper.Mapper;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.format.annotation.DateTimeFormat.ISO;
@ -31,7 +31,7 @@ public class HsOfficeCoopSharesTransactionController implements HsOfficeCoopShar
private Context context;
@Autowired
private Mapper mapper;
private StandardMapper mapper;
@Autowired
private HsOfficeCoopSharesTransactionRepository coopSharesTransactionRepo;

View File

@ -7,8 +7,8 @@ import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeDebito
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeDebitorResource;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealRepository;
import net.hostsharing.hsadminng.mapper.Mapper;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.persistence.EntityExistsValidator;
import org.apache.commons.lang3.Validate;
import org.hibernate.Hibernate;
import org.springframework.beans.factory.annotation.Autowired;
@ -23,7 +23,6 @@ import jakarta.validation.ValidationException;
import java.util.List;
import java.util.UUID;
import static net.hostsharing.hsadminng.errors.DisplayAs.DisplayName;
import static net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType.DEBITOR;
@RestController
@ -34,7 +33,7 @@ public class HsOfficeDebitorController implements HsOfficeDebitorsApi {
private Context context;
@Autowired
private Mapper mapper;
private StandardMapper mapper;
@Autowired
private HsOfficeDebitorRepository debitorRepo;
@ -42,6 +41,9 @@ public class HsOfficeDebitorController implements HsOfficeDebitorsApi {
@Autowired
private HsOfficeRelationRealRepository relrealRepo;
@Autowired
private EntityExistsValidator entityValidator;
@PersistenceContext
private EntityManager em;
@ -84,10 +86,10 @@ public class HsOfficeDebitorController implements HsOfficeDebitorsApi {
final var entityToSave = mapper.map(body, HsOfficeDebitorEntity.class);
if ( body.getDebitorRel() != null ) {
body.getDebitorRel().setType(DEBITOR.name());
final var debitorRel = mapper.map(body.getDebitorRel(), HsOfficeRelationRealEntity.class);
validateEntityExists("debitorRel.anchorUuid", debitorRel.getAnchor());
validateEntityExists("debitorRel.holderUuid", debitorRel.getHolder());
validateEntityExists("debitorRel.contactUuid", debitorRel.getContact());
final var debitorRel = mapper.map("debitorRel.", body.getDebitorRel(), HsOfficeRelationRealEntity.class);
entityValidator.validateEntityExists("debitorRel.anchorUuid", debitorRel.getAnchor());
entityValidator.validateEntityExists("debitorRel.holderUuid", debitorRel.getHolder());
entityValidator.validateEntityExists("debitorRel.contactUuid", debitorRel.getContact());
entityToSave.setDebitorRel(relrealRepo.save(debitorRel));
} else {
final var debitorRelOptional = relrealRepo.findByUuid(body.getDebitorRelUuid());
@ -160,15 +162,4 @@ public class HsOfficeDebitorController implements HsOfficeDebitorsApi {
final var mapped = mapper.map(saved, HsOfficeDebitorResource.class);
return ResponseEntity.ok(mapped);
}
// TODO.impl: extract this to some generally usable class?
private <T extends BaseEntity<T>> T validateEntityExists(final String property, final T entitySkeleton) {
final var foundEntity = em.find(entitySkeleton.getClass(), entitySkeleton.getUuid());
if ( foundEntity == null) {
throw new ValidationException("Unable to find " + DisplayName.of(entitySkeleton) + " by " + property + ": " + entitySkeleton.getUuid());
}
//noinspection unchecked
return (T) foundEntity;
}
}

View File

@ -5,7 +5,7 @@ import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeMembersh
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeMembershipInsertResource;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeMembershipPatchResource;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeMembershipResource;
import net.hostsharing.hsadminng.mapper.Mapper;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
@ -24,7 +24,7 @@ public class HsOfficeMembershipController implements HsOfficeMembershipsApi {
private Context context;
@Autowired
private Mapper mapper;
private StandardMapper mapper;
@Autowired
private HsOfficeMembershipRepository membershipRepo;

View File

@ -2,18 +2,18 @@ package net.hostsharing.hsadminng.hs.office.membership;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeMembershipPatchResource;
import net.hostsharing.hsadminng.mapper.EntityPatcher;
import net.hostsharing.hsadminng.mapper.Mapper;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.mapper.OptionalFromJson;
import java.util.Optional;
public class HsOfficeMembershipEntityPatcher implements EntityPatcher<HsOfficeMembershipPatchResource> {
private final Mapper mapper;
private final StandardMapper mapper;
private final HsOfficeMembershipEntity entity;
public HsOfficeMembershipEntityPatcher(
final Mapper mapper,
final StandardMapper mapper,
final HsOfficeMembershipEntity entity) {
this.mapper = mapper;
this.entity = entity;

View File

@ -12,7 +12,7 @@ import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealRepository;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType;
import net.hostsharing.hsadminng.mapper.Mapper;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
@ -36,7 +36,7 @@ public class HsOfficePartnerController implements HsOfficePartnersApi {
private Context context;
@Autowired
private Mapper mapper;
private StandardMapper mapper;
@Autowired
private HsOfficePartnerRepository partnerRepo;

View File

@ -1,6 +1,6 @@
package net.hostsharing.hsadminng.hs.office.person;
import net.hostsharing.hsadminng.mapper.Mapper;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficePersonsApi;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonInsertResource;
@ -23,7 +23,7 @@ public class HsOfficePersonController implements HsOfficePersonsApi {
private Context context;
@Autowired
private Mapper mapper;
private StandardMapper mapper;
@Autowired
private HsOfficePersonRepository personRepo;

View File

@ -5,7 +5,7 @@ import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealRepository
import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeRelationsApi;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.*;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRepository;
import net.hostsharing.hsadminng.mapper.Mapper;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
@ -28,7 +28,7 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi {
private Context context;
@Autowired
private Mapper mapper;
private StandardMapper mapper;
@Autowired
private HsOfficeRelationRbacRepository relationRbacRepo;

View File

@ -5,7 +5,7 @@ import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeSepaMand
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeSepaMandateInsertResource;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeSepaMandatePatchResource;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeSepaMandateResource;
import net.hostsharing.hsadminng.mapper.Mapper;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
@ -28,7 +28,7 @@ public class HsOfficeSepaMandateController implements HsOfficeSepaMandatesApi {
private Context context;
@Autowired
private Mapper mapper;
private StandardMapper mapper;
@Autowired
private HsOfficeSepaMandateRepository sepaMandateRepo;

View File

@ -130,7 +130,7 @@ public abstract class HsEntityValidator<E extends PropertiesProvider> {
}
public Map<String, Object> revampProperties(final EntityManager em, final E entity, final Map<String, Object> 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);

View File

@ -11,18 +11,20 @@ import java.lang.reflect.Field;
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;
/**
* A nicer API for ModelMapper.
*/
public class Mapper extends ModelMapper {
abstract class Mapper extends ModelMapper {
@PersistenceContext
EntityManager em;
public Mapper() {
Mapper() {
getConfiguration().setAmbiguityIgnored(true);
}
@ -45,8 +47,12 @@ public class Mapper extends ModelMapper {
@Override
public <D> D map(final Object source, final Class<D> destinationType) {
return map("", source, destinationType);
}
public <D> D map(final String namePrefix, final Object source, final Class<D> 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;
}
@ -64,18 +70,30 @@ public class Mapper extends ModelMapper {
if (subEntityUuid == null) {
continue;
}
ReflectionUtils.setField(f, target, findEntityById(f.getType(), subEntityUuid));
ReflectionUtils.setField(f, target, fetchEntity(namePrefix + f.getName() + ".uuid", f.getType(), subEntityUuid));
}
return target;
}
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);
private static <D> Field[] getDeclaredFieldsIncludingSuperClasses(final Class<D> destinationType) {
if (destinationType == null) {
return new Field[0];
}
return Stream.concat(
stream(destinationType.getDeclaredFields()),
stream(getDeclaredFieldsIncludingSuperClasses(destinationType.getSuperclass())))
.toArray(Field[]::new);
}
public <E> E fetchEntity(final String propertyName, final Class<E> entityClass, final Object subEntityUuid) {
final var entity = em.getReference(entityClass, subEntityUuid);
if (entity != null) {
return entity;
}
throw new ValidationException("Unable to find " + DisplayName.of(entityClass) + " by uuid: " + subEntityUuid);
throw new ValidationException(
"Unable to find " + DisplayName.of(entityClass) +
" by " + propertyName + ": " + subEntityUuid);
}
public <S, T> T map(final S source, final Class<T> targetClass, final BiConsumer<S, T> postMapper) {
@ -86,4 +104,13 @@ public class Mapper extends ModelMapper {
postMapper.accept(source, target);
return target;
}
public <S, T> T map(final String namePrefix, final S source, final Class<T> targetClass, final BiConsumer<S, T> postMapper) {
if (source == null) {
return null;
}
final var target = map(source, targetClass);
postMapper.accept(source, target);
return target;
}
}

View File

@ -1,13 +0,0 @@
package net.hostsharing.hsadminng.mapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MapperConfiguration {
@Bean
public Mapper modelMapper() {
return new Mapper();
}
}

View File

@ -0,0 +1,14 @@
package net.hostsharing.hsadminng.mapper;
import org.springframework.stereotype.Component;
/**
* A nicer API for ModelMapper in standard mode.
*/
@Component
public class StandardMapper extends Mapper {
public StandardMapper() {
getConfiguration().setAmbiguityIgnored(true);
}
}

View File

@ -0,0 +1,19 @@
package net.hostsharing.hsadminng.mapper;
import org.springframework.stereotype.Component;
import static org.modelmapper.convention.MatchingStrategies.STRICT;
/**
* A nicer API for ModelMapper in strict mode.
*
* <p>This makes sure that resource.whateverUuid does not accidentally get mapped to entity.uuid,
* if resource.uuid does not exist.</p>
*/
@Component
public class StrictMapper extends Mapper {
public StrictMapper() {
getConfiguration().setMatchingStrategy(STRICT);
}
}

View File

@ -0,0 +1,39 @@
package net.hostsharing.hsadminng.persistence;
import net.hostsharing.hsadminng.errors.DisplayAs.DisplayName;
import net.hostsharing.hsadminng.rbac.object.BaseEntity;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import jakarta.persistence.Entity;
import jakarta.validation.ValidationException;
@Service
public class EntityExistsValidator {
@Autowired
private EntityManagerWrapper em;
public <T extends BaseEntity<T>> void validateEntityExists(final String property, final T entitySkeleton) {
final var foundEntity = em.find(entityClass(entitySkeleton), entitySkeleton.getUuid());
if ( foundEntity == null) {
throw new ValidationException("Unable to find " + DisplayName.of(entitySkeleton) + " by " + property + ": " + entitySkeleton.getUuid());
}
}
private static <T extends BaseEntity<T>> Class<?> entityClass(final T entityOrProxy) {
final var entityClass = entityClass(entityOrProxy.getClass());
if (entityClass == null) {
throw new IllegalArgumentException("@Entity not found in superclass hierarchy of " + entityOrProxy.getClass());
}
return entityClass;
}
private static Class<?> entityClass(final Class<?> entityOrProxyClass) {
return entityOrProxyClass.isAnnotationPresent(Entity.class)
? entityOrProxyClass
: entityOrProxyClass.getSuperclass() == null
? null
: entityClass(entityOrProxyClass.getSuperclass());
}
}

View File

@ -1,7 +1,7 @@
package net.hostsharing.hsadminng.rbac.grant;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.mapper.Mapper;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.rbac.generated.api.v1.api.RbacGrantsApi;
import net.hostsharing.hsadminng.rbac.generated.api.v1.model.RbacGrantResource;
import org.springframework.beans.factory.annotation.Autowired;
@ -22,7 +22,7 @@ public class RbacGrantController implements RbacGrantsApi {
private Context context;
@Autowired
private Mapper mapper;
private StandardMapper mapper;
@Autowired
private RbacGrantRepository rbacGrantRepository;

View File

@ -1,7 +1,7 @@
package net.hostsharing.hsadminng.rbac.role;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.mapper.Mapper;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.rbac.generated.api.v1.api.RbacRolesApi;
import net.hostsharing.hsadminng.rbac.generated.api.v1.model.RbacRoleResource;
import org.springframework.beans.factory.annotation.Autowired;
@ -18,7 +18,7 @@ public class RbacRoleController implements RbacRolesApi {
private Context context;
@Autowired
private Mapper mapper;
private StandardMapper mapper;
@Autowired
private RbacRoleRepository rbacRoleRepository;

View File

@ -1,7 +1,7 @@
package net.hostsharing.hsadminng.rbac.subject;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.mapper.Mapper;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.rbac.generated.api.v1.api.RbacSubjectsApi;
import net.hostsharing.hsadminng.rbac.generated.api.v1.model.RbacSubjectPermissionResource;
import net.hostsharing.hsadminng.rbac.generated.api.v1.model.RbacSubjectResource;
@ -21,7 +21,7 @@ public class RbacSubjectController implements RbacSubjectsApi {
private Context context;
@Autowired
private Mapper mapper;
private StandardMapper mapper;
@Autowired
private RbacSubjectRepository rbacSubjectRepository;

View File

@ -1,7 +1,7 @@
package net.hostsharing.hsadminng.rbac.test.cust;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.mapper.Mapper;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.test.generated.api.v1.api.TestCustomersApi;
import net.hostsharing.hsadminng.test.generated.api.v1.model.TestCustomerResource;
import org.springframework.beans.factory.annotation.Autowired;
@ -21,7 +21,7 @@ public class TestCustomerController implements TestCustomersApi {
private Context context;
@Autowired
private Mapper mapper;
private StandardMapper mapper;
@Autowired
private TestCustomerRepository testCustomerRepository;

View File

@ -1,6 +1,6 @@
package net.hostsharing.hsadminng.rbac.test.pac;
import net.hostsharing.hsadminng.mapper.Mapper;
import net.hostsharing.hsadminng.mapper.StandardMapper;
import net.hostsharing.hsadminng.mapper.OptionalFromJson;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.test.generated.api.v1.api.TestPackagesApi;
@ -21,7 +21,7 @@ public class TestPackageController implements TestPackagesApi {
private Context context;
@Autowired
private Mapper mapper;
private StandardMapper mapper;
@Autowired
private TestPackageRepository testPackageRepository;

View File

@ -1,17 +1,20 @@
package net.hostsharing.hsadminng.hs.booking.item;
import net.hostsharing.hsadminng.config.JsonObjectMapperConfiguration;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealEntity;
import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealRepository;
import net.hostsharing.hsadminng.mapper.Mapper;
import net.hostsharing.hsadminng.mapper.StrictMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
@ -28,13 +31,14 @@ import java.util.UUID;
import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals;
import static org.hamcrest.Matchers.matchesRegex;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(HsBookingItemController.class)
@Import(Mapper.class)
@Import({StrictMapper.class, JsonObjectMapperConfiguration.class})
@RunWith(SpringRunner.class)
class HsBookingItemControllerRestTest {
@ -44,8 +48,12 @@ class HsBookingItemControllerRestTest {
@MockBean
Context contextMock;
@Mock
EntityManager em;
@Autowired
@SuppressWarnings("unused") // not used in test, but in controller class
StrictMapper mapper;
@MockBean
EntityManagerWrapper em;
@MockBean
EntityManagerFactory emf;
@ -56,6 +64,16 @@ class HsBookingItemControllerRestTest {
@MockBean
HsBookingItemRbacRepository rbacBookingItemRepo;
@TestConfiguration
public static class TestConfig {
@Bean
public EntityManager entityManager() {
return mock(EntityManager.class);
}
}
@BeforeEach
void init() {
when(emf.createEntityManager()).thenReturn(em);

View File

@ -35,7 +35,8 @@ class HsDomainSetupBookingItemValidatorUnitTest {
.project(project)
.caption("Test-Domain")
.resources(Map.ofEntries(
entry("domainName", "example.org")
entry("domainName", "example.org"),
entry("targetUnixUser", "xyz00")
))
.build();
@ -55,6 +56,7 @@ class HsDomainSetupBookingItemValidatorUnitTest {
.caption("Test-Domain")
.resources(Map.ofEntries(
entry("domainName", "example.org"),
entry("targetUnixUser", "xyz00"),
entry("verificationCode", "1234-5678-9100")
))
.build();
@ -73,7 +75,8 @@ class HsDomainSetupBookingItemValidatorUnitTest {
.project(project)
.caption("Test-Domain")
.resources(Map.ofEntries(
entry("domainName", right(TOO_LONG_DOMAIN_NAME, 253))
entry("domainName", right(TOO_LONG_DOMAIN_NAME, 253)),
entry("targetUnixUser", "xyz00")
))
.build();
@ -91,7 +94,8 @@ class HsDomainSetupBookingItemValidatorUnitTest {
.project(project)
.caption("Test-Domain")
.resources(Map.ofEntries(
entry("domainName", right(TOO_LONG_DOMAIN_NAME, 254))
entry("domainName", right(TOO_LONG_DOMAIN_NAME, 254)),
entry("targetUnixUser", "xyz00")
))
.build();
@ -102,6 +106,44 @@ class HsDomainSetupBookingItemValidatorUnitTest {
assertThat(result).contains("'D-12345:Test-Project:Test-Domain.resources.domainName' length is expected to be at max 253 but length of 'dfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.example.org' is 254");
}
@Test
void acceptsValidUnixUser() {
final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder()
.type(DOMAIN_SETUP)
.project(project)
.caption("Test-Domain")
.resources(Map.ofEntries(
entry("domainName", "example.com"),
entry("targetUnixUser", "xyz00-test")
))
.build();
// when
final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity);
// then
assertThat(result).isEmpty();
}
@Test
void rejectsInvalidUnixUser() {
final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder()
.type(DOMAIN_SETUP)
.project(project)
.caption("Test-Domain")
.resources(Map.ofEntries(
entry("domainName", "example.com"),
entry("targetUnixUser", "xyz00test")
))
.build();
// when
final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity);
// then
assertThat(result).contains("'D-12345:Test-Project:Test-Domain.resources.targetUnixUser' = 'xyz00test' is not a valid unix-user name");
}
@ParameterizedTest
@ValueSource(strings = {
"de", "com", "net", "org", "actually-any-top-level-domain",
@ -123,7 +165,8 @@ class HsDomainSetupBookingItemValidatorUnitTest {
.project(project)
.caption("Test-Domain")
.resources(Map.ofEntries(
entry("domainName", secondLevelRegistrarDomain)
entry("domainName", secondLevelRegistrarDomain),
entry("targetUnixUser", "xyz00")
))
.build();
@ -148,7 +191,8 @@ class HsDomainSetupBookingItemValidatorUnitTest {
.project(project)
.caption("Test-Domain")
.resources(Map.ofEntries(
entry("domainName", secondLevelRegistrarDomain)
entry("domainName", secondLevelRegistrarDomain),
entry("targetUnixUser", "xyz00")
))
.build();
@ -170,6 +214,7 @@ class HsDomainSetupBookingItemValidatorUnitTest {
// then
assertThat(validator.properties()).map(Map::toString).containsExactlyInAnyOrder(
"{type=string, propertyName=domainName, matchesRegEx=[^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,12}], matchesRegExDescription=is not a (non-top-level) fully qualified domain name, notMatchesRegEx=[[^.]+, (co|org|gov|ac|sch)\\.uk, (com|net|org|edu|gov|asn|id)\\.au, (co|ne|or|ac|go)\\.jp, (com|net|org|gov|edu|ac)\\.cn, (com|net|org|gov|edu|mil|art)\\.br, (co|net|org|gen|firm|ind)\\.in, (com|net|org|gob|edu)\\.mx, (gov|edu)\\.it, (co|net|org|govt|ac|school|geek|kiwi)\\.nz, (co|ne|or|go|re|pe)\\.kr], notMatchesRegExDescription=is a forbidden registrar-level domain name, maxLength=253, required=true, writeOnce=true}",
"{type=string, propertyName=targetUnixUser, matchesRegEx=[^[a-z][a-z0-9]{2}[0-9]{2}$|^[a-z][a-z0-9]{2}[0-9]{2}-[a-z0-9\\._-]+$], matchesRegExDescription=is not a valid unix-user name, maxLength=253, required=true, writeOnce=true}",
"{type=string, propertyName=verificationCode, minLength=12, maxLength=64, computed=IN_INIT}");
}
}