-
Notifications
You must be signed in to change notification settings - Fork 473
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Allow links on a particular rel to be displayed as an array even if there is only one link #288
Comments
Allows links to be represented as a multiple even if there is only one.
Allows links under a single rel to be represented as a multiple (i.e., serialized to an array) even if there is only one.
Allows links under a single rel to be represented as a multiple (i.e., serialized to an array) even if there is only one.
Allows links under a single rel to be represented as a multiple (i.e., serialized to an array) even if there is only one.
Allows links under a single rel to be represented as a multiple (i.e., serialized to an array) even if there is only one.
This change allows links behind certain rels to always be wrapped by an array regardless of cardinality.
I think the problem is in Jackson2HalModule:OptionalListJackson2Serializer:355 to 366 (version 0.16). if (list.isEmpty()) {
return;
}
if (list.size() == 1) {
serializeContents(list.iterator(), jgen, provider);
return;
}
jgen.writeStartArray();
serializeContents(list.iterator(), jgen, provider);
jgen.writeEndArray(); You always want to write an array, so at least the public void serialize(List<Link> value, JsonGenerator jgen, SerializerProvider provider) throws IOException, JsonGenerationException {
// sort links according to their relation
Map<String, Object[]> sortedLinks = new LinkedHashMap<String, Object[]>();
for (Link link : value) {
String rel = link.getRel();
Object[] existingLink = sortedLinks.get(rel);
if (existingLink == null) {
sortedLinks.put(rel, new Object[] { link });
} else {
// Resize array
sortedLinks.put(rel, ArrayUtils.add(existingLink, link));
}
}
TypeFactory typeFactory = provider.getConfig().getTypeFactory();
JavaType keyType = typeFactory.uncheckedSimpleType(String.class);
JavaType valueType = typeFactory.constructArrayType(Object.class);
JavaType mapType = typeFactory.constructMapType(HashMap.class, keyType, valueType);
MapSerializer serializer = MapSerializer.construct(new String[] {}, mapType, true, null,
provider.findKeySerializer(keyType, null), provider.findValueSerializer(valueType, null), null);
serializer.serialize(sortedLinks, jgen, provider);
} |
Yup! I have a fix on this pull-request. |
This change allows links behind certain rels to always be wrapped by an array regardless of cardinality.
Hello everyone, I have a workaround for this issue, and I think you can use the same concepts for issue #324 as well. It's a little ugly and involves duplicating logic from Spring HATEOAS, but it works (at least for me). I'll post the code here, but when I get time, I'll set up a small demo project that showcases this workaround. I think this is a good stop-gap measure until my pull request is accepted or if Spring HATEOAS implements its own solution for this problem. I got the idea after I read up on Jackson's MixIn Annotations. Using these, you can attach Jackson annotations to fields on classes, whose source you do not control. The main problem with the current implementation is that the serializer for links always serializes a rel with a single link directly into a JSON object, and a rel with multiple links into an array of objects. There is no way to control the representation. With MixIn annotations, you can override the serializer for the 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 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: This class unfortunately duplicates logic, but it's not too bad. The key difference is that instead of using 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;
}
}
} Step 4: Deal with serializer lifecycle and dependency issues: We have annotated the 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);
}
} Note: The neat thing about this instantiator is that if you have any custom serializers that require access to Step 5: Put it all together: We have to reconfigure Spring HATEOAS' Jackson object-mapper to use our custom classes. You can do this in your application-configuration class. The code below is an example based on some actual code from a Spring Boot application that I maintain. I reconfigured the mapper inside a method that returns my @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 HttpMessageConverters customConverters() {
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 Spring HATEOAS' Jackson2HalModule
SimpleModule module = new Jackson2HalModule();
//Override the serializer for the link list in ResourceSupport using our mixin class
module.setMixInAnnotation(ResourceSupport.class, HalLinkListMixin.class);
//Register our module with the HAL object mapper
halObjectMapper.registerModule(module);
//Set the mapper's handler instantiator to our custom instantiator
halObjectMapper.setHandlerInstantiator(new HalHandlerInstantiator(relProvider, curieProvider, halMultipleLinkRels()));
//The code below this line is just specific to my case.
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
converter.setSupportedMediaTypes(Arrays.asList(
new MediaType("application", "json", Charset.defaultCharset()),
new MediaType("application", "*+json", Charset.defaultCharset()),
new MediaType("application", "hal+json")
));
converter.setObjectMapper(halObjectMapper);
return new HttpMessageConverters(converter);
}
//Populate our container class with the list of rels whose link representations must always be an
//array of objects
private HalMultipleLinkRels halMultipleLinkRels() {
return new HalMultipleLinkRels(
"order",
"person",
"blah"
);
}
} Note: For your case, you can probably just have a method annotated with I hope this was helpful. This works for me, but I don't know if this is the best/most-elegant way of doing this, or if I am completely hosing Spring HATEOAS' regular behavior. Based on my tests, I haven't seen any strange behavior. Perhaps @olivergierke can comment if this is a valid workaround. |
Allows links under a single rel to be represented as a multiple (i.e., serialized to an array) even if there is only one.
Person person = new Person("Dave", "Matthews");
EmbeddedWrappers wrappers = new EmbeddedWrappers(true);
EmbeddedWrapper embeddedPerson = wrappers.wrap(person);
System.out.println(mapper.writeValueAsString(new Resources<EmbeddedWrapper>(Arrays.asList(embeddedPerson)))); yields {
"_embedded" : {
"persons" : [ {
"firstname" : "Dave",
"lastname" : "Matthews"
} ]
}
} |
@olivergierke Is this addressed to me? I'm not using embedded resources; just regular links. Are you saying that this is applicable to links as well? |
@olivergierke Something like that for the link builder would definitely be convenient, but that would mean that you would have to explicitly say that you want a collection every time you use that rel, and it would be easy to miss doing that in some place. It would be better imo to enforce that across the entire API by doing that once, since a rel shouldn't have varying representations - it should either always be represented as a JSON object, or always be represented as a JSON array. With the first, the meaning is that the link rel always describes a single resource, and with the second the meaning is that the link rel describes multiple resources. The workaround I described above lets me do that right now EDIT I looked back at what I did and it looks like I just extended |
What I would like to see is: "ex:persons/person-resource": {
"href": "..."
} being represented as "ex:persons/person-resource": [{
"href": "..."
}] If told to do so for that rel. For example, if the owning resource was persons.add(linkTo(
methodOn(PersonController.class).person(personId)
).withRel("person-resource").asLinkArray()); Or perhaps the constructor the the link builder can take a boolean (kind of like how you have for the embedded wrapper). I have a patch where you can enforce this across the entire API. I concede that this may not be the right approach, and imo it's due to some ambiguity in the JSON+HAL standard. There's a sort of implicit cardinality conveyed via the representation when you have At any rate, I just need a way to make sure that a rel's links are always represented under |
This change allows links behind certain rels to always be wrapped by an array regardless of cardinality.
Any update on this issue? |
Is there any update on this? We are facing the same issue with our links |
Is there any update on this? I am using spring-boot-starter-hateos 1.5.1.RELEASE and am facing the same issues as people are describing above. A solution exactly like #288 (comment) would be ideal but for links and not embedded elements. |
Hello, I'd also like to force a _link rel to be an array even if it points to a single href.
{
"firstName": "Canonball",
"lastName": "Adderley",
"_links": {
"self": {
"href": "http://localhost:8080/musicians/1"
},
"instruments": [
{
"href": "http://localhost:8080/referential/instruments/saxophone"
}
]
}
} |
I think this project is largely abandoned. There doesn't seem to be much interest from the maintainer. |
@vivin If the project isn't moving at your pace, what value do you find leaving such comments? FYI: I recently came back on the team and am developing a strategy to get it moving again. You'd know this if you checked the commit logs for the past week. Small stuff, but we're ramping back up. |
@gregturn If you guys are seriously ramping back up, then that's great. But forgive me if I don't seem that enthusiastic. I've basically stopped using Spring HATEOAS, anyway. I was really excited when I first found out that Spring had a project for HATEOAS. When I ran into issues, I was eager to contribute to help resolve those issues as well. I opened this issue and others almost two years ago. I was quite engaged, and tried to have multiple discussions, proposed multiple alternatives, provided more than a few pull-requests, and even asked for clarity multiple times, without getting any sort of feedback from the maintainers -- I was basically ignored. This thread itself is a great example -- a question for clarification from the maintainer, multiple responses providing clarification by me, and others, and then nothing. This is why I think that the project is abandoned. But as I said before, if you guys really are ramping up, then that's great -- hopefully the project will be more engaged with those who are trying to contribute, this time. |
Does a corpse have pace? |
@Berastau Sorry about the state of things in the past, but check the commit logs. There is motion. I'm warming up with doc changes, and other smaller stuff, while getting ramped up on the bigger ticket items. There's a bit of backlog, which I'm also trying to groom and capture some low hanging fruit. |
@vivin @olivergierke - Spring has a Jackson HandlerInstantiator that uses the Application Context to source beans - thus allowing you to do Autowiring. Can't you use this instead? This is probably a separate improvement. If you agree, I will raise as such. |
…ng options. We now expose HalConfiguration to be defined as Spring bean in user configuration to control certain aspects of the HAL rendering. Initially we allow to control whether links shall always be rendered as collection. If no user-provided bean is available, we register a default instance. Original pull request: #295 Related issues: #291
Moved RenderSingleLinks enum into HalConfiguration. Simplified HalConfiguration setup by moving the default into the class. The lookup of a user-provided HalConfiguration is now handled on the bean name level to avoid premature initialization of a potentially defined bean. Formatting. Original pull request: #295 Related issues: #291
Is this issue still being looked at ? I see the overall status of the issue is closed. |
See 7cd1fb8 |
That commit appears to provide an option to make an all-or-none configuration change, but the questions in this thread are more geared toward picking and choosing which relationships should be represented as arrays. The unit test in that commit, for instance, represent self as an array which should never be the case. |
@provDaveStimpert If we need to polish a unit test showing a different rel than self, that's fine. When it comes to rendering, the serializer doesn't know self from foobar. The original use case presented reads:
At the time, it sounded like @vivin wanted to have single-item links turns into arrays, i.e. ALL link collections to be rendered as an array. Re-reading that today, with your emphasis, I can see the wrinkle of the possibility of ONLY doing this is the field is But I feel we need a new ticket to capture it. We can link back to this one to track the conversion. Honestly, fine tuning the representation of HAL documents is a good sign from a community perspective. |
Just wanted to clarify why I originally opened the issue. It wasn't that I wanted all link collections to be rendered as an array; only links for certain
So the idea was that it would be configurable. For example, if we had a resource |
I guess need a new ticket for that then. One path we might want to explore is to connect the decision which way a relation is rendered with the already existing That said, I'd argue its good API design practice to decide for a globally consistent way of rendering rels as otherwise the client has to be made aware of the way to lookup the elements per rel which creates coupling. |
Currently,
Jackson2HalModule.HalLinkListSerializer
will serialize arel
with only one link, into an object. If therel
has more than one link, it will serialize it into an array.I know that
rel
s have nothing to do with cardinality. However, for consistency's sake I would like to force certain arel
to always represent its links as being in an array. I ran into this issue in a case where I return a "collection" representation that contains links to individual resources. If I only have one element in the collection, the link is serialized as:However, if I have multiple items, I will see:
From the perspective of an API user, this is remarkably inconsistent and confusing. I would rather that the representation not change based on the cardinality of elements. It is fine if the representation is always going to contain a link to a single element under some
rel
. In that case, it is alright that the link is serialized into a object (IMHO, it's a bit of a weakness because you don't see the same issue in HAL+XML where you can put a<link>
element inside a<links>
whether there is one or more<link>
, but that's another issue). However in the case of a representation that is returning a collection of elements, I think it would be valuable from a consistency and usability perspective to always return the link as an array.It would be nice if this was configurable when building the link somehow. A way, perhaps, to let the serializer know that the links should be serialized as an array instead of an object, regardless of cardinality.
Also, the HAL specification says:
Currently, there is no way to force a link to be multiple in Spring HATEOAS. I hope you guys will consider this feature request. I can also try and solve this problem myself and make a pull request.
If this is not feasible, or if I am misinterpreting the HAL specification, please let me know!
The text was updated successfully, but these errors were encountered: