I have a workaround for this issue that is along similar lines to Chris' answer. The main difference is that I did not extend Jackson2HalModule
, but created a new handler-instantiator and set it as the handler-instantiator for a new instance of Jackson2HalModule
that I create myself. I hope Spring HATEOAS will eventually support this functionality natively; I have a pull request that attempts to do this. Here's how I implemented my workaround:
Step 1: Create the mixin class:
public abstract class HalLinkListMixin {
@JsonProperty("_links") @JsonSerialize(using = HalLinkListSerializer.class)
public abstract List<Link> getLinks();
}
This mixin class will associate the HalLinkListSerializer
(shown later) serializer with the links
property.
Step 2: Create a container class that holds the rels whose link representations should always be an array of link objects:
public class HalMultipleLinkRels {
private final Set<String> rels;
public HalMultipleLinkRels(String... rels) {
this.rels = new HashSet<String>(Arrays.asList(rels));
}
public Set<String> getRels() {
return Collections.unmodifiableSet(rels);
}
}
Step 3: Create our new serializer that will override Spring HATEOAS's link-list serializer:
public class HalLinkListSerializer extends ContainerSerializer<List<Link>> implements ContextualSerializer {
private final BeanProperty property;
private CurieProvider curieProvider;
private HalMultipleLinkRels halMultipleLinkRels;
public HalLinkListSerializer() {
this(null, null, new HalMultipleLinkRels());
}
public HalLinkListSerializer(CurieProvider curieProvider, HalMultipleLinkRels halMultipleLinkRels) {
this(null, curieProvider, halMultipleLinkRels);
}
public HalLinkListSerializer(BeanProperty property, CurieProvider curieProvider, HalMultipleLinkRels halMultipleLinkRels) {
super(List.class, false);
this.property = property;
this.curieProvider = curieProvider;
this.halMultipleLinkRels = halMultipleLinkRels;
}
@Override
public void serialize(List<Link> value, JsonGenerator jgen, SerializerProvider provider) throws IOException, JsonGenerationException {
// sort links according to their relation
Map<String, List<Object>> sortedLinks = new LinkedHashMap<>();
List<Link> links = new ArrayList<>();
boolean prefixingRequired = curieProvider != null;
boolean curiedLinkPresent = false;
for (Link link : value) {
String rel = prefixingRequired ? curieProvider.getNamespacedRelFrom(link) : link.getRel();
if (!link.getRel().equals(rel)) {
curiedLinkPresent = true;
}
if (sortedLinks.get(rel) == null) {
sortedLinks.put(rel, new ArrayList<>());
}
links.add(link);
sortedLinks.get(rel).add(link);
}
if (prefixingRequired && curiedLinkPresent) {
ArrayList<Object> curies = new ArrayList<>();
curies.add(curieProvider.getCurieInformation(new Links(links)));
sortedLinks.put("curies", curies);
}
TypeFactory typeFactory = provider.getConfig().getTypeFactory();
JavaType keyType = typeFactory.uncheckedSimpleType(String.class);
JavaType valueType = typeFactory.constructCollectionType(ArrayList.class, Object.class);
JavaType mapType = typeFactory.constructMapType(HashMap.class, keyType, valueType);
MapSerializer serializer = MapSerializer.construct(new String[]{}, mapType, true, null,
provider.findKeySerializer(keyType, null), new ListJackson2Serializer(property, halMultipleLinkRels), null);
serializer.serialize(sortedLinks, jgen, provider);
}
@Override
public JavaType getContentType() {
return null;
}
@Override
public JsonSerializer<?> getContentSerializer() {
return null;
}
@Override
public boolean hasSingleElement(List<Link> value) {
return value.size() == 1;
}
@Override
protected ContainerSerializer<?> _withValueTypeSerializer(TypeSerializer vts) {
return null;
}
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException {
return new HalLinkListSerializer(property, curieProvider, halMultipleLinkRels);
}
private static class ListJackson2Serializer extends ContainerSerializer<Object> implements ContextualSerializer {
private final BeanProperty property;
private final Map<Class<?>, JsonSerializer<Object>> serializers = new HashMap<>();
private final HalMultipleLinkRels halMultipleLinkRels;
public ListJackson2Serializer() {
this(null, null);
}
public ListJackson2Serializer(BeanProperty property, HalMultipleLinkRels halMultipleLinkRels) {
super(List.class, false);
this.property = property;
this.halMultipleLinkRels = halMultipleLinkRels;
}
@Override
public void serialize(Object value, JsonGenerator jgen, SerializerProvider provider) throws IOException, JsonGenerationException {
List<?> list = (List<?>) value;
if (list.isEmpty()) {
return;
}
if (list.size() == 1) {
Object element = list.get(0);
if (element instanceof Link) {
Link link = (Link) element;
if (halMultipleLinkRels.getRels().contains(link.getRel())) {
jgen.writeStartArray();
serializeContents(list.iterator(), jgen, provider);
jgen.writeEndArray();
return;
}
}
serializeContents(list.iterator(), jgen, provider);
return;
}
jgen.writeStartArray();
serializeContents(list.iterator(), jgen, provider);
jgen.writeEndArray();
}
@Override
public JavaType getContentType() {
return null;
}
@Override
public JsonSerializer<?> getContentSerializer() {
return null;
}
@Override
public boolean hasSingleElement(Object value) {
return false;
}
@Override
protected ContainerSerializer<?> _withValueTypeSerializer(TypeSerializer vts) {
throw new UnsupportedOperationException("not implemented");
}
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException {
return new ListJackson2Serializer(property, halMultipleLinkRels);
}
private void serializeContents(Iterator<?> value, JsonGenerator jgen, SerializerProvider provider) throws IOException, JsonGenerationException {
while (value.hasNext()) {
Object elem = value.next();
if (elem == null) {
provider.defaultSerializeNull(jgen);
} else {
getOrLookupSerializerFor(elem.getClass(), provider).serialize(elem, jgen, provider);
}
}
}
private JsonSerializer<Object> getOrLookupSerializerFor(Class<?> type, SerializerProvider provider) throws JsonMappingException {
JsonSerializer<Object> serializer = serializers.get(type);
if (serializer == null) {
serializer = provider.findValueSerializer(type, property);
serializers.put(type, serializer);
}
return serializer;
}
}
}
This class unfortunately duplicates logic, but it's not too bad. The key difference is that instead of using OptionalListJackson2Serializer
, I'm using ListJackson2Serializer
, which will force a rel's link representation as an array, if that rel exists in the container of rel overrides (HalMultipleLinkRels
):
Step 4: Create a custom handler-instantiator:
public class HalHandlerInstantiator extends HandlerInstantiator {
private final Jackson2HalModule.HalHandlerInstantiator halHandlerInstantiator;
private final Map<Class<?>, JsonSerializer<?>> serializerMap = new HashMap<>();
public HalHandlerInstantiator(RelProvider relProvider, CurieProvider curieProvider, HalMultipleLinkRels halMultipleLinkRels) {
this(relProvider, curieProvider, halMultipleLinkRels, true);
}
public HalHandlerInstantiator(RelProvider relProvider, CurieProvider curieProvider, HalMultipleLinkRels halMultipleLinkRels, boolean enforceEmbeddedCollections) {
halHandlerInstantiator = new Jackson2HalModule.HalHandlerInstantiator(relProvider, curieProvider, enforceEmbeddedCollections);
serializerMap.put(HalLinkListSerializer.class, new HalLinkListSerializer(curieProvider, halMultipleLinkRels));
}
@Override
public JsonDeserializer<?> deserializerInstance(DeserializationConfig config, Annotated annotated, Class<?> deserClass) {
return halHandlerInstantiator.deserializerInstance(config, annotated, deserClass);
}
@Override
public KeyDeserializer keyDeserializerInstance(DeserializationConfig config, Annotated annotated, Class<?> keyDeserClass) {
return halHandlerInstantiator.keyDeserializerInstance(config, annotated, keyDeserClass);
}
@Override
public JsonSerializer<?> serializerInstance(SerializationConfig config, Annotated annotated, Class<?> serClass) {
if(serializerMap.containsKey(serClass)) {
return serializerMap.get(serClass);
} else {
return halHandlerInstantiator.serializerInstance(config, annotated, serClass);
}
}
@Override
public TypeResolverBuilder<?> typeResolverBuilderInstance(MapperConfig<?> config, Annotated annotated, Class<?> builderClass) {
return halHandlerInstantiator.typeResolverBuilderInstance(config, annotated, builderClass);
}
@Override
public TypeIdResolver typeIdResolverInstance(MapperConfig<?> config, Annotated annotated, Class<?> resolverClass) {
return halHandlerInstantiator.typeIdResolverInstance(config, annotated, resolverClass);
}
}
This instantiator will control the lifecycle for our custom serializer. It maintains an internal instance of Jackson2HalModule.HalHandlerInstantiator
, and delegates to that instance for all other serializers.
Step 5: Put it all together:
@Configuration
public class ApplicationConfiguration {
private static final String HAL_OBJECT_MAPPER_BEAN_NAME = "_halObjectMapper";
private static final String DELEGATING_REL_PROVIDER_BEAN_NAME = "_relProvider";
@Autowired
private BeanFactory beanFactory;
private static CurieProvider getCurieProvider(BeanFactory factory) {
try {
return factory.getBean(CurieProvider.class);
} catch (NoSuchBeanDefinitionException e) {
return null;
}
}
@Bean
public ObjectMapper objectMapper() {
CurieProvider curieProvider = getCurieProvider(beanFactory);
RelProvider relProvider = beanFactory.getBean(DELEGATING_REL_PROVIDER_BEAN_NAME, RelProvider.class);
ObjectMapper halObjectMapper = beanFactory.getBean(HAL_OBJECT_MAPPER_BEAN_NAME, ObjectMapper.class);
//Create a new instance of Jackson2HalModule
SimpleModule module = new Jackson2HalModule();
//Provide the mix-in class so that we can override the serializer for links with our custom serializer
module.setMixInAnnotation(ResourceSupport.class, HalLinkListMixin.class);
//Register the module in the object mapper
halObjectMapper.registerModule(module);
//Set the handler instantiator on the mapper to our custom handler-instantiator
halObjectMapper.setHandlerInstantiator(new HalHandlerInstantiator(relProvider, curieProvider, halMultipleLinkRels()));
return halObjectMapper;
}
...
}