Skip to content

Modernize MIME negotiation and serialization for Spring Boot 4.1 - #16237

Merged
jdaugherty merged 663 commits into
apache:9.0.xfrom
codeconsole:feature/spring-media-type-negotiation-8.0.x
Oct 21, 2026
Merged

jdaugherty merged 663 commits into
apache:9.0.xfrom
codeconsole:feature/spring-media-type-negotiation-8.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Modernizes Grails MIME negotiation and JSON/XML rendering on Spring Boot 4.1, with a migration path. In Grails 9 the Grails 8 behaviour stays the default, apart from the changes Compatibility lists. Grails 10 makes Spring JSON rendering for respond, Jackson rendering for grails.converters.JSON, Jackson parsing of request bodies for data binding, and Spring MVC's exception handlers and @ResponseBody for controller actions the default. Grails 11 removes the legacy respond rendering and request-body parsing, the JSON converter's named configurations and legacy XML conversion.

  • Grails MIME types bridge to Spring MediaType, and the Grails data formats are registered with Spring MVC as format aliases. The static negotiation state is gone.
  • respond() can render JSON as Spring MVC writes the value a @ResponseBody handler method returns, with its response body handler, so the application's ResponseBodyAdvice beans apply, scoped to the controller as for a Spring MVC controller, also for a jsonConfiguration, and the action's @JsonView selects the properties written, other than a problem's or a named configuration's. A Grails mapper derived from Boot's JsonMapper writes domain objects with Jackson's bean serializer, shaped as grails.converters.JSON renders them with legacy: false: persistent properties, association references, proxies on every GORM datastore, includes/excludes projections and circular references. Jackson annotations, mix-ins and the mapper's naming and inclusion apply.
  • grails.converters.JSON can render through the application's JsonMapper too, and JSON.parse and request.JSON then parse with it.
  • An injectable, request-safe NamedJsonConfigurationRegistry is shared by render json:, respond and direct serialization, with a built-in deep configuration in place of JSON.use('deep').
  • Validation errors are rendered as RFC 9457 Problem Details on the Spring path, unless an _errors.gson renders them, as with JSON views, with a title and detail the application's messages translate. respond hands a ProblemDetail, or an ErrorResponse such as ErrorResponseException, to the return value handler of Spring MVC's handler adapter, whatever the settings, so it is written as by a Spring MVC controller method that returns it: with the application's ResponseBodyAdvice and ErrorResponse.Interceptor beans, the problem's status, or a status argument as the status of a ResponseEntity, an ErrorResponse's headers and message codes, and the type Spring MVC's content negotiation selects. That is application/problem+xml only where the client asks for a problem in XML, the action responds in XML and Spring MVC has an XML converter, which needs jackson-dataformat-xml, and application/problem+json otherwise, clients asking for application/xml and browsers included. A request Spring MVC cannot negotiate, such as one whose Accept header has a quality value above 1, gets the problem's status alone, or Spring MVC's HttpMediaTypeNotAcceptableException (406) for a status that is not an error status. A view renders a problem only when respond names it, and where the format respond picks has no view of the name, a GSP in HTML, the problem is written in that format if it is JSON or XML, the JSON view of the name renders it in any other if there is one, and otherwise it is written as without a view. A renderer the application registers for problems renders them; the default renderer of a format, and one registered for Object, do not.
  • HTTP XML payload support moves into the grails-xml module, which the Grails 9 web starter and grails-web-boot still bring, so generated applications keep XML until Grails 10. Plugin descriptors are unchanged, and the XML escaping codec stays in grails-converters.
  • JSON data binding can parse request bodies with Jackson, with the mapper Spring MVC reads a @RequestBody with.
  • With grails.exceptionresolver.springHandlers: true, an exception a controller action throws is resolved first by the @ExceptionHandler methods of the controller and of the @ControllerAdvice beans that apply to it, Spring Boot's spring.mvc.problemdetails.enabled handler among them; one none of them handles reaches the Grails exception resolver as before.
  • A controller action can return what a Spring MVC handler method returns to write the response: a ResponseEntity or another HttpEntity, HttpHeaders, a ProblemDetail, an ErrorResponse, a StreamingResponseBody or a ResponseBodyEmitter, and, with grails.controllers.responseBody: true, any value of an action annotated @ResponseBody, or of an action of a controller annotated so, also through @RestController. The return value handlers of Spring MVC's handler adapter write it, with the ResponseBodyAdvice beans that apply to the controller and the action's @JsonView and @ResponseStatus, also for an action of an error page, and a CompletableFuture, DeferredResult or Callable is processed asynchronously with the adapter's executor, timeout and interceptors, the value it completes with written as the action's.
  • Controller unit tests build Spring MVC's message converters from the test classpath and Boot's configuration, after the test's doWithConfig is applied, its content negotiation manager, which decides the type of a ProblemDetail, from those converters, spring.mvc.contentnegotiation.* and the test's WebMvcConfigurer beans, and Spring MVC's handler adapter from both, so that the ResponseBodyAdvice and ErrorResponse.Interceptor beans a test declares apply, unless the test declares them. They keep the URL mappings holder, link generator and locale resolver a test defines, also under an alias of its name or from a @Bean method its configuration class inherits, and any locale resolver the context has, the one the application configures among them, as JsonViewUnitTest does now; with the URL mappings plugin included, a test declares its holder in doWithSpring or beanRegistrar().

Usage

grails:
    web:
        rendering:
            json:
                spring: true     # Spring JSON for respond(); default false in 9, true in 10
    converters:
        json:
            legacy: false        # grails.converters.JSON through the JsonMapper; default true in 9, false in 10
    databinding:
        json:
            jackson: true        # Jackson request parsing; default false in 9, true in 10
    exceptionresolver:
        springHandlers: true     # Spring MVC @ExceptionHandler methods for actions; default false in 9, true in 10
    controllers:
        responseBody: true       # Spring MVC writes the values of @ResponseBody actions; default false in 9, true in 10
class BootStrap {
    NamedJsonConfigurationRegistry namedJsonConfigurationRegistry

    def init = { servletContext ->
        namedJsonConfigurationRegistry.register('catalog') {
            it.serializer(Book, new BookValueSerializer())
        }
    }
}

class BookController {
    ValidationProblemDetailFactory validationProblemDetailFactory

    def show(Book book) {
        respond book, jsonConfiguration: 'catalog'
    }

    def summary(Book book) {
        render json: book, excludes: ['isbn']
    }

    def save(Book book) {
        if (book.hasErrors()) {
            respond validationProblemDetailFactory.create(book.errors)   // 422 application/problem+json
            return
        }
        // ...
    }
}

Compatibility

  • JSON.registerObjectMarshaller stays supported for render ... as JSON and the legacy respond() path. JSON.createNamedConfig, JSON.use, JSON.getNamedConfig and JSON.withDefaultConfiguration are deprecated for removal in Grails 11 in favor of the registry. jsonConfiguration: selects only registry configurations, for the default JSON renderers and JsonRenderer beans; respond writes a ProblemDetail as Spring MVC writes it, whatever configuration, includes or excludes it is given, and validation errors as they are without a configuration.
  • withFormat, response.format and response.mimeTypes resolve as in Grails 8, except that media type names in the Accept and Content-Type headers now match case-insensitively, as do parameter names such as q; that the problem types of RFC 9457 are the json and xml formats, read as application/json and application/xml, so a body sent as application/problem+json binds as JSON, and a client that sends only one is answered as one that asks for that format; that text/xml and application/xml in one Accept header merge into xml with the larger quality and the place of the one listed first; and that each grails.mime.disable.accept.header.userAgents entry is a regular expression of its own, matched ignoring case, with an invalid one failing at startup. Spring MVC's own negotiation (@RestController, produces, spring.mvc.contentnegotiation.*) is unchanged. Grails registers its data-format aliases (json, xml, hal, atom, rss, csv, text) over Spring Framework's defaults, so a type grails.mime.types gives one of them replaces Spring's, while spring.mvc.contentnegotiation.media-types and the application's WebMvcConfigurer beans override them. Spring MVC negotiates with them only when spring.mvc.contentnegotiation.favor-parameter is enabled; they also count as safe extensions in its reflected-file-download check, which only hal was not already.
  • With grails.converters.json.legacy: false, the JSON converter has Jackson write collections, maps, arrays, records, enums and beans, as Spring Boot does, and Grails adds only its own rules (enums by name, the converter's includes and excludes, which name a property by its bean name or the name Jackson writes it with, @PersistenceMethod/@ControllerMethod methods left out). Domain classes render as Jackson beans of their persistent properties. Validation errors are written without the rejected value, a map with a null key fails, a Map.Entry is written as {"k":1}, a value nested deeper than the mapper's limit, 500 levels by default, fails with a ConverterException, and JSON.parse accepts strict JSON unless spring.jackson.json.read.* features allow more, so grails.converters.json.date: javascript, whose new Date(...) is not JSON, fails at startup with legacy: false. The Grails 8 rendering stays the default in Grails 9 (legacy: true).
  • Boot's shared mapper gets only a GString serializer, plus the domain serializer when grails.converters.json.domain.jackson.enabled is set; Errors and projection handling live on the Grails mapper, which writes every value respond writes with Jackson but a problem, which Spring's converter writes with Boot's mapper. An application's Jackson serializers take precedence over the Grails ones in both the converter and respond, also for an associated domain class instance, which is otherwise written as a reference, and where respond writes with a Jackson mapper it reports a failure to write as Spring MVC's converters do, so that Spring MVC answers it before the Grails exception resolver.
  • grails-converters depends on spring-boot-jackson, so Spring Boot's JsonMapper is in every application context that has the converters. An application without spring-boot-starter-web, which the web starter brings with SiteMesh 3, now has it too, and Spring MVC's JSON converter writes with it.
  • respond problemDetail answers as Spring MVC answers a handler method that returns it: with the problem's status, as application/problem+xml for a client that asks for a problem in XML where the action responds in XML and Spring MVC has an XML converter, and as application/problem+json for any other, also without jackson-dataformat-xml. Grails 8 wrote the problem as a bean with a 200 to JSON and XML clients, and rendered the action's view with a 200 for any other. respond errorResponse answers with its problem, status and headers, where Grails 8 wrote it as a bean.
  • An action that returns a ResponseEntity or another HttpEntity, HttpHeaders, a ProblemDetail, an ErrorResponse, a StreamingResponseBody or a ResponseBodyEmitter has it written, whatever the settings, which Grails 8 did not do. An action that returns null, or renders, redirects or forwards itself, is unaffected. With grails.controllers.responseBody: true, the value of an action annotated @ResponseBody, directly or through an annotation such as @RestController, whose annotation Grails 8 ignored, is written as the body in place of the action's view, before an interceptor's after runs, which then sees no model; without it, such an action renders its view as in Grails 8. Spring MVC writes a returned domain class instance with Boot's JsonMapper, as Grails writes it only with grails.converters.json.domain.jackson.enabled. GrailsWebRequest.isResponseRendered() tells a response an action rendered from one whose status is an error's, which isRenderView() is false for either way.
  • JSON that respond writes through Spring MVC, with grails.web.rendering.json.spring or a jsonConfiguration, and problems are written in UTF-8 and labelled application/json, or text/json for a client that asks for it, as Spring MVC writes JSON, whatever grails.converters.encoding says, which the legacy converter and render json: still write in. So is the XML respond writes with a Spring converter under grails.web.rendering.xml.spring, in the type respond negotiated.
  • respond answers in the format it found a renderer for, labelled so, and a client that accepts any type in the first of the action's formats a renderer renders the value in, so without grails-xml an action that lists ['xml', 'json'] answers such clients in JSON. Validation errors try the client's next type where its first has no renderer. Grails 8 took the action's first format and sent 406, or 404 for validation errors, when nothing rendered it. With JSON views, validation errors with no errors template are written as without JSON views, with a 422, where Grails 8 rendered the object template, null with a 200.
  • grails-xml leaves the web starter and grails-web-boot in Grails 10, when @Resource becomes JSON-only. Without it, request.XML throws an IllegalStateException that names the module; a library compiled against Grails 8 that reads request.XML or request.JSON statically runs without being recompiled. Legacy XML conversion is deprecated for removal in Grails 11: grails.converters.XML, the XML, HAL XML, Atom and Vnd.Error XML renderers, and the XML validation-error marshaller.
  • Whatever grails.converters.json.legacy says, the converter's includes and excludes for a proxy, which respond passes it, apply to the instance written in its place, with the DEFAULT circular reference behaviour an object that refers to itself is written as {"_ref":".",…}, and a value nested deeper than the thread's stack lets the converter write fails with a ConverterException; Grails 8 ignored the first, failed on the second and threw a StackOverflowError for the third.
  • Each legacy path logs one deprecation warning per application: respond's JSON, the XML converter and the HAL XML, Atom and Vnd.Error XML renderers when they first render, and request-body parsing at startup. While grails.converters.json.legacy is not set, a warning at startup says its default changes in Grails 10. With grails.web.rendering.json.spring enabled, each type the legacy converter still writes is reported once for each reason, such as a projection asked of an application's own converter.
  • The keys and defaults of grails.web.rendering.json.spring, grails.web.rendering.xml.spring, grails.databinding.json.jackson, grails.exceptionresolver.springHandlers and grails.controllers.responseBody are constants of grails.config.Settings.
  • The Upgrading from Grails 8 to Grails 9 guide covers each change, and section 4 lists the settings with their defaults. The REST guide has a page on customizing JSON with Jackson and on named configurations.

Limitations

  • With grails.web.rendering.xml.spring: true, only ordinary beans use Spring's XML converter. Domain objects, collections, maps, arrays, validation errors, projections and named configurations stay on the Grails XML converter, so XML validation errors keep the rejected values the problem JSON leaves out.
  • With grails.converters.json.legacy: false, the JSON converter writes a property a bean unwraps with the name of another twice, where Jackson, and the mappers of responses and named configurations, reject the bean: the class name and version of a domain class instance it writes depend on the rendering of each write, so the check would reject beans whose own version never conflicts.
  • Spring HATEOAS models are not bridged into respond.
  • Grails' negotiation picks the format of renderers, views and withFormat, and Spring MVC's the media type of what it writes, so the two can answer one request differently; resolving Grails formats through Spring MVC's negotiation is Resolve Grails formats through Spring MVC's content negotiation #16698.
  • ResponseBodyAdvice applies to the JSON respond writes through Spring MVC, to the ordinary beans it writes with Spring XML under grails.web.rendering.xml.spring, to problems and to the values of actions Spring MVC writes; the legacy JSON and XML converters and views do not go through Spring MVC's response body handler.
  • A ResponseEntity of a StreamingResponseBody or a ResponseBodyEmitter is streamed where the action declares that type and returns it, not where an asynchronous value the action returns completes with it, which Spring MVC types by the value it completes with.

@codecov

codecov Bot commented Aug 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.89547% with 306 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.3683%. Comparing base (1d3bf77) to head (36a1e7b).
⚠️ Report is 155 commits behind head on 9.0.x.

Files with missing lines Patch % Lines
...rails/web/converters/jackson/DomainReferences.java 74.1117% 22 Missing and 29 partials ⚠️
...ls/web/converters/jackson/GatedPropertyWriter.java 68.1416% 25 Missing and 11 partials ⚠️
...ls/web/converters/jackson/DomainClassModifier.java 83.3333% 4 Missing and 30 partials ⚠️
...onverters/json/NamedJsonConfigurationRegistry.java 73.8636% 21 Missing and 2 partials ⚠️
...ugins/web/rest/render/ProblemDetailResponse.groovy 69.0141% 11 Missing and 11 partials ⚠️
.../grails/web/converters/jackson/DomainIdWriter.java 64.7059% 11 Missing and 7 partials ⚠️
...erters/src/main/groovy/grails/converters/JSON.java 91.5254% 3 Missing and 7 partials ⚠️
...g/grails/web/converters/jackson/CycleTracking.java 82.7586% 5 Missing and 5 partials ⚠️
...s/web/converters/jackson/DomainClassRendering.java 70.8333% 3 Missing and 4 partials ⚠️
...ls/web/converters/jackson/DomainLeadingWriter.java 82.5000% 3 Missing and 4 partials ⚠️
... and 28 more
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                9.0.x     #16237        +/-   ##
==================================================
+ Coverage     61.4500%   64.3683%   +2.9183%     
- Complexity      27010      30010      +3000     
==================================================
  Files            2260       2363       +103     
  Lines          112192     116357      +4165     
  Branches        20351      21244       +893     
==================================================
+ Hits            68942      74897      +5955     
+ Misses          34190      31839      -2351     
- Partials         9060       9621       +561     
Files with missing lines Coverage Δ
...s/plugins/converters/ConvertersGrailsPlugin.groovy 96.8750% <100.0000%> (+2.1382%) ⬆️
...on/AbstractConvertersConfigurationInitializer.java 100.0000% <100.0000%> (ø)
...s/configuration/ConvertersConfigurationHolder.java 87.5000% <ø> (ø)
...eb/converters/jackson/CircularReferenceModule.java 100.0000% <100.0000%> (ø)
...b/converters/jackson/DomainClassJacksonModule.java 91.6667% <100.0000%> (+3.6667%) ⬆️
...b/converters/jackson/JacksonNamedJsonRenderer.java 100.0000% <100.0000%> (ø)
...converters/jackson/SpringErrorsJsonSerializer.java 100.0000% <100.0000%> (ø)
...onverters/marshaller/json/ByteArrayMarshaller.java 40.0000% <ø> (ø)
...converters/marshaller/json/CalendarMarshaller.java 86.6667% <ø> (ø)
...web/converters/marshaller/json/DateMarshaller.java 86.6667% <ø> (ø)
... and 93 more

... and 216 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codeconsole

Copy link
Copy Markdown
Contributor Author

Deprecation migration examples

This comment maps every API deprecated by this PR to its modern replacement. Overloads are grouped where their migration is identical.

The XML migrations intentionally change the response representation to JSON. The deprecated XML implementations remain available in grails-xml for clients that cannot migrate immediately.

1. JSON.registerObjectMarshaller(...) (all four overloads)

This covers (Class, Closure), (Class, int, Closure), (ObjectMarshaller), and (ObjectMarshaller, int).

Before:

JSON.registerObjectMarshaller(Book, 100) { Book book ->
    [id: book.id, title: book.title]
}

After, for an application-wide Jackson representation:

import org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer
import org.springframework.context.annotation.Bean
import tools.jackson.databind.module.SimpleModule

@Bean
JsonMapperBuilderCustomizer bookJsonCustomizer() {
    { builder ->
        builder.addModule(new SimpleModule()
                .addSerializer(Book, new BookValueSerializer()))
    } as JsonMapperBuilderCustomizer
}

BookValueSerializer is a Jackson 3 ValueSerializer<Book>. Jackson module registration replaces legacy marshaller priority; serializer/module ordering should be made explicit when serializers overlap.

2. JSON.withDefaultConfiguration(...)

Before:

JSON.withDefaultConfiguration {
    it.registerObjectMarshaller(Book) { Book book ->
        [id: book.id, title: book.title]
    }
}

After:

@Bean
JsonMapperBuilderCustomizer bookJsonCustomizer() {
    { builder ->
        builder.addModule(new SimpleModule()
                .addSerializer(Book, new BookValueSerializer()))
    } as JsonMapperBuilderCustomizer
}

This customizes Spring Boot's managed JsonMapper, so the representation is shared by respond, Spring MVC message conversion, and other Jackson integrations.

3. JSON.createNamedConfig(...)

Before:

JSON.createNamedConfig('deep') {
    it.registerObjectMarshaller(Type, deepObjectMarshaller)
    it.registerObjectMarshaller(Category, deepObjectMarshaller)
}

After:

import grails.converters.json.NamedJsonConfigurationRegistry

class BootStrap {
    NamedJsonConfigurationRegistry namedJsonConfigurationRegistry

    def init = { servletContext ->
        namedJsonConfigurationRegistry.register('deep') {
            it.serializer(Type, new TypeValueSerializer())
            it.serializer(Category, new CategoryValueSerializer())
        }
    }
}

The named configuration derives an isolated mapper from Boot's configured JsonMapper; it does not mutate global or thread-local converter state.

4. JSON.use(String, Closure)

Before:

String json = JSON.use('deep') {
    new JSON(book).toString()
}

After, for direct serialization:

String json = namedJsonConfigurationRegistry.writeValueAsString('deep', book)

Or stream directly:

namedJsonConfigurationRegistry.writeValue('deep', writer, book)

For controller responses, the same configuration works with both APIs:

render book, jsonConfiguration: 'deep'
respond book, jsonConfiguration: 'deep'

5. JSON.use(String)

Before:

JSON.use('deep')
try {
    String json = new JSON(book).toString()
} finally {
    JSON.use('default')
}

After:

String json = namedJsonConfigurationRegistry.writeValueAsString('deep', book)

Configuration is selected explicitly per operation, eliminating mutable thread-local state.

6. JSON.getNamedConfig(String)

Before:

def configuration = JSON.getNamedConfig('deep')

After:

ObjectWriter writer = namedJsonConfigurationRegistry.writer('deep')
String json = writer.writeValueAsString(book)

Registration remains encapsulated in NamedJsonConfigurationRegistry.register(...); consumers receive the configured, immutable-style Jackson writer used for serialization.

7. HalXmlRenderer

Before:

import grails.rest.render.hal.HalXmlRenderer

beans = {
    halBookRenderer(HalXmlRenderer, Book)
}

After, using Grails HAL JSON:

import grails.rest.render.hal.HalJsonRenderer

beans = {
    halBookRenderer(HalJsonRenderer, Book)
}

Clients negotiate it with:

Accept: application/hal+json

Applications that prefer Spring's hypermedia model can instead add the optional grails-spring-hateoas module.

8. HalXmlCollectionRenderer

Before:

import grails.rest.render.hal.HalXmlCollectionRenderer

beans = {
    halBooksRenderer(HalXmlCollectionRenderer, Book)
}

After:

import grails.rest.render.hal.HalJsonCollectionRenderer

beans = {
    halBooksRenderer(HalJsonCollectionRenderer, Book)
}

Clients use Accept: application/hal+json rather than application/hal+xml.

9. VndErrorXmlRenderer

Before:

import grails.rest.render.errors.VndErrorXmlRenderer

beans = {
    vndXmlErrorRenderer(VndErrorXmlRenderer)
}

// Client: Accept: application/vnd.error+xml
respond book.errors

After, using the default RFC 9457 validation response:

// No Vnd.Error renderer registration is required.
// Client: Accept: application/json or application/problem+json
respond book.errors

The response uses application/problem+json and status 422 for validation failures.

10. XML ValidationErrorsMarshaller

Before:

import grails.converters.XML
import org.grails.web.converters.marshaller.xml.ValidationErrorsMarshaller

XML.registerObjectMarshaller(new ValidationErrorsMarshaller(), 100)
render book.errors as XML

After:

// Client: Accept: application/json or application/problem+json
respond book.errors

Grails renders the validation errors as RFC 9457 application/problem+json. Keep the deprecated marshaller only while an existing client still requires the legacy XML error schema.

@codeconsole
codeconsole force-pushed the feature/spring-media-type-negotiation-8.0.x branch from 82fe9d8 to c86bcff Compare August 30, 2026 19:10
@codeconsole

Copy link
Copy Markdown
Contributor Author

Why grails-xml is a separate module

grails-xml was separated so XML support is an optional application capability rather than a mandatory part of Grails' core REST stack.

The reasons are:

  • Most new REST APIs use JSON. JSON-only applications should not carry XML converters, renderers, data binding, marshallers, and XML-specific dependencies.
  • Spring Boot 4.1 organizes serialization around media-type-specific HTTP message converters. An optional XML module fits that model: JSON follows the normal Boot/Jackson path, while XML infrastructure is installed only when requested.
  • The module creates a clear boundary around XML HTTP payload behavior:
    • XML rendering and conversion
    • XML request data binding
    • legacy XML marshalling
    • HAL XML compatibility
    • Vnd.Error XML compatibility
  • Legacy XML support can remain available during migration without keeping deprecated XML formats permanently coupled to the core REST module.
  • Future removal or replacement of legacy XML components becomes safer because those components no longer intersect the core JSON path.

Applications that need XML add the module:

dependencies {
    implementation 'org.apache.grails:grails-xml'
}

They can then advertise XML on the relevant REST artefact:

static responseFormats = ['json', 'xml']

JSON-only applications do not need to do anything.

Conceptually, the boundary is:

Core REST support
├── JSON / Problem Details / content negotiation
└── optional grails-xml
    ├── XML HTTP conversion
    ├── XML data binding
    ├── legacy XML marshalling
    ├── HAL XML compatibility
    └── Vnd.Error XML compatibility

This separation concerns application HTTP request and response payloads. It does not remove or change XML plugin descriptors; plugin descriptor publication and consumption are a separate concern and remain supported.

The intent is a smaller, modular modern default—not to declare XML unsupported.

@codeconsole
codeconsole marked this pull request as ready for review September 2, 2026 03:18
@codeconsole
codeconsole requested review from borinquenkid, jamesfredley, jdaugherty and matrei and removed request for jdaugherty and matrei September 2, 2026 03:47
@jdaugherty

Copy link
Copy Markdown
Contributor

This is an extremely large change that I think needs deferred to 8.1 or possibly 9 with feedback from multiple committers to merge.

@codeconsole

codeconsole commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor Author

@jdaugherty I think it is too much for 8.1. I am fine with 9 if we can get it reviewed in a timely manner and released as a 9.0 milestone prior to any 8.1 milestone

@matrei

matrei commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Review: PR #16237 — Modernize MIME negotiation and serialization for Spring Boot 4.1

I agree on targeting Grails 9 if this merges

Head: d486fd9130908bd2f239f1e53fd69d26b08ba08f (codeconsole:feature/spring-media-type-negotiation-8.0.x)
Base: 8.0.x — merge-base 3067d0a855, branch is a clean merge on top of it (81 commits, 134 files, +5343/−472).

What I ran locally (all green):

  • ./gradlew codeStyle validateDependencyVersions
  • Module tests: grails-mimetypes (57), grails-web-common (97), grails-converters (110), grails-xml (28), grails-rest-transforms (76), grails-controllers (93), grails-web-databinding (60), grails-test-core (3), grails-testing-support-core (9), grails-databinding (33) — 0 failures.
  • check -x test (checkstyle / codenarc / pmd / spotbugs) on every changed module — no violations reported.
  • grails-test-suite-web (435), grails-test-suite-uber (576), grails-fields (682) — run with --no-build-cache and cleanTest so they actually executed; 0 failures.
  • A scratch Jackson 3.1.6 + Groovy 5.1.2 script to confirm the GString and String findings below.

Verdict: request changes. The negotiation cleanup (removal of the static state in HttpServletResponseExtension, the strategy held off the bean graph so Spring Security does not adopt it, Spring MediaType parsing) is good and I would take it on its own. The serialization half is not ready: it has two concrete output-corruption bugs on the new default path, the unit-test harness never exercises that path, and the default flips the JSON shape of every existing app's respond on a patch/minor line. I agree with @jdaugherty that this cannot land on 8.0.x; the findings below apply regardless of which line it targets.


Blocking

1. GString values become bean garbage on the new default respond path

GrailsJsonMapperCustomizer.customize (grails-converters/src/main/groovy/org/grails/web/converters/jackson/GrailsJsonMapperCustomizer.java:81-99) registers only the domain and Errors serializers. Nothing in the repo registers a GString/CharSequence serializer for Boot's mapper (grep confirms). Jackson serializes GStringImpl as a bean:

respond([message: "Saved ${book.title}"])
// production output on this branch:
{"message":{"blank":false,"bytes":"U2F2ZWQgeA==","empty":false,"strings":["Saved ",""],"valueCount":1,"values":["x"]}}

Verified with a scratch script against Jackson 3.1.6 / Groovy 5.1.2. The legacy converter handled CharSequence correctly, so this is a regression for one of the most common Grails idioms. Because the customizer is a JsonMapperBuilderCustomizer on the primary Boot mapper, a ToStringSerializer for GString belongs in that module. Neither GrailsJsonMapperCustomizerSpec nor DefaultJsonRendererSpec covers it — the renderer spec only uses Mock(HttpMessageConverter), so no test on this branch writes a real Jackson body through respond.

2. respond "text" writes the raw string, not a JSON string

DefaultJsonRenderer.renderWithSpringConverter picks the first converter whose canWrite returns true (grails-rest-transforms/src/main/groovy/org/grails/plugins/web/rest/render/json/DefaultJsonRenderer.groovy:185). In Spring MVC's default list ByteArrayHttpMessageConverter and StringHttpMessageConverter precede the Jackson converter and both advertise */*, so respond 'ok' with Accept: application/json now emits ok where the legacy path emitted "ok"; respond bytes emits raw bytes. DefaultXmlRenderer.findSpringConverter (grails-xml/src/main/groovy/org/grails/plugins/web/rest/render/xml/DefaultXmlRenderer.groovy:160) has the same selection rule, so respond 'ok' as XML loses its <string> element. Select only converters whose supported media types for the target type include a concrete JSON/XML type (not */*), or route CharSequence/byte[] to the legacy path. The upgrade note at grails-doc/src/en/guide/upgrading/upgrading80x.adoc:3815 documents the current "first converter that can write" rule, so the doc needs the same correction.

3. Controller unit tests never exercise the Spring path — test and production diverge

SpringMessageConverters only receives a list when Spring MVC calls extendMessageConverters; until then it is List.of() (grails-rest-transforms/src/main/groovy/org/grails/plugins/web/rest/render/SpringMessageConverters.groovy:40). The unit-test harness never initializes MVC (no WebMvcConfigurationSupport/converters anywhere under grails-testing-support-*), so DefaultJsonRenderer.canUseSpringConverter (DefaultJsonRenderer.groovy:177) is always false in a ControllerUnitTest and every test runs the legacy converter. Consequences:

  • A user's response.json assertions pass in unit tests against the legacy shape and then production serves the Jackson shape (bug 1 is invisible to every test).
  • respond book.errors renders {"errors":[{"object":..,"field":..,"rejected-value":..}]} as application/json in tests, but application/problem+json with a different body in production (the legacy shape is even asserted as the contract in JsonCompatibilitySpec:151).
  • The 22 test-suite-web specs that this PR touched all still pass on the legacy path, which is why they did not catch 1 or 2.

The web test support (WebSetupSpecInterceptor defines rendererRegistry) should give the registry a converter list built from the test context's JsonMapper, or the renderer should fall back to a JacksonJsonHttpMessageConverter when the supplier is empty.

4. Named JSON configurations cannot be used in unit tests at all

NamedJsonConfigurationRegistry.writer throws IllegalStateException when no JsonMapper bean exists (grails-converters/src/main/groovy/grails/converters/json/NamedJsonConfigurationRegistry.java:81), and the registry is created with a deferred beanProvider(JsonMapper) (grails-converters/src/main/groovy/org/grails/plugins/converters/ConvertersGrailsPlugin.groovy:79). The test harness registers no JsonMapper (grep of grails-testing-support-* and grails-test-core), so render json: x, jsonConfiguration: 'deep' and respond x, jsonConfiguration: 'deep' fail in every ControllerUnitTest. The PR's own specs avoid this: NamedJsonRenderArgumentSpec injects a hand-built renderer into a plain controller instance and RespondMethodSpec has no named-configuration case. Either testing support registers a mapper (built with GrailsJsonMapperCustomizer), or the registry falls back to one when Boot's is absent, and a spec must exercise the argument through the real harness.

5. Domain classes on the shared Boot mapper ignore Jackson's property model

GrailsDomainJsonSerializer.serialize writes persistent properties through BeanWrapper (grails-converters/src/main/groovy/org/grails/web/converters/jackson/GrailsDomainJsonSerializer.java:60-87). For every mapped domain class this bypasses @JsonIgnore, @JsonProperty, @JsonInclude, @JsonView, property-level @JsonFormat, mixins, spring.jackson.property-naming-strategy, spring.jackson.default-property-inclusion, and any transient/derived getter. Since GrailsJsonMapperCustomizer is applied to Boot's primary mapper, this also changes what a plain Spring @RestController returns for a domain instance — that worked with Jackson's normal rules on 7.x and 8.0.x today. The upgrade note claims the opposite: "Standard Jackson 3 modules, mixins, naming strategies, and mapper customizations therefore apply consistently to MVC and Grails REST responses" (upgrading80x.adoc:3819). Either build the serializer on Jackson's bean serializer (a BeanSerializerModifier that drops non-persistent properties and rewrites association properties would keep annotations working), or scope the domain serializer to Grails' own writers rather than the global mapper, and in both cases document and test what is honored.

Should fix before merge

6. The default flips every existing app's respond JSON on a minor line

useSpringJson defaults to true (grails-rest-transforms/src/main/groovy/org/grails/plugins/web/rest/render/DefaultRendererRegistry.groovy:90). Beyond 1, 2 and 5, the default path changes: JSON.registerObjectMarshaller registrations are silently ignored by respond while still honored by render ... as JSON; Date moves from the legacy UTC yyyy-MM-dd'T'HH:mm:ss'Z' to Jackson's default; non-domain Groovy beans go through Jackson's bean serializer instead of the Groovy bean marshaller; self-referencing non-domain beans, which the legacy converter handled via circular.reference.behaviour, fail in Jackson; validation errors change content type, status body and shape. The flag is documented (upgrading80x.adoc:3813), but this is a behaviour change for every REST app with no code change on their side. If any of this lands on 8.0.x the default has to be false; otherwise it is a 9.0 change as already discussed on the PR.

7. DEFAULT_INCLUDED_PLUGINS now depends on an optional runtime module being on the classpath

grails-testing-support-core/src/main/groovy/org/grails/testing/GrailsApplicationBuilder.groovy:78 adds xml. IncludingPluginFilter expands dependsOn recursively, and XmlGrailsPlugin.dependsOn is [converters, dataBinding, restResponder], which in turn pull controllers, domainClass, urlMappings, i18n. So in any application that adds grails-xml, every unit test — ServiceUnitTest, plain GrailsUnitTest — now boots the web/REST/binding plugins, and the same test boots a different context depending on whether an optional module is present. The commit that added it (f54be58be9) did so to fix 22 test-suite-web specs, which is a symptom of 3/4 rather than a reason to change the global default. Load the XML plugin (or its beans) from the web test traits only, and document it.

8. beanProvider(MessageSource).getIfAvailable() fails startup with two MessageSource beans

grails-rest-transforms/src/main/groovy/org/grails/plugins/web/rest/plugin/RestResponderGrailsPlugin.groovy:77. getIfAvailable() throws NoUniqueBeanDefinitionException when more than one non-primary candidate exists, which plugins that ship their own MessageSource do. Use it.bean('messageSource', MessageSource) or getIfUnique(). The JsonMapper provider in ConvertersGrailsPlugin.groovy:79 has the same exposure, deferred to the first write (a 500 instead of a startup failure).

9. render json: is only usable with a named configuration

ResponseRenderer intercepts any json argument (grails-controllers/src/main/groovy/grails/artefact/controller/support/ResponseRenderer.groovy:322) and throws if jsonConfiguration is missing (ResponseRenderer.groovy:289). render json: [ok: true] is the obvious thing to type and is now an IllegalArgumentException. Either write with the default mapper when no configuration is named, or name the argument after what it is (jsonConfiguration alone would do; the value could simply be render value, jsonConfiguration: 'deep' as respond already does).

10. Documentation claims not backed by code or tests

  • Section 68 (upgrading80x.adoc:3892-3905) says adding spring-boot-starter-hateoas and respond EntityModel.of(book) "is enough". With Accept: application/hal+json the Grails renderer registry has no HAL renderer for EntityModel, so respond does not reach the Boot HAL converter; with Accept: application/json Boot's HAL converter only accepts application/hal+json, so the plain Jackson converter writes links: [...] rather than _links. The module and tests that would have backed this were removed in 8c98b8db23; nothing on the branch tests it. Verify with a test or drop the section.
  • upgrading80x.adoc:3819 — see 5.
  • upgrading80x.adoc:3815 — see 2.
  • The PR description still lists "add an optional grails-spring-hateoas adapter module"; it was removed.

Minor

  • DefaultJsonRenderer.groovy:194 passes grails.converters.encoding to Jackson via the media type; Jackson only honours UTF-8/16/32 and otherwise emits UTF-8, so a non-UTF encoding is mis-decoded by WriterOutputStream. The comment above it claims the opposite. Either restrict to UTF encodings or decode with the charset Jackson actually used.
  • GrailsContentNegotiationStrategy.resolveMimeTypes (grails-mimetypes/src/main/groovy/org/grails/web/mime/GrailsContentNegotiationStrategy.groovy:88) returns mimeTypes[0] for an unknown ?format=, i.e. */* with the default config. Same as the old getMimeTypeForRequest fallback, but it is now also the answer for withFormat/response.mimeTypes, which previously ignored the parameter and used the Accept header. Worth a line in the upgrade notes.
  • WriterOutputStream.write(int) allocates a byte array per call; harmless because converters use bulk writes, but input.put((byte) b) + decode(false) is simpler.
  • SpringErrorsJsonSerializer (SpringErrorsJsonSerializer.java:44) makes every Errors on Boot's mapper serialize as {"errors":[...]} with no rejected values — including in @RestControllers. Fine as a default, but it is a global change and is not mentioned in the docs.

Verified as correct

  • Static negotiation state (disableForUserAgents, useAcceptHeader*, cached mimeTypes, the ShutdownOperations hook) is gone; no remaining references in the repo. The strategy is reachable only through GrailsMimeTypesWebMvcConfigurer, and SpringSecurityContentNegotiationSpec proves Spring Security does not adopt it.
  • The deleted test hunks are only the HttpServletResponseExtension.@mimeTypes = null isolation workarounds that the removed static made necessary. No assertions were removed.
  • DefaultAcceptHeaderParser parses through MediaType.parseMediaType with the lenient legacy fallback; pre-sorting by quality is stable, so the text/xml/application/xml merge and +xml reordering keep their header-order semantics. MimeType now trims the name before the ;.
  • GrailsContentNegotiationStrategy guards getParameter('format') with WebUtils.isError, so error dispatch no longer parses the request body.
  • GrailsMimeTypesWebMvcConfigurer only contributes extension aliases; Spring MVC's own negotiation, 406 behaviour and spring.mvc.contentnegotiation.* are untouched, as the upgrade note at upgrading80x.adoc:1526 says.
  • grails-xml: every moved file is content-identical apart from @Deprecated and @CompileStatic additions; XmlConvertersConfigurationInitializer reproduces the removed initXMLConfiguration/initDeepXMLConfiguration exactly; renderers are contributed as Renderer beans that DefaultRendererRegistry.setRenderers routes correctly (XmlErrorsRenderer as a container renderer keyed (Errors, Object)); module is in settings.gradle and publishedProjects (BOM), and the example apps that render XML add it.
  • application/problem+json bodies are flattened: Boot 4.1's JacksonAutoConfiguration$JsonProblemDetailsConfiguration registers ProblemDetailJacksonMixin on the primary mapper (checked in the jar), and DefaultJsonRenderer sets the content type before the first write so a committed response keeps it.
  • NamedJsonConfiguration derives one ObjectWriter per configuration lazily and thread-safely from Boot's mapper via rebuild(), so spring.jackson.* and application customizers are retained; per-response includes/excludes are layered as writer attributes and the domain serializer honours them.
  • GrailsDomainSerializers distinguishes "GORM not ready" (GrailsConfigurationException) from "not a domain class" and hands back a deferred serializer so Jackson cannot cache a bean serializer for a domain class; GrailsDomainSerializersSpec covers the ordering.
  • JsonDataBindingSourceCreator resolves the mapper lazily (avoids pulling Jackson auto-configuration ahead of GORM), reads floats as BigDecimal, and maps JacksonException to InvalidRequestBodyException.
  • GrailsMockHttpServletRequest keeps grails.converters.XML out of signatures so the class loads without grails-xml, and reports a clear error when XML conversion is requested without it.
  • REST profile templates advertise ['json'] only, consistent with the documented change.

@codeconsole

Copy link
Copy Markdown
Contributor Author

@matrei Addressed the review in e8bb2eb0a5.

  1. GString and converter selection: GStrings serialize as strings. JSON/XML renderers require converters to advertise a media type in the requested family, preventing wildcard string/byte converters from emitting raw JSON or losing the XML string element. Tests cover scalar and nested values, quoted strings, byte-array base64 output, and XML fallback.
  2. Controller test parity and named configurations: Web tests initialize Boot's Jackson configuration and populate real Spring converters from the context mapper. New controller tests exercise application customizers, named render/respond, domain values, and RFC 9457 response body/content type/status through the harness.
  3. Domain serialization scope: The domain compatibility serializer now belongs to isolated Grails writers. The shared Boot mapper retains Jackson annotations, mixins, naming, inclusion, views, formatting, and derived getters. Tests cover that separation and application serializer precedence; the guide documents the Grails writer's property-model limitations.
  4. 8.0 compatibility: This PR still targets 8.0.x, and grails.web.rendering.json.spring now defaults to false. Spring JSON rendering requires explicit opt-in.
  5. Plugin isolation and bean ambiguity: Web/XML plugins load automatically only for web tests, with service/plain-test isolation coverage. MessageSource resolution uses the application bean name; mapper resolution uses the unique/primary candidate or the conventional jacksonJsonMapper bean.
  6. Default JSON rendering: render json: [ok: true] now works without a named configuration.
  7. Documentation and minor fixes: Removed the automatic Spring HATEOAS integration claims and the stale adapter-module claim in the PR description. Documented unknown-format fallback and the global Spring Errors serializer. Jackson's intermediate output is decoded with its actual UTF-8 encoding before writing the configured response encoding, and WriterOutputStream.write(int) no longer allocates a byte array.

Validation: The final scoped run completed successfully, including aggregate violations, affected-module checks, GSP, web/uber suites, fields, the guide build, and combined test report (-PonlyCoreTests --max-workers=2 --no-daemon --continue -x aggregateGroovydoc). PMD/SpotBugs were not enabled. An earlier clean repository-wide run lost its Gradle daemon; the successful scoped run is not a full-repository pass.

@matrei

matrei commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Review round 2

Head: e8bb2eb0a5bf4227aee06cab334adb057e50a34f (codeconsole:feature/spring-media-type-negotiation-8.0.x)
Base: 8.0.x — merge-base still 3067d0a855, clean merge on top of it (82 commits, 140 files, +5894/−474).
Delta since round 1: one commit, e8bb2eb0a5 (29 files, +656/−107).

What I ran locally (all green):

  • ./gradlew codeStyle
  • Module tests with --no-build-cache and cleanTest (result XML confirmed fresh): grails-converters (114), grails-rest-transforms (84), grails-controllers (93), grails-xml (29), grails-web-common (97), grails-testing-support-core (10) — 0 failures.
  • Suites the same way: grails-test-suite-web (446), grails-test-suite-uber (576) — 0 failures.
  • check -x test (checkstyle / codenarc / pmd / spotbugs) on every changed module plus grails-gsp — no violations.
  • CI on this head is green (30805 tests; one known-flaky Geb login test, issue Test Dashboard #16030).
  • Three scratch specs, since deleted: serializer precedence with an initialised KeyValueMappingContext; a ControllerUnitTest that dumps the loaded plugin list, bean count and both render paths; and CompiledTagInvocationSpec with the new setup() removed.

Verdict: the serialization findings from round 1 are resolved. One new should-fix remains, in the test harness. Every blocking item (GString, converter selection, harness parity, named configurations in unit tests, domain serializer scope) and every should-fix (default flag, plugin loading, MessageSource ambiguity, render json:, docs) has a real code change with a test that exercises the production path. The fix for round-1 item 7, however, moved the plugin loading rather than removed it: every web unit test now boots nine plugins instead of two, and that is observable in user tests, which the PR's own GSP spec change demonstrates. Details below.


Should fix before merge

1. Web unit tests now boot the full controllers/urlMappings/i18n plugin graph, and it changes existing tests

GrailsApplicationBuilder.registerPluginDiscoveryBean (grails-testing-support-core/src/main/groovy/org/grails/testing/GrailsApplicationBuilder.groovy:208) adds converters, restResponder and xml for any GrailsWebUnitTest. IncludingPluginFilter expands dependsOn, and converters depends on controllers and domainClass, controllers on i18n and urlMappings, xml on dataBinding. Measured in a plain ControllerUnitTest on this branch:

plugins: controllers, converters, core, dataBinding, domainClass, i18n, restResponder, urlMappings, xml
bean definitions: 156

On 8.0.x the same test loads core and eventBus and the interceptor defines a curated bean set. The new graph is visible to user tests. UrlMappingsGrailsPlugin.groovy:119 registers DefaultUrlMappings when the application has none, so g.link/createLink without a controller in the request now throws instead of producing a URL. The PR hit this itself and patched the one in-repo spec: CompiledTagInvocationSpec gained webRequest.controllerName = 'book' (grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledTagInvocationSpec.groovy:34). Removing that line reproduces it, four tests fail with:

grails.web.mapping.exceptions.UrlMappingException: Unable to create URL for mapping [/(*)/(*)?/(*)?(.(*))?] and parameters [{}]. Parameter [controller] is required, but was not specified!
    at org.grails.web.mapping.DefaultLinkGenerator.link(DefaultLinkGenerator.groovy:272)
    at org.grails.plugins.web.taglib.ApplicationTagLib.doCreate

Any application TagLibUnitTest or ControllerUnitTest that renders a link without a controller in the request will fail the same way after upgrading to this 8.0.x. Nothing in upgrading80x.adoc mentions it; section 65 (upgrading80x.adoc:3870) only says the XML plugin is loaded by web traits.

Suggested fix: the Spring path needs only two things at refresh time, a grailsJsonMapperCustomizer definition present before JacksonAutoConfiguration builds the mapper, and the restResponder beans (SpringMessageConverters, rendererRegistry, validationProblemDetailFactory). restResponder has no dependsOn, so including it alone drags nothing in. Register the customizer definition directly in the builder for web tests, and load XmlGrailsPlugin the way ConvertersGrailsPlugin and CodecsGrailsPlugin already are in WebSetupSpecInterceptor, via defineBeans(plugin) when the class is present. That keeps the context the harness had on 8.0.x. If the full graph is kept deliberately, it has to be documented as a behaviour change for existing unit tests, and the duplicate definitions in point 2 cleaned up.

2. Beans defined twice in the web harness

With the plugins loaded, WebSetupSpecInterceptor (grails-testing-support-web/src/main/groovy/org/grails/testing/spock/WebSetupSpecInterceptor.groovy:81-119) still defines rendererRegistry, grailsUrlMappingsHolder, grailsLinkGenerator, localeResolver and re-applies ConvertersGrailsPlugin after refresh. The later definition wins in every case, so tests pass, but the sequence is confusing: Boot's mapper is customised at refresh by the plugin's grailsJsonMapperCustomizer, then the interceptor replaces that bean with a second instance, and forGrails runs on the second one against a mapper customised by the first. It works only because customize and forGrails are independent. Whichever way point 1 goes, there should be one definition of each.

Minor

  • respond 'ok' on the legacy path, which is now the default, still throws GroovyCastException: Cannot cast object 'ok' ... to class 'grails.converters.JSON' (DefaultJsonRenderer.groovy:169 and :172, object as JSON; StringGroovyMethods.asType shadows the converters extension). This is pre-existing on 8.0.x, but this commit switched DefaultXmlRenderer to new XML(object) for exactly that reason (DefaultXmlRenderer.groovy:142), so the JSON renderer should get new JSON(object) too. Section 65 says "strings remain JSON strings" for the Spring path only; with the default off, a plain string still 500s.
  • DefaultJsonRenderer.groovy:200-204 builds a new JacksonJsonHttpMessageConverter on every response when it substitutes the Grails mapper. forGrails is cached per mapper; the converter could be cached per source converter the same way.
  • The NamedJsonConfigurationRegistry supplier (ConvertersGrailsPlugin.groovy:81-92) does a getIfUnique type scan plus a bean lookup on every writer() call. Memoise the resolved mapper after the first successful lookup.
  • isWebTest() adds the web plugins even when a spec overrides getIncludePlugins() to restrict the set. Reasonable, but worth a sentence in the testing docs.

Round-1 findings, status

# Finding Status
1 GString serialised as a bean Fixed. GrailsJsonMapperCustomizer.customize registers ToStringSerializer for GString on Boot's mapper (GrailsJsonMapperCustomizer.java:93); covered nested and root in GrailsJsonMapperCustomizerSpec, through real converters in DefaultJsonRendererSpec, and through the harness in ControllerJsonSerializationSpec.
2 respond 'text' / byte[] picked the String/ByteArray converter Fixed. Both renderers require a converter to advertise a JSON (DefaultJsonRenderer.groovy:190-193) or XML (DefaultXmlRenderer.groovy:160-163) media type for the class; tests use the real ByteArrayHttpMessageConverter, StringHttpMessageConverter and Jackson converter and assert "ok", "Saved Grails", base64 and the XML <string> element. Doc corrected (upgrading80x.adoc:3826-3832).
3 Unit tests never exercised the Spring path Fixed. JacksonAutoConfiguration is registered for web tests (GrailsApplicationBuilder.groovy:181), WebSetupSpecInterceptor populates SpringMessageConverters from the context mapper (:83-90), and ControllerJsonSerializationSpec asserts the Jackson shape, the application/problem+json 422 body, application JsonMapperBuilderCustomizers and named configurations through respond and render.
4 Named configurations unusable in unit tests Fixed, same mechanism; render json: x, jsonConfiguration: and respond x, jsonConfiguration: covered in the harness.
5 Domain serializer on the shared Boot mapper Fixed. customize no longer adds the domain module; forGrails derives a separate mapper (GrailsJsonMapperCustomizer.java:105-116) used only by the substituted converter and the named-configuration registry. See "Verified" for why the module reordering is sound. ControllerJsonSerializationSpec proves @JsonIgnore is honoured on Boot's mapper and ignored on the Grails path; the guide now states the domain-path limitations (upgrading80x.adoc:3834-3841).
6 Default flipped respond JSON on a minor line Fixed. grails.web.rendering.json.spring defaults to false (DefaultRendererRegistry.groovy:94); DefaultRendererRegistrySpec covers unset/false/true, RespondMethodSpec asserts the legacy shape by default. Doc rewritten as opt-in.
7 DEFAULT_INCLUDED_PLUGINS gained xml Reverted for non-web tests (NonWebPluginIsolationSpec, DefineBeansPluginHooksSpec assert it), but moved to web tests with a larger graph. See point 1 above.
8 getIfAvailable() with two MessageSource beans Fixed. it.bean('messageSource', MessageSource) (RestResponderGrailsPlugin.groovy:75), test with a second MessageSource bean. Mapper resolution uses getIfUnique() then the jacksonJsonMapper name (ConvertersGrailsPlugin.groovy:82-90), test with two mapper beans.
9 render json: required a configuration Fixed. Null name means the default writer (ResponseRenderer.groovy:287-292, NamedJsonConfigurationRegistry.java:74-85); covered in NamedJsonRenderArgumentSpec and the harness.
10 Doc claims Fixed. HATEOAS section replaced with an accurate "HAL Rendering" note (upgrading80x.adoc:3926, hal.adoc:128-131); the "Jackson annotations apply consistently" claim is replaced by the scoped description; PR description no longer lists the adapter module.
minor Encoding, unknown ?format=, write(int), global Errors serializer All addressed: UTF-8 intermediate bytes with the servlet writer applying the configured encoding (DefaultJsonRenderer.groovy:205-209, tested with ISO-8859-1); unknown-format fallback documented (upgrading80x.adoc:1538); write(int) no longer allocates; Errors serializer documented (upgrading80x.adoc:3843-3846).

Verified as correct

  • forGrails module reordering is sound on Jackson 3.1.6. MapperBuilder.saveStateApplyModules saves the builder state before modules run, and rebuild() restores that pristine state and re-adds the modules, so withModules → removeAllModules → domain module → addModules(original) genuinely applies the domain serializers first and the application's last. SimpleSerializers are prepended on registration, so the last module wins. My scratch spec with an initialised mapping context confirmed: an application ToStringSerializer for a domain class beats the domain serializer, another domain class still gets {"id":5,"name":"Ada"}, a snake-case naming strategy from the source mapper survives for non-domain beans, and forGrails returns the same instance per mapper.
  • The PR's own precedence spec (GrailsJsonMapperCustomizerSpec, "application domain serializers keep precedence") is discriminating even without a mapping context: DefaultGrailsApplication.getMappingContext() returns a throwing proxy, so the domain path yields a DeferredDomainSerializer that would raise IllegalStateException if it won.
  • ControllerJsonSerializationSpec runs the real Boot converter through respond; the problem body asserts status 422, content type application/problem+json;charset=UTF-8, resolved message, no rejectedValue, no properties wrapper.
  • SpringErrorsJsonSerializer and the GString serializer are the only global additions to Boot's mapper; both are now documented.
  • NamedJsonConfiguration.writer still caches one ObjectWriter per configuration; the derived writer now comes from the Grails mapper, so named configurations keep domain compatibility.
  • NonWebPluginIsolationSpec and DefineBeansPluginHooksSpec prove ServiceUnitTest/GrailsUnitTest contexts contain no rendererRegistry, xmlRenderer or namedJsonConfigurationRegistry even with grails-xml on the classpath.
  • WriterOutputStreamSpec now streams a 2000× multi-byte body with emoji through one-byte-per-write and stays intact.
  • DefaultXmlRenderer new XML(object) is equivalent to object as XML for non-String values and fixes the String case; SpringXmlRendererSpec covers the <string> element with a real StringHttpMessageConverter.
  • Everything in the round-1 "Verified as correct" list is unchanged by this commit.

@codeconsole

Copy link
Copy Markdown
Contributor Author

@matrei Addressed the valid findings from Review round 2 in ab080d2.

  • Web plugin graph: web traits automatically include only restResponder. JSON and optional XML bean definitions are contributed without discovering their plugins' transitive dependencies. The regression spec overrides getIncludePlugins() and verifies that only core and restResponder load, no default URL mappings appear, and JSON/XML support remains available. Removed the CompiledTagInvocationSpec controller-name workaround; its tests pass without it.
  • Duplicate definitions: removed the interceptor's second converters registration and renderer registry definition; URL mappings, link generator and locale resolver definitions are conditional on their absence. A regression assertion verifies that the mapper customizer used while Boot constructs the mapper is the same instance retained in the context.
  • Legacy strings: both paths now use new JSON(object). This also exposed a second underlying issue: JSONWriter rejected scalar roots. It now accepts one scalar root and rejects subsequent root values. Tests cover ordinary/named legacy string rendering, scalar values/escaping and existing enum rendering.
  • Converter allocation: cache the Grails Jackson converter per source converter, retaining the guards for custom converters and per-type mappers. Repeated-render coverage checks that distinct source mapper configurations remain distinct.
  • Mapper lookup: memoize the first successful supplier result; an absent mapper remains retryable. The regression test checks lookup counts and successful default/named output after an initial missing mapper.
  • Documentation: clarified automatic restResponder inclusion even with getIncludePlugins() overrides, optional XML bean contributions, and the absence of automatic controllers/URL mappings/i18n plugin loading.

One refinement to the suggested XML approach: contributing XML beans in the interceptor after refresh broke existing nested XML binding tests because the binding registry had already captured its source creators. Both JSON and optional XML bean definitions therefore need to be contributed before refresh, while still avoiding plugin dependency expansion. Existing XML binding tests now pass.

Validation was deliberately limited to the affected modules, with --max-workers=2 -PmaxTestParallel=1 --no-parallel; no full repository test sweep was run. The check tasks passed for grails-test-suite-web, grails-testing-support-core, grails-testing-support-web, grails-web-common, grails-converters, grails-rest-transforms, and grails-gsp. Their test reports total 1,480 tests, nine skipped, zero failures/errors (including the web suite's isolated tests). Checkstyle/CodeNarc XML reports contain zero violations. PMD/SpotBugs were requested, but this checkout does not expose codeAnalysis tasks in these modules, so those tools are not claimed as verified.

@matrei

matrei commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Review round 3

Head: ab080d24ae173820e0b186b47d437abf74cfa4d9 (codeconsole:feature/spring-media-type-negotiation-8.0.x)
Base: 8.0.x — merge-base still 3067d0a855, clean merge on top of it.
Delta since round 2: one commit, ab080d24ae (13 files, +188/−34).

What I ran locally:

  • ./gradlew codeStyle — green.
  • Module tests with --no-build-cache and cleanTest (result XML confirmed fresh), 0 failures: grails-converters (115), grails-rest-transforms (87), grails-web-common (97), grails-testing-support-core (10), grails-xml (29), grails-gsp plugin (666), grails-test-suite-web (454), grails-test-suite-uber (576).
  • :grails-test-examples-hibernate7-demo33:test --tests demo.PersonControllerHibernateSpec — fails, same as CI (see point 1).
  • Two scratch specs, since deleted: a HibernateSpec + ControllerUnitTest probe in demo33 that prints singleton state before controller is touched, and a ControllerUnitTest in grails-test-suite-web covering the plugin list, bean count, link generation and a pretty-printed scalar root.
  • CI on this head is red: 6 jobs. Four are the demo33 failure below (Build Grails shard 1, three Hibernate7 functional jobs), one is the known-flaky Geb login test (UserControllerSpec > User list, Test Dashboard #16030), and the macOS job timed out at 6h.

Verdict: round-2 findings are resolved, but the harness fix introduced a real regression that fails CI. One blocking item, one should-fix. The curated plugin graph is back (core + restResponder, 152 bean definitions, CompiledTagInvocationSpec passes without its workaround), duplicate definitions are gone, and every round-2 minor was addressed. What is left is a bean cycle in XmlGrailsPlugin that the new registration order exposes, and an incomplete JSONWriter change.


Blocking

1. xmlRenderer ↔ groovyPageLocator cycle fails HibernateSpec controller tests (CI red)

PersonControllerHibernateSpec in grails-test-examples/hibernate7/demo33 fails deterministically, locally and in four CI jobs:

BeanCreationException: Error creating bean with name 'xmlRenderer': Instantiation of supplied bean failed
    at GrailsUnitTest.defineBeans(GrailsUnitTest.groovy:110)
    at GrailsWebUnitTest.mockController(GrailsWebUnitTest.groovy:132)
Caused by: UnsatisfiedDependencyException: Error creating bean with name 'groovyPageLocator': Unsatisfied dependency
  expressed through method 'setMimeTypeResolver' parameter 0: Error creating bean with name 'xmlRenderer':
  Requested bean is currently in creation
    at XmlGrailsPlugin$_beanRegistrar_closure1.closure2$_closure5(XmlGrailsPlugin.groovy:75)
Caused by: BeanCurrentlyInCreationException: ... 'xmlRenderer'
    at DefaultListableBeanFactory.resolveMultipleBeanCollection   <- Collection<MimeTypeProvider>
    at ConstructorResolver.autowireConstructor                    <- MimeTypesConfiguration
    at ConstructorResolver.instantiateUsingFactoryMethod          <- mimeTypeResolver

The cycle is in production code, not in the harness:

xmlRenderer supplier (XmlGrailsPlugin.groovy:75, beanProvider(GrailsConventionGroovyPageLocator).getIfAvailable()) → groovyPageLocator.setMimeTypeResolver (GrailsConventionGroovyPageLocator.java:59) → mimeTypeResolver factory method (MimeTypesConfiguration.groovy:123) → MimeTypesConfiguration constructor, which autowires every MimeTypeProvider → Renderer extends MimeTypeProvider (Renderer.groovy:29) → xmlRenderer, which is currently in creation. It is constructor injection on one side and a supplier on the other, so allowCircularReferences cannot break it.

It only stays hidden while mimeTypesConfiguration happens to be instantiated before xmlRenderer, or while groovyPageLocator does not exist yet. In an ordinary ControllerUnitTest the second condition holds: xmlRenderer is built at refresh, before WebSetupSpecInterceptor defines the locator (my probe: xmlRenderer singleton present, its groovyPageLocator is null). The Hibernate 7 HibernateSpec takes its "context already exists" branch (HibernateSpec.groovy:154, configureForBeanDefinitionRegistry on the live context), which registers definitions into the already refreshed context; I did not trace which registration does it, but the effect is that singletons built at refresh are gone afterwards. My probe in that spec, before controller is touched:

XML_SINGLETON: false
LOCATOR_DEF: true
MIME_CFG: [mimeTypesConfiguration=false]

The next defineBeans → preInstantiateSingletons() then recreates xmlRenderer with the locator defined and mimeTypesConfiguration not yet built, and the cycle closes. The H5 demo33 has the same spec and grails-xml but no such branch, so it passes. Round 2's head was green because the plugin-manager registration order differed; the hazard itself was already there. A real application with grails-xml and GSP has the same ordering dependence; the functional tests pass, so the order is favourable today, but nothing guarantees it.

Suggested fix, in the plugin rather than the harness: do not resolve the locator inside the xmlRenderer supplier. Either make the locator lazy (pass an ObjectProvider/Supplier<GrailsConventionGroovyPageLocator> into DefaultXmlRenderer and resolve on first view lookup), or drop it from the bean and let DefaultRendererRegistry hand its own autowired groovyPageLocator (DefaultRendererRegistry.groovy:66) to renderers when they are registered. The lazy variant also fixes the side effect noted under Minor. Please add a test that reproduces the ordering (define groovyPageLocator, destroy the xmlRenderer and mimeTypesConfiguration singletons, call preInstantiateSingletons()), so this does not depend on an example app to be caught.

Should fix before merge

2. Scalar JSON roots: PrettyPrintJSONWriter was not updated, and the upgrade guide now contradicts the code

JSONWriter.append(Writable) now accepts INIT and moves to DONE (JSONWriter.java:113-126), which is what makes respond 'ok' and render Role.HEAD as JSON work on the legacy path. PrettyPrintJSONWriter overrides append with its own copy of the old condition (PrettyPrintJSONWriter.java:80-103, still mode == OBJECT || mode == ARRAY), and JSON.render picks that writer whenever prettyPrint is on (JSON.java:111). Scratch spec on this head:

def json = new JSON('ok'); json.prettyPrint = true; json.render(out)
// ConverterException: JSONException: Value out of sequence: expected mode to be OBJECT or ARRAY when writing 'ok' but was INIT

Same result through a controller with render json. So with grails.converters.json.pretty.print=true, respond 'ok', a root enum, number or boolean still 500. JSONWriterSpec only covers the base class; the new table should run against both writers.

This is also a user-visible behaviour change that goes beyond respond: every x as JSON / new JSON(x) with a scalar root now renders instead of throwing. upgrading80x.adoc section 25 ("Rendering Enum values as JSON", line 1095) still says rendering a single enum "will now instead throw a ConverterException", and JSONConverterTests.testJSONEnumConverting was flipped from asserting that exception to asserting "HEAD" in this commit. Section 25 needs rewriting to the new outcome (a JSON string of the enum name, via SimpleEnumMarshaller), and the scalar-root change deserves a sentence of its own.

Minor

  • In web unit tests xmlRenderer is built before groovyPageLocator exists, so its locator is null for the life of the spec and respond with Accept: application/xml never looks for a GSP view. On 8.0.x the registry created the default XML renderer with its own autowired locator. The lazy locator from point 1 restores that.
  • registerPluginBeans (GrailsApplicationBuilder.groovy:205-211) guards the XML plugin with ClassUtils.isPresent but loads ConvertersGrailsPlugin unconditionally. It holds today because grails-testing-support-web brings grails-rest-transforms → grails-converters via api, but the core module excludes grails-converters (build.gradle:48), so the same guard would be cheap insurance.
  • NamedJsonConfigurationRegistry.resolveMapper calls the supplier, which does Spring bean lookups, while holding the registry monitor. No deadlock path that I can construct at request time, but a plain racy read-then-write of the volatile would do the same job without a lock (forGrails is idempotent per mapper).
  • DefaultJsonRenderer.groovy:62: the new instance field sits above the static final PROBLEM_JSON constant.

Round-2 findings, status

# Finding Status
1 Web unit tests booted the full controllers/urlMappings/i18n graph Fixed as suggested. Only restResponder is added to the filter (GrailsApplicationBuilder.groovy:232); the converters and XML bean registrars are applied directly before refresh (:188-199), skipped when the test includes those plugins itself. Measured: plugins [core, restResponder], 152 bean definitions, harness URL mapping /(*)/(*)?/(*)? as on 8.0.x. CompiledTagInvocationSpec lost its controllerName workaround and passes. ControllerJsonSerializationSpec asserts the plugin set, no UrlMappings artefact, and that an application JsonMapperBuilderCustomizer sees the same GrailsJsonMapperCustomizer bean. Documented in unitTesting.adoc and section 65. Side effect: point 1 above.
2 Beans defined twice in the web harness Fixed. rendererRegistry, the first localeResolver and the post-refresh ConvertersGrailsPlugin are gone from WebSetupSpecInterceptor; grailsUrlMappingsHolder, grailsLinkGenerator and localeResolver are guarded by containsBean.
minor respond 'ok' on the legacy path Fixed for the default writer (new JSON(object), DefaultJsonRenderer.groovy:173-176, plus the JSONWriter change). Not for pretty print, see point 2.
minor Converter rebuilt per response Fixed, cached per source converter (DefaultJsonRenderer.groovy:207-209); the spec alternates two source converters with different naming strategies.
minor Registry supplier scanned on every write Fixed, first non-null mapper is cached and absence is retried; covered in NamedJsonConfigurationRegistrySpec.
minor isWebTest() overrides getIncludePlugins() undocumented Fixed in unitTesting.adoc and section 65.

Verified as correct

  • Link generation in the web harness behaves as on 8.0.x: a link with only action fails against the curated /(*)/(*)?/(*)? mapping, not against DefaultUrlMappings; the urlMappings plugin is not loaded.
  • encodeAsXML() needing mockCodec(XMLCodec) in unit tests is unchanged from 8.0.x; registering only the XML bean registrar (not providedArtefacts) does not regress it.
  • The grailsConverters cache key is the source converter instance (HttpMessageConverter has identity equality), and forGrails is itself cached per mapper, so two converters sharing a mapper still share the derived mapper.
  • JSONWriter base class: a second root value after a scalar throws and leaves the output untouched; object()/array() after DONE still throw. JSONWriterSpec covers string escaping, GString, numbers, booleans and null.
  • The scaffolding UserControllerSpec > User list failure is the known Geb login flake (Test Dashboard #16030), unrelated to this PR.
  • Everything in the round-1 and round-2 "Verified as correct" lists is unchanged by this commit.

@matrei matrei added this to the grails:9.0.0-M1 milestone Sep 18, 2026
@matrei matrei moved this to In Progress in Apache Grails Sep 18, 2026
@codeconsole

Copy link
Copy Markdown
Contributor Author

@matrei Addressed Review round 3 in d95b438c24.

XML initialization and GSP lookup

  • The XML renderer now resolves the GSP locator and renderer registry lazily when rendering. Deferring the registry is also necessary: its locator injection otherwise closes the same cycle when creation starts from mimeTypeResolver.
  • Added regression coverage that destroys the relevant singletons, recreates them starting from either xmlRenderer or mimeTypeResolver, and calls preInstantiateSingletons().
  • Added coverage for XML responses finding GSP views registered after renderer creation.
  • PersonControllerHibernateSpec now passes. Its fixture saves use failOnError: true so initialization failures surfaced as validation errors cannot silently turn into an empty result assertion.

Scalar JSON and documentation

  • PrettyPrintJSONWriter now accepts a scalar root and transitions to DONE.
  • The scalar/second-root rejection table runs against both writers. Controller coverage exercises pretty-printed strings, GStrings, enums, numbers, booleans, and direct conversion of null.
  • Rewrote upgrade-guide section 25 to describe enum-name strings and scalar roots. It also clarifies using new JSON(value) for strings and numbers, whose built-in Groovy casts do not support as JSON.

Minor items

  • Guarded optional converters-plugin loading with ClassUtils.isPresent.
  • Removed the registry monitor around mapper supplier calls, retaining the volatile cache and retry after absence. Added a concurrent-first-write regression test.
  • Moved PROBLEM_JSON above instance fields.

Validation

  • Clean aggregate style checks, core test aggregation, web checks, and guide build passed:
    clean aggregateViolations :grails-test-report:check :grails-test-suite-web:check :grails-doc:publishGuide -x aggregateGroovydoc --continue -PonlyCoreTests --max-workers=2 -PmaxTestParallel=1 --no-daemon.
  • Combined report: 8,377 tests, 0 failures/errors, 33 skipped.
  • Full :grails-test-examples-hibernate7-demo33:test: 79 tests, 0 failures/errors, 3 skipped, including the reported controller regression.
  • Checkstyle and CodeNarc reports are clean. PMD and SpotBugs were not enabled. The core-only sweep excludes the remaining database and functional suites.

The PR remains targeted at Grails 8.

@jdaugherty jdaugherty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review is about the migration path, not the implementation. The three rounds with @matrei cover the implementation and I have not re-reviewed them. Head reviewed: 520998f.

As it stands, an upgrading application gets four behaviour changes in the same release that introduces their replacements: respond() switches to Jackson by default, XML leaves the default classpath, JSON request binding switches parser, and validation errors change media type and body. Each has a legacy path, but none has a release in which the old behaviour is still the default and warns. That sequence is what I am -1 on, not the direction.

What I am asking for, with the specifics on the lines below:

  1. Grails 9: keep the legacy behaviour as the default, log one deprecation warning when it is used, and let applications opt in (grails.web.rendering.json.spring: true, removing grails-xml by hand).
  2. Grails 10: flip the defaults.
  3. The release after that: remove the legacy paths, and name that release now in every forRemoval annotation and in the guide.

The current head is red in CI for exactly this reason: RestApiDocumentFunctionalSpec in grails-test-examples/openapi-rest-api, merged to 9.0.x after this branch, expects XML answers and fails because that example never added grails-xml (Build Grails shard 1). That is the upgrade every existing XML application will hit.

Blocking: the respond() default and XML leaving the default classpath. Should fix: the binding parser swap, the global Errors serializer, the unnamed removal release, and the overlap with #16414.

Implementation review

The inline threads above the policy ones carry the verified implementation findings, one blocking (the malformed Accept crash in DefaultAcceptHeaderParser) and the rest should-fix. Minor items that do not need a thread of their own:

  • SpringMediaTypeAdapter.toMediaType throws IllegalArgumentException for q=2.0 and NumberFormatException for q=abc, both of which AcceptHeaderParserSpec says the parser accepts. Not reachable through Spring's manager today, but it is a public ContentNegotiationStrategy; clamp or drop the parameter.
  • GrailsMimeTypesWebMvcConfigurer parses every grails.mime.types name with MediaType.parseMediaType, so a configured name Spring rejects now fails startup inside mvcContentNegotiationManager; catch and skip.
  • Section 8 documents only the unknown ?format= case. response.mimeTypes now also reads the RESPONSE_FORMAT attribute that getFormat and withFormat write, so after a matched withFormat { html {} } it returns [html] instead of the Accept list, and a known-but-unhandled format (?format=xml with only html and json blocks) no longer falls back to the Accept header. Worth a sentence.
  • render json: book, encoding: 'ISO-8859-1' ends up charset=UTF-8: applyContentType returns false when only encoding: is given and the fallback hard-codes UTF-8, while respond honours grails.converters.encoding and render text: honours encoding:.
  • The derived Grails converter is new JacksonJsonHttpMessageConverter(forGrails(source.mapper)), which drops the source's supportedMediaTypes, defaultCharset and JSON prefix; an application that set setPrefixJson(true) gets the prefix on @RestController bodies and not on respond.
  • An unknown jsonConfiguration on respond fails with the legacy Converter Configuration with name 'x' not found!, while render json: throws Named JSON configuration [x] is not registered..
  • Association references write the identity property's name instead of the hard-coded "id" the legacy marshaller wrote ({"tag":{"tagId":3}} for id name: 'tagId'). Fine, but it is a wire change section 2 should list.
  • NamedJsonConfigurationRegistry.contains(null) throws from ConcurrentHashMap.containsKey while null is the documented default name, and NamedJsonConfiguration keeps public mutators after register although writer() caches the first derived writer, so later serializer() calls are silently ignored.
  • JsonDataBindingSourceCreator resolves the mapper with getIfAvailable(), ConvertersGrailsPlugin with getIfUnique() plus the jacksonJsonMapper fallback; the first surfaces NoUniqueBeanDefinitionException as a DataBindingSourceCreationException on the first request when two non-primary mappers exist. Align them.
  • grails-xml declares api project(':grails-databinding') (the plugin module), which nothing in its sources references; what it compiles against is grails-web-databinding, reached only transitively. grails-converters and grails-rest-transforms add testImplementation project(':grails-xml') to keep XML-only specs (XMLParseSpec, NonPublicClassMarshallingSpec, DomainClassMarshallerSpec, the XML cases of VndErrorRenderingSpec) where they are; they belong in grails-xml with the code they test.
  • Tests through internal APIs: ContentNegotiationSpec:63 and ContentFormatControllerTests:41 call new XmlConvertersConfigurationInitializer(...).initialize() with a comment saying the slice does not load the plugin, but GrailsApplicationBuilder now registers the XML plugin's beans for every web test and the initializer is an InitializingBean, so both calls are redundant. GrailsDomainSerializersSpec constructs the package-private GrailsDomainSerializers and calls findSerializer with a null config, duplicating what GrailsJsonMapperCustomizerSpec covers through forGrails.
  • JsonBindingSpec:136-137: the comment says the assertion does not pin the parser; the next line asserts contains 'tools.jackson'.
  • GrailsMockHttpServletRequest:171-181 throws the "Object-to-XML conversion requires grails-xml" message from getXML(), which parses rather than converts.
  • DefaultJsonRenderer:83 keeps the deprecated HttpStatus.UNPROCESSABLE_ENTITY while the factory uses UNPROCESSABLE_CONTENT, and ValidationProblemDetailFactory never sets instance although the resource path is in the context (Spring MVC sets it).

Comment thread grails-converters/src/main/groovy/grails/converters/JSON.java Outdated
Comment thread grails-doc/src/en/guide/upgrading/upgrading90x.adoc Outdated
Comment thread grails-doc/src/en/guide/upgrading/upgrading90x.adoc
Comment thread grails-doc/src/en/guide/upgrading/upgrading90x.adoc Outdated
@codeconsole

Copy link
Copy Markdown
Contributor Author

@jdaugherty Addressed on c76fe76, with 9.0.x merged in.

Migration path:

  • Grails 9 keeps the legacy defaults and warns once when they are used: respond() JSON (grails.web.rendering.json.spring, default false), JSON request binding (grails.databinding.json.jackson, default false) and XML (grails-xml is back in starter-web, and XML.render warns). Validation errors keep {"errors": [...]} unless Spring JSON is on (b520a96, 4b2ae67).
  • Grails 10 flips those defaults, drops grails-xml from the starter and makes @Resource JSON-only. Grails 11 removes the legacy paths. Every forRemoval Javadoc and the guide name Grails 11.
  • JSON.registerObjectMarshaller is no longer deprecated and no longer decides which path respond() takes, matching Write grails.converters.JSON through Spring Boot's JsonMapper #16414 (d81e395).

Minor items:

  • q=2.0 and q=abc are dropped when adapting to Spring. An invalid grails.mime.types name is skipped instead of failing startup (fdd7fc1).
  • Section 8 covers response.mimeTypes after withFormat and the known-but-unhandled format (d1668df).
  • render json:, encoding: keeps the encoding (be751d3).
  • The source converter's prefix, media types and charset are kept: only the value's JSON comes from the Grails mapper (be751d3).
  • An unknown jsonConfiguration fails with the same message on respond and render (be751d3).
  • Section 2 lists the identity property name in association references.
  • contains(null) is true, and a registered configuration rejects changes (d81e395).
  • JsonDataBindingSourceCreator resolves the mapper as the converters plugin does (4b2ae67).
  • grails-xml depends on grails-web-databinding, and the XML-only specs moved into grails-xml (be751d3).
  • Removed the redundant initializer calls and GrailsDomainSerializersSpec. Fixed the JsonBindingSpec comment and the getXML() message. Uses UNPROCESSABLE_CONTENT, and instance is filled from the resource path (be751d3).

@codeconsole
codeconsole requested a review from jdaugherty October 8, 2026 00:55
…tion's messages

ValidationProblemDetailFactory resolved the message of each error with
the MessageSource for the current locale, but wrote the problem's title
and detail in English, where Spring MVC resolves those of the problems
it writes through message codes.

The title and detail are now the messages of the
grails.validation.problem.title and grails.validation.problem.detail
codes, the latter given the number of errors, for the current locale,
and the English text they had otherwise.
A map association written as references iterated its entries itself,
so a null key was written as "null", where Jackson fails on a null key
of a map unless the mapper has a null key serializer, as the upgrade
guide says; and ORDER_MAP_ENTRIES_BY_KEYS did not order its entries.

A null key is now written with the mapper's null key serializer, which
fails by default, and the entries are ordered by their keys where that
feature is enabled, a null key first, as Jackson's map serializer orders
them, failing for keys that are not comparable where
FAIL_ON_ORDER_MAP_BY_INCOMPARABLE_KEY is enabled.
grails-converters finds the XML converter of grails-xml by its name,
since grails-xml depends on it, so that renaming the converter would
still compile and leave the XML encoder escaping objects as their
strings. The binding source registry and the converters initializer,
which find grails-xml classes the same way, are tested from grails-xml;
the encoder was not.

grails-xml now tests that the encoder writes an object with the XML
converter, and grails-converters that it escapes it as its string
without grails-xml.
No example application set grails.web.rendering.json.spring or
grails.databinding.json.jackson, so the Spring JSON path of respond, the
Jackson parsing of request bodies and the problems Spring MVC writes
were tested only in unit tests, whose wiring copies Spring Boot's.

The spring-json-respond example sets them, with
grails.converters.json.legacy false, and tests over HTTP that respond
writes a domain class instance with the Grails mapper and the excludes
it is given, that a JSON body is bound with Jackson, and that the
validation errors of a body, a ProblemDetail and an ErrorResponse are
written by Spring MVC with the application's ResponseBodyAdvice, in
JSON for a client that asks for XML without jackson-dataformat-xml, and
with the status alone for an Accept header Spring MVC cannot read.
Grails 10 makes Spring JSON rendering for respond and Jackson parsing
of request bodies the default, while grails.converters.json.legacy was
to change in a later release. Its defaults would then be the
combination the upgrade guide warns against: data binding parsing
strict JSON with Jackson, while request.JSON and JSON.parse accept what
the legacy parser accepts, and render ... as JSON writing with the
Grails 8 marshallers while respond writes with the Grails mapper.

The guides, the startup warning, the configuration metadata and the
deprecation notices of the legacy marshallers now say that Grails 10
changes the three JSON settings together. The Grails 9 defaults are
unchanged.
With grails.web.rendering.json.spring, respond picked a converter of
Spring MVC's and wrote the value itself, through the response's writer:
with the Grails mapper after the converter's prefix, or with the
converter through a bridge from its bytes to the writer, and a named
configuration wrote to the writer directly. Only problems went through
Spring MVC, so the application's ResponseBodyAdvice applied to them and
to no other JSON respond wrote.

The value is now written by Spring MVC's RequestResponseBodyMethodProcessor,
as the value a @responsebody handler method of the controller returns:
with the application's ResponseBodyAdvice beans, found as the handler
adapter finds them and applied as to a method of the controller, in the
type respond negotiated, with the converter it selected, or with a
GrailsMapperJsonConverter that writes with the Grails mapper derived
from Spring's Jackson converter, its types and prefix, and the
projection of the response; a named configuration is written by a
converter of its own. Since Spring's converter creates its generator
apart from the write, a write of the Grails mapper keeps the objects it
is writing in the writer, so that one a serializer writes with
writePOJO is still found where it repeats.

JSON written this way is in UTF-8 and labelled application/json, or
text/json, as Spring MVC writes it, whatever grails.converters.encoding
says, which the legacy converter and render json: still write in.
Spring MVC's ExceptionHandlerExceptionResolver applies @ExceptionHandler
methods, those of a controller and of the @ControllerAdvice beans, only
to a handler method, which a Grails action is not, so neither those
methods nor Spring Boot's handler of spring.mvc.problemdetails.enabled
saw an exception a Grails action threw, which the Grails exception
resolver rendered the error page for.

With grails.exceptionresolver.springHandlers, an ActionExceptionHandlerResolver,
ordered ahead of Spring MVC's resolvers as that resolver is ahead of
Spring MVC's others, gives Spring MVC's resolver the action as a
handler method of the controller, so that the exception handler methods
of the controller and of the advice scoped to it resolve the exception
as for a Spring MVC controller, and one none of them handles is
resolved as before. It is off by default in Grails 9 and planned as
the default in Grails 10, as the other settings of this change are,
since advice an application meant for its Spring MVC controllers alone
would otherwise start answering the exceptions of its Grails actions.
…orward

The gated writers wrap Jackson's writer of the entries of a
@JsonAnyGetter and its writer of an unwrapped property in subclasses of
those classes, which Jackson takes for its own, and forward to the
writer they wrap the methods that read its state, such as the accessor
and the serializer of the entries, which a subclass cannot read from
another instance. A method a later Jackson added to those classes would
run on the wrapper's own, unset state, and fail, or write ungated, only
when a response wrote such a property.

A contract test now fails where those classes declare a method the
wrappers neither override nor are reviewed not to need. Jackson 3.2,
which Grails manages, added two for an ObjectNode of entries, which
only the methods the wrapper forwards call, and a test writes such
entries with a projection.

Javadoc of the gated writer says why the gates are writers rather than
a PropertyFilter: a mapper has one FilterProvider, which the
application's filters use, which Spring's Jackson converter replaces
with the one a write is given, and which an application's
SimpleFilterProvider fails for a filter id it does not know.
… grails-xml

e0328c6 kept grails-xml's own uses of the deprecated XML converter free of removal warnings, but XmlRuntimeHints, which registers the converter by its class, was still reported by javac. It now suppresses them, as the marshallers do.
The Vnd.Error page told new applications to use the RFC 9457 problem that grails.web.rendering.json.spring opts into, ahead of the Grails 10 default, while Grails 9 sends the errors body the page shows first. It now describes the problem as what the setting sends.
ActionExceptionHandlerResolver looked up the action's method by the name
the URL gives it, which the hyphenated URL converter turns into show-book
for a showBook action, so the @ExceptionHandler methods of such an action
were not found. It now looks the method up by the name the controller
class maps the URL's name to.
An action that returns a ResponseEntity or another HttpEntity, HttpHeaders,
a ProblemDetail, an ErrorResponse, a StreamingResponseBody or a
ResponseBodyEmitter now has it written by the return value handlers of
Spring MVC's handler adapter, as the value of a handler method is, where
Grails did not write it. So does any value of an action annotated
@responsebody, or of an action of a controller annotated so, also through
an annotation such as @RestController, whose annotation Grails ignored.
The ResponseBodyAdvice beans that apply to the controller, and the action's
@JSONVIEW and @ResponseStatus, apply, and a CompletableFuture,
DeferredResult or Callable is processed asynchronously, with the executor,
timeout and interceptors of the handler adapter, and the value it completes
with written on the redispatch.

What an action of an error page returns is written whatever the status of
the response, which the new GrailsWebRequest.isResponseRendered() tells
apart from isRenderView(). A null value, the value of an action that
rendered the response itself, redirected, forwarded or processes the
request asynchronously already, the model of an action not annotated
@responsebody, and an application without Spring MVC's handler adapter
keep the Grails behaviour.
Grails 8 ignored @responsebody on an action, or on its controller, also
through @RestController, and rendered the action's view for a model, so
Spring MVC writes the value of such an action as the body only with
grails.controllers.responseBody: true, which Grails 10 makes the default,
as grails.exceptionresolver.springHandlers opts actions into Spring MVC's
exception handlers. The values Spring MVC writes whatever a handler method
is annotated with, such as a ResponseEntity, which Grails 8 did not write,
are written whatever it says.
With grails.web.rendering.xml.spring, respond wrote an ordinary bean with
the Spring XML converter through the response's writer, so the
application's ResponseBodyAdvice beans did not apply to it as they apply to
the JSON respond writes through Spring MVC. It now writes it as Spring MVC
writes the value of a @responsebody handler method of the controller, with
the converter it selected and the type respond negotiated, in UTF-8 as
Spring MVC writes XML, whatever grails.converters.encoding says. The
writer bridge that only this path used, WriterOutputStream and
SpringMessageConverters.writeCharset, is removed. The values the Grails XML
converter writes keep its path.
respond wrote a value through Spring MVC's response body handler as the
return value of a method that stood for the action, so Spring's
JsonViewResponseBodyAdvice, which reads @JSONVIEW from the handler method,
never saw the action's. The handler method is now the action's own,
without parameters, which carries its annotations, of the controller, and
declared to return Object, where the request names an action, as Grails
and the controller unit tests name it. A problem respond writes keeps the
stand-in, so that a view meant for the action's value does not leave its
properties out, and a named configuration, which is not a Jackson
converter, is not given the view.
The default detail of a validation problem nested {0,number,#} in a
choice format, whose parser in Java 17 and 21 reads the # as the limit
separator of a choice, so every validation problem failed with "Choice
Pattern incorrect" there, which Java 25 parses. {0,number,0} writes the
count the same way, without a grouping separator, on each of them.
@codeconsole

codeconsole commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor Author

@jdaugherty Thanks for round 4. Your notes are answered in their threads. Since the approved head (a30f1d4) there are 24 commits, and several change what round 4 verified, so they need a look before merge:

  • Problems. respond no longer negotiates the type of a ProblemDetail itself. It hands the problem to the return value handler of Spring MVC's handler adapter (4223262). So ResponseBodyAdvice beans scoped to the controller and ErrorResponse.Interceptor beans apply, the status-mismatch warning is Spring MVC's, and an Accept header Spring MVC cannot read fails with its HttpMediaTypeNotAcceptableException for a status that is not an error status, which its resolver answers with 406.
    • respond takes an ErrorResponse (6711b33), and web unit tests get Spring MVC's handler adapter (eb33d5c).
    • The title and detail of a validation problem are resolved with the application's messages (6f0e233). Its default detail failed to parse on Java 17 and 21, which is what TestLens reported for 308382d; e8df1c3 fixes it.
  • JSON and XML through Spring MVC's response body handler. With grails.web.rendering.json.spring, and for a jsonConfiguration, respond writes through Spring MVC's RequestResponseBodyMethodProcessor, so ResponseBodyAdvice applies to all of it (713e9ce). With grails.web.rendering.xml.spring, so do the ordinary beans it writes with Spring's XML converter, and the writer bridge that path used is gone (c89dd8d).
    • These bodies are UTF-8, and JSON is application/json, as Spring MVC writes them, whatever grails.converters.encoding says.
    • The action's @JsonView selects the properties written, other than a problem's or a named configuration's (2edf529).
    • Fallbacks to the legacy converter are reported per type and reason (b6a8045), and binding takes the mapper Spring MVC reads a @RequestBody with (20d80ce).
  • Actions can return what a Spring MVC handler method returns (31f7b77). A ResponseEntity or another HttpEntity, HttpHeaders, a ProblemDetail, an ErrorResponse, a StreamingResponseBody or a ResponseBodyEmitter is written by the return value handlers of Spring MVC's handler adapter, with the ResponseBodyAdvice that applies to the controller and the action's @JsonView and @ResponseStatus, also from an error page's action. Grails 8 wrote none of these, so they are written whatever the settings.
    • The value of an action annotated @ResponseBody, or of a controller annotated so, also through @RestController, is written as the body only with the new grails.controllers.responseBody, false in Grails 9 and planned true in Grails 10 (25a4633), so a Grails 9 action annotated so still renders its view by default.
    • A CompletableFuture, DeferredResult or Callable such an action returns is processed with the handler adapter's executor, timeout and interceptors.
  • New opt-in, grails.exceptionresolver.springHandlers. It is false in Grails 9 and planned true in Grails 10. The @ExceptionHandler methods of the controller and of the @ControllerAdvice beans scoped to it, Spring Boot's spring.mvc.problemdetails.enabled handler among them, resolve the exceptions actions throw, ahead of the Grails exception resolver (8ad6ea2). It also finds the action of a URL a URL converter names, such as show-book with the hyphenated one (30ba287).
  • Settings. The keys and defaults of the new switches are in grails.config.Settings (1fd9e1d, 25a4633).
    • The guides now plan grails.converters.json.legacy to change in Grails 10 together with the other two JSON settings (932df60), so that Grails 10 does not default to strict binding next to a lenient request.JSON. That changes the release plan agreed in round 2, so please confirm it.
  • Also:
    • null keys and key order of map associations as Jackson writes them (c302ac4);
    • a test of the XML encoder's lookup of grails-xml (fc5c6e8);
    • a contract test that fails where Jackson adds a method the gated writers do not forward (e30e151);
    • the spring-json-respond example, which tests these end to end over HTTP (085f560).
  • Follow-up. Grails' negotiation picks the format of renderers, views and withFormat, and Spring MVC's the type of what it writes. Resolving Grails formats through Spring MVC's negotiation is Resolve Grails formats through Spring MVC's content negotiation #16698, for Grails 10.

The module tests of each change, the spring-json-respond example on Java 21 and 25, and the web suites (grails-web-common, grails-web-url-mappings, grails-url-mappings, grails-async, grails-rest-transforms, grails-xml, grails-views-gson, grails-openapi, grails-controllers, grails-test-suite-web) pass locally; CI on e8df1c3 is running.

@jdaugherty jdaugherty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 5, verified at 785f668: the 24 commits since a30f1d4, read in full, sources, tests, docs and the spring-json-respond example, plus the 9.0.x merge on top, which brings only the MongoDB runner image and the JaCoCo cli source set. CI on e8df1c3, the last head before that merge, is green but for two jobs that do not touch this change: the Hibernate7 functional job (Java 25, indy=false) failed with Geb's DriverCreationException creating the Chrome container driver, so OsivGspRenderingSpec, FieldsValidationSpec and ScaffoldingFunctionalSpec never ran, while SpringJsonRespondSpec passed all 17 features in that job and on the Java 21 lane; the Groovy snapshot canary failed on the MongoDB flake of #16030, which the merge's runner change addresses.

Verified this round:

  • The four notes of round 4: the prefix is read once per converter in a weak map, with DefaultJsonRendererSpec covering a reconfigured converter and another converter; ProblemXml and ProblemBeans are gone and the XML renderer writes a problem as Spring MVC does; XmlRuntimeHints compiles without the removal warning; the Vnd.Error page no longer steers new applications to the problem response.
  • Problems: respond hands a ProblemDetail to the first return value handler of the application's requestMappingHandlerAdapter that supports ResponseEntity<ProblemDetail>, which is HttpEntityMethodProcessor with the adapter's converters, negotiation manager, advice and ErrorResponse.Interceptors. The producible types are the problem types a converter writes, problem+xml only where the action lists xml, and the containing class of the return type is the controller, so @ControllerAdvice(assignableTypes = ...) scopes as for a Spring MVC controller. An Accept header Spring MVC cannot read gives the status alone for a 4xx or 5xx and HttpMediaTypeNotAcceptableException otherwise, which Spring MVC's composite resolver, at order 0, answers with 406 ahead of the Grails exceptionHandler bean, which keeps AbstractHandlerExceptionResolver's lowest precedence. An ErrorResponse has its message codes resolved with the application's messages and the request locale before negotiation, and its headers reach the response on every path, a view's included.
  • JSON and XML through RequestResponseBodyMethodProcessor: the handler method is the action's no-argument method, onto which ControllerActionTransformer copies the annotations, so @JsonView applies to an action that takes parameters; ActionReturnType reports the controller as containing class and Object as the type, and a problem or a named configuration keeps the stand-in. The advice is the @ControllerAdvice beans that are ResponseBodyAdvice, in ControllerAdviceBean order ahead of JsonViewResponseBodyAdvice, as the adapter orders them. WriterOutputStream and writeCharset are gone with their last reader.
  • Actions returning Spring MVC values: isBody leaves null, a rendered, committed, redirected or forwarded response and a request already in concurrent handling to Grails; the listed types are written whatever the settings, and @ResponseBody values only with grails.controllers.responseBody, read through AnnotatedElementUtils, so @RestController and a superclass count. The value is typed by itself, as Spring MVC types the value of a method declared to return Object; the @ResponseStatus is applied as RequestMappingHandlerAdapter applies it, sendError for a reason; the async state is copied from the adapter's private fields, whose names I checked against spring-webmvc 7.1.0-M2. Grails 8 answered a returned ResponseEntity with an empty 200, since the adapter built no ModelAndView for a value that is neither a Map, a ModelAndView nor null, so writing it now changes nothing an application could have relied on; the compatibility section and section 15 say so, and that is the right call for a value whose only effect was an empty response.
  • grails.exceptionresolver.springHandlers: ActionExceptionHandlerResolver, at order -1, hands ExceptionHandlerExceptionResolver the action as a HandlerMethod, so shouldApplyTo passes and both the controller's @ExceptionHandler methods and the advice scoped to it resolve the exception; without the setting, and for a handler that is not a Grails mapping, it answers null. A Grails exception handler method, one Exception parameter, is still caught inside the action by the transformer, so it keeps precedence. The URL converter case resolves through actionUriToViewName.
  • grails.converters.json.legacy changing in Grails 10 together with grails.web.rendering.json.spring and grails.databinding.json.jackson: confirmed. A Grails 10 that bound and responded with Jackson while request.JSON and render as JSON kept the Grails 8 parser and marshallers would be the mix the guide warns against. The schedule stays deprecate in 9, change in 10, remove in 11, and the guide, the metadata, the startup warning and the deprecation notices now all say Grails 10; MigrationSettingsMetadataSpec pins the five defaults to the metadata.
  • Binding reads the mapper of the adapter's first converter that reads a JSON Map, Spring's Jackson converter only, so a Gson-first application keeps the application mapper. The validation problem's title and detail resolve through grails.validation.problem.title and .detail with the English text as fallback, and the ChoiceFormat parse of {0,number,#} on Java 17 and 21 is fixed with {0,number,0}.
  • Web unit tests get a lazy requestMappingHandlerAdapter, built by WebMvcConfigurationSupport from the test's converters, negotiation manager and WebMvcConfigurer beans with the validator initialised; ControllerProblemHandlerAdapterSpec and ControllerProblemSpringMvcSpec cover advice and interceptors, and a test's own adapter bean wins.
  • Map associations: a null key fails with Jackson's own message, and ORDER_MAP_ENTRIES_BY_KEYS orders the entries with a null key first, as Jackson's map serializer does; the gated writers have a contract spec; the XML encoder's lookup of grails.converters.XML is tested on both sides of the module boundary.
  • The spring-json-respond example runs every setting the later releases default to over HTTP, spring.mvc.problemdetails.enabled included.

Four non-blocking notes inline: the English fallback detail is formatted for the request's locale, text/json on the application-converter path, What's New for sections 14 and 15, and the Javadoc of MvcAdapter. Nothing blocks the merge once CI on the merge commit is green.

return message;
}
}
return new MessageFormat(defaultMessage, locale).format(arguments);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. The fallback text is English, but it is formatted for the request's locale, so a client in a locale whose numbering system is not Latin reads Request validation failed with ٢ errors. (ar-EG; fa formats 1000 as ۱۰۰۰). new MessageFormat(defaultMessage, Locale.ENGLISH) keeps the fallback consistent with its words; a translation of grails.validation.problem.detail still gets its own locale's digits through the MessageSource.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in f6d5f9e. The default title and detail are formatted with Locale.ENGLISH, the language of their words, and a translation of grails.validation.problem.title or .detail that the MessageSource resolves keeps the digits of its locale. ValidationProblemDetailFactorySpec writes 2 for ar-EG and 1000 for fa.

projection?.reportUnapplied()
return null
}
SpringResponseBody.write(object, (ServletRenderContext) context, converter, converterType)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking. On this path a client that asked for text/json is answered as application/json: converterType is the rewritten type, and the fixed strategy SpringResponseBody builds from it selects it. The Grails-mapper path above passes mediaType, and GrailsMapperJsonConverter adds text/json, so only an application's own converter answers such a client with the other label; before 713e9ce the renderer's text/json stayed on the response, since the converter wrote into its writer. The respond reference says text/json for a client that asks for it. Either qualify that sentence, an application converter writes the JSON type it supports, or wrap the application converter as the Grails mapper is wrapped; the doc change is enough for me.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took the doc change in 6b59179. The respond reference and section 4 of the upgrade guide now say that a client asking for text/json gets it where the Grails mapper or a named configuration writes the JSON. They also say that an application's own JSON converter writes a JSON type it supports: a subclass of Spring's Jackson converter, one with mappers for the type, or a converter of another library.

the action responds in XML and `jackson-dataformat-xml` is present, with the status the problem carries unless `respond`
is given one, as a `ResponseEntity` is in Spring MVC. A Spring `ErrorResponse`, such as an `ErrorResponseException`,
is answered the same way, with its status and headers. `ValidationProblemDetailFactory` creates a problem for
validation errors.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commenting on the last line of this paragraph, though the ask is for the section: sections 14 and 15 of the upgrade guide, Spring MVC's @ExceptionHandler methods for actions (grails.exceptionresolver.springHandlers) and actions returning Spring MVC responses (ResponseEntity, ProblemDetail, StreamingResponseBody, and @ResponseBody with grails.controllers.responseBody), have no entry in What's New, and the pointer at the end of this section still reads "sections 3 to 10, 12 and 13". Both are headline features of 9.0 with a guide page each, <> and the addition to <>, so please give them a paragraph here and extend the pointer to 14 and 15.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 401c21c. One paragraph covers grails.exceptionresolver.springHandlers and links to Declarative Controller Exception Handling. Another covers actions returning Spring MVC responses, with @ResponseBody under grails.controllers.responseBody and the action's @JsonView for respond, and links to Returning Spring MVC Responses. The pointer now reads sections 3 to 10 and 12 to 15.

* configuration and the application's {@code WebMvcConfigurer} beans set them.
*/
@CompileStatic
private static final class MvcAdapter {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking. MvcAdapter reads four private fields of RequestMappingHandlerAdapter, which has no getters for them, and field answers null where a Spring release renames one, so the loss would be silent at runtime: the default SimpleAsyncTaskExecutor, the container's timeout, no interceptors. ActionReturnValuesSpec pins all four, the return-values- thread name, the 1234 timeout and the INTERCEPTED and DEFERRED attributes, so a rename fails the build rather than a request. Please say so in this Javadoc, as GatedPropertyWriter names its contract spec, so that whoever touches this next keeps that coverage.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 36a1e7b. The Javadoc says the four are read from private fields and that a renamed field reads as null, which falls back silently to the default executor, the container's timeout and no interceptors. It names ActionReturnValuesSpec as the spec that pins each of them, so that the coverage is kept.

Without a message of grails.validation.problem.title or .detail, the
English default was formatted for the request's locale, so a client in
ar-EG read "Request validation failed with ٢ errors." and one in fa read
۱۰۰۰ for 1000. The default is now formatted in English, the language of
its words; a translation the MessageSource resolves keeps the digits of
its locale.
respond answers a client that asks for text/json with it where the
Grails mapper or a named configuration writes the JSON, both of which
write text/json, but an application's own JSON converter, such as a
subclass of Spring's Jackson converter, one with mappers for the type or
a converter of another library, writes a JSON type it supports, which
Spring MVC selects. The respond reference and the upgrade guide say so.
Sections 14 and 15 of the upgrade guide, Spring MVC's exception handlers
for actions with grails.exceptionresolver.springHandlers and actions
returning Spring MVC responses, with @responsebody under
grails.controllers.responseBody, had no entry in What's New. Each now
has a paragraph with its guide page, and the pointer to the upgrade guide
names sections 12 to 15.
MvcAdapter reads the executor, timeout and interceptors of Spring MVC's
handler adapter from its private fields, and a field a Spring release
renames reads as null, which would fall back silently at run time. Its
Javadoc now says so and names ActionReturnValuesSpec, which pins all four,
as GatedPropertyWriter names its contract spec.
@codeconsole

codeconsole commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor Author

@jdaugherty Thanks for round 5. The four notes are answered in their threads.

00e5455 merges 9.0.x again, up to 2a92881, which brings the GORM multi-tenancy and Hibernate fixes, the GSP build cache and the CI policy. It merges cleanly and touches none of this change's files. The four commits for the notes are on top of it, so the run that has to be green is now the one on the head, 36a1e7b, which includes the merge.

On that head, the module tests, the spring-json-respond example and the web suites pass locally (6,138 tests); CI on it is running.

@testlens-app

testlens-app Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

🚨 TestLens detected 239 failed tests 🚨

Test Summary

Tip

Inspect all details with the TestLens CLI: testlens pr 16237

CI / Build Grails Forge (Java 21, indy=false) > :test-core:test [grails-forge]

Test Runs Flakiness
AsciidoctorBuildSpec > an app with asciidoctor builds its guide, without the configuration cache the plugin does not support ❌ 2% 🟡

CI / Build Grails Forge (Java 21, indy=true) > :test-core:test [grails-forge]

Test Runs Flakiness
AsciidoctorBuildSpec > an app with asciidoctor builds its guide, without the configuration cache the plugin does not support ❌ 2% 🟡

Groovy Snapshot Canary Build / Build Grails (shard 0) > :grails-test-examples-mongodb-hibernate5:test

Test Runs Flakiness
BookFongoSpec ❌ 1% 🟡

Groovy Snapshot Canary Build / Build Grails (shard 1) > :grails-data-mongodb-core:test (first 40 of 224)

Test Runs Flakiness
AggregateMethodSpec ❌ 1% 🟡
AssignedIdentifierSpec ❌ 1% 🟡
AttachMethodSpec ❌ 1% 🟡
AutowireServicesSpec ❌ 1% 🟡
BasicArraySpec ❌ 1% 🟡
BasicCollectionTypeSpec ❌ 1% 🟡
BasicCollectionsSpec ❌ 1% 🟡
BatchUpdateDeleteSpec ❌ 1% 🟡
BeforeInsertUpdateSpec ❌ 1% 🟡
BeforeUpdatePropertyPersistenceSpec ❌ 1% 🟡
BigDecimalSpec ❌ 1% 🟡
BrokenManyToManyAssociationSpec ❌ 1% 🟡
BuildIndexAsyncResultSpec ❌ 1% 🟡
BuildIndexesAsyncSpec ❌ 1% 🟡
BuildIndexesBackgroundFailureSpec ❌ 1% 🟡
BuildIndexesClassificationCostSpec ❌ 1% 🟡
BuildIndexesDefaultConstraintsSpec ❌ 1% 🟡
BuildIndexesDisabledSpec ❌ 1% 🟡
BuildIndexesEnabledByDefaultSpec ❌ 1% 🟡
BuildIndexesFailureSummarySpec ❌ 1% 🟡
BuildIndexesHookSpec ❌ 1% 🟡
BuildIndexesKeyOrderSpec ❌ 1% 🟡
BuildIndexesLifecycleSpec ❌ 1% 🟡
BuildIndexesNameConflictSpec ❌ 1% 🟡
BuildIndexesOnDemandSpec ❌ 1% 🟡
BuildIndexesPartialClassificationSpec ❌ 1% 🟡
BuildIndexesPerConnectionSpec ❌ 1% 🟡
BuildIndexesRecreateSummarySpec ❌ 1% 🟡
BuildIndexesSharedDeclarationSpec ❌ 1% 🟡
BuildIndexesStaleSnapshotSpec ❌ 1% 🟡
BuildIndexesSummaryLogSpec ❌ 1% 🟡
BuildIndexesUnfinishedSummarySpec ❌ 1% 🟡
BuildIndexesUnreadableIndexListSpec ❌ 1% 🟡
BuiltinUniqueConstraintWorksWithTargetProxiesConstraintsSpec ❌ 1% 🟡
CascadeDeleteOneToOneSpec ❌ 1% 🟡
CascadeDeleteSpec ❌ 1% 🟡
CheckpointRestoreConnectionsSpec ❌ 1% 🟡
CircularBidirectionalOneToManySpec ❌ 1% 🟡
CircularEmbeddedListSpec ❌ 1% 🟡
CircularOneToManySpec ❌ 1% 🟡

Groovy Snapshot Canary Build / Build Grails (shard 1) > :grails-test-examples-mongodb-test-data-service:integrationTest

Test Runs Flakiness
StudentServiceSpec > test regular service autowire by type in a Data Service ❌ 1% 🟡
TestServiceSpec > test autowire by name works ❌ 1% 🟡
TestServiceSpec > test autowire by type ❌ 1% 🟡
TestServiceSpec > test data-service is loaded correctly ❌ 1% 🟡
TestServiceSpec > test that there is only one bookService ❌ 1% 🟡

Groovy Snapshot Canary Build / Build Grails (shard 2) > :grails-data-mongodb-spring-boot:test

Test Runs Flakiness
MongoDbGormAutoConfigurationDiscoverySpec ❌ 0% 🟢
MongoDbGormAutoConfigurationSpec ❌ 0% 🟢
MongoDbGormAutoConfigureWithGeoSpacialSpec ❌ 0% 🟢

Groovy Snapshot Canary Build / Build Grails (shard 2) > :grails-test-examples-mongodb-base:test

Test Runs Flakiness
BookUnitSpec ❌ 0% 🟢
LocalMongoUnitSpec ❌ 0% 🟢
PersonSpec ❌ 0% 🟢
TeamUnitSpec ❌ 0% 🟢

🏷️ Commit: 36a1e7b
▶️ Tests: 153150 executed
⚪️ Checks: 98/98 completed

Test Failures (first 10 of 236)

BookFongoSpec (:grails-test-examples-mongodb-hibernate5:test in Groovy Snapshot Canary Build / Build Grails (shard 0))
org.testcontainers.containers.ContainerLaunchException: Container startup failed for image mongo:8.0
	at org.testcontainers.containers.GenericContainer.doStart(GenericContainer.java:346)
	at org.testcontainers.containers.GenericContainer.start(GenericContainer.java:317)
	at functional.tests.EmbeddedMongoClient$Trait$Helper.createMongoClient(EmbeddedMongoClient.groovy:33)
	at grails.test.mongodb.MongoSpec.setupSpec(MongoSpec.groovy:117)
Caused by: org.rnorth.ducttape.RetryCountExceededException: Retry limit hit with exception
	at org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess(Unreliables.java:88)
	at org.testcontainers.containers.GenericContainer.doStart(GenericContainer.java:331)
	... 3 more
Caused by: org.testcontainers.containers.ContainerLaunchException: Could not create/start container
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:551)
	at org.testcontainers.containers.GenericContainer.lambda$doStart$0(GenericContainer.java:341)
	at org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess(Unreliables.java:81)
	... 4 more
Caused by: java.lang.IllegalStateException: Wait strategy failed. Container exited with code 1
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:520)
	... 6 more
