Skip to content

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

Open
codeconsole wants to merge 82 commits into
apache:8.0.xfrom
codeconsole:feature/spring-media-type-negotiation-8.0.x
Open

codeconsole wants to merge 82 commits into
apache:8.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

Modernize Grails MIME negotiation and provide an opt-in Spring/Jackson JSON rendering path on 8.0.x, while preserving legacy JSON rendering as the default.

  • Bridge Grails MIME types to Spring MediaType, remove static negotiation state, and resolve strategy bean ambiguity.
  • Integrate Spring MVC message converters with Grails rendering. JSON/XML selection requires a supported media type in the requested family, so generic string and byte-array converters cannot claim structured payloads through */*.
  • Provide an injectable, request-safe NamedJsonConfigurationRegistry shared by controller rendering and direct serialization.
  • Scope domain compatibility serialization to Grails writers; preserve Jackson's normal property model on the shared Boot mapper.
  • Extract HTTP XML payload support into optional grails-xml, keep plugin descriptors unchanged, and make new REST artefacts JSON-only by default.
  • Support RFC 9457 validation responses through Spring JSON rendering; retain explicit Vnd.Error, XML, HAL, and Atom renderer options.
  • Initialize controller unit tests with Boot Jackson configuration and real message converters. Restrict automatic web/XML plugin loading to web tests.

Compatibility and migration

  • grails.web.rendering.json.spring defaults to false on 8.0.x. Set it to true to opt into Spring JSON rendering. Legacy marshallers and ordinary legacy response shapes remain the default.
  • render json: value uses the default Jackson writer. render json: value, jsonConfiguration: 'deep' and respond value, jsonConfiguration: 'deep' select the same registered configuration.
  • Named configurations derive isolated writers from the configured mapper. Legacy named configurations remain supported as a fallback while their APIs are deprecated.
  • Grails domain compatibility writers use persistent-property and association rules. Their property handling differs from Jackson bean serialization; the upgrade guide documents those limits. Application serializers retain precedence.
  • The shared Boot mapper keeps normal Jackson annotations, mixins, naming strategies, inclusion, views, and derived getters for domain instances. Grails contributes GString and Spring Errors serializers globally; the latter's error representation is documented.
  • XML HTTP endpoints add grails-xml and opt into XML. XML plugin descriptors are unaffected.
  • Spring HATEOAS is not automatically bridged into Grails respond. Use an explicitly registered Grails HAL renderer or Spring MVC controller handling. This PR does not add a grails-spring-hateoas module.
  • The guide documents scalar JSON/XML handling, byte-array base64 output on the Jackson path, response encoding, and the fallback for unknown ?format= values.

Verification

The final scoped verification completed successfully: aggregate violations, checks for grails-web-common, grails-converters, grails-rest-transforms, grails-controllers, grails-xml, the three testing-support modules, grails-gsp, grails-test-suite-web, grails-test-suite-uber, and grails-fields, plus the guide build and combined Markdown test report.

The run used -PonlyCoreTests --max-workers=2 --no-daemon --continue -x aggregateGroovydoc. PMD and SpotBugs were not enabled. An earlier clean repository-wide attempt ended when the Gradle daemon disappeared; this is not a claim that the full repository suite passed.

@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.95964% with 114 lines in your changes missing coverage. Please review.
✅ Project coverage is 55.9736%. Comparing base (3067d0a) to head (e8bb2eb).
⚠️ Report is 40 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...ns/web/rest/render/json/DefaultJsonRenderer.groovy 66.0000% 3 Missing and 14 partials ⚠️
...converters/jackson/GrailsDomainJsonSerializer.java 78.2609% 4 Missing and 11 partials ⚠️
...s/web/mime/GrailsContentNegotiationStrategy.groovy 73.1707% 3 Missing and 8 partials ⚠️
...gins/web/rest/render/xml/DefaultXmlRenderer.groovy 64.2857% 2 Missing and 8 partials ⚠️
...eb/converters/jackson/GrailsDomainSerializers.java 88.0000% 2 Missing and 4 partials ⚠️
...lugins/testing/GrailsMockHttpServletRequest.groovy 50.0000% 5 Missing and 1 partial ⚠️
...uration/XmlConvertersConfigurationInitializer.java 90.9091% 4 Missing and 2 partials ⚠️
...onverters/json/NamedJsonConfigurationRegistry.java 83.8710% 2 Missing and 3 partials ⚠️
...grails/converters/json/NamedJsonConfiguration.java 82.6087% 2 Missing and 2 partials ⚠️
...converters/jackson/GrailsJsonMapperCustomizer.java 90.0000% 1 Missing and 3 partials ⚠️
... and 11 more
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16237        +/-   ##
==================================================
+ Coverage     55.2582%   55.9736%   +0.7154%     
- Complexity      20976      21401       +425     
==================================================
  Files            2113       2131        +18     
  Lines          101632     102325       +693     
  Branches        18045      18192       +147     
==================================================
+ Hits            56160      57275      +1115     
+ Misses          37422      36844       -578     
- Partials         8050       8206       +156     
Files with missing lines Coverage Δ
...erters/src/main/groovy/grails/converters/JSON.java 36.8231% <ø> (+11.5523%) ⬆️
...s/plugins/converters/ConvertersGrailsPlugin.groovy 100.0000% <100.0000%> (ø)
...g/grails/web/converters/ConvertersExtension.groovy 66.6667% <ø> (+9.5238%) ⬆️
...figuration/ConvertersConfigurationInitializer.java 82.9546% <ø> (-4.3470%) ⬇️
...converters/jackson/SpringErrorsJsonSerializer.java 100.0000% <100.0000%> (ø)
...plugins/databinding/DataBindingGrailsPlugin.groovy 100.0000% <ø> (ø)
...ils/plugins/web/mime/MimeTypesConfiguration.groovy 82.9268% <100.0000%> (+0.8755%) ⬆️
.../render/errors/ValidationProblemDetailFactory.java 100.0000% <100.0000%> (ø)
...s/web/rest/plugin/RestResponderGrailsPlugin.groovy 71.4286% <100.0000%> (+6.7227%) ⬆️
...ins/web/rest/render/DefaultRendererRegistry.groovy 73.1092% <100.0000%> (+2.4196%) ⬆️
... and 51 more

... and 38 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.

Selecting a named configuration returned before the projection was
considered, so respond with both a jsonConfiguration and includes or
excludes silently dropped the projection the legacy converter applied.

Give NamedJsonRenderer an overload carrying the projection, derive the
writer with the include and exclude attributes the domain serializer
reads, and pass the response's projection through.
Two problems with resolving domain serializers lazily.

The catch treated any runtime failure as "GORM is not ready" and fell
back to ordinary bean serialization, so a genuine mapping defect would
silently serialize a domain object as a plain bean, potentially
exposing properties the domain serializer would not write. Catch only
the GORM-not-initialized failure.

The window in which that happens exists because
JsonDataBindingSourceCreator injected the JsonMapper, pulling Jackson's
auto-configuration into a graph MimeTypesConfiguration depends on, so
Boot's mapper was built before GORM. Resolve it when a request body is
first parsed instead, which is after startup.
Removing the render(Map, Object) overload took the named-configuration
render form with it, leaving respond as the only way to select one.

Restore it as a render argument instead:

    render json: book, jsonConfiguration: 'deep'

Keying off the argument map means no other two-argument render call can
be captured by it, which is what made the overload unsafe. Projections
are passed through, matching respond.
Returning null while GORM's metadata was unreadable let Jackson select
and cache its ordinary bean serializer for a domain class. That choice
survived GORM starting, so the class kept serializing with the wrong
shape for the life of the mapper. Deferring the data-binding mapper
lookup made that less likely but any early component using Boot's
JsonMapper can still reach it.

A class is recognisable as a domain artefact from the artefact
registry, which does not need GORM. When one is asked for too early,
hand back a serializer of ours that binds to the persistent metadata on
first write, so Jackson caches that rather than a bean serializer.
Writing before the metadata exists now fails with a message instead of
emitting a different shape.

Covers the sequence: build the mapper before GORM, write the class,
initialize GORM, write it again, and assert the domain serializer is
used.
The projection overload was a default method delegating to the
projection-free one, so any other implementation of this new public
interface would drop includes and excludes with nothing to show a
projection had been asked for. The interface is unreleased, so nothing
is gained by tolerating that; make the method abstract.

SpringMessageConverters returned the list Spring itself configures,
which a caller could mutate. Wrap it unmodifiable: the wrapper still
observes later configurers, without offering a way to alter Spring
MVC's converters through Grails.
Adds status, the default JSON content type, an explicit content type,
excludes reaching the renderer, and view rendering staying enabled when
writing fails. The spec removed with the old overload covered status
and content type; that coverage is restored here.
The readiness probe asked whether the mapping context was null, but
DefaultGrailsApplication.getMappingContext never returns null: it hands
out a proxy that fails only when one of its methods is called. The
probe therefore reported ready, no deferred serializer was installed,
and Jackson still cached a bean serializer for a domain class written
too early -- the very case the previous change set out to fix.

Let the failed lookup out of persistentEntity instead, so the caller
can tell "GORM is not initialized" from "this type is not mapped", and
decide domain-ness from DomainClassArtefactHandler.isDomainClass, which
needs neither GORM nor a registered artefact handler.

The earlier regression test passed only because it overrode
getMappingContext to throw, which no real application does. It now uses
a real DefaultGrailsApplication and its proxy, and unit tests cover the
selection directly for each of the four cases.
The remaining test in this spec still overrode getMappingContext to
throw, which no application does -- the real one returns a proxy that
fails on use. Overriding the method under test can only confirm the
assumption being made about it, which is how the ordering bug survived
a passing test once already.

Use a real DefaultGrailsApplication and set its mapping context when
GORM would.
The Javadoc still described the behaviour from before the fix, saying
null covered both an unmapped type and GORM not being initialized. The
second case now propagates GrailsConfigurationException, and the whole
correction turns on the caller being able to tell them apart --
DeferredDomainSerializer catches that exception, which the old wording
made look like dead code.
@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

Conflict resolutions:

* SpringIOUtils: upstream replaced the inline setFeature block with the
  cached factories and the XmlParserFeature enum. That rewrite already
  carries the http:// feature identifiers this branch was fixing, so
  upstream's version is taken whole.

* upgrading80x.adoc: both sides appended sections. Upstream's 53-60 are
  kept in place and this branch's five sections are renumbered 61-65.

Semantic conflicts git did not flag:

* XmlDataBindingSourceCreatorSpec, added upstream in grails-web-databinding,
  moved to grails-xml, where this branch moved the creator it exercises.
  grails-web-databinding cannot depend on grails-xml.

* The XML NonPublicClassMarshallingSpec stays in grails-converters beside
  its JSON twin and the bean fixtures it shares, reached through the
  existing testImplementation project(':grails-xml').

* XmlCompatibilitySpec expected XML.parse to accept a DOCTYPE and resolve
  internal entities. Upstream's parser hardening refuses any declaration,
  internal or not, as upgrading.adoc and threat model P10 both state. The
  two affected features now assert refusal on the converter and binding
  paths.
@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.

@testlens-app

testlens-app Bot commented Sep 15, 2026

Copy link
Copy Markdown

🚨 TestLens detected 1 failed test 🚨

Here is what you can do:

  1. Inspect the test failures carefully.
  2. If you are convinced that some of the tests are flaky, you can mute them below.
  3. Finally, trigger a rerun by checking the rerun checkbox.

Test Summary

CI / Functional Tests (Java 21, indy=false) > :grails-test-examples-scaffolding:integrationTest

Test Runs Flakiness
UserControllerSpec > User list 6% 🟠

🏷️ Commit: e8bb2eb
▶️ Tests: 86024 executed
🟡 Checks: 84/86 completed

Test Failures

UserControllerSpec > User list (:grails-test-examples-scaffolding:integrationTest in CI / Functional Tests (Java 21, indy=false))
geb.waiting.WaitTimeoutException: condition did not pass in 30 seconds (failed with exception)
	at geb.waiting.Wait.waitFor(Wait.groovy:128)
	at geb.waiting.DefaultWaitingSupport.doWaitFor(DefaultWaitingSupport.groovy:55)
	at geb.waiting.DefaultWaitingSupport.waitFor(DefaultWaitingSupport.groovy:41)
	at geb.Page.waitFor(Page.groovy:120)
	at com.example.pages.LoginPage.login(LoginPage.groovy:39)
	at com.example.UserControllerSpec.User list(UserControllerSpec.groovy:48)
Caused by: Assertion failed: 

title != pageTitle && $('input', name: 'username').empty
|     |  |         |
|     |  |         false
|     |  'Please sign in'
|     false
'Please sign in'

	at com.example.pages.LoginPage.login_closure1(LoginPage.groovy:39)
	at com.example.pages.LoginPage.login_closure1(LoginPage.groovy)
	at geb.waiting.Wait.waitFor(Wait.groovy:117)
	... 5 more

Rerun Controls

Note

Checks are currently running using the configuration below.

Select tests to mute in this pull request:

🔲 UserControllerSpec > User list

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants