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

Suspend handler methods fail on nullable value class parameters #32353

Closed
efemoney opened this issue Mar 1, 2024 · 4 comments
Closed

Suspend handler methods fail on nullable value class parameters #32353

efemoney opened this issue Mar 1, 2024 · 4 comments
Assignees
Labels
in: web Issues in web modules (web, webmvc, webflux, websocket) theme: kotlin An issue related to Kotlin support type: bug A general bug
Milestone

Comments

@efemoney
Copy link

efemoney commented Mar 1, 2024

Affects: Spring Framework 6.1.4, Spring Boot 3.2.3


This is related to support added in #27345 for value classes. This is because it tries to box an already boxed value class.


Take for example this handler method:

@JvmInline value class SomeId(val int: Int)

@GetMapping("/path")
suspend fun handle(
  @RequestParam nonNull: SomeId,
  @RequestParam nullable: SomeId?,
)

Calling /path?nonNull=1&nullable=2 will fail because the type of the nullable method parameter is SomeId? and Spring cannot find a Converter from String -> SomeId.

If we instead add a custom converter from String -> SomeId, the same endpoint will still fail because now the resolved argument has type SomeId but invokeSuspendingFunction wants to box it because its a value class.

My current workaround is to override invokeSuspendingFunction with my own variant that checks the arg[index] type first before boxing.


Adding excerpt from my own code comments:

Because of the way value classes work on the jvm, the runtime java reflection types of both parameters,
and hence the type that the spring handler method arguments have are Class<Int> & Class<SomeId> respectively.
These are the types used to [resolve arguments][HandlerMethodArgumentResolver.resolveArgument] and
more importantly, to convert from request param, request header etc.

(In our case, since value classes are not handled by default, we configure a special [GenericConverter]
that will convert into a value class type from request params, headers etc.)

However, run time kotlin reflection types for both parameters are KClass<SomeId> && KClass<SomeId?>
and these are the types used in [CoroutinesUtils.invokeSuspendingFunction] to determine whether
to box the converted argument or not.

This argument could be of wrapped type or value class type
depending on whether the declaration is nullable or not and boxing will fail on the latter!

This method is a kotlin port of [CoroutinesUtils.invokeSuspendingFunction] that checks
if the instance type is the value class just before deciding whether to box or not

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged or decided on label Mar 1, 2024
@jhoeller jhoeller added in: web Issues in web modules (web, webmvc, webflux, websocket) theme: kotlin An issue related to Kotlin support labels Mar 1, 2024
@sdeleuze sdeleuze added type: bug A general bug and removed status: waiting-for-triage An issue we've not yet triaged or decided on labels Mar 1, 2024
@sdeleuze sdeleuze added this to the 6.1.5 milestone Mar 1, 2024
@sdeleuze
Copy link
Contributor

sdeleuze commented Mar 1, 2024

@efemoney Could you please test 6.1.5-SNAPSHOT when this build will be finished? I would be interested by your confirmation that this change and #32324 one are working as expected for your use cases before the release.

@efemoney
Copy link
Author

efemoney commented Mar 2, 2024

Very clever to change the conversion type for (inline) value classes to the wrapped type, I didn't think of that!

Tested this and it works for the majority of cases but it fails for one case:

nullable value class without a default value supplied

@JvmInline value class SomeId(val id: Int)

suspend fun something(
  @RequestParam nullable: SomeId?,
)

This is because, according to kotlin, parameter is not optional (which triggers value constructor) but the query param can be left out because nullable type so its trying to call constructor with null value.

I believe we might need another check here of parameter.getType().isMarkedNullable() && arg[index] == null.


Here are my test cases & results

I use @WebMvcTest to test here the entire pipeline from params conversion to suspend function invocation.

CleanShot 2024-03-02 at 10  53 52@2x

@WebMvcTest(TestController::class)
class SuspendHandlerTests(@Autowired private val mockMvc: MockMvc) {

  @Test
  fun `test both params optional, with no values passed`() {
    mockMvc.get("/both-optional")
      .asyncDispatch()
      .andExpect {
        status { isOk() }
        content { string("1 - null") }
      }
  }

  @Test
  fun `test both params optional, with only nullable value passed`() {
    mockMvc.get("/both-optional") { param("nullable", "2") }
      .asyncDispatch()
      .andExpect {
        status { isOk() }
        content { string("1 - 2") }
      }
  }