Caused by: org.testcontainers.containers.ContainerLaunchException: Timed out waiting for log output matching '(?i).*waiting for connections.*'
	at org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy.waitUntilReady(LogMessageWaitStrategy.java:47)
	at org.testcontainers.containers.wait.strategy.AbstractWaitStrategy.waitUntilReady(AbstractWaitStrategy.java:52)
	at org.testcontainers.containers.GenericContainer.waitUntilContainerStarted(GenericContainer.java:904)
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:487)
	... 6 more
AggregateMethodSpec (:grails-data-mongodb-core:test in Groovy Snapshot Canary Build / Build Grails (shard 1))
org.testcontainers.containers.ContainerLaunchException: Container startup failed for image mongo:8.0
	at org.testcontainers.containers.GenericContainer.doStart(GenericContainer.java:346)
	at org.testcontainers.containers.GenericContainer.start(GenericContainer.java:317)
	at org.apache.grails.data.mongo.core.GrailsDataMongoTckManager.setupSpec(GrailsDataMongoTckManager.groovy:80)
	at org.apache.grails.data.testing.tck.base.GrailsDataTckSpec.setupSpec(GrailsDataTckSpec.groovy:40)
Caused by: org.rnorth.ducttape.RetryCountExceededException: Retry limit hit with exception
	at org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess(Unreliables.java:88)
	at org.testcontainers.containers.GenericContainer.doStart(GenericContainer.java:331)
	... 3 more
Caused by: org.testcontainers.containers.ContainerLaunchException: Could not create/start container
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:551)
	at org.testcontainers.containers.GenericContainer.lambda$doStart$0(GenericContainer.java:341)
	at org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess(Unreliables.java:81)
	... 4 more
Caused by: java.lang.IllegalStateException: Wait strategy failed. Container exited with code 1
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:520)
	... 6 more
Caused by: org.testcontainers.containers.ContainerLaunchException: Timed out waiting for log output matching '(?i).*waiting for connections.*'
	at org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy.waitUntilReady(LogMessageWaitStrategy.java:47)
	at org.testcontainers.containers.wait.strategy.AbstractWaitStrategy.waitUntilReady(AbstractWaitStrategy.java:52)
	at org.testcontainers.containers.GenericContainer.waitUntilContainerStarted(GenericContainer.java:904)
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:487)
	... 6 more
AssignedIdentifierSpec (:grails-data-mongodb-core:test in Groovy Snapshot Canary Build / Build Grails (shard 1))
org.testcontainers.containers.ContainerLaunchException: Container startup failed for image mongo:8.0
	at org.testcontainers.containers.GenericContainer.doStart(GenericContainer.java:346)
	at org.testcontainers.containers.GenericContainer.start(GenericContainer.java:317)
	at org.apache.grails.data.mongo.core.GrailsDataMongoTckManager.setupSpec(GrailsDataMongoTckManager.groovy:80)
	at org.apache.grails.data.testing.tck.base.GrailsDataTckSpec.setupSpec(GrailsDataTckSpec.groovy:40)
Caused by: org.rnorth.ducttape.RetryCountExceededException: Retry limit hit with exception
	at org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess(Unreliables.java:88)
	at org.testcontainers.containers.GenericContainer.doStart(GenericContainer.java:331)
	... 3 more
Caused by: org.testcontainers.containers.ContainerLaunchException: Could not create/start container
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:551)
	at org.testcontainers.containers.GenericContainer.lambda$doStart$0(GenericContainer.java:341)
	at org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess(Unreliables.java:81)
	... 4 more
Caused by: java.lang.IllegalStateException: Wait strategy failed. Container exited with code 1
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:520)
	... 6 more
Caused by: org.testcontainers.containers.ContainerLaunchException: Timed out waiting for log output matching '(?i).*waiting for connections.*'
	at org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy.waitUntilReady(LogMessageWaitStrategy.java:47)
	at org.testcontainers.containers.wait.strategy.AbstractWaitStrategy.waitUntilReady(AbstractWaitStrategy.java:52)
	at org.testcontainers.containers.GenericContainer.waitUntilContainerStarted(GenericContainer.java:904)
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:487)
	... 6 more
AttachMethodSpec (:grails-data-mongodb-core:test in Groovy Snapshot Canary Build / Build Grails (shard 1))
org.testcontainers.containers.ContainerLaunchException: Container startup failed for image mongo:8.0
	at org.testcontainers.containers.GenericContainer.doStart(GenericContainer.java:346)
	at org.testcontainers.containers.GenericContainer.start(GenericContainer.java:317)
	at org.apache.grails.data.mongo.core.GrailsDataMongoTckManager.setupSpec(GrailsDataMongoTckManager.groovy:80)
	at org.apache.grails.data.testing.tck.base.GrailsDataTckSpec.setupSpec(GrailsDataTckSpec.groovy:40)
Caused by: org.rnorth.ducttape.RetryCountExceededException: Retry limit hit with exception
	at org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess(Unreliables.java:88)
	at org.testcontainers.containers.GenericContainer.doStart(GenericContainer.java:331)
	... 3 more
Caused by: org.testcontainers.containers.ContainerLaunchException: Could not create/start container
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:551)
	at org.testcontainers.containers.GenericContainer.lambda$doStart$0(GenericContainer.java:341)
	at org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess(Unreliables.java:81)
	... 4 more
Caused by: java.lang.IllegalStateException: Wait strategy failed. Container exited with code 1
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:520)
	... 6 more
Caused by: org.testcontainers.containers.ContainerLaunchException: Timed out waiting for log output matching '(?i).*waiting for connections.*'
	at org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy.waitUntilReady(LogMessageWaitStrategy.java:47)
	at org.testcontainers.containers.wait.strategy.AbstractWaitStrategy.waitUntilReady(AbstractWaitStrategy.java:52)
	at org.testcontainers.containers.GenericContainer.waitUntilContainerStarted(GenericContainer.java:904)
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:487)
	... 6 more
AutowireServicesSpec (:grails-data-mongodb-core:test in Groovy Snapshot Canary Build / Build Grails (shard 1))
org.testcontainers.containers.ContainerLaunchException: Container startup failed for image mongo:8.0
	at org.testcontainers.containers.GenericContainer.doStart(GenericContainer.java:346)
	at org.testcontainers.containers.GenericContainer.start(GenericContainer.java:317)
	at org.apache.grails.data.mongo.core.GrailsDataMongoTckManager.setupSpec(GrailsDataMongoTckManager.groovy:80)
	at org.apache.grails.data.testing.tck.base.GrailsDataTckSpec.setupSpec(GrailsDataTckSpec.groovy:40)
Caused by: org.rnorth.ducttape.RetryCountExceededException: Retry limit hit with exception
	at org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess(Unreliables.java:88)
	at org.testcontainers.containers.GenericContainer.doStart(GenericContainer.java:331)
	... 3 more
Caused by: org.testcontainers.containers.ContainerLaunchException: Could not create/start container
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:551)
	at org.testcontainers.containers.GenericContainer.lambda$doStart$0(GenericContainer.java:341)
	at org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess(Unreliables.java:81)
	... 4 more
Caused by: java.lang.IllegalStateException: Wait strategy failed. Container exited with code 1
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:520)
	... 6 more
Caused by: org.testcontainers.containers.ContainerLaunchException: Timed out waiting for log output matching '(?i).*waiting for connections.*'
	at org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy.waitUntilReady(LogMessageWaitStrategy.java:47)
	at org.testcontainers.containers.wait.strategy.AbstractWaitStrategy.waitUntilReady(AbstractWaitStrategy.java:52)
	at org.testcontainers.containers.GenericContainer.waitUntilContainerStarted(GenericContainer.java:904)
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:487)
	... 6 more
BasicArraySpec (:grails-data-mongodb-core:test in Groovy Snapshot Canary Build / Build Grails (shard 1))
org.testcontainers.containers.ContainerLaunchException: Container startup failed for image mongo:8.0
	at org.testcontainers.containers.GenericContainer.doStart(GenericContainer.java:346)
	at org.testcontainers.containers.GenericContainer.start(GenericContainer.java:317)
	at org.apache.grails.data.mongo.core.GrailsDataMongoTckManager.setupSpec(GrailsDataMongoTckManager.groovy:80)
	at org.apache.grails.data.testing.tck.base.GrailsDataTckSpec.setupSpec(GrailsDataTckSpec.groovy:40)
Caused by: org.rnorth.ducttape.RetryCountExceededException: Retry limit hit with exception
	at org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess(Unreliables.java:88)
	at org.testcontainers.containers.GenericContainer.doStart(GenericContainer.java:331)
	... 3 more
Caused by: org.testcontainers.containers.ContainerLaunchException: Could not create/start container
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:551)
	at org.testcontainers.containers.GenericContainer.lambda$doStart$0(GenericContainer.java:341)
	at org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess(Unreliables.java:81)
	... 4 more
Caused by: java.lang.IllegalStateException: Wait strategy failed. Container exited with code 1
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:520)
	... 6 more
Caused by: org.testcontainers.containers.ContainerLaunchException: Timed out waiting for log output matching '(?i).*waiting for connections.*'
	at org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy.waitUntilReady(LogMessageWaitStrategy.java:47)
	at org.testcontainers.containers.wait.strategy.AbstractWaitStrategy.waitUntilReady(AbstractWaitStrategy.java:52)
	at org.testcontainers.containers.GenericContainer.waitUntilContainerStarted(GenericContainer.java:904)
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:487)
	... 6 more
BasicCollectionTypeSpec (:grails-data-mongodb-core:test in Groovy Snapshot Canary Build / Build Grails (shard 1))
org.testcontainers.containers.ContainerLaunchException: Container startup failed for image mongo:8.0
	at org.testcontainers.containers.GenericContainer.doStart(GenericContainer.java:346)
	at org.testcontainers.containers.GenericContainer.start(GenericContainer.java:317)
	at org.apache.grails.data.mongo.core.GrailsDataMongoTckManager.setupSpec(GrailsDataMongoTckManager.groovy:80)
	at org.apache.grails.data.testing.tck.base.GrailsDataTckSpec.setupSpec(GrailsDataTckSpec.groovy:40)
Caused by: org.rnorth.ducttape.RetryCountExceededException: Retry limit hit with exception
	at org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess(Unreliables.java:88)
	at org.testcontainers.containers.GenericContainer.doStart(GenericContainer.java:331)
	... 3 more
Caused by: org.testcontainers.containers.ContainerLaunchException: Could not create/start container
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:551)
	at org.testcontainers.containers.GenericContainer.lambda$doStart$0(GenericContainer.java:341)
	at org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess(Unreliables.java:81)
	... 4 more
Caused by: java.lang.IllegalStateException: Wait strategy failed. Container exited with code 1
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:520)
	... 6 more
Caused by: org.testcontainers.containers.ContainerLaunchException: Timed out waiting for log output matching '(?i).*waiting for connections.*'
	at org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy.waitUntilReady(LogMessageWaitStrategy.java:47)
	at org.testcontainers.containers.wait.strategy.AbstractWaitStrategy.waitUntilReady(AbstractWaitStrategy.java:52)
	at org.testcontainers.containers.GenericContainer.waitUntilContainerStarted(GenericContainer.java:904)
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:487)
	... 6 more
BasicCollectionsSpec (:grails-data-mongodb-core:test in Groovy Snapshot Canary Build / Build Grails (shard 1))
org.testcontainers.containers.ContainerLaunchException: Container startup failed for image mongo:8.0
	at org.testcontainers.containers.GenericContainer.doStart(GenericContainer.java:346)
	at org.testcontainers.containers.GenericContainer.start(GenericContainer.java:317)
	at org.apache.grails.data.mongo.core.GrailsDataMongoTckManager.setupSpec(GrailsDataMongoTckManager.groovy:80)
	at org.apache.grails.data.testing.tck.base.GrailsDataTckSpec.setupSpec(GrailsDataTckSpec.groovy:40)
Caused by: org.rnorth.ducttape.RetryCountExceededException: Retry limit hit with exception
	at org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess(Unreliables.java:88)
	at org.testcontainers.containers.GenericContainer.doStart(GenericContainer.java:331)
	... 3 more
Caused by: org.testcontainers.containers.ContainerLaunchException: Could not create/start container
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:551)
	at org.testcontainers.containers.GenericContainer.lambda$doStart$0(GenericContainer.java:341)
	at org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess(Unreliables.java:81)
	... 4 more
Caused by: java.lang.IllegalStateException: Wait strategy failed. Container exited with code 1
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:520)
	... 6 more
Caused by: org.testcontainers.containers.ContainerLaunchException: Timed out waiting for log output matching '(?i).*waiting for connections.*'
	at org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy.waitUntilReady(LogMessageWaitStrategy.java:47)
	at org.testcontainers.containers.wait.strategy.AbstractWaitStrategy.waitUntilReady(AbstractWaitStrategy.java:52)
	at org.testcontainers.containers.GenericContainer.waitUntilContainerStarted(GenericContainer.java:904)
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:487)
	... 6 more
BatchUpdateDeleteSpec (:grails-data-mongodb-core:test in Groovy Snapshot Canary Build / Build Grails (shard 1))
org.testcontainers.containers.ContainerLaunchException: Container startup failed for image mongo:8.0
	at org.testcontainers.containers.GenericContainer.doStart(GenericContainer.java:346)
	at org.testcontainers.containers.GenericContainer.start(GenericContainer.java:317)
	at org.apache.grails.data.mongo.core.GrailsDataMongoTckManager.setupSpec(GrailsDataMongoTckManager.groovy:80)
	at org.apache.grails.data.testing.tck.base.GrailsDataTckSpec.setupSpec(GrailsDataTckSpec.groovy:40)
Caused by: org.rnorth.ducttape.RetryCountExceededException: Retry limit hit with exception
	at org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess(Unreliables.java:88)
	at org.testcontainers.containers.GenericContainer.doStart(GenericContainer.java:331)
	... 3 more
Caused by: org.testcontainers.containers.ContainerLaunchException: Could not create/start container
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:551)
	at org.testcontainers.containers.GenericContainer.lambda$doStart$0(GenericContainer.java:341)
	at org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess(Unreliables.java:81)
	... 4 more
Caused by: java.lang.IllegalStateException: Wait strategy failed. Container exited with code 1
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:520)
	... 6 more
Caused by: org.testcontainers.containers.ContainerLaunchException: Timed out waiting for log output matching '(?i).*waiting for connections.*'
	at org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy.waitUntilReady(LogMessageWaitStrategy.java:47)
	at org.testcontainers.containers.wait.strategy.AbstractWaitStrategy.waitUntilReady(AbstractWaitStrategy.java:52)
	at org.testcontainers.containers.GenericContainer.waitUntilContainerStarted(GenericContainer.java:904)
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:487)
	... 6 more
BeforeInsertUpdateSpec (:grails-data-mongodb-core:test in Groovy Snapshot Canary Build / Build Grails (shard 1))
org.testcontainers.containers.ContainerLaunchException: Container startup failed for image mongo:8.0
	at org.testcontainers.containers.GenericContainer.doStart(GenericContainer.java:346)
	at org.testcontainers.containers.GenericContainer.start(GenericContainer.java:317)
	at org.apache.grails.data.mongo.core.GrailsDataMongoTckManager.setupSpec(GrailsDataMongoTckManager.groovy:80)
	at org.apache.grails.data.testing.tck.base.GrailsDataTckSpec.setupSpec(GrailsDataTckSpec.groovy:40)
Caused by: org.rnorth.ducttape.RetryCountExceededException: Retry limit hit with exception
	at org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess(Unreliables.java:88)
	at org.testcontainers.containers.GenericContainer.doStart(GenericContainer.java:331)
	... 3 more
Caused by: org.testcontainers.containers.ContainerLaunchException: Could not create/start container
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:551)
	at org.testcontainers.containers.GenericContainer.lambda$doStart$0(GenericContainer.java:341)
	at org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess(Unreliables.java:81)
	... 4 more
Caused by: java.lang.IllegalStateException: Wait strategy failed. Container exited with code 1
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:520)
	... 6 more
Caused by: org.testcontainers.containers.ContainerLaunchException: Timed out waiting for log output matching '(?i).*waiting for connections.*'
	at org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy.waitUntilReady(LogMessageWaitStrategy.java:47)
	at org.testcontainers.containers.wait.strategy.AbstractWaitStrategy.waitUntilReady(AbstractWaitStrategy.java:52)
	at org.testcontainers.containers.GenericContainer.waitUntilContainerStarted(GenericContainer.java:904)
	at org.testcontainers.containers.GenericContainer.tryStart(GenericContainer.java:487)
	... 6 more

Rerun Controls (first 20 of 239)

Select tests to mute in this pull request:

  • AggregateMethodSpec
  • AsciidoctorBuildSpec > an app with asciidoctor builds its guide, without the configuration cache the plugin does not support
  • AssignedIdentifierSpec
  • AttachMethodSpec
  • AutowireServicesSpec
  • BasicArraySpec
  • BasicCollectionTypeSpec
  • BasicCollectionsSpec
  • BatchUpdateDeleteSpec
  • BeforeInsertUpdateSpec
  • BeforeUpdatePropertyPersistenceSpec
  • BigDecimalSpec
  • BookFongoSpec
  • BookUnitSpec
  • BrokenManyToManyAssociationSpec
  • BuildIndexAsyncResultSpec
  • BuildIndexesAsyncSpec
  • BuildIndexesBackgroundFailureSpec
  • BuildIndexesClassificationCostSpec
  • BuildIndexesDefaultConstraintsSpec

Reuse successful test results:

  • ♻️ Only rerun the tests that failed or were muted before

Click the checkbox to trigger a rerun:

  • Rerun jobs

Learn more about TestLens at testlens.app/docs.

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. I did not find a production regression in the MIME and serialization changes. Grails 9 keeps the legacy JSON defaults, Spring rendering does not let the generic string and byte-array converters win JSON selection, and the XML extraction and problem-media-type changes match the documented behavior. Spring MVC keeps its own negotiation rather than inheriting Grails' permissive fallback parser. This is a source review; I did not re-run the Gradle suites.

@jdaugherty
jdaugherty merged commit 0a74fad into apache:9.0.x Oct 21, 2026
91 of 93 checks passed
@github-project-automation github-project-automation Bot moved this from In Progress to Done in Apache Grails Oct 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants