Skip to content
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

Spring HATEOAS and custom (vendor-specific) media-types #263

Closed
vivin opened this issue Nov 11, 2014 · 25 comments
Closed

Spring HATEOAS and custom (vendor-specific) media-types #263

vivin opened this issue Nov 11, 2014 · 25 comments

Comments

@vivin
Copy link

vivin commented Nov 11, 2014

I'm using Spring HATEOAS with Spring Boot for a service. Originally we simply had the endpoints returning application/json. Links and everything were created properly by Spring HATEOAS, but I noticed that the links property was an array of maps. Instead, of simply being a map where the rels are keys. I was able to fix that by adding @EnableHypermediaSupport(type=EnableHypermediaSupport.HypermediaType.HAL).

My next step was to support semantic media-types (Level 3 of Richardson Maturity Model). I was able to create custom media-type converters for my custom media types by extending MappingJackson2HttpMessageConverter, and then adding the appropriate produces and consumes values on my @RequestMapping annotations.

However, I noticed that now the links are back to the old format where they are an array of hashes. I imagine this is because the converters are extending MappingJackson2HttpMessageConverter. I looked at the source code for Spring HATEOAS and I see that the converter that handle application/hal+json is being defined in HyperMediaSupportBeanDefinitionRegistrar:

private List<HttpMessageConverter<?>> potentiallyRegisterModule(List<HttpMessageConverter<?>> converters) {
    ...

    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);

    halObjectMapper.registerModule(new Jackson2HalModule());
    halObjectMapper.setHandlerInstantiator(new Jackson2HalModule.HalHandlerInstantiator(relProvider, curieProvider));

    MappingJackson2HttpMessageConverter halConverter = new MappingJackson2HttpMessageConverter();
    halConverter.setSupportedMediaTypes(Arrays.asList(HAL_JSON)); //HAL_JSON is just a MediaType instance for application/hal+json
    halConverter.setObjectMapper(halObjectMapper);

    ...
}

How can I ensure that HAL-type links work with my custom media-types? Will I have to duplicate this code inside each of my converters?

@jomarl
Copy link

jomarl commented Nov 12, 2014

I think "application/*+hal+json" should be included in the list of supported media types on the halConverter.

We worked around this problem by manually registering a message converter that handles both "application/hal+json" and "application/*+hal+json". The latter adds support for vendor specific information in your media type, e.g. "application/api-v2.1+hal+json". Another workaround could be to implement your own post processor that overrides the mediatypes on the halConverter.

@vivin
Copy link
Author

vivin commented Nov 12, 2014

I was thinking of doing the same thing they're doing; basically adding the HAL mapper to my converters but using application/*.hal+json in addition to application/hal+json.

Another thing I was thinking of doing was having an abstract class that encapsulates all that logic, and then adding the the HAL mapper to my custom media-type converter. Does that make sense?

@vivin
Copy link
Author

vivin commented Nov 12, 2014

So this is my workaround. I'm using Spring Boot, so I have an application configuration class. I imagine you could do something similar in a regular Spring application.

@Configuration
public class ApplicationConfiguration {

    @Autowired
    private BeanFactory beanFactory;

    private static CurieProvider getCurieProvider(BeanFactory factory) {
        try {
            return factory.getBean(CurieProvider.class);
        } catch (NoSuchBeanDefinitionException e) {
            return null;
        }
    }

    private static final String DELEGATING_REL_PROVIDER_BEAN_NAME = "_relProvider";
    private static final String HAL_OBJECT_MAPPER_BEAN_NAME = "_halObjectMapper";

    ...

    @Bean
    public HttpMessageConverters customConverters() {
        return new HttpMessageConverters(
                new CreateTrackMediaTypeConverter(),
                new ReplaceTrackMediaTypeConverter(),
                enableHal(new TrackMediaTypeConverter()),
                enableHal(new TracksMediaTypeConverter()),
                new CreateTrackWalkthruMediaTypeConverter(),
                enableHal(new TrackWalkthruMediaTypeConverter()),
                enableHal(new TrackWalkthrusMediaTypeConverter()),
                new CreateWalkthruMediaTypeConverter(),
                new ReplaceWalkthruMediaTypeConverter(),
                enableHal(new WalkthruMediaTypeConverter()),
                enableHal(new WalkthrusMediaTypeConverter())
        );
    }

    public HttpMessageConverter enableHal(MappingJackson2HttpMessageConverter converter) {
        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);

        halObjectMapper.registerModule(new Jackson2HalModule());
        halObjectMapper.setHandlerInstantiator(new Jackson2HalModule.HalHandlerInstantiator(relProvider, curieProvider));

        converter.setObjectMapper(halObjectMapper);
        return converter;
    }
}

@vivin
Copy link
Author

vivin commented Nov 12, 2014

I have a better workaround. I realized I didn't really need those explicit converters since I wasn't doing anything different other than processing regular JSON or JSON+HAL. So I did this instead:

    @Bean
    public HttpMessageConverters customConverters() {
        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"),
                new MediaType("application", "*hal+json")
        ));

        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);

        halObjectMapper.registerModule(new Jackson2HalModule());
        halObjectMapper.setHandlerInstantiator(new Jackson2HalModule.HalHandlerInstantiator(relProvider, curieProvider));

        converter.setObjectMapper(halObjectMapper);

        return new HttpMessageConverters(converter);
    }

@DanailMinchev
Copy link

Thanks for the workaround @vivin

The problem is: when I request "application/json" the representation will be HAL+JSON and not clean JSON.

I modified your code to use in WebMvcConfigurerAdapter.configureMessageConverters for support both application/json and application/hal+json:

    @Override
    protected void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
        super.configureMessageConverters(converters);
        addDefaultHttpMessageConverters(converters);

        // --- custom HttpMessageConverters
        // jsonHalConverter
        MappingJackson2HttpMessageConverter jsonHalConverter = new MappingJackson2HttpMessageConverter();
        jsonHalConverter.setSupportedMediaTypes(Arrays.asList(
            new MediaType("application", "hal+json", Charset.defaultCharset()),
            new MediaType("application", "*+hal+json", Charset.defaultCharset())
        ));
        ObjectMapper halObjectMapper = beanFactory.getBean("_halObjectMapper", ObjectMapper.class);
        jsonHalConverter.setObjectMapper(halObjectMapper);
        converters.add(jsonHalConverter);
    }

This way we don't need to look for CurieProvider, RelProvider and prepare halObjectMapper.
We just register two MappingJackson2HttpMessageConverter instances: one for JSON and one for HAL+JSON.

Maybe there is another workaround to handle JSON and HAL+JSON separately, please let me know if any.

@vivin
Copy link
Author

vivin commented Nov 18, 2014

@DanailMinchev yes your solution is cleaner. Another thing: I realized that the appropriate wildcard should be application/*hal+json and not application/*+hal+json. A suffix has to be approved for it to be used, and +hal is not an approved suffix.

Also regarding CurieProvider and friends, I am assuming it is not required since you're just pulling the bean that Spring HATEOAS has already instantiated?

@DanailMinchev
Copy link

@vivin :
yes, objects needed for _halObjectMapper has already been registered by Spring HATEOAS as well as _halObjectMapper object. So, I just pulled it from the context.

@vivin
Copy link
Author

vivin commented Nov 21, 2014

@DanailMinchev

I found a slightly cleaner way for spring boot. When you define message converters in spring boot, you cannot have multiple instances because if you try to add two of the same type, the new one will override the other. So that was why I decide to override the existing JSON converter by adding the HAL media-types as well. But I found a better way for spring boot to do the same thing you are doing in a regular spring app (i.e., separating out the HAL converter):

@Configuration
public class ApplicationConfiguration {

    private static final String HAL_OBJECT_MAPPER_BEAN_NAME = "_halObjectMapper";

    @Autowired
    private BeanFactory beanFactory;

    @Bean
    public HttpMessageConverters customConverters() {
        return new HttpMessageConverters(new HalMappingJackson2HttpMessageConverter());
    }

    private class HalMappingJackson2HttpMessageConverter extends MappingJackson2HttpMessageConverter {
        public HalMappingJackson2HttpMessageConverter() {
            setSupportedMediaTypes(Arrays.asList(
                new MediaType("application", "hal+json"),
                new MediaType("application", "*hal+json")
            ));

            ObjectMapper halObjectMapper = beanFactory.getBean(HAL_OBJECT_MAPPER_BEAN_NAME, ObjectMapper.class);
            setObjectMapper(halObjectMapper);
        }
    }
}

So here I just have a private class that extends the existing JSON converter, but which supports HAL media types.

@DanailMinchev
Copy link

@vivin
Solution with HalMappingJackson2HttpMessageConverter is working in regular Spring app, too. I didn't migrate to Spring Boot yet, so I didn't notice this problem. Thanks

@vivin
Copy link
Author

vivin commented Nov 23, 2014

@DanailMinchev No problem!

@vivin
Copy link
Author

vivin commented Dec 12, 2014

Another update.

I'm not sure why I didn't notice this before, but my new workaround DOES NOT WORK.

Yes, you end up getting the serializations for the links in JSON, but you don't get the links serialized per HAL even when you have @EnableHypermediaSupport(type=EnableHypermediaSupport.HypermediaType.HAL). You end up getting an array of link objects where the href is a separate property, instead of getting an object with the href itself as key.

The original solution (adding all media-types, including hal to MappingJackson2HttpMessageConverter) worked only has a matter of coincidence. It comes down to this: any media-type that uses JSON and hal, for example application/vnd.vendor.service.entity.v1.hal+json is not recognized as a media-type that is compatible with application/*hal+json. This is because the isCompatibleWith method in MimeType identifies valid subtypes as starting with *+. Since we have *hal+json, the media-type doesn't get recognized as a hal subtype. This means that the original solution worked only as a matter of coincidence for two reasons:

  • application/*+json is compatible with application/vnd.vendor.service.entity.v1.hal+json
  • The HAL object mapper is attached to the instance of MappingJackson2HttpMessageConverter.

So what are the solutions? We could use application/*+hal+json as a media type, but that violates section 4.2.8 of RFC6838 because +hal is not a registered subtype.

Another option is to use my original workaround, but that means we can only use application/json and application/*+json, and application/hal along with the HAL object mapper. In this instance, our custom media-type gets recognized only as a matter of coincidence. Furthermore, we are polluting the original converter with HAL concerns.

One more option is based on what I already have: create an extended version of MappingJackson2HttpMessageConverter, but instead of specifying application/*hal+json, we explicitly specify all our custom media types (that are HAL subtypes) in addition to application/hal+json.

I think a lot of this confusion stems from there not being clarity on what the acceptable wild-card type is for HAL. All HAL is JSON, but all JSON is not HAL. Also, even if application/*hal+json is the acceptable wild-card type for HAL, it will never get recognized by MimeType since it looks for *+ for a wildcard subtype. I'm not sure if it would make sense to modify MimeType, so we may have to see if *+hal+json gets recognized as a registered subtype. Which means, that the actual media-type for something that is HAL or JSON would have to be application/vnd.vendor.service.entity.v1+hal+json and not application/vnd.vendor.service.entity.v1.hal+json.

So for the time being, I think the only thing that makes sense semantically is explicitly spelling out your exact custom media types and then instantiating a MappingJackson2HttpMessageConverter instance that recognizes those as supported media-types, and uses the HAL object mapper.

If anyone has any ideas, please let me know because there seems to be a lot of confusion surrounding this. This basically makes it very difficult (or at least, not very elegant) to create a true, Level 3 RESTful API.

@vivin
Copy link
Author

vivin commented Dec 15, 2014

I've summarized my concerns in a StackOverflow question.

@kewne
Copy link

kewne commented Dec 1, 2016

@vivin, correct me if my interpretation of the discussion is wrong (I know it's late and you've probably fixed your original problem but the issue is still open): your requirement of custom media types seems to revolve about assigning a media type to a given domain entity such that, for example, an order would be a response with media type application/vnd.order.v1 (or similar, I forget the exact format).

While I agree that supporting more media types and, particularly, removing the current mandatory usage of HAL, I'd like to challenge the notion that Level 3 in the Richardson REST Maturity Model implies usage of custom media types, especially to represent domain entities: the way I understand his QCon presentation, he describes custom media types as something that's somewhat tied to user agents (hope I'm using the term correctly) and the context of the data.
He even uses ATOM (which represents news feeds to be used be feed readers) and HTML (which represents a "printable" document used by browsers)

What you seem to require is link semantics, meaning that a link of type "order" is a link to an object that contains order information.
However, media type is merely a way of representing that order resource, which could be represented as HAL+JSON, HAL+XML or ATOM;
what determines the semantics of a resource represented as a media type is the link's rel attribute, which can be extended by using CURIE.

Sorry for the long comment and let me know if I totally missed the point.

@vivin
Copy link
Author

vivin commented Dec 1, 2016

@kewne No worries! Yeah, I agree that Level 3 doesn't mandate custom media-types; it only mandates the use of semantically-appropriate media-types, in the sense that the user-agent has the ability to specify exactly what resource it wants in addition to the way the resource should be represented. So the content-type is basically application/<what-resource-means>+<what-resource-looks-like>.

I think you helped clarify things for me :). I was going about the whole thing wrong anyway because whether something uses HAL or not would (should) be documented in its media-type. So a media-type for a resource that has HAL links is necessarily different from one that does not (even for the same resource). HAL describes the linking semantics of the resource, so a representation of the same resource without HAL has a different meaning, in the sense that it may convey linking semantics in a completely different way or not at all. I think that was the root of my mistake - I assumed that the HAL portion changed the way the resource was represented when really what it does is provide additional semantics for the way the resource is linked to other resources - now that information could be represented in different ways like you said: as XML or JSON, or some other format.

@shuraa
Copy link

shuraa commented Dec 7, 2016

So, there is no simple way to register custom media type with HAL support?

@elnur
Copy link

elnur commented Mar 25, 2017

TBH, Spring HATEOAS seems to be abandoned most of the time. It gets so little attention from the contributors. Have to patch it up all over the place. 😞

@elnur
Copy link

elnur commented Mar 25, 2017

So here's my current hack to solve the problem. It only makes sense if you only emit HAL and don't support other representations.

First, I enable hypermedia support explicitly:

@EnableHypermediaSupport(type = HAL)

That fixes the problem in no time but introduces other problems like breaking a lot of nice support and configurability of Jackson. For instance, you can't configure stuff like the following in your application.yml file anymore:

spring:
  jackson:
    serialization:
      write_dates_as_timestamps: false

Now, to bring all those awesome features back, I have a configuration class that uses the default object mapper builder to configure the object mapper created by Spring HATEOAS:

@Configuration
class JacksonConfig {
    @Autowired
    private ObjectMapper objectMapper;
    @Autowired
    private Jackson2ObjectMapperBuilder objectMapperBuilder;

    @Bean
    ObjectMapper objectMapper() {
        objectMapperBuilder.configure(objectMapper);
        return objectMapper;
    }
}

This is the simplest hack I've seen so far. If someone comes up with a simpler one, I'll be glad to switch to that.

@jack-harrison
Copy link

All workarounds appear to be ugly. In our case, this was the least invasive option, by simply adding an additional supported media type to the existing message converter. No custom message converters, no overriding of object mappers. Just a simple self-contained class which can be removed if this issue gets resolved one day.

@Component
public class HalMediaTypeEnabler {
    private static final MediaType CUSTOM_MEDIA_TYPE = new MediaType("application", "*+hal+json");
    private final RequestMappingHandlerAdapter requestMappingHandlerAdapter;

    @Autowired
    HalMediaTypeEnabler(RequestMappingHandlerAdapter requestMappingHandlerAdapter) {
        this.requestMappingHandlerAdapter = requestMappingHandlerAdapter;
    }

    @PostConstruct
    public void enableVndHalJson() {
        for (HttpMessageConverter<?> converter : requestMappingHandlerAdapter.getMessageConverters()) {
            if (converter instanceof MappingJackson2HttpMessageConverter && converter.getSupportedMediaTypes().contains(HAL_JSON)) {
                MappingJackson2HttpMessageConverter messageConverter = (MappingJackson2HttpMessageConverter) converter;
                messageConverter.setSupportedMediaTypes(Arrays.asList(HAL_JSON, CUSTOM_MEDIA_TYPE));
            }
        }
    }
}

@jkubrynski
Copy link

Any progress on this issue?

@gregturn
Copy link
Contributor

I have spent quite a bit of time upgrading Spring Data REST to use the new Spring HATEAOS Affordances API. This means SDR being able to product not only HAL but HAL-FORMS and Collection+JSON documents.

So some of these other issues have not received as much love and attention.

@vpavic
Copy link
Contributor

vpavic commented Feb 13, 2019

Are there any updates on this issue? We're also using a custom media type that's based on application/hal+json and were forced to resort to similar workaround as outlined by @jack-harrison.

Our concrete solution was to do it via BeanPostProcessor:

public class RequestMappingHandlerAdapterPostProcessor implements BeanPostProcessor {

    @Override
    public Object postProcessBeforeInitialization(@NonNull Object bean, String beanName) {
        if (bean instanceof RequestMappingHandlerAdapter) {
            RequestMappingHandlerAdapter adapter = (RequestMappingHandlerAdapter) bean;
            for (HttpMessageConverter<?> messageConverter : adapter.getMessageConverters()) {
                if (isHateoasEnabledMessageConverter(messageConverter)) {
                    configureMediaTypes((MappingJackson2HttpMessageConverter) messageConverter);
                }
            }
        }
        return bean;
    }

    private static boolean isHateoasEnabledMessageConverter(HttpMessageConverter<?> messageConverter) {
        return (messageConverter instanceof MappingJackson2HttpMessageConverter)
                && messageConverter.getSupportedMediaTypes().contains(MediaTypes.HAL_JSON);
    }

    private static void configureMediaTypes(MappingJackson2HttpMessageConverter messageConverter) {
        messageConverter.setSupportedMediaTypes(Collections.singletonList(MyMediaTypes.VND_JSON));
    }

}

@gregturn
Copy link
Contributor

I’m actually working on a new feature (https://github.com/spring-projects/spring-hateoas/tree/feature/new-mediatypes) to register your own media type.

@vpavic
Copy link
Contributor

vpavic commented Feb 14, 2019

That great to hear @gregturn - is this expected to make it into 1.0.0.M1?

@gregturn
Copy link
Contributor

@vpavic We're knocking 'em out as we can, so I'm not really sure.

@gregturn
Copy link
Contributor

Superceded and resolved via #833.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

10 participants