apache / apache/grails-core

Grails 7 plugins passing HttpHeaders where a MultiValueMap is expected fail on Grails 8 with IncompatibleClassChangeError

Open
#16,127 0 comments 0 reactions 0 assignees View on GitHub
relates-to: external-plugin
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.