#1132 - HAL link relation rendering now applies Jackson's PropertyNamingStrategy.

The rendering of HAL's link relations now applies Jackson's PropertyNamingStrategy if one is configured except it's a IANA link relation. This behavior can be opt out from by setting HalConfiguration.withApplyPropertyNamingStrategy(false).

LinkRelation now has a ….map(…) method allowing the post-processing of it given a Function<String, String>. By default, that's applied to all relations that are not IANA ones. HalLinkRelation applies this transformation to only the local part of the relation. Tweaked IanaLinkRelations.isIanaLinkRelation(…) to do a simple ….contains(…) check first before we stream over all values to apply a case insensitive check.
This commit is contained in:
Oliver Drotbohm
2019-12-06 12:05:02 +01:00
parent 8f1f355c9f
commit a2290c692a
12 changed files with 249 additions and 55 deletions

View File

@@ -786,8 +786,8 @@ public class IanaLinkRelations {
Assert.notNull(relation, "Link relation must not be null!");
return LINK_RELATIONS.stream() //
.anyMatch(it -> it.value().equalsIgnoreCase(relation.value()));
return LINK_RELATIONS.contains(relation) //
|| LINK_RELATIONS.stream().anyMatch(it -> it.isSameAs(relation));
}

View File

@@ -16,6 +16,7 @@
package org.springframework.hateoas;
import java.util.Arrays;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.util.Assert;
@@ -75,4 +76,28 @@ public interface LinkRelation {
return this.value().equalsIgnoreCase(relation.value());
}
/**
* Returns a new {@link LinkRelation} with its relation mapped by the given function, unless it is an IANA one.
* Implementors are encouraged to override this method to redeclare the return type to be itself.
*
* @param mapper must not be {@literal null}.
* @return
* @see IanaLinkRelations
*/
default LinkRelation map(Function<String, String> mapper) {
if (mapper == Function.<String> identity() || IanaLinkRelations.isIanaRel(this)) {
return this;
}
Assert.notNull(mapper, "Mapper must not be null!");
String source = value();
String mapped = mapper.apply(source);
return source.equals(mapped) //
? this //
: LinkRelation.of(mapper.apply(value()));
}
}

View File

@@ -48,6 +48,12 @@ public class HalConfiguration {
private final @Wither @Getter RenderSingleLinks renderSingleLinks;
private final @Wither(AccessLevel.PRIVATE) Map<String, RenderSingleLinks> singleLinksPerPattern;
/**
* Configures whether the Jackson property naming strategy is applied to link relations and within {@code _embedded}
* clauses.
*/
private final @Wither @Getter boolean applyPropertyNamingStrategy;
/**
* Creates a new default {@link HalConfiguration} rendering single links as immediate sub-document.
*/
@@ -55,6 +61,7 @@ public class HalConfiguration {
this.renderSingleLinks = RenderSingleLinks.AS_SINGLE;
this.singleLinksPerPattern = new LinkedHashMap<>();
this.applyPropertyNamingStrategy = true;
}
/**

View File

@@ -15,12 +15,17 @@
*/
package org.springframework.hateoas.mediatype.hal;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.experimental.Wither;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.LinkRelation;
@@ -37,15 +42,22 @@ import org.springframework.util.Assert;
* @author Oliver Gierke
* @author Dietrich Schulten
*/
@AllArgsConstructor(access = AccessLevel.PRIVATE)
class HalEmbeddedBuilder {
private static final String INVALID_EMBEDDED_WRAPPER = "Embedded wrapper %s returned null for both the static rel and the rel target type! Make sure one of the two returns a non-null value!";
private static final Function<String, String> NO_TRANSFORMER = Function.identity();
private final Map<HalLinkRelation, Object> embeddeds = new HashMap<>();
private final LinkRelationProvider provider;
private final CurieProvider curieProvider;
private final EmbeddedWrappers wrappers;
/**
* Returns a {@link HalEmbeddedBuilder} with the given transformer
*/
private final @Wither Function<String, String> relationTransformer;
/**
* Creates a new {@link HalEmbeddedBuilder} using the given {@link LinkRelationProvider} and prefer collection rels
* flag.
@@ -61,6 +73,7 @@ class HalEmbeddedBuilder {
this.provider = provider;
this.curieProvider = curieProvider;
this.wrappers = new EmbeddedWrappers(preferCollectionRels);
this.relationTransformer = NO_TRANSFORMER;
}
/**
@@ -124,6 +137,8 @@ class HalEmbeddedBuilder {
? provider.getCollectionResourceRelFor(type) //
: provider.getItemResourceRelFor(type);
rel = relationTransformer == NO_TRANSFORMER ? rel : rel.map(relationTransformer);
return curieProvider != CurieProvider.NONE //
? curieProvider.getNamespacedRelFor(rel) //
: HalLinkRelation.of(rel);

View File

@@ -21,6 +21,7 @@ import lombok.Getter;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import java.util.function.Function;
import java.util.stream.Stream;
import org.springframework.context.MessageSourceResolvable;
@@ -151,6 +152,20 @@ public class HalLinkRelation implements LinkRelation, MessageSourceResolvable {
return curie != null;
}
/*
* (non-Javadoc)
* @see org.springframework.hateoas.LinkRelation#map(java.util.function.Function)
*/
@Override
public HalLinkRelation map(Function<String, String> mapper) {
String mappedLocalPart = mapper.apply(localPart);
return localPart.equals(mappedLocalPart) //
? this //
: new HalLinkRelation(curie, mappedLocalPart);
}
/*
* (non-Javadoc)
* @see org.springframework.hateoas.LinkRelation#value()

View File

@@ -15,6 +15,10 @@
*/
package org.springframework.hateoas.mediatype.hal;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.RequiredArgsConstructor;
import java.io.IOException;
import java.lang.reflect.Type;
import java.util.ArrayList;
@@ -24,6 +28,7 @@ import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
@@ -49,6 +54,7 @@ import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.core.Version;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.PropertyNamingStrategy.PropertyNamingStrategyBase;
import com.fasterxml.jackson.databind.cfg.HandlerInstantiator;
import com.fasterxml.jackson.databind.cfg.MapperConfig;
import com.fasterxml.jackson.databind.deser.ContextualDeserializer;
@@ -155,10 +161,15 @@ public class Jackson2HalModule extends SimpleModule {
Object currentValue = jgen.getCurrentValue();
if (currentValue instanceof CollectionModel) {
if (mapper.hasCuriedEmbed((CollectionModel<?>) currentValue)) {
curiedLinkPresent = true;
}
PropertyNamingStrategy propertyNamingStrategy = provider.getConfig().getPropertyNamingStrategy();
EmbeddedMapper transformingMapper = halConfiguration.isApplyPropertyNamingStrategy() //
? mapper.with(propertyNamingStrategy)
: mapper;
if (currentValue instanceof CollectionModel
&& transformingMapper.hasCuriedEmbed((CollectionModel<?>) currentValue)) {
curiedLinkPresent = true;
}
for (Link link : value) {
@@ -173,9 +184,11 @@ public class Jackson2HalModule extends SimpleModule {
curiedLinkPresent = true;
}
HalLinkRelation relation = transformingMapper.map(rel);
sortedLinks //
.computeIfAbsent(rel, key -> new ArrayList<>())//
.add(toHalLink(link));
.computeIfAbsent(relation, key -> new ArrayList<>())//
.add(toHalLink(link, relation));
links.add(link);
}
@@ -205,10 +218,7 @@ public class Jackson2HalModule extends SimpleModule {
* @param link must not be {@literal null}.
* @return
*/
private HalLink toHalLink(Link link) {
HalLinkRelation rel = HalLinkRelation.of(link.getRel());
private HalLink toHalLink(Link link, HalLinkRelation rel) {
return new HalLink(link, resolver.resolve(rel));
}
@@ -287,19 +297,22 @@ public class Jackson2HalModule extends SimpleModule {
private static final long serialVersionUID = 8030706944344625390L;
private final BeanProperty property;
private final EmbeddedMapper embeddedMapper;
private final HalConfiguration configuration;
private final BeanProperty property;
public HalResourcesSerializer(EmbeddedMapper embeddedMapper) {
this(null, embeddedMapper);
public HalResourcesSerializer(EmbeddedMapper embeddedMapper, HalConfiguration configuration) {
this(embeddedMapper, configuration, null);
}
public HalResourcesSerializer(@Nullable BeanProperty property, EmbeddedMapper embeddedMapper) {
public HalResourcesSerializer(EmbeddedMapper embeddedMapper, HalConfiguration configuration,
@Nullable BeanProperty property) {
super(TypeFactory.defaultInstance().constructType(Collection.class));
this.property = property;
this.embeddedMapper = embeddedMapper;
this.configuration = configuration;
this.property = property;
}
/*
@@ -312,13 +325,17 @@ public class Jackson2HalModule extends SimpleModule {
@SuppressWarnings("null")
public void serialize(Collection<?> value, JsonGenerator jgen, SerializerProvider provider) throws IOException {
Map<HalLinkRelation, Object> embeddeds = embeddedMapper.map(value);
EmbeddedMapper mapper = configuration.isApplyPropertyNamingStrategy() //
? embeddedMapper.with(provider.getConfig().getPropertyNamingStrategy()) //
: embeddedMapper;
Map<HalLinkRelation, Object> embeddeds = mapper.map(value);
Object currentValue = jgen.getCurrentValue();
if (currentValue instanceof RepresentationModel) {
if (embeddedMapper.hasCuriedEmbed(value)) {
if (mapper.hasCuriedEmbed(value)) {
((RepresentationModel<?>) currentValue).add(CURIES_REQUIRED_DUE_TO_EMBEDS);
}
}
@@ -334,7 +351,7 @@ public class Jackson2HalModule extends SimpleModule {
@SuppressWarnings("null")
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property)
throws JsonMappingException {
return new HalResourcesSerializer(property, embeddedMapper);
return new HalResourcesSerializer(embeddedMapper, configuration, property);
}
/*
@@ -748,7 +765,7 @@ public class Jackson2HalModule extends SimpleModule {
this.delegate = delegate;
this.serializers.put(HalResourcesSerializer.class, new HalResourcesSerializer(mapper));
this.serializers.put(HalResourcesSerializer.class, new HalResourcesSerializer(mapper, halConfiguration));
this.serializers.put(HalLinkListSerializer.class,
new HalLinkListSerializer(curieProvider, mapper, resolver, halConfiguration));
}
@@ -880,27 +897,34 @@ public class Jackson2HalModule extends SimpleModule {
*
* @author Oliver Gierke
*/
@RequiredArgsConstructor
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public static class EmbeddedMapper {
private LinkRelationProvider relProvider;
private CurieProvider curieProvider;
private boolean preferCollectionRels;
private static final Function<String, String> NO_OP = Function.identity();
private final @lombok.NonNull LinkRelationProvider relProvider;
private final CurieProvider curieProvider;
private final boolean preferCollectionRels;
private Function<String, String> relationTransformer = Function.identity();
/**
* Creates a new {@link EmbeddedMapper} for the given {@link LinkRelationProvider}, {@link CurieProvider} and flag
* whether to prefer collection relations.
* Registers the given {@link PropertyNamingStrategy} with the current mapper to forward that strategy as relation
* transformer, so that {@link LinkRelation}s used as key for the embedding will be transformed using the given
* strategy.
*
* @param relProvider must not be {@literal null}.
* @param curieProvider must not be {@literal null}.
* @param preferCollectionRels
* @param strategy must not be {@literal null}.
* @return an {@link EmbeddedMapper} applying the given strategy when mapping embedded objects.
*/
public EmbeddedMapper(LinkRelationProvider relProvider, CurieProvider curieProvider, boolean preferCollectionRels) {
public EmbeddedMapper with(@Nullable PropertyNamingStrategy strategy) {
Assert.notNull(relProvider, "RelProvider must not be null!");
if (!(strategy instanceof PropertyNamingStrategyBase)) {
return this;
}
this.relProvider = relProvider;
this.curieProvider = curieProvider;
this.preferCollectionRels = preferCollectionRels;
return new EmbeddedMapper(relProvider, curieProvider, preferCollectionRels,
((PropertyNamingStrategyBase) strategy)::translate);
}
/**
@@ -913,13 +937,27 @@ public class Jackson2HalModule extends SimpleModule {
Assert.notNull(source, "Elements must not be null!");
HalEmbeddedBuilder builder = new HalEmbeddedBuilder(relProvider, curieProvider, preferCollectionRels);
HalEmbeddedBuilder builder = new HalEmbeddedBuilder(relProvider, curieProvider, preferCollectionRels) //
.withRelationTransformer(relationTransformer);
source.forEach(builder::add);
return builder.asMap();
}
/**
* Maps the given {@link HalLinkRelation} using the underlying relation transformer.
*
* @param source must not be {@literal null}.
* @return
*/
public HalLinkRelation map(LinkRelation source) {
Assert.notNull(source, "Link relation must not be null!");
return HalLinkRelation.of(relationTransformer == NO_OP ? source : source.map(relationTransformer));
}
/**
* Returns whether the given source elements will be namespaced.
*

View File

@@ -22,8 +22,10 @@ import org.springframework.hateoas.CollectionModel;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.PagedModel;
import org.springframework.hateoas.RepresentationModel;
import org.springframework.hateoas.mediatype.hal.HalConfiguration;
import org.springframework.hateoas.mediatype.hal.HalLinkRelation;
import org.springframework.hateoas.mediatype.hal.Jackson2HalModule;
import org.springframework.hateoas.mediatype.hal.Jackson2HalModule.EmbeddedMapper;
import org.springframework.lang.Nullable;
import com.fasterxml.jackson.core.JsonGenerator;
@@ -215,20 +217,23 @@ class HalFormsSerializers {
private final BeanProperty property;
private final Jackson2HalModule.EmbeddedMapper embeddedMapper;
private final HalFormsTemplateBuilder customizations;
private final HalConfiguration configuration;
HalFormsCollectionModelSerializer(HalFormsTemplateBuilder customizations, @Nullable BeanProperty property,
Jackson2HalModule.EmbeddedMapper embeddedMapper) {
HalFormsCollectionModelSerializer(HalFormsTemplateBuilder customizations,
Jackson2HalModule.EmbeddedMapper embeddedMapper, HalConfiguration configuration,
@Nullable BeanProperty property) {
super(CollectionModel.class, false);
this.property = property;
this.embeddedMapper = embeddedMapper;
this.customizations = customizations;
this.configuration = configuration;
}
HalFormsCollectionModelSerializer(HalFormsTemplateBuilder customizations,
Jackson2HalModule.EmbeddedMapper embeddedMapper) {
this(customizations, null, embeddedMapper);
Jackson2HalModule.EmbeddedMapper embeddedMapper, HalConfiguration configuration) {
this(customizations, embeddedMapper, configuration, null);
}
/*
@@ -239,7 +244,11 @@ class HalFormsSerializers {
@SuppressWarnings("null")
public void serialize(CollectionModel<?> value, JsonGenerator gen, SerializerProvider provider) throws IOException {
Map<HalLinkRelation, Object> embeddeds = embeddedMapper.map(value);
EmbeddedMapper mapper = configuration.isApplyPropertyNamingStrategy() //
? embeddedMapper.with(provider.getConfig().getPropertyNamingStrategy()) //
: embeddedMapper;
Map<HalLinkRelation, Object> embeddeds = mapper.map(value);
HalFormsDocument<?> doc;
@@ -311,7 +320,7 @@ class HalFormsSerializers {
@SuppressWarnings("null")
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property)
throws JsonMappingException {
return new HalFormsCollectionModelSerializer(customizations, property, embeddedMapper);
return new HalFormsCollectionModelSerializer(customizations, embeddedMapper, configuration, property);
}
}
}

View File

@@ -29,6 +29,7 @@ import org.springframework.hateoas.PagedModel;
import org.springframework.hateoas.RepresentationModel;
import org.springframework.hateoas.mediatype.MessageResolver;
import org.springframework.hateoas.mediatype.hal.CurieProvider;
import org.springframework.hateoas.mediatype.hal.HalConfiguration;
import org.springframework.hateoas.mediatype.hal.Jackson2HalModule.EmbeddedMapper;
import org.springframework.hateoas.mediatype.hal.Jackson2HalModule.HalHandlerInstantiator;
import org.springframework.hateoas.mediatype.hal.Jackson2HalModule.HalLinkListDeserializer;
@@ -169,14 +170,15 @@ public class Jackson2HalFormsModule extends SimpleModule {
EmbeddedMapper mapper = new EmbeddedMapper(resolver, curieProvider, enforceEmbeddedCollections);
HalFormsTemplateBuilder builder = new HalFormsTemplateBuilder(configuration, accessor);
HalConfiguration halConfiguration = configuration.getHalConfiguration();
this.serializers.put(HalFormsRepresentationModelSerializer.class,
new HalFormsRepresentationModelSerializer(builder));
this.serializers.put(HalFormsEntityModelSerializer.class, new HalFormsEntityModelSerializer(builder));
this.serializers.put(HalFormsCollectionModelSerializer.class,
new HalFormsCollectionModelSerializer(builder, mapper));
new HalFormsCollectionModelSerializer(builder, mapper, halConfiguration));
this.serializers.put(HalLinkListSerializer.class,
new HalLinkListSerializer(curieProvider, mapper, accessor, configuration.getHalConfiguration()));
new HalLinkListSerializer(curieProvider, mapper, accessor, halConfiguration));
}
public HalFormsHandlerInstantiator(LinkRelationProvider relProvider, CurieProvider curieProvider,