diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/common/LinkedResourceProcessor.java b/alfa-service/src/main/java/de/ozgcloud/alfa/common/LinkedResourceProcessor.java new file mode 100644 index 0000000000000000000000000000000000000000..e8bd48da57469f3d1451a50770c17465ddace303 --- /dev/null +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/common/LinkedResourceProcessor.java @@ -0,0 +1,95 @@ +package de.ozgcloud.alfa.common; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Collection; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Stream; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.server.RepresentationModelProcessor; +import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder; +import org.springframework.stereotype.Component; + +import de.ozgcloud.alfa.common.user.UserManagerUrlProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +@Component +@RequiredArgsConstructor +public class LinkedResourceProcessor implements RepresentationModelProcessor<EntityModel<?>> { + + private final UserManagerUrlProvider userManagerUrlProvider; + + @Override + public EntityModel<?> process(EntityModel<?> model) { + addLinkByLinkedResourceAnnotationIfMissing(model); + addLinkByLinkedUserProfileResourceAnnotationIfMissing(model); + return model; + } + + void addLinkByLinkedResourceAnnotationIfMissing(EntityModel<?> model) { + getFields(LinkedResource.class, model.getContent()) + .filter(field -> shouldAddLink(model, field)) + .forEach(field -> addLinkForLinkedResourceField(model, field)); + } + + void addLinkForLinkedResourceField(EntityModel<?> model, Field field) { + getEntityFieldValue(model.getContent(), field).map(Object::toString).filter(StringUtils::isNotBlank) + .ifPresent(val -> model + .add(WebMvcLinkBuilder.linkTo(field.getAnnotation(LinkedResource.class).controllerClass()).slash(val) + .withRel(trimIdSuffix(field.getName())))); + } + + void addLinkByLinkedUserProfileResourceAnnotationIfMissing(EntityModel<?> resource) { + getFields(LinkedUserProfileResource.class, resource.getContent()) + .filter(field -> shouldAddLink(resource, field)) + .forEach(field -> addLinkForLinkedUserProfileResourceField(resource, field)); + } + + Stream<Field> getFields(Class<? extends Annotation> annotationClass, Object content) { + if (Objects.isNull(content)) { + return Stream.empty(); + } + return Arrays.stream(content.getClass().getDeclaredFields()) + .filter(field -> field.isAnnotationPresent(annotationClass)); + } + + boolean shouldAddLink(EntityModel<?> resource, Field field) { + return !(field.getType().isArray() || Collection.class.isAssignableFrom(field.getType()) || resource.hasLink(trimIdSuffix(field.getName()))); + } + + void addLinkForLinkedUserProfileResourceField(EntityModel<?> model, Field field) { + getEntityFieldValue(model.getContent(), field).map(Object::toString).filter(StringUtils::isNotBlank) + .ifPresent(value -> addUserProfileLink(model, field, value)); + } + + private void addUserProfileLink(EntityModel<?> model, Field field, String value) { + Optional.ofNullable(userManagerUrlProvider.getUserProfileTemplate()).filter(StringUtils::isNotBlank) + .ifPresent(template -> model.add(Link.of(template.formatted(value)).withRel(trimIdSuffix(field.getName())))); + } + + private Optional<Object> getEntityFieldValue(Object content, Field field) { + try { + field.setAccessible(true); + Optional<Object> value = Optional.ofNullable(field.get(content)); + field.setAccessible(false); + return value; + } catch (IllegalArgumentException | IllegalAccessException e) { + LOG.warn("Cannot access field value of LinkedResource field.", e); + } + return Optional.empty(); + } + + private String trimIdSuffix(String fieldName) { + if (fieldName.endsWith("Id")) { + return fieldName.substring(0, fieldName.indexOf("Id")); + } + return fieldName; + } +} diff --git a/alfa-service/src/main/java/de/ozgcloud/alfa/common/user/UserManagerUrlProvider.java b/alfa-service/src/main/java/de/ozgcloud/alfa/common/user/UserManagerUrlProvider.java index 8777af4d2960ce65c5d956b663ab844da6ae8453..819a7beb378b9f7a615ed86c627d49693250550f 100644 --- a/alfa-service/src/main/java/de/ozgcloud/alfa/common/user/UserManagerUrlProvider.java +++ b/alfa-service/src/main/java/de/ozgcloud/alfa/common/user/UserManagerUrlProvider.java @@ -27,21 +27,21 @@ import java.util.Optional; import java.util.function.Predicate; import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.web.util.UriComponentsBuilder; import de.ozgcloud.alfa.postfach.PostfachMail; +import lombok.RequiredArgsConstructor; @Service +@RequiredArgsConstructor public class UserManagerUrlProvider { public static final String SYSTEM_USER_IDENTIFIER = "system"; public static final Predicate<PostfachMail> SENT_BY_CLIENT_USER = postfachNachricht -> Optional.ofNullable(postfachNachricht.getCreatedBy()) .map(createdBy -> !createdBy.toString().startsWith(SYSTEM_USER_IDENTIFIER)).orElse(false); - @Autowired - private UserManagerProperties userManagerProperties; + private final UserManagerProperties userManagerProperties; /** only for building links */ public String getUserProfileTemplate() { diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/common/LinkedResourceProcessorITCase.java b/alfa-service/src/test/java/de/ozgcloud/alfa/common/LinkedResourceProcessorITCase.java new file mode 100644 index 0000000000000000000000000000000000000000..894a5c12438e876e7253b3283a6a68a566b545ba --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/common/LinkedResourceProcessorITCase.java @@ -0,0 +1,107 @@ +package de.ozgcloud.alfa.common; + +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.hateoas.EntityModel; +import org.springframework.test.web.servlet.assertj.MockMvcTester; +import org.springframework.test.web.servlet.assertj.MvcTestResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; + +import de.ozgcloud.common.test.ITCase; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.SneakyThrows; + +@ITCase +class LinkedResourceProcessorITCase { + + @Autowired + private WebApplicationContext wac; + private MockMvcTester mockMvc; + + @BeforeEach + void setup() { + this.mockMvc = MockMvcTester.from(this.wac); + } + + @Test + @SneakyThrows + void shouldHaveAddLinkByLinkedResource() { + var result = doRequest(); + + result.assertThat().bodyJson().extractingPath("$._links.id.href") + .isEqualTo("http://localhost" + TestIdController.PATH + "/" + TestEntityTestFactory.ID); + } + + @Test + @SneakyThrows + void shouldHaveUserProfileLink() { + var result = doRequest(); + + result.assertThat().bodyJson().extractingPath("$._links.user.href") + .isEqualTo("https://localhost/api/userProfiles/" + TestEntityTestFactory.USER); + } + + private MvcTestResult doRequest() { + return mockMvc.get().uri(TestEntityController.PATH).exchange(); + } + + @Builder + @Getter + @EqualsAndHashCode + static class TestEntity { + + @LinkedResource(controllerClass = TestIdController.class) + private String id; + + private String foo; + + @LinkedUserProfileResource + private String user; + } + + @RequestMapping(TestEntityController.PATH) + @RestController + static class TestEntityController { + + static final String PATH = "/api/entity"; + + @GetMapping + public EntityModel<TestEntity> getTestEntity() { + return EntityModel.of(TestEntityTestFactory.create()); + } + } + + @RequestMapping(TestIdController.PATH) + static class TestIdController { + + static final String PATH = "/api/test"; + + static final String USER_REL = "user"; + static final String ID_REL = "id"; + + } + + static class TestEntityTestFactory { + + static final String USER = UUID.randomUUID().toString(); + static final String ID = UUID.randomUUID().toString(); + + public static TestEntity create() { + return createBuilder().build(); + } + + public static TestEntity.TestEntityBuilder createBuilder() { + return TestEntity.builder() + .id(ID) + .user(USER); + } + } +} diff --git a/alfa-service/src/test/java/de/ozgcloud/alfa/common/LinkedResourceProcessorTest.java b/alfa-service/src/test/java/de/ozgcloud/alfa/common/LinkedResourceProcessorTest.java new file mode 100644 index 0000000000000000000000000000000000000000..c7953a70483b77e99be0349f1f4d3060949b581d --- /dev/null +++ b/alfa-service/src/test/java/de/ozgcloud/alfa/common/LinkedResourceProcessorTest.java @@ -0,0 +1,428 @@ +package de.ozgcloud.alfa.common; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.Link; +import org.springframework.web.bind.annotation.RequestMapping; + +import com.thedeanda.lorem.LoremIpsum; + +import de.ozgcloud.alfa.common.user.UserManagerUrlProvider; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.SneakyThrows; + +class LinkedResourceProcessorTest { + + @Spy + @InjectMocks + private LinkedResourceProcessor processor; + + @Mock + private UserManagerUrlProvider userManagerUrlProvider; + + @Nested + class TestProcess { + + private final TestEntity entity = TestEntityTestFactory.create(); + private final EntityModel<TestEntity> model = EntityModel.of(entity); + + @BeforeEach + void mock() { + doNothing().when(processor).addLinkByLinkedResourceAnnotationIfMissing(any()); + doNothing().when(processor).addLinkByLinkedUserProfileResourceAnnotationIfMissing(any()); + } + + @Test + void shouldReturnSameEntityModel() { + var result = processor.process(model); + + assertThat(result).isSameAs(model); + } + + @Test + void shouldCallAddLinkByLinkedResourceAnnotationIfMissing() { + processor.process(model); + + verify(processor).addLinkByLinkedResourceAnnotationIfMissing(model); + } + + @Test + void shouldCallAddLinkByLinkedUserProfileResourceAnnotationIfMissing() { + processor.process(model); + + verify(processor).addLinkByLinkedUserProfileResourceAnnotationIfMissing(model); + } + } + + @Nested + class TestAddLinkByLinkedResourceAnnotationIfMissing { + + private final TestEntity entity = TestEntityTestFactory.create(); + private final EntityModel<TestEntity> model = EntityModel.of(entity); + + @Mock + private Field field; + + @BeforeEach + void mock() { + doReturn(Stream.of(field)).when(processor).getFields(any(), any()); + } + + @Test + void shouldGetFieldsWithLinkedResourceAnnotation() { + doReturn(false).when(processor).shouldAddLink(any(), any()); + + processor.addLinkByLinkedResourceAnnotationIfMissing(model); + + verify(processor).getFields(LinkedResource.class, entity); + } + + @Test + void shouldCallShouldAddLink() { + doReturn(false).when(processor).shouldAddLink(any(), any()); + + processor.addLinkByLinkedResourceAnnotationIfMissing(model); + + verify(processor).shouldAddLink(model, field); + } + + @Test + void shouldCallAddLinkForLinkedResourceField() { + doReturn(true).when(processor).shouldAddLink(any(), any()); + + processor.addLinkByLinkedResourceAnnotationIfMissing(model); + + verify(processor).addLinkForLinkedResourceField(model, field); + } + + @Test + void shouldNotCallAddLinkForLinkedResourceField() { + doReturn(false).when(processor).shouldAddLink(any(), any()); + + processor.addLinkByLinkedResourceAnnotationIfMissing(model); + + verify(processor, never()).addLinkForLinkedResourceField(any(), any()); + } + } + + @Nested + class TestAddLinkByLinkedUserProfileResourceAnnotationIfMissing { + + private final TestEntity entity = TestEntityTestFactory.create(); + private final EntityModel<TestEntity> model = EntityModel.of(entity); + + @Mock + private Field field; + + @BeforeEach + void mock() { + doReturn(Stream.of(field)).when(processor).getFields(any(), any()); + } + + @Test + void shouldGetFieldsWithLinkedUserProfileResourceAnnotation() { + doReturn(false).when(processor).shouldAddLink(any(), any()); + + processor.addLinkByLinkedUserProfileResourceAnnotationIfMissing(model); + + verify(processor).getFields(LinkedUserProfileResource.class, entity); + } + + @Test + void shouldCallShouldAddLink() { + doReturn(false).when(processor).shouldAddLink(any(), any()); + + processor.addLinkByLinkedUserProfileResourceAnnotationIfMissing(model); + + verify(processor).shouldAddLink(model, field); + } + + @Test + void shouldCallAddLinkForLinkedUserProfileResourceField() { + doReturn(true).when(processor).shouldAddLink(any(), any()); + + processor.addLinkByLinkedUserProfileResourceAnnotationIfMissing(model); + + verify(processor).addLinkForLinkedUserProfileResourceField(model, field); + } + + @Test + void shouldNotCallAddLinkForLinkedUserProfileResourceField() { + doReturn(false).when(processor).shouldAddLink(any(), any()); + + processor.addLinkByLinkedUserProfileResourceAnnotationIfMissing(model); + + verify(processor, never()).addLinkForLinkedUserProfileResourceField(any(), any()); + } + } + + @Nested + class TestGetFields { + + @Test + void shouldReturnEmptyStreamIfContentIsNull() { + var result = processor.getFields(LinkedResource.class, null); + + assertThat(result).isEmpty(); + } + + @Test + void shouldReturnAllFieldsWithLinkedResourceAnnotation() { + var entity = TestEntityTestFactory.create(); + var expectedFields = List.of(getField("linkedResource"), getField("testId")); + + var result = processor.getFields(LinkedResource.class, entity); + + assertThat(result).containsExactlyInAnyOrderElementsOf(expectedFields); + } + + @Test + void shouldReturnAllFieldsWithLinkedUserProfileResource() { + var entity = TestEntityTestFactory.create(); + var expectedFields = List.of(getField("user"), getField("differentUserId")); + + var result = processor.getFields(LinkedUserProfileResource.class, entity); + + assertThat(result).containsExactlyInAnyOrderElementsOf(expectedFields); + } + + @SneakyThrows + private Field getField(String fieldName) { + return TestEntity.class.getDeclaredField(fieldName); + } + } + + @Nested + class TestShouldAddLink { + + private final TestEntity entity = TestEntityTestFactory.create(); + private final EntityModel<TestEntity> model = EntityModel.of(entity); + + @Test + void shouldReturnFalseIfFieldIsArray() { + var field = getField("arrayField"); + + var result = processor.shouldAddLink(model, field); + + assertThat(result).isFalse(); + } + + @Test + void shouldReturnFalseIfFieldIsCollection() { + var field = getField("collectionField"); + + var result = processor.shouldAddLink(model, field); + + assertThat(result).isFalse(); + } + + @Test + void shouldReturnFalseIfFieldHasLink() { + var field = getField("linkedResource"); + model.add(Link.of(LoremIpsum.getInstance().getUrl()).withRel(TestIdController.LINKED_RESOURCE_REL)); + + var result = processor.shouldAddLink(model, field); + + assertThat(result).isFalse(); + } + + @Test + void shouldReturnTrueOtherwise() { + var fieldsToBuildLinksFor = Arrays.stream(TestEntity.class.getDeclaredFields()) + .filter(field -> !(field.getName().equals("collectionField") || field.getName().equals("arrayField"))); + + var result = fieldsToBuildLinksFor.map(field -> processor.shouldAddLink(model, field)); + + assertThat(result).isNotEmpty().allMatch(value -> value.equals(true)); + } + + @SneakyThrows + private Field getField(String fieldName) { + return TestEntity.class.getDeclaredField(fieldName); + } + } + + @Nested + class TestAddLinkForLinkedResourceField { + + @Test + void shouldAddLinkOfLinkedResource() { + var model = EntityModel.of(TestEntityTestFactory.create()); + var field = getField("linkedResource"); + + processor.addLinkForLinkedResourceField(model, field); + + assertThat(model.getLink(TestIdController.LINKED_RESOURCE_REL).get().getHref()) + .isEqualTo(TestIdController.PATH + "/" + TestEntityTestFactory.LINKED_RESOURCE); + } + + @Test + void shouldTrimIdSuffix() { + var model = EntityModel.of(TestEntityTestFactory.create()); + var field = getField("testId"); + + processor.addLinkForLinkedResourceField(model, field); + + assertThat(model.getLink(TestIdController.ID_REL).get().getHref()) + .isEqualTo(TestIdController.PATH + "/" + TestEntityTestFactory.ID); + } + + @ParameterizedTest + @NullAndEmptySource + void shouldNotAddLinkIfFieldValueIsNullOrBlank(String linkedResourceValue) { + var model = EntityModel.of(TestEntityTestFactory.createBuilder().linkedResource(linkedResourceValue).build()); + var field = getField("linkedResource"); + + processor.addLinkForLinkedResourceField(model, field); + + assertThat(model.getLink(TestIdController.LINKED_RESOURCE_REL)).isEmpty(); + } + + @SneakyThrows + private Field getField(String fieldName) { + return TestEntity.class.getDeclaredField(fieldName); + } + } + + @Nested + class TestAddLinkForLinkedUserProfileResourceField { + + private final String userProfileTemplate = LoremIpsum.getInstance().getUrl() + "/%s"; + + @Test + void shouldGetUserProfileTemplate() { + when(userManagerUrlProvider.getUserProfileTemplate()).thenReturn(userProfileTemplate); + var field = getField("user"); + + processor.addLinkForLinkedUserProfileResourceField(EntityModel.of(TestEntityTestFactory.create()), field); + + verify(userManagerUrlProvider).getUserProfileTemplate(); + } + + @Test + void shouldAddLinkOfLinkedUserProfileResource() { + when(userManagerUrlProvider.getUserProfileTemplate()).thenReturn(userProfileTemplate); + var model = EntityModel.of(TestEntityTestFactory.create()); + var field = getField("user"); + + processor.addLinkForLinkedUserProfileResourceField(model, field); + + assertThat(model.getLink(TestIdController.USER_REL).get().getHref()) + .isEqualTo(userProfileTemplate.formatted(TestEntityTestFactory.USER)); + } + + @Test + void shouldTrimIdSuffix() { + when(userManagerUrlProvider.getUserProfileTemplate()).thenReturn(userProfileTemplate); + var model = EntityModel.of(TestEntityTestFactory.create()); + var field = getField("differentUserId"); + + processor.addLinkForLinkedUserProfileResourceField(model, field); + + assertThat(model.getLink(TestIdController.DIFFERENT_USER_REL).get().getHref()) + .isEqualTo(userProfileTemplate.formatted(TestEntityTestFactory.DIFFERENT_USER_ID)); + } + + @ParameterizedTest + @NullAndEmptySource + void shouldNotAddLinkIfFieldValueIsNullOrBlank(String userValue) { + var model = EntityModel.of(TestEntityTestFactory.createBuilder().user(userValue).build()); + var field = getField("user"); + + processor.addLinkForLinkedUserProfileResourceField(model, field); + + assertThat(model.getLink(TestIdController.USER_REL)).isEmpty(); + } + + @ParameterizedTest + @NullAndEmptySource + void shouldNotAddLinkIfUserManagerUrlIsNullOrBlank(String userManagerUrl) { + when(userManagerUrlProvider.getUserProfileTemplate()).thenReturn(userManagerUrl); + var model = EntityModel.of(TestEntityTestFactory.create()); + var field = getField("user"); + + processor.addLinkForLinkedUserProfileResourceField(model, field); + + assertThat(model.getLink(TestIdController.USER_REL)).isEmpty(); + } + + @SneakyThrows + private Field getField(String fieldName) { + return TestEntity.class.getDeclaredField(fieldName); + } + } + + @Builder + @Getter + @EqualsAndHashCode + static class TestEntity { + + @LinkedResource(controllerClass = TestIdController.class) + private String linkedResource; + @LinkedResource(controllerClass = TestIdController.class) + private String testId; + + private String foo; + private Collection<String> collectionField; + private String[] arrayField; + + @LinkedUserProfileResource + private String user; + + @LinkedUserProfileResource + private String differentUserId; + } + + @RequestMapping(TestIdController.PATH) + static class TestIdController { + + static final String PATH = "/api/test"; + + static final String USER_REL = "user"; + static final String DIFFERENT_USER_REL = "differentUser"; + static final String ID_REL = "test"; + static final String LINKED_RESOURCE_REL = "linkedResource"; + + } + + static class TestEntityTestFactory { + + static final String USER = UUID.randomUUID().toString(); + static final String DIFFERENT_USER_ID = UUID.randomUUID().toString(); + static final String ID = UUID.randomUUID().toString(); + static final String LINKED_RESOURCE = UUID.randomUUID().toString(); + + public static TestEntity create() { + return createBuilder().build(); + } + + public static TestEntity.TestEntityBuilder createBuilder() { + return TestEntity.builder() + .testId(ID) + .linkedResource(LINKED_RESOURCE) + .user(USER) + .differentUserId(DIFFERENT_USER_ID) + .foo("bar"); + } + } +}