Grails 7 plugins passing HttpHeaders where a MultiValueMap is expected fail on Grails 8 with IncompatibleClassChangeError
- Dominant language
- Groovy
- Stars
- 2.9k
- Forks
- 975
- Avg merge
- 1d 22h
- Merged PRs (30d)
- 92
Description
### Summary
Grails 7 plugins compiled against Spring Framework 6 fail at runtime on Grails 8 with `IncompatibleClassChangeError` when they pass an `HttpHeaders` into any API whose parameter is typed `MultiValueMap`. Spring Framework 7 removed `MultiValueMap` from the interfaces implemented by `org.springframework.http.HttpHeaders`, so bytecode that was valid under Spring 6 is now invalid.
The plugin resolves, compiles, loads, and produces a working `bootJar`. The failure appears only when the plugin's public API is actually called.
Found while auditing released Grails 7 plugins against `8.0.0-M5`, using `io.github.gpc:grails-datastore-rest-client-legacy:7.0.3` (latest release, catalog constraint `7.0.0 > *`).
### Grails Version
8.0.0-M5 (Groovy 5.0.8, Spring Boot 4.1.0, Spring Framework 7.0.8, JDK 21.0.11)
### Steps to Reproduce
In a stock `create-app --profile=web` application on 8.0.0-M5:
```groovy
implementation "io.github.gpc:grails-datastore-rest-client-legacy:7.0.3"
```
Add a trivial controller action that returns `rest-client-ok`, then call the plugin's documented public API against the running embedded server:
```groovy
@Integration
class RestBuilderIntegrationSpec extends Specification {
@Value('${local.server.port}')
int serverPort
void 'RestBuilder GET invokes the local controller'() {
when:
def response = new RestBuilder().get("http://127.0.0.1:${serverPort}/audit/ping")
then:
response.status == 200
response.text == 'rest-client-ok'
}
}
```
### Actual Behaviour
```text
java.lang.IncompatibleClassChangeError: Class org.springframework.http.HttpHeaders
does not implement the requested interface org.springframework.util.MultiValueMap
at org.springframework.http.HttpHeaders.isEmpty(HttpHeaders.java:1902)
at org.springframework.web.client.RestTemplate$HttpEntityRequestCallback.doWithRequest(RestTemplate.java:947)
at org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:752)
at org.springframework.web.client.RestTemplate.execute(RestTemplate.java:697)
at org.springframework.web.client.RestTemplate.exchange(RestTemplate.java:571)
at grails.plugins.rest.client.RestBuilder.invokeRestTemplate(RestBuilder.groovy:335)
at grails.plugins.rest.client.RestBuilder.doRequestInternal(RestBuilder.groovy:299)
at grails.plugins.rest.client.RestBuilder.doRequestInternal(RestBuilder.groovy:287)
at grails.plugins.rest.client.RestBuilder.get(RestBuilder.groovy:132)
at restclient.RestBuilderIntegrationSpec.RestBuilder GET invokes the local controller(RestBuilderIntegrationSpec.groovy:19)
```
The application itself started normally - `Tomcat started on port 51899`, and the plugin appears in the load order as `grailsDatastoreRestClientLegacy (7.0.3)`.
### Root Cause
This is **not** a mixed-Spring-version problem. I checked both:
- The built `bootJar` contains exactly one Spring Web: `BOOT-INF/lib/spring-web-7.0.8.jar` (plus `spring-core-7.0.8`, `spring-webmvc-7.0.8`).
- The isolated Gradle cache for this build only ever downloaded `spring-web-7.0.8.jar`.
- The plugin jar ships no `org/springframework/**` classes of its own.
The cause is in the plugin's own compiled bytecode. `grails.plugins.rest.client.RequestCustomizer` holds the headers as `HttpHeaders`:
```text
private org.springframework.http.HttpHeaders headers;
private org.springframework.util.MultiValueMap mvm;
public org.springframework.http.HttpEntity createEntity();
```
and `createEntity()` passes that `HttpHeaders` field into the `MultiValueMap` parameter of `HttpEntity`:
```text
30: getfield #35 // Field headers:Lorg/springframework/http/HttpHeaders;
33: invokespecial #270 // Method org/springframework/http/HttpEntity."":(Ljava/lang/Object;Lorg/springframework/util/MultiValueMap;)V
```
Under Spring 6 this was legal, because `HttpHeaders implements MultiValueMap`. Under Spring 7 it is not: `HttpHeaders` no longer implements `MultiValueMap`, so the `HttpEntity`'s `MultiValueMap`-typed header slot now holds an object that does not implement that interface. When `RestTemplate` later asks the entity for its headers and calls `isEmpty()`, the interface dispatch fails with `IncompatibleClassChangeError`.
The constructor `HttpEntity(Object, MultiValueMap)` still exists in Spring 7, so nothing fails at resolution, load, or class-verification time - only when the code path actually runs.
### Expected Behaviour
At minimum this should be a documented Grails 8 upgrade note: `HttpHeaders` no longer implements `MultiValueMap`, so any plugin or application code passing `HttpHeaders` where a `MultiValueMap` is expected must be recompiled against Spring 7, and any such code that is only distributed as a compiled artifact will fail at runtime rather than at build time.
### Why this is worth tracking here
The blast radius is larger than one plugin. `new HttpEntity(body, headers)` with an `HttpHeaders` argument is an extremely common Spring idiom, and it was the natural way to write it for the entire Spring 4/5/6 era. Any Grails 7 plugin that performs an outbound HTTP call is a candidate.
The failure mode is also unusually unhelpful:
- It survives dependency resolution, compilation, plugin loading, and `bootJar`.
- The error names two Spring classes and no Grails or plugin component.
- It only appears when that specific code path executes, so a smoke test that merely boots the application will pass.
### Distinct from previously reported issues
This does not match #16122 (Metadata dynamic key access), #16123 (generic trait with fields / `$Trait$FieldHelper`), #16124 (partial Jackson 2 classpath), #16125 (pre-Apache `org.grails` coordinates), or #16126 (AST transformation targeting a private trait method). It is a Spring 6 -> 7 binary incompatibility rather than a Groovy or Grails one.
### Notes
Part of a compatibility sweep of released Grails 7 plugins against 8.0.0-M5.
Contributor guide
Research direction
Start from the issue's Spring 7 compatibility findings and the documented RestBuilder integration reproduction; no repository documentation file is named in the report. Done means adding an upgrade note explaining the HttpHeaders/MultiValueMap incompatibility, the affected compiled plugins or application code, and the need to recompile against Spring 7.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- groovy, spring
- Domain
- documentation
- Issue type
- Documentation
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 58/100