Affects: \2.5.3

When migrating a reactive web project (written in Kotlin) from 2.4.x to 2.5.3, I noticed that the HTTP POST Request that was used to create a new entity was failing with HTTP Status 406.

{
  "timestamp": "2021-08-18T23:56:17.183+00:00",
  "path": "/api",
  "status": 406,
  "error": "Not Acceptable",
  "message": "Could not find acceptable representation"
}

After investigating, I found out that the error occurs when all of the below conditions are fullfilled:

  • EntityModel from Hateoas is wrapped in a ResponseEntity
  • Controller function is a Coroutine (marked with the suspend keyword, the error does not happen when using the traditional way with Project Reactor's Mono)
  • Hypermedia Support is enabled
  • Spring Boot Version is at least 2.5.0

Here is a small repro project created with the Spring Initializr

https://github.com/enolive/spring-hateoas

package com.example.hateoastest

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.hateoas.EntityModel
import org.springframework.hateoas.Link
import org.springframework.hateoas.config.EnableHypermediaSupport
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.net.URI

@SpringBootApplication
@EnableHypermediaSupport(type = [EnableHypermediaSupport.HypermediaType.HAL])
class HateoasTestApplication

@RestController
@RequestMapping("/api")
class Controller {
  @GetMapping
  suspend fun test() =
    Greetings("Hello, World!")
      .withSelfLink()
      .wrappedInResponseEntity()
}

private fun Greetings.withSelfLink() =
  EntityModel.of(this, Link.of("/api").withSelfRel())

private fun <T> EntityModel<T>.wrappedInResponseEntity() =
  ResponseEntity.created(URI("/api")).body(this)

data class Greetings(val message: String)

fun main(args: Array<String>) {
  runApplication<HateoasTestApplication>(*args)
}

When switching the parent version back to 2.4.x (say, 2.4.9), everything works fine and I will get the expected output.

GET http://localhost:8080/api

HTTP/1.1 201 Created
Location: /api
Content-Type: application/hal+json
Content-Length: 61

{
  "message": "Hello, World!",
  "_links": {
    "self": {
      "href": "/api"
    }
  }
}

As a side note, explicitly setting the Accept Header to application/hal+json removes the error message. However, the hypermedia representation of the returned content seems wrong (links instead of _links):

GET http://localhost:8080/api
Accept: application/hal+json

HTTP/1.1 201 Created
Location: /api
Content-Type: application/hal+json
Content-Length: 66

{
  "message": "Hello, World!",
  "links": [
    {
      "rel": "self",
      "href": "/api"
    }
  ]
}

Response code: 201 (Created); Time: 225ms; Content length: 66 bytes

Am I doing something wrong?

Please let me know whether I should provide additional information

I used following dependencies in my maven pom.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.5.3</version>
    <relativePath/> <!-- lookup parent from repository -->
  </parent>
  <groupId>com.example</groupId>
  <artifactId>hateoas-test</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <name>hateoas-test</name>
  <description>hateoas-test</description>
  <properties>
    <java.version>11</java.version>
    <kotlin.version>1.5.21</kotlin.version>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-hateoas</artifactId>
      <exclusions>
        <exclusion>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-web</artifactId>
        </exclusion>
      </exclusions>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-webflux</artifactId>
    </dependency>
    <dependency>
      <groupId>com.fasterxml.jackson.module</groupId>
      <artifactId>jackson-module-kotlin</artifactId>
    </dependency>
    <dependency>
      <groupId>io.projectreactor.kotlin</groupId>
      <artifactId>reactor-kotlin-extensions</artifactId>
    </dependency>
    <dependency>
      <groupId>org.jetbrains.kotlin</groupId>
      <artifactId>kotlin-reflect</artifactId>
    </dependency>
    <dependency>
      <groupId>org.jetbrains.kotlin</groupId>
      <artifactId>kotlin-stdlib-jdk8</artifactId>
    </dependency>
    <dependency>
      <groupId>org.jetbrains.kotlinx</groupId>
      <artifactId>kotlinx-coroutines-reactor</artifactId>
    </dependency>

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-devtools</artifactId>
      <scope>runtime</scope>
      <optional>true</optional>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>io.projectreactor</groupId>
      <artifactId>reactor-test</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
    <testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
      <plugin>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-maven-plugin</artifactId>
        <configuration>
          <args>
            <arg>-Xjsr305=strict</arg>
          </args>
          <compilerPlugins>
            <plugin>spring</plugin>
          </compilerPlugins>
        </configuration>
        <dependencies>
          <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-maven-allopen</artifactId>
            <version>${kotlin.version}</version>
          </dependency>
        </dependencies>
      </plugin>
    </plugins>
  </build>

</project>

Comment From: rstoyanchev

@enolive thanks for the report and all the details. Packaging the above in a sample project would greatly reduce the time required to investigate and would be much appreciated.

Comment From: enolive

ok, done! I've added a link to my issue description

Comment From: rstoyanchev

Using the sample fixed at Boot 2.4.9, changing Spring HATEOAS from 1.2.9 to 1.3.0 shows the issue.

On the one hand, this seems related to https://github.com/spring-projects/spring-hateoas/issues/1453. In 1.3.0 we try to match the element type Greetings to the new RepresentationModel specific ObjectMapper registration, and fail. In 1.2.9 the elementType does not need to be checked because there is a "hal+json" Jackson2Encoder ordered first.

On the other hand, the elementType determination looks wrong independent of that, and HATEOAS 1.3.0 just happens to expose it. Here we have a method that returns ResponseEntity<RepresentationModel<Greetings>> and this should result in elementType RepresentationModel<Greetings> (nested one level). CoroutinesUtils.invokeSuspendingFunction returns a Mono and in that case we nest twice in ResponseEntityResultHandler to also unwrap the Mono but MethodParameter doesn't have a Mono and we end up nesting all the way to Greetings, which is wrong.

It seems that adding a Mono wrapper for a suspend method can cause issues with return value type determination which looks at the actual value and sees the Mono wrapper and therefore does an extra nesting on the MethodParameter which in turn does not have such a Mono wrapper.

@sdeleuze, do you have any insight? Do we need to adjust result handlers to check if the method is suspending, and adjust nesting logic accordingly, or is there something else that needs to be fixed?