Skip to content

Commit

Permalink
Merge pull request #99 from xenit-eu/generic-link-relations
Browse files Browse the repository at this point in the history
Add support for generic link relations based on datamodel
  • Loading branch information
vierbergenlars authored Aug 3, 2023
2 parents 3ce7eb2 + 4fd38af commit ada906b
Show file tree
Hide file tree
Showing 21 changed files with 749 additions and 8 deletions.
2 changes: 2 additions & 0 deletions contentgrid-spring-boot-autoconfigure/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ dependencies {
compileOnly 'org.springframework.integration:spring-integration-core'
compileOnly "com.github.paulcwarren:spring-content-autoconfigure"
compileOnly "com.github.paulcwarren:spring-content-s3"
compileOnly "com.github.paulcwarren:spring-content-rest"
compileOnly 'org.springframework.data:spring-data-rest-webmvc'
compileOnly 'jakarta.persistence:jakarta.persistence-api'

Expand All @@ -47,6 +48,7 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.data:spring-data-rest-webmvc'
testImplementation 'com.github.paulcwarren:spring-content-s3-boot-starter'
testImplementation 'com.github.paulcwarren:spring-content-rest-boot-starter'

testCompileOnly 'org.projectlombok:lombok'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,37 @@
import com.contentgrid.spring.data.rest.affordances.ContentGridSpringDataRestAffordancesConfiguration;
import com.contentgrid.spring.data.rest.hal.ContentGridCurieConfiguration;
import com.contentgrid.spring.data.rest.hal.CurieProviderCustomizer;
import com.contentgrid.spring.data.rest.links.ContentGridSpringContentRestLinksConfiguration;
import com.contentgrid.spring.data.rest.links.ContentGridSpringDataLinksConfiguration;
import com.contentgrid.spring.data.rest.webmvc.ContentGridSpringDataRestProfileConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.hateoas.HypermediaAutoConfiguration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.content.rest.config.RestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.data.rest.webmvc.ContentGridRestProperties;
import org.springframework.data.rest.webmvc.ContentGridSpringDataRestConfiguration;
import org.springframework.data.rest.webmvc.config.RepositoryRestMvcConfiguration;

@Configuration
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ContentGridSpringDataRestConfiguration.class, RepositoryRestMvcConfiguration.class})
@Import({
ContentGridSpringDataRestConfiguration.class,
ContentGridSpringDataRestProfileConfiguration.class,
ContentGridSpringDataRestAffordancesConfiguration.class
ContentGridSpringDataRestAffordancesConfiguration.class,
})
@AutoConfigureAfter(HypermediaAutoConfiguration.class)
@AutoConfigureAfter(
name = {
// Specifically ContentGridSpringContentRestLinksAutoConfiguration must run after spring-content
// is initialized so @ConditionalOnBean works correctly. Putting the annotation directly on that class
// does not work, because it is not an autoconfiguration, but is initialized directly when this parent class
// is initialized.
"internal.org.springframework.content.rest.boot.autoconfigure.ContentRestAutoConfiguration"
}
)
public class ContentGridSpringDataRestAutoConfiguration {

@Bean
Expand All @@ -32,10 +42,21 @@ ContentGridRestProperties contentGridRestProperties() {
return new ContentGridRestProperties();
}

@Configuration(proxyBeanMethods = false)
@ConditionalOnBean(CurieProviderCustomizer.class)
@Import(ContentGridCurieConfiguration.class)
static class CurieAutoConfiguration {
@Import({
ContentGridCurieConfiguration.class,
ContentGridSpringDataLinksConfiguration.class
})
static class ContentGridSpringDataRestCurieAutoConfiguration {

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(RestConfiguration.class)
@ConditionalOnBean(type = "org.springframework.content.commons.storeservice.Stores")
@Import(ContentGridSpringContentRestLinksConfiguration.class)
static class ContentGridSpringContentRestLinksAutoConfiguration {

}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
import static org.assertj.core.api.Assertions.assertThat;

import com.contentgrid.spring.data.rest.hal.CurieProviderCustomizer;
import internal.org.springframework.content.rest.boot.autoconfigure.ContentRestAutoConfiguration;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.data.rest.RepositoryRestMvcAutoConfiguration;
import org.springframework.boot.context.annotation.UserConfigurations;
import org.springframework.boot.test.context.FilteredClassLoader;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.content.rest.config.RestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.rest.webmvc.ContentGridSpringDataRestConfiguration;
Expand Down Expand Up @@ -85,6 +87,47 @@ private static class CurieCustomizerConfiguration {
CurieProviderCustomizer myCurieProviderCustomizer() {
return CurieProviderCustomizer.register("test", "https://test.invalid/{rel}");
}
}

@Test
void without_curieProviderCustomizer_when_springContent_isPresentAndConfigured() {
contextRunner
.withConfiguration(AutoConfigurations.of(ContentRestAutoConfiguration.class))
.run(context -> {
assertThat(context).doesNotHaveBean("contentGridSpringContentLinkCollector");
assertThat(context).hasNotFailed();
});
}

@Test
void with_curieProviderCustomizer_when_springContent_isPresentAndConfigured() {
contextRunner
.withConfiguration(AutoConfigurations.of(ContentRestAutoConfiguration.class))
.withConfiguration(UserConfigurations.of(CurieCustomizerConfiguration.class))
.run(context -> {
assertThat(context).hasBean("contentGridSpringContentLinkCollector");
assertThat(context).hasNotFailed();
});
}

@Test
void with_curieProviderCustomizer_when_springContent_isPresentAndUnconfigured() {
contextRunner
.withConfiguration(UserConfigurations.of(CurieCustomizerConfiguration.class))
.run(context -> {
assertThat(context).doesNotHaveBean("contentGridSpringContentLinkCollector");
assertThat(context).hasNotFailed();
});
}

@Test
void with_curieProviderCustomizer_when_springContent_isAbsent() {
contextRunner
.withClassLoader(new FilteredClassLoader(RestConfiguration.class))
.withConfiguration(UserConfigurations.of(CurieCustomizerConfiguration.class))
.run(context -> {
assertThat(context).doesNotHaveBean("contentGridSpringContentLinkCollector");
assertThat(context).hasNotFailed();
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.contentgrid.spring.data.rest.links;

import lombok.RequiredArgsConstructor;
import org.springframework.data.rest.webmvc.mapping.LinkCollector;
import org.springframework.hateoas.Links;

@RequiredArgsConstructor
class AggregateLinkCollector implements LinkCollector {
private final LinkCollector delegate;
private final Iterable<ContentGridLinkCollector> collectors;

@Override
public Links getLinksFor(Object object) {
return getLinksFor(object, Links.NONE);
}

@Override
public Links getLinksFor(Object object, Links existing) {
existing = delegate.getLinksFor(object, existing);
for (var collector : collectors) {
existing = collector.getLinksFor(object, existing);
}
return existing;
}

@Override
public Links getLinksForNested(Object object, Links existing) {
existing = delegate.getLinksFor(object, existing);
for (var collector : collectors) {
existing = collector.getLinksForNested(object, existing);
}
return existing;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.contentgrid.spring.data.rest.links;

import org.springframework.hateoas.Links;

public interface ContentGridLinkCollector {

Links getLinksFor(Object object, Links existing);

Links getLinksForNested(Object object, Links existing);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.contentgrid.spring.data.rest.links;

import lombok.experimental.UtilityClass;
import org.springframework.hateoas.LinkRelation;
import org.springframework.hateoas.UriTemplate;
import org.springframework.hateoas.mediatype.hal.HalLinkRelation;

@UtilityClass
public class ContentGridLinkRelations {
final static String CURIE = "cg";
final static UriTemplate TEMPLATE = UriTemplate.of("https://contentgrid.com/rels/contentgrid/{rel}");

public final static LinkRelation ENTITY = HalLinkRelation.curied(CURIE, "entity");
public final static LinkRelation RELATION = HalLinkRelation.curied(CURIE, "relation");
public final static LinkRelation CONTENT = HalLinkRelation.curied(CURIE, "content");

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.contentgrid.spring.data.rest.links;

import internal.org.springframework.content.rest.mappingcontext.ContentPropertyToLinkrelMappingContext;
import internal.org.springframework.content.rest.mappingcontext.ContentPropertyToRequestMappingContext;
import org.springframework.content.commons.mappingcontext.MappingContext;
import org.springframework.content.commons.storeservice.Stores;
import org.springframework.content.rest.config.RestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.data.mapping.context.PersistentEntities;

@Configuration(proxyBeanMethods = false)
@Import(ContentGridSpringDataLinksConfiguration.class)
public class ContentGridSpringContentRestLinksConfiguration {
@Bean
ContentGridLinkCollector contentGridSpringContentLinkCollector(
PersistentEntities entities, Stores stores, MappingContext mappingContext, RestConfiguration restConfiguration, ContentPropertyToRequestMappingContext requestMappingContext, ContentPropertyToLinkrelMappingContext linkrelMappingContext) {
return new SpringContentLinkCollector(entities, stores, mappingContext, restConfiguration, requestMappingContext, linkrelMappingContext);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.contentgrid.spring.data.rest.links;

import com.contentgrid.spring.data.rest.hal.CurieProviderCustomizer;
import com.contentgrid.spring.data.rest.webmvc.ProfileLinksResource;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mapping.context.PersistentEntities;
import org.springframework.data.repository.support.Repositories;
import org.springframework.data.rest.core.config.RepositoryRestConfiguration;
import org.springframework.data.rest.core.mapping.ResourceMappings;
import org.springframework.data.rest.core.support.SelfLinkProvider;
import org.springframework.data.rest.webmvc.RepositoryLinksResource;
import org.springframework.data.rest.webmvc.RestControllerConfiguration;
import org.springframework.data.rest.webmvc.config.RepositoryRestConfigurer;
import org.springframework.data.rest.webmvc.mapping.Associations;
import org.springframework.data.rest.webmvc.mapping.LinkCollector;
import org.springframework.hateoas.server.EntityLinks;
import org.springframework.hateoas.server.RepresentationModelProcessor;

@Configuration(proxyBeanMethods = false)
public class ContentGridSpringDataLinksConfiguration {
@Bean
RepositoryRestConfigurer contentGridLinkCollectorConfigurer(
ObjectProvider<ContentGridLinkCollector> collectors
) {
return new RepositoryRestConfigurer() {
@Override
public LinkCollector customizeLinkCollector(LinkCollector collector) {
return new AggregateLinkCollector(collector, collectors);
}
};
}

@Bean
ContentGridLinkCollector contentGridRelationLinkCollector(PersistentEntities entities, Associations associations, SelfLinkProvider selfLinkProvider) {
return new SpringDataAssociationLinkCollector(entities, associations, selfLinkProvider);
}

@Bean
CurieProviderCustomizer contentGridCurieProviderCustomizer() {
return CurieProviderCustomizer.register(ContentGridLinkRelations.CURIE, ContentGridLinkRelations.TEMPLATE);
}

@Bean
RepresentationModelProcessor<RepositoryLinksResource> contentGridRepositoryLinksResourceProcessor(Repositories repositories, ResourceMappings resourceMappings, EntityLinks entityLinks) {
return new SpringDataRepositoryLinksResourceProcessor(repositories, resourceMappings, entityLinks);
}

@Bean
RepresentationModelProcessor<ProfileLinksResource> contentGridProfileLinksResourceProcessor(Repositories repositories, ResourceMappings resourceMappings, RepositoryRestConfiguration configuration) {
return new SpringDataProfileLinksResourceProcessor(repositories, resourceMappings, configuration);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.contentgrid.spring.data.rest.links;

import internal.org.springframework.content.rest.links.ContentLinksResourceProcessor.StoreLinkBuilder;
import internal.org.springframework.content.rest.mappingcontext.ContentPropertyToLinkrelMappingContext;
import internal.org.springframework.content.rest.mappingcontext.ContentPropertyToRequestMappingContext;
import java.util.ArrayList;
import java.util.Map;
import java.util.Map.Entry;
import lombok.RequiredArgsConstructor;
import org.springframework.content.commons.mappingcontext.ContentProperty;
import org.springframework.content.commons.mappingcontext.MappingContext;
import org.springframework.content.commons.repository.AssociativeStore;
import org.springframework.content.commons.storeservice.Stores;
import org.springframework.content.rest.config.RestConfiguration;
import org.springframework.data.mapping.context.PersistentEntities;
import org.springframework.data.rest.webmvc.BaseUri;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.LinkRelation;
import org.springframework.hateoas.Links;
import org.springframework.hateoas.mediatype.hal.HalLinkRelation;
import org.springframework.util.StringUtils;

/**
* Collects links to spring-content content objects into the {@link ContentGridLinkRelations#CONTENT} link-relation
*/
@RequiredArgsConstructor
class SpringContentLinkCollector implements ContentGridLinkCollector {
private final PersistentEntities entities;
private final Stores stores;
private final MappingContext mappingContext;
private final RestConfiguration restConfiguration;
private final ContentPropertyToRequestMappingContext requestMappingContext;
private final ContentPropertyToLinkrelMappingContext linkrelMappingContext;

@Override
public Links getLinksFor(Object object, Links existing) {
// This implementation is inspired on the ContentLinksResourceProcessor from spring-content, but adapted to the context of a LinkCollector

var persistentEntity = entities.getRequiredPersistentEntity(object.getClass());

var entityId = persistentEntity.getIdentifierAccessor(object).getIdentifier();

if(entityId == null) {
// No entity ID, so no content links (because they reference the entity ID)
return existing;
}

var storeInfo = stores.getStore(AssociativeStore.class, Stores.withDomainClass(persistentEntity.getType()));
if(storeInfo == null) {
// No store, we don't have to add any links
return existing;
}

Map<String, ContentProperty> contentProperties = mappingContext.getContentPropertyMap(persistentEntity.getType());

var links = new ArrayList<Link>(contentProperties.size());

for (Entry<String, ContentProperty> contentProperty : contentProperties.entrySet()) {
var linkBuilder = StoreLinkBuilder.linkTo(new BaseUri(restConfiguration.getBaseUri()), storeInfo);
linkBuilder = linkBuilder.slash(entityId);

String requestMapping = requestMappingContext.getMappings(storeInfo.getDomainObjectClass()).get(contentProperty.getKey());

if(StringUtils.hasText(requestMapping)) {
linkBuilder = linkBuilder.slash(requestMapping);
} else {
linkBuilder = linkBuilder.slash(contentProperty.getKey());
}

String linkRel = linkrelMappingContext.getMappings(storeInfo.getDomainObjectClass()).get(contentProperty.getKey());
if(!StringUtils.hasLength(linkRel)) {
linkRel = contentProperty.getKey();
}
// Cut off a potential CURIE prefix from the link relation
var linkName = HalLinkRelation.of(LinkRelation.of(linkRel)).getLocalPart();


var link = linkBuilder
.withRel(ContentGridLinkRelations.CONTENT)
.withName(linkName);

// var mimeType = contentProperty.getValue().getMimeType(object);
// if(mimeType != null) {
// link = link.withType(String.valueOf(mimeType));
// }
links.add(link);
}


return existing.and(links);
}

@Override
public Links getLinksForNested(Object object, Links existing) {
return existing;
}
}
Loading

0 comments on commit ada906b

Please sign in to comment.