implement holder and contact data in HTTP POST to relations

This commit is contained in:
Michael Hoennig 2024-12-11 16:08:32 +01:00
parent 19fac6b5e1
commit 4cdf6f7068
6 changed files with 169 additions and 39 deletions

View File

@ -13,8 +13,16 @@ public class Validate {
return new Validate(variableNames); 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) { 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( throw new ValidationException(
"Exactly one of (" + variableNames + ") must be non-null, " + "Exactly one of (" + variableNames + ") must be non-null, " +
"but are (" + var1 + ", " + var2 + ")"); "but are (" + var1 + ", " + var2 + ")");

View File

@ -43,7 +43,7 @@ public class HsOfficeMembershipController implements HsOfficeMembershipsApi {
final String partnerNumber) { final String partnerNumber) {
context.define(currentSubject, assumedRoles); context.define(currentSubject, assumedRoles);
validate("partnerUuid, partnerNumber").atMaxOneNonNull(partnerUuid, partnerNumber); validate("partnerUuid, partnerNumber").atMaxOne(partnerUuid, partnerNumber);
final var entities = partnerNumber != null final var entities = partnerNumber != null
? membershipRepo.findMembershipsByPartnerNumber( ? membershipRepo.findMembershipsByPartnerNumber(

View File

@ -2,9 +2,12 @@ package net.hostsharing.hsadminng.hs.office.relation;
import io.micrometer.core.annotation.Timed; import io.micrometer.core.annotation.Timed;
import net.hostsharing.hsadminng.context.Context; 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.contact.HsOfficeContactRealRepository;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeRelationsApi; 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.generated.api.v1.model.*;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository;
import net.hostsharing.hsadminng.mapper.StandardMapper; import net.hostsharing.hsadminng.mapper.StandardMapper;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -20,6 +23,7 @@ import java.util.NoSuchElementException;
import java.util.UUID; import java.util.UUID;
import java.util.function.BiConsumer; import java.util.function.BiConsumer;
import static net.hostsharing.hsadminng.mapper.KeyValueMap.from;
@RestController @RestController
public class HsOfficeRelationController implements HsOfficeRelationsApi { public class HsOfficeRelationController implements HsOfficeRelationsApi {
@ -31,10 +35,10 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi {
private StandardMapper mapper; private StandardMapper mapper;
@Autowired @Autowired
private HsOfficeRelationRbacRepository relationRbacRepo; private HsOfficeRelationRbacRepository rbacRelationRepo;
@Autowired @Autowired
private HsOfficePersonRealRepository personRepo; private HsOfficePersonRealRepository realPersonRepo;
@Autowired @Autowired
private HsOfficeContactRealRepository realContactRepo; private HsOfficeContactRealRepository realContactRepo;
@ -55,7 +59,7 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi {
context.define(currentSubject, assumedRoles); context.define(currentSubject, assumedRoles);
final List<HsOfficeRelationRbacEntity> entities = final List<HsOfficeRelationRbacEntity> entities =
relationRbacRepo.findRelationRelatedToPersonUuidRelationTypePersonAndContactData( rbacRelationRepo.findRelationRelatedToPersonUuidRelationTypePersonAndContactData(
personUuid, personUuid,
relationType == null ? null : HsOfficeRelationType.valueOf(relationType.name()), relationType == null ? null : HsOfficeRelationType.valueOf(relationType.name()),
personData, contactData); personData, contactData);
@ -78,17 +82,34 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi {
final var entityToSave = new HsOfficeRelationRbacEntity(); final var entityToSave = new HsOfficeRelationRbacEntity();
entityToSave.setType(HsOfficeRelationType.valueOf(body.getType())); entityToSave.setType(HsOfficeRelationType.valueOf(body.getType()));
entityToSave.setMark(body.getMark()); 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()) () -> new NoSuchElementException("cannot find Person by anchorUuid: " + body.getAnchorUuid())
)); ));
entityToSave.setHolder(personRepo.findByUuid(body.getHolderUuid()).orElseThrow(
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()) () -> 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( entityToSave.setContact(realContactRepo.findByUuid(body.getContactUuid()).orElseThrow(
() -> new NoSuchElementException("cannot find Contact by contactUuid: " + body.getContactUuid()) () -> 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 = relationRbacRepo.save(entityToSave); final var saved = rbacRelationRepo.save(entityToSave);
final var uri = final var uri =
MvcUriComponentsBuilder.fromController(getClass()) MvcUriComponentsBuilder.fromController(getClass())
@ -110,7 +131,7 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi {
context.define(currentSubject, assumedRoles); context.define(currentSubject, assumedRoles);
final var result = relationRbacRepo.findByUuid(relationUuid); final var result = rbacRelationRepo.findByUuid(relationUuid);
if (result.isEmpty()) { if (result.isEmpty()) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
@ -126,7 +147,7 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi {
final UUID relationUuid) { final UUID relationUuid) {
context.define(currentSubject, assumedRoles); context.define(currentSubject, assumedRoles);
final var result = relationRbacRepo.deleteByUuid(relationUuid); final var result = rbacRelationRepo.deleteByUuid(relationUuid);
if (result == 0) { if (result == 0) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
@ -145,11 +166,11 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi {
context.define(currentSubject, assumedRoles); context.define(currentSubject, assumedRoles);
final var current = relationRbacRepo.findByUuid(relationUuid).orElseThrow(); final var current = rbacRelationRepo.findByUuid(relationUuid).orElseThrow();
new HsOfficeRelationEntityPatcher(em, current).apply(body); 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); final var mapped = mapper.map(saved, HsOfficeRelationResource.class);
return ResponseEntity.ok(mapped); return ResponseEntity.ok(mapped);
} }
@ -159,4 +180,11 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi {
resource.setHolder(mapper.map(entity.getHolder(), HsOfficePersonResource.class)); resource.setHolder(mapper.map(entity.getHolder(), HsOfficePersonResource.class));
resource.setContact(mapper.map(entity.getContact(), HsOfficeContactResource.class)); resource.setContact(mapper.map(entity.getContact(), HsOfficeContactResource.class));
}; };
@SuppressWarnings("unchecked")
final BiConsumer<HsOfficeContactInsertResource, HsOfficeContactRealEntity> CONTACT_RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
entity.putEmailAddresses(from(resource.getEmailAddresses()));
entity.putPhoneNumbers(from(resource.getPhoneNumbers()));
};
} }

View File

@ -52,6 +52,8 @@ components:
holder.uuid: holder.uuid:
type: string type: string
format: uuid format: uuid
holder:
$ref: 'hs-office-person-schemas.yaml#/components/schemas/HsOfficePersonInsert'
type: type:
type: string type: string
nullable: true nullable: true
@ -61,11 +63,17 @@ components:
contact.uuid: contact.uuid:
type: string type: string
format: uuid format: uuid
contact:
$ref: 'hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContactInsert'
required: required:
- anchor.uuid - anchor.uuid
- holder.uuid
- type - type
- contact.uuid # 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 # relation created as a sub-element with implicitly known type
HsOfficeRelationSubInsert: HsOfficeRelationSubInsert:

View File

@ -1,5 +1,6 @@
package net.hostsharing.hsadminng.errors; package net.hostsharing.hsadminng.errors;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import jakarta.validation.ValidationException; import jakarta.validation.ValidationException;
@ -9,23 +10,53 @@ import static org.assertj.core.api.Assertions.catchThrowable;
class ValidateUnitTest { class ValidateUnitTest {
@Nested
class AtMaxOne {
@Test @Test
void shouldFailValidationIfBothParametersAreNotNull() { void shouldFailValidationIfBothParametersAreNotNull() {
final var throwable = catchThrowable(() -> final var throwable = catchThrowable(() ->
Validate.validate("var1, var2").atMaxOneNonNull("val1", "val2") 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");
}
}
@Nested
class ExactlyOne {
@Test
void shouldFailValidationIfBothParametersAreNotNull() {
final var throwable = catchThrowable(() ->
Validate.validate("var1, var2").exactlyOne("val1", "val2")
); );
assertThat(throwable).isInstanceOf(ValidationException.class) assertThat(throwable).isInstanceOf(ValidationException.class)
.hasMessage("Exactly one of (var1, var2) must be non-null, but are (val1, val2)"); .hasMessage("Exactly one of (var1, var2) must be non-null, but are (val1, val2)");
} }
@Test @Test
void shouldNotFailValidationIfBothParametersAreull() { void shouldFailValidationIfBothParametersAreNull() {
Validate.validate("var1, var2").atMaxOneNonNull(null, null); 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 @Test
void shouldNotFailValidationIfExactlyOneParameterIsNonNull() { void shouldNotFailValidationIfExactlyOneParameterIsNonNull() {
Validate.validate("var1, var2").atMaxOneNonNull("val1", null); Validate.validate("var1, var2").exactlyOne("val1", null);
Validate.validate("var1, var2").atMaxOneNonNull(null, "val2"); Validate.validate("var1, var2").exactlyOne(null, "val2");
}
} }
} }

View File

@ -223,7 +223,7 @@ class HsOfficeRelationControllerAcceptanceTest extends ContextBasedTestWithClean
class AddRelation { class AddRelation {
@Test @Test
void globalAdmin_withoutAssumedRole_canAddRelation() { void globalAdmin_withoutAssumedRole_canAddRelationWithHolderUuidAndContactUuid() {
context.define("superuser-alex@hostsharing.net"); context.define("superuser-alex@hostsharing.net");
final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Third").get(0); final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Third").get(0);
@ -269,6 +269,61 @@ class HsOfficeRelationControllerAcceptanceTest extends ContextBasedTestWithClean
assertThat(newSubjectUuid).isNotNull(); 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(
location.substring(location.lastIndexOf('/') + 1)));
assertThat(newSubjectUuid).isNotNull();
}
@Test @Test
void globalAdmin_canNotAddRelation_ifAnchorPersonDoesNotExist() { void globalAdmin_canNotAddRelation_ifAnchorPersonDoesNotExist() {
@ -277,7 +332,7 @@ class HsOfficeRelationControllerAcceptanceTest extends ContextBasedTestWithClean
final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Smith").get(0); final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Smith").get(0);
final var givenContact = contactrealRepo.findContactByOptionalCaptionLike("fourth").get(0); final var givenContact = contactrealRepo.findContactByOptionalCaptionLike("fourth").get(0);
final var location = RestAssured // @formatter:off RestAssured // @formatter:off
.given() .given()
.header("current-subject", "superuser-alex@hostsharing.net") .header("current-subject", "superuser-alex@hostsharing.net")
.contentType(ContentType.JSON) .contentType(ContentType.JSON)