  @Test
  fun `test both params optional, with both values passed`() {
    mockMvc.get("/both-optional") { param("nonNull", "3"); param("nullable", "2") }
      .asyncDispatch()
      .andExpect {
        status { isOk() }
        content { string("3 - 2") }
      }
  }

  @Test
  fun `test both params optional, nullable default, with no values passed`() {
    mockMvc.get("/both-optional-nullable-default")
      .asyncDispatch()
      .andExpect {
        status { isOk() }
        content { string("1 - 2") }
      }
  }

  @Test
  fun `test both params optional, nullable default, with only nullable value passed`() {
    mockMvc.get("/both-optional-nullable-default") { param("nullable", "3") }
      .asyncDispatch()
      .andExpect {
        status { isOk() }
        content { string("1 - 3") }
      }
  }

  @Test
  fun `test both params optional, nullable default, with both values passed`() {
    mockMvc.get("/both-optional-nullable-default") { param("nonNull", "3"); param("nullable", "4") }
      .asyncDispatch()
      .andExpect {
        status { isOk() }
        content { string("3 - 4") }
      }
  }

  @Test
  fun `test one param optional one required, with required value passed`() {
    mockMvc.get("/one-required-one-optional") { param("nonNull", "3") }
      .asyncDispatch()
      .andExpect {
        status { isOk() }
        content { string("3 - null") }
      }
  }

  @Test
  fun `test one param optional one required, with both values passed`() {
    mockMvc.get("/one-required-one-optional") { param("nonNull", "3"); param("nullable", "4") }
      .asyncDispatch()
      .andExpect {
        status { isOk() }
        content { string("3 - 4") }
      }
  }

  @Test
  fun `test one param optional one required, nullable default, with required value passed`() {
    mockMvc.get("/one-required-one-optional-nullable-default") { param("nonNull", "3") }
      .asyncDispatch()
      .andExpect {
        status { isOk() }
        content { string("3 - 2") }
      }
  }

  @Test
  fun `test one param optional one required, nullable default, with both values passed`() {
    mockMvc.get("/one-required-one-optional-nullable-default") { param("nonNull", "3"); param("nullable", "4") }
      .asyncDispatch()
      .andExpect {
        status { isOk() }
        content { string("3 - 4") }
      }
  }
}

@RestController
private class TestController {
  // "optional" in this context means that the url query parameter is optional
  //  not necessarily that the kotlin parameter is optional 

  @GetMapping("/both-optional")
  suspend fun bothOptional(
    @RequestParam nonNull: SomeId = SomeId(1),
    @RequestParam nullable: SomeId?,
  ): String {
    return nonNull.id.toString() + " - " + nullable?.id.toString()
  }

  @GetMapping("/both-optional-nullable-default")
  suspend fun bothOptionalDefault(
    @RequestParam nonNull: SomeId = SomeId(1),
    @RequestParam nullable: SomeId? = SomeId(2),
  ): String {
    return nonNull.id.toString() + " - " + nullable?.id.toString()
  }

  @GetMapping("/one-required-one-optional")
  suspend fun oneRequiredOneOptional(
    @RequestParam nonNull: SomeId,
    @RequestParam nullable: SomeId?,
  ): String {
    return nonNull.id.toString() + " - " + nullable?.id.toString()
  }

  @GetMapping("/one-required-one-optional-nullable-default")
  suspend fun oneRequiredOneOptionalDefault(
    @RequestParam nonNull: SomeId,
    @RequestParam nullable: SomeId? = SomeId(2),
  ): String {
    return nonNull.id.toString() + " - " + nullable?.id.toString()
  }
}

// Needs to be public or else will throw access restrictions exceptions
@JvmInline value class SomeId(val id: Int)

@sdeleuze sdeleuze reopened this Mar 3, 2024
@sdeleuze
Copy link
Contributor

sdeleuze commented Mar 3, 2024

@efemoney Please test again when this build will be finished. Thanks for your feedback, much appreciated.

@efemoney
Copy link
Author

efemoney commented Mar 4, 2024

Tested and all my tests pass without any workarounds!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in: web Issues in web modules (web, webmvc, webflux, websocket) theme: kotlin An issue related to Kotlin support type: bug A general bug
Projects
None yet
Development

No branches or pull requests

4 participants