spring-hateoas icon indicating copy to clipboard operation
spring-hateoas copied to clipboard

`Enum.toString()` isn't used in links generated by `WebMvcLinkBuilder` since 1.4

Open Chazoshtare opened this issue 3 years ago • 5 comments

I'm using WebMvcLinkBuilder to generate links to controller methods with specific predefined parameters represented by a list of enums. These enums have custom toString() implementations to provide required labels for given API calls. However, it's not used since 1.4 resulting in incorrect links.

Simplified example:

enum class Words(private val text: String) {
    HELLO("Hello,"),
    WORLD("world!");

    override fun toString(): String {
        return text
    }
}
val invocation = WebMvcLinkBuilder.methodOn(TestController::class.java)
        .get(listOf(Words.HELLO, Words.WORLD))
val actualLink = WebMvcLinkBuilder.linkTo(invocation).withSelfRel().href

Currently, since 1.4, they are represented using their name(): /?words=HELLO&words=WORLD

However, before 1.4, enums were converted using their toString() implementation, generated link was: /?words=Hello,&words=world!

Sample project with unit test: demo-hateoas.zip

Chazoshtare avatar Jan 13 '22 18:01 Chazoshtare

While I can see that this is a change that alters the previous output, it's actually needed to make the default handling symmetric. Using the enum's toString() method would prevent the resulting value from being resolvable back into the enum as Spring's StringToEnumConverterFactory uses Enum.valueOf(enumType, source.trim()), i.e. uses Words.valueOf(…) that would not find HELLO for a Hello received as a request parameter.

If you say those values worked for you before 1.4, how did you tweak the parameter resolution?

odrotbohm avatar May 11 '22 15:05 odrotbohm

There is demo app and you can check it changing hateoas version. I think there should be "knob" to choose strategy for the enums. If you ask about handling those strings in controller it's done by adding custom converter afair.

wyhasany avatar May 11 '22 16:05 wyhasany

I did that. What I am trying to say is that a generated URL of /?words=Hello,&words=world! will not properly resolve into the MVC controller you have declared in the example. I.e. you cannot issue an HTTP request using that URI as the enums will not be parsed correctly unless you also register some converter to adapt the parsing the same way you tweak it for the printing. I.e. using the enum's ….toString() method by default breaks symmetry and renders projects broken that do solely declared the method for other purposes. Thus, if you would like to switch back to ….toString(), register two converters: one for the printing and one for parsing. Or a Formatter that does both things.

odrotbohm avatar May 12 '22 06:05 odrotbohm

The original code missed this part of our custom Converter which handled converting String to Enum in following way. Unfortunately with WebMvcConfigurer it doesn't register converters for spring-hateoas. How can we register custom converters for spring-hateoas?

enum class Words(private val text: String) {
    HELLO("Hello,"),
    WORLD("world!");

    companion object {
        val CACHE: Map<String, Words> = values().associateBy { it.text }
    }

    override fun toString(): String {
        return text
    }
}

class StringToWordsConverter: Converter<String, Words> {
    override fun convert(source: String): Words? = Words.CACHE[source]
}

class WordsToStringConverter: Converter<Words, String> {
    override fun convert(source: Words): String = source.toString()
}

open class ExampleResponse(val enum: List<Words>)

@Configuration
class WebRequestEnumConverter: WebMvcConfigurer {

    override fun addFormatters(registry: FormatterRegistry) {
        registry.addConverter(StringToWordsConverter())
        registry.addConverter(WordsToStringConverter())
    }
}

wyhasany avatar May 12 '22 15:05 wyhasany

In the end we've figured out how to setup unit test with custom converters. That requires "hadouken" setup code 🤣 image

like this:

RequestContextHolder.setRequestAttributes(
    ServletRequestAttributes(
        MockHttpServletRequest(
            MockServletContext().apply {
                this.setAttribute(
                    WebApplicationContext::class.java.name + ".ROOT",
                    StaticWebApplicationContext().apply {
                        this.registerBean(
                            "mvcConversionService",
                            ConversionService::class.java,
                            { ->
                                val format = WebMvcProperties.Format()
                                val registry = WebConversionService(
                                    DateTimeFormatters()
                                        .dateFormat(format.date).timeFormat(format.time).dateTimeFormat(format.dateTime)
                                )
                                registry.addConverter(StringToWordsConverter())
                                registry.addConverter(WordsToStringConverter())
                                registry
                            }
                        )
                    }
                )
            }
        )
    )
)

Full working example of test code below: demo.zip

That's caused by org/springframework/hateoas/server/mvc/WebMvcLinkBuilderFactory.java:197 as there is no Servlet context in unit tests so it uses FALLBACK_CONVERSION_SERVICE. That broke our unit tests before. Is there an easier way to setup such a test? If not, maybe it should be documented somewhere?

wyhasany avatar May 12 '22 16:05 wyhasany