From 03f208a27a3ca7c116491a028343368b6cd23ec6 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Wed, 11 Dec 2024 16:08:32 +0100 Subject: [PATCH] implement holder and contact data in HTTP POST to relations --- .../hsadminng/errors/Validate.java | 10 ++- .../HsOfficeMembershipController.java | 2 +- .../relation/HsOfficeRelationController.java | 58 +++++++++++++----- .../hs-office/hs-office-relation-schemas.yaml | 16 +++-- .../hsadminng/errors/ValidateUnitTest.java | 61 ++++++++++++++----- ...fficeRelationControllerAcceptanceTest.java | 61 ++++++++++++++++++- 6 files changed, 169 insertions(+), 39 deletions(-) diff --git a/src/main/java/net/hostsharing/hsadminng/errors/Validate.java b/src/main/java/net/hostsharing/hsadminng/errors/Validate.java index 3ce68fe9..73f02e89 100644 --- a/src/main/java/net/hostsharing/hsadminng/errors/Validate.java +++ b/src/main/java/net/hostsharing/hsadminng/errors/Validate.java @@ -13,8 +13,16 @@ public class Validate { return new Validate(variableNames); } - public final void atMaxOneNonNull(final Object var1, final Object var2) { + public final void atMaxOne(final Object var1, final Object var2) { if (var1 != null && var2 != null) { + throw new ValidationException( + "At maximum one of (" + variableNames + ") must be non-null, " + + "but are (" + var1 + ", " + var2 + ")"); + } + } + + public final void exactlyOne(final Object var1, final Object var2) { + if ((var1 != null) == (var2 != null)) { throw new ValidationException( "Exactly one of (" + variableNames + ") must be non-null, " + "but are (" + var1 + ", " + var2 + ")"); diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java index 84994ceb..43cc292d 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java @@ -43,7 +43,7 @@ public class HsOfficeMembershipController implements HsOfficeMembershipsApi { final String partnerNumber) { context.define(currentSubject, assumedRoles); - validate("partnerUuid, partnerNumber").atMaxOneNonNull(partnerUuid, partnerNumber); + validate("partnerUuid, partnerNumber").atMaxOne(partnerUuid, partnerNumber); final var entities = partnerNumber != null ? membershipRepo.findMembershipsByPartnerNumber( diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationController.java index d543e81e..97e7dac0 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationController.java @@ -2,9 +2,12 @@ package net.hostsharing.hsadminng.hs.office.relation; import io.micrometer.core.annotation.Timed; import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.errors.Validate; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity; 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.HsOfficePersonRealEntity; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository; import net.hostsharing.hsadminng.mapper.StandardMapper; import org.springframework.beans.factory.annotation.Autowired; @@ -20,6 +23,7 @@ import java.util.NoSuchElementException; import java.util.UUID; import java.util.function.BiConsumer; +import static net.hostsharing.hsadminng.mapper.KeyValueMap.from; @RestController public class HsOfficeRelationController implements HsOfficeRelationsApi { @@ -31,10 +35,10 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi { private StandardMapper mapper; @Autowired - private HsOfficeRelationRbacRepository relationRbacRepo; + private HsOfficeRelationRbacRepository rbacRelationRepo; @Autowired - private HsOfficePersonRealRepository personRepo; + private HsOfficePersonRealRepository realPersonRepo; @Autowired private HsOfficeContactRealRepository realContactRepo; @@ -55,7 +59,7 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi { context.define(currentSubject, assumedRoles); final List entities = - relationRbacRepo.findRelationRelatedToPersonUuidRelationTypePersonAndContactData( + rbacRelationRepo.findRelationRelatedToPersonUuidRelationTypePersonAndContactData( personUuid, relationType == null ? null : HsOfficeRelationType.valueOf(relationType.name()), personData, contactData); @@ -78,17 +82,34 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi { final var entityToSave = new HsOfficeRelationRbacEntity(); entityToSave.setType(HsOfficeRelationType.valueOf(body.getType())); entityToSave.setMark(body.getMark()); - entityToSave.setAnchor(personRepo.findByUuid(body.getAnchorUuid()).orElseThrow( + + entityToSave.setAnchor(realPersonRepo.findByUuid(body.getAnchorUuid()).orElseThrow( () -> new NoSuchElementException("cannot find Person by anchorUuid: " + body.getAnchorUuid()) )); - entityToSave.setHolder(personRepo.findByUuid(body.getHolderUuid()).orElseThrow( - () -> new NoSuchElementException("cannot find Person by holderUuid: " + body.getHolderUuid()) - )); - entityToSave.setContact(realContactRepo.findByUuid(body.getContactUuid()).orElseThrow( - () -> new NoSuchElementException("cannot find Contact by contactUuid: " + body.getContactUuid()) - )); - final var saved = relationRbacRepo.save(entityToSave); + Validate.validate("anchor, anchor.uuid").exactlyOne(body.getHolder(), body.getHolderUuid()); + if ( body.getHolderUuid() != null) { + entityToSave.setHolder(realPersonRepo.findByUuid(body.getHolderUuid()).orElseThrow( + () -> new NoSuchElementException("cannot find Person by holderUuid: " + body.getHolderUuid()) + )); + } else { + entityToSave.setHolder(realPersonRepo.save( + mapper.map(body.getHolder(), HsOfficePersonRealEntity.class) + ) ); + } + + Validate.validate("contact, contact.uuid").exactlyOne(body.getContact(), body.getContactUuid()); + if ( body.getContactUuid() != null) { + entityToSave.setContact(realContactRepo.findByUuid(body.getContactUuid()).orElseThrow( + () -> new NoSuchElementException("cannot find Contact by contactUuid: " + body.getContactUuid()) + )); + } else { + entityToSave.setContact(realContactRepo.save( + mapper.map(body.getContact(), HsOfficeContactRealEntity.class, CONTACT_RESOURCE_TO_ENTITY_POSTMAPPER) + ) ); + } + + final var saved = rbacRelationRepo.save(entityToSave); final var uri = MvcUriComponentsBuilder.fromController(getClass()) @@ -110,7 +131,7 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi { context.define(currentSubject, assumedRoles); - final var result = relationRbacRepo.findByUuid(relationUuid); + final var result = rbacRelationRepo.findByUuid(relationUuid); if (result.isEmpty()) { return ResponseEntity.notFound().build(); } @@ -126,7 +147,7 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi { final UUID relationUuid) { context.define(currentSubject, assumedRoles); - final var result = relationRbacRepo.deleteByUuid(relationUuid); + final var result = rbacRelationRepo.deleteByUuid(relationUuid); if (result == 0) { return ResponseEntity.notFound().build(); } @@ -145,11 +166,11 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi { context.define(currentSubject, assumedRoles); - final var current = relationRbacRepo.findByUuid(relationUuid).orElseThrow(); + final var current = rbacRelationRepo.findByUuid(relationUuid).orElseThrow(); new HsOfficeRelationEntityPatcher(em, current).apply(body); - final var saved = relationRbacRepo.save(current); + final var saved = rbacRelationRepo.save(current); final var mapped = mapper.map(saved, HsOfficeRelationResource.class); return ResponseEntity.ok(mapped); } @@ -159,4 +180,11 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi { resource.setHolder(mapper.map(entity.getHolder(), HsOfficePersonResource.class)); resource.setContact(mapper.map(entity.getContact(), HsOfficeContactResource.class)); }; + + + @SuppressWarnings("unchecked") + final BiConsumer CONTACT_RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> { + entity.putEmailAddresses(from(resource.getEmailAddresses())); + entity.putPhoneNumbers(from(resource.getPhoneNumbers())); + }; } diff --git a/src/main/resources/api-definition/hs-office/hs-office-relation-schemas.yaml b/src/main/resources/api-definition/hs-office/hs-office-relation-schemas.yaml index 8cf1f03f..9b8d2e46 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-relation-schemas.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-relation-schemas.yaml @@ -52,6 +52,8 @@ components: holder.uuid: type: string format: uuid + holder: + $ref: 'hs-office-person-schemas.yaml#/components/schemas/HsOfficePersonInsert' type: type: string nullable: true @@ -61,11 +63,17 @@ components: contact.uuid: type: string format: uuid + contact: + $ref: 'hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContactInsert' required: - - anchor.uuid - - holder.uuid - - type - - contact.uuid + - anchor.uuid + - type + # soon we might need to be able to use this: + # https://community.smartbear.com/discussions/swaggerostools/defining-conditional-attributes-in-openapi/222410 + # For now we just describe the conditionally required properties: + description: + Additionally to `type` and `anchor.uuid`, either `anchor.uuid` or `anchor` + and either `contact` or `contact.uuid` need to be given. # relation created as a sub-element with implicitly known type HsOfficeRelationSubInsert: diff --git a/src/test/java/net/hostsharing/hsadminng/errors/ValidateUnitTest.java b/src/test/java/net/hostsharing/hsadminng/errors/ValidateUnitTest.java index 0d212c90..97e73ec1 100644 --- a/src/test/java/net/hostsharing/hsadminng/errors/ValidateUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/errors/ValidateUnitTest.java @@ -1,5 +1,6 @@ package net.hostsharing.hsadminng.errors; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import jakarta.validation.ValidationException; @@ -9,23 +10,53 @@ import static org.assertj.core.api.Assertions.catchThrowable; class ValidateUnitTest { - @Test - void shouldFailValidationIfBothParametersAreNotNull() { - final var throwable = catchThrowable(() -> - Validate.validate("var1, var2").atMaxOneNonNull("val1", "val2") - ); - assertThat(throwable).isInstanceOf(ValidationException.class) - .hasMessage("Exactly one of (var1, var2) must be non-null, but are (val1, val2)"); + @Nested + class AtMaxOne { + @Test + void shouldFailValidationIfBothParametersAreNotNull() { + final var throwable = catchThrowable(() -> + Validate.validate("var1, var2").atMaxOne("val1", "val2") + ); + assertThat(throwable).isInstanceOf(ValidationException.class) + .hasMessage("At maximum one of (var1, var2) must be non-null, but are (val1, val2)"); + } + + @Test + void shouldNotFailValidationIfBothParametersAreNull() { + Validate.validate("var1, var2").atMaxOne(null, null); + } + + @Test + void shouldNotFailValidationIfExactlyOneParameterIsNonNull() { + Validate.validate("var1, var2").atMaxOne("val1", null); + Validate.validate("var1, var2").atMaxOne(null, "val2"); + } } - @Test - void shouldNotFailValidationIfBothParametersAreull() { - Validate.validate("var1, var2").atMaxOneNonNull(null, null); - } + @Nested + class ExactlyOne { + @Test + void shouldFailValidationIfBothParametersAreNotNull() { + final var throwable = catchThrowable(() -> + Validate.validate("var1, var2").exactlyOne("val1", "val2") + ); + assertThat(throwable).isInstanceOf(ValidationException.class) + .hasMessage("Exactly one of (var1, var2) must be non-null, but are (val1, val2)"); + } - @Test - void shouldNotFailValidationIfExactlyOneParameterIsNonNull() { - Validate.validate("var1, var2").atMaxOneNonNull("val1", null); - Validate.validate("var1, var2").atMaxOneNonNull(null, "val2"); + @Test + void shouldFailValidationIfBothParametersAreNull() { + final var throwable = catchThrowable(() -> + Validate.validate("var1, var2").exactlyOne(null, null) + ); + assertThat(throwable).isInstanceOf(ValidationException.class) + .hasMessage("Exactly one of (var1, var2) must be non-null, but are (null, null)"); + } + + @Test + void shouldNotFailValidationIfExactlyOneParameterIsNonNull() { + Validate.validate("var1, var2").exactlyOne("val1", null); + Validate.validate("var1, var2").exactlyOne(null, "val2"); + } } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationControllerAcceptanceTest.java index 53e1f591..d79ddbfa 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationControllerAcceptanceTest.java @@ -223,7 +223,7 @@ class HsOfficeRelationControllerAcceptanceTest extends ContextBasedTestWithClean class AddRelation { @Test - void globalAdmin_withoutAssumedRole_canAddRelation() { + void globalAdmin_withoutAssumedRole_canAddRelationWithHolderUuidAndContactUuid() { context.define("superuser-alex@hostsharing.net"); final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Third").get(0); @@ -261,7 +261,62 @@ class HsOfficeRelationControllerAcceptanceTest extends ContextBasedTestWithClean .body("holder.givenName", is("Paul")) .body("contact.caption", is("second contact")) .header("Location", startsWith("http://localhost")) - .extract().header("Location"); // @formatter:on + .extract().header("Location"); // @formatter:on + + // finally, the new relation can be accessed under the generated UUID + final var newSubjectUuid = toCleanup(HsOfficeRelationRealEntity.class, UUID.fromString( + location.substring(location.lastIndexOf('/') + 1))); + assertThat(newSubjectUuid).isNotNull(); + } + + @Test + void globalAdmin_withoutAssumedRole_canAddRelationWithHolderAndContactData() { + + context.define("superuser-alex@hostsharing.net"); + final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Third").get(0); + + final var location = RestAssured // @formatter:off + .given() + .header("current-subject", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "type": "%s", + "mark": "%s", + "anchor.uuid": "%s", + "holder": { + "personType": "NATURAL_PERSON", + "familyName": "Person", + "givenName": "Temp" + }, + "contact": { + "caption": "Temp Contact", + "emailAddresses": { + "main": "test@example.org" + } + } + } + """.formatted( + HsOfficeRelationTypeResource.SUBSCRIBER, + "operations-discuss", + givenAnchorPerson.getUuid() + ) + ) + .port(port) + .when() + .post("http://localhost/api/hs/office/relations") + .then().log().all().assertThat() + .statusCode(201) + .contentType(ContentType.JSON) + .body("uuid", isUuidValid()) + .body("type", is("SUBSCRIBER")) + .body("mark", is("operations-discuss")) + .body("anchor.tradeName", is("Third OHG")) + .body("holder.givenName", is("Temp")) + .body("holder.familyName", is("Person")) + .body("contact.caption", is("Temp Contact")) + .header("Location", startsWith("http://localhost")) + .extract().header("Location"); // @formatter:on // finally, the new relation can be accessed under the generated UUID final var newSubjectUuid = toCleanup(HsOfficeRelationRealEntity.class, UUID.fromString( @@ -277,7 +332,7 @@ class HsOfficeRelationControllerAcceptanceTest extends ContextBasedTestWithClean final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Smith").get(0); final var givenContact = contactrealRepo.findContactByOptionalCaptionLike("fourth").get(0); - final var location = RestAssured // @formatter:off + RestAssured // @formatter:off .given() .header("current-subject", "superuser-alex@hostsharing.net") .contentType(ContentType.JSON)