diff --git a/goofy-server/src/main/java/de/itvsh/goofy/common/LinkedUserProfileResource.java b/goofy-server/src/main/java/de/itvsh/goofy/common/LinkedUserProfileResource.java index 0eb257fcd5fbf02d84f840cc09d373086632cfb0..45f83c9bd99d426945c9bdb944a9bd84c13be7f5 100644 --- a/goofy-server/src/main/java/de/itvsh/goofy/common/LinkedUserProfileResource.java +++ b/goofy-server/src/main/java/de/itvsh/goofy/common/LinkedUserProfileResource.java @@ -18,9 +18,8 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize; @JsonSerialize(using = LinkedUserProfileResourceSerializer.class) @JsonDeserialize(using = LinkedUserProfileResourceDeserializer.class) public @interface LinkedUserProfileResource { - Class<? extends IdExtractor<Object>> extractor() - default ToStringExtractor.class; + Class<? extends IdExtractor<Object>> extractor() default ToStringExtractor.class; Class<? extends ObjectBuilder<Object>> builder() default IdBuilder.class; } diff --git a/goofy-server/src/main/java/de/itvsh/goofy/common/ModelBuilder.java b/goofy-server/src/main/java/de/itvsh/goofy/common/ModelBuilder.java index 4b54af83ff9c81cd85efef0bf52abf4789d2d9e9..99764401b87bc5e96ce02d21112d1f12745d2edd 100644 --- a/goofy-server/src/main/java/de/itvsh/goofy/common/ModelBuilder.java +++ b/goofy-server/src/main/java/de/itvsh/goofy/common/ModelBuilder.java @@ -4,6 +4,8 @@ import java.lang.annotation.Annotation; import java.lang.reflect.Field; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; import java.util.Iterator; import java.util.LinkedList; import java.util.List; @@ -22,6 +24,7 @@ import org.apache.commons.collections.CollectionUtils; import org.apache.commons.lang3.reflect.FieldUtils; import org.springframework.hateoas.EntityModel; import org.springframework.hateoas.Link; +import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; @@ -29,7 +32,7 @@ import lombok.extern.log4j.Log4j2; @Log4j2 public class ModelBuilder<T> { - private static final Map<Class<?>, List<Field>> ANNOTATED_FIELDS = new ConcurrentHashMap<>(); + private static final Map<Class<?>, Map<Object, List<Field>>> ANNOTATED_FIELDS_BY_ANNOTATION = new ConcurrentHashMap<>(); private final T entity; private final EntityModel<T> model; @@ -88,7 +91,9 @@ public class ModelBuilder<T> { EntityModel<T> buildedModel = Objects.isNull(model) ? EntityModel.of(entity) : model; buildedModel = buildedModel.add(filteredLinks); - addLinkByAnnotationIfMissing(buildedModel, LinkedResource.class, LinkedUserProfileResource.class); + + addLinkByLinkedResourceAnnotationIfMissing(buildedModel); + addLinkByLinkedUserProfileResourceAnnotationIfMissing(buildedModel); return applyMapper(buildedModel); } @@ -102,29 +107,50 @@ public class ModelBuilder<T> { return result; } - private T getEntity() { - return Optional.ofNullable(entity == null ? model.getContent() : entity) - .orElseThrow(() -> new IllegalStateException("Entity must not null for ModelBuilding")); + private void addLinkByLinkedResourceAnnotationIfMissing(EntityModel<T> resource) { + getFields(LinkedResource.class).stream() + .filter(field -> shouldAddLink(resource, field)) + .forEach(field -> handleLinkedResourceField(resource, field)); } - @SafeVarargs - private void addLinkByAnnotationIfMissing(EntityModel<T> resource, Class<? extends Annotation>... annotationClasses) { - Arrays.stream(annotationClasses).forEach(annotation -> { - var fields = ANNOTATED_FIELDS.get(getEntity().getClass()); - if (CollectionUtils.isEmpty(fields)) { - fields = FieldUtils.getFieldsListWithAnnotation(getEntity().getClass(), annotation); - ANNOTATED_FIELDS.put(getEntity().getClass(), fields); - } + private void handleLinkedResourceField(EntityModel<T> resource, Field field) { + getEntityFieldValue(field).ifPresent(val -> resource + .add(WebMvcLinkBuilder.linkTo(field.getAnnotation(LinkedResource.class).controllerClass()).slash(val) + .withRel(sanitizeName(field.getName())))); + } - fields.forEach(field -> { - String fieldName = sanitizeName(field.getName()); - if (field.getType().isArray() || Collection.class.isAssignableFrom(field.getType()) || resource.hasLink(fieldName)) { - return; - } + private void addLinkByLinkedUserProfileResourceAnnotationIfMissing(EntityModel<T> resource) { + getFields(LinkedUserProfileResource.class).stream() + .filter(field -> shouldAddLink(resource, field)) + .forEach(field -> handleLinkedUserProfileResourceField(resource, field)); + } + + private void handleLinkedUserProfileResourceField(EntityModel<T> resource, Field field) { + getEntityFieldValue(field).ifPresent(val -> resource.add(Link.of(UserProfileUrlProvider.getUrl(val)).withRel(sanitizeName(field.getName())))); + } - getEntityFieldValue(field).ifPresent(val -> resource.add(Link.of(UserProfileUrlProvider.getUrl(val)).withRel(fieldName))); - }); - }); + private boolean shouldAddLink(EntityModel<T> resource, Field field) { + return !(field.getType().isArray() || Collection.class.isAssignableFrom(field.getType()) || resource.hasLink(sanitizeName(field.getName()))); + } + + private List<Field> getFields(Class<? extends Annotation> annotationClass) { + var fields = Optional.ofNullable(ANNOTATED_FIELDS_BY_ANNOTATION.get(getEntity().getClass())) + .map(fieldsByAnnotation -> fieldsByAnnotation.get(annotationClass)) + .orElseGet(() -> Collections.emptyList()); + + if (CollectionUtils.isEmpty(fields)) { + fields = FieldUtils.getFieldsListWithAnnotation(getEntity().getClass(), annotationClass); + + updateFields(annotationClass, fields); + } + return fields; + } + + private void updateFields(Class<? extends Annotation> annotationClass, List<Field> fields) { + var annotationMap = Optional.ofNullable(ANNOTATED_FIELDS_BY_ANNOTATION.get(getEntity().getClass())).orElseGet(HashMap::new); + annotationMap.put(annotationClass, fields); + + ANNOTATED_FIELDS_BY_ANNOTATION.put(annotationClass, annotationMap); } private String sanitizeName(String fieldName) { @@ -146,6 +172,11 @@ public class ModelBuilder<T> { return Optional.empty(); } + private T getEntity() { + return Optional.ofNullable(entity == null ? model.getContent() : entity) + .orElseThrow(() -> new IllegalStateException("Entity must not null for ModelBuilding")); + } + @RequiredArgsConstructor public class ConditionalLinkAdder { diff --git a/goofy-server/src/test/java/de/itvsh/goofy/common/ModelBuilderTest.java b/goofy-server/src/test/java/de/itvsh/goofy/common/ModelBuilderTest.java new file mode 100644 index 0000000000000000000000000000000000000000..658f4214b10359dba4cee7658f40f1a235f48f2e --- /dev/null +++ b/goofy-server/src/test/java/de/itvsh/goofy/common/ModelBuilderTest.java @@ -0,0 +1,92 @@ +package de.itvsh.goofy.common; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.springframework.context.ApplicationContext; +import org.springframework.core.env.Environment; +import org.springframework.web.bind.annotation.RequestMapping; + +import lombok.Builder; + +class ModelBuilderTest { + + @DisplayName("Add link by annotation if missing") + @Nested + class TestAddLinkByAnnotationIfMissing { + + private static final String USER_MANAGER_URL = "http://localhost"; + private static final String USER_MANAGER_PROFILE_TEMPLATE = "/api/profile/%s"; + + private UserProfileUrlProvider provider = new UserProfileUrlProvider(); + + @Mock + private ApplicationContext context; + @Mock + private Environment env; + + private TestEntity entity = TestEntityTestFactory.create(); + + @BeforeEach + void mockEnvironment() { + when(env.getProperty(UserProfileUrlProvider.URL_ROOT_KEY)).thenReturn(USER_MANAGER_URL); + when(env.getProperty(UserProfileUrlProvider.USER_PROFILES_TEMPLATE_KEY)).thenReturn(USER_MANAGER_PROFILE_TEMPLATE); + when(context.getEnvironment()).thenReturn(env); + + provider.setApplicationContext(context); + } + + @Test + void shouldHaveAddLinkByLinkedResource() { + var model = ModelBuilder.fromEntity(entity).buildModel(); + + assertThat(model.getLink(TestController.FILE_REL).get().getHref()).isEqualTo("/api/test/" + TestEntityTestFactory.FILE); + } + + @Test + void shouldHaveAddLinkByLinkedUserProfileResource() { + var model = ModelBuilder.fromEntity(entity).buildModel(); + + assertThat(model.getLink(TestController.USER_REL).get().getHref()) + .isEqualTo(String.format(USER_MANAGER_URL + USER_MANAGER_PROFILE_TEMPLATE, TestEntityTestFactory.USER)); + } + } +} + +@Builder +class TestEntity { + + @LinkedResource(controllerClass = TestController.class) + private String file; + + @LinkedUserProfileResource + private String user; +} + +@RequestMapping("/api/test") +class TestController { + + static final String USER_REL = "user"; + static final String FILE_REL = "file"; + +} + +class TestEntityTestFactory { + + static final String USER = UUID.randomUUID().toString(); + static final String FILE = UUID.randomUUID().toString(); + + public static TestEntity create() { + return TestEntity.builder() + .file(FILE) + .user(USER) + .build(); + } +} diff --git a/goofy-server/src/test/java/de/itvsh/goofy/wiedervorlage/WiedervorlageControllerITCase.java b/goofy-server/src/test/java/de/itvsh/goofy/wiedervorlage/WiedervorlageControllerITCase.java index 93bdce9cb73cfbeda48d710fe2dc52e27c2d3db8..b3d22fb76f5a9d886d1909fec28df0230c8688d8 100644 --- a/goofy-server/src/test/java/de/itvsh/goofy/wiedervorlage/WiedervorlageControllerITCase.java +++ b/goofy-server/src/test/java/de/itvsh/goofy/wiedervorlage/WiedervorlageControllerITCase.java @@ -74,6 +74,8 @@ public class WiedervorlageControllerITCase { var response = callEndpoint(); response.andDo(print()) + .andExpect(jsonPath("$._links.self").exists()) + .andExpect(jsonPath("$._links.createdBy").exists()) .andExpect(jsonPath("$.createdAt").value(WiedervorlageTestFactory.CREATED_AT_STR)) .andExpect(jsonPath("$.frist").value(WiedervorlageTestFactory.FRIST.atStartOfDay().format(DateTimeFormatter.ISO_DATE_TIME))); }