Skip to content

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

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

Modernize MIME negotiation and serialization for Spring Boot 4.1#16237
codeconsole wants to merge 77 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

  • bridge Grails MIME types to Spring MediaType and integrate negotiation with Spring MVC
  • remove static MIME negotiation state and resolve strategy bean ambiguity
  • use Spring MVC message converters and the Boot JsonMapper for ordinary JSON responses, with legacy compatibility fallbacks
  • replace named legacy JSON configuration with an injectable, request-safe Jackson registry shared by render, respond, and direct serialization
  • extract XML HTTP payload support into the optional grails-xml module without changing XML plugin descriptors
  • select XML converters from Spring MVC, keep new REST artefacts JSON-only by default, and preserve secure legacy XML compatibility
  • render REST validation errors as RFC 9457 application/problem+json; keep Vnd.Error opt-in and deprecate legacy XML error formats
  • add an optional grails-spring-hateoas adapter module, deprecate HAL XML, and keep Atom/feed rendering opt-in
  • statically compile changed production Groovy paths; Java additions use the Java 21 baseline

Lines of code

Area Added Removed Net Deprecated Net after deprecated removal
Production 1,790 220 +1,570 574 +996
Tests 1,715 194 +1,521 0 +1,521
Documentation 612 37 +575 0 +575
Total 4,117 451 +3,666 574 +3,092

Added and removed counts come from git diff --numstat upstream/8.0.x...HEAD; pure renames count as zero. Test sources are counted as tests. grails-doc and Markdown files are counted as documentation. Build/module wiring is counted as production.

Tests and documentation account for +2,096 LOC. Production is +1,570 net before future deprecated-code removal and +996 afterward. The remaining production growth implements Spring MVC negotiation/converter bridges, named Jackson configurations, Jackson domain compatibility, Problem Details, the optional XML boundary, and the optional Spring HATEOAS adapter.

The deprecated count is current source covered by @Deprecated(since = "8.0", forRemoval = false): the legacy JSON configuration/marshaller API blocks plus Vnd.Error XML, legacy XML validation marshalling, and HAL XML renderer classes. These remain supported on 8.0.x for a deprecation cycle.

Compatibility and migration

  • Plugin descriptors remain XML and are unaffected; grails-xml controls application HTTP XML payload support only.
  • NamedJsonConfigurationRegistry replaces JSON.createNamedConfig(...) and JSON.use(...) for named registration and direct serialization.
  • render value, jsonConfiguration: 'deep' and respond value, jsonConfiguration: 'deep' select the same registered Jackson configuration.
  • Legacy named JSON configurations remain supported as a fallback while their APIs are deprecated.
  • Per-response include/exclude projections retain the legacy converter path.
  • Applications can explicitly register Vnd.Error JSON, XML compatibility renderers, HAL renderers, and Atom renderers.
  • New REST artefacts advertise JSON only. XML endpoints add grails-xml and opt the artefact into XML.

Verification

Scoped affected-module tests, checks, dependency validation, profile builds, and guide builds passed with --max-workers=2. The final named-Jackson change ran all tests and checks for grails-web-common, grails-converters, grails-controllers, and grails-rest-transforms, plus the Grails guide build.

A full repository sweep was intentionally not run on the development machine because of its resource constraints; broader validation is left to CI.

@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.56000% with 109 lines in your changes missing coverage. Please review.
✅ Project coverage is 55.1599%. Comparing base (d71c218) to head (1fb9890).
⚠️ Report is 95 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...converters/jackson/GrailsDomainJsonSerializer.java 78.2609% 4 Missing and 11 partials ⚠️
...ns/web/rest/render/json/DefaultJsonRenderer.groovy 69.7674% 2 Missing and 11 partials ⚠️
...s/web/mime/GrailsContentNegotiationStrategy.groovy 71.7949% 3 Missing and 8 partials ⚠️
...gins/web/rest/render/xml/DefaultXmlRenderer.groovy 66.6667% 1 Missing and 7 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 ⚠️
...converters/jackson/GrailsJsonMapperCustomizer.java 81.4815% 3 Missing and 2 partials ⚠️
...in/groovy/org/grails/io/support/SpringIOUtils.java 0.0000% 5 Missing ⚠️
...grails/converters/json/NamedJsonConfiguration.java 82.6087% 2 Missing and 2 partials ⚠️
... and 11 more
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16237        +/-   ##
==================================================
+ Coverage     54.7020%   55.1599%   +0.4579%     
- Complexity      20424      20729       +305     
==================================================
  Files            2101       2119        +18     
  Lines          100978     101474       +496     
  Branches        17907      17988        +81     
==================================================
+ Hits            55237      55973       +736     
+ Misses          37877      37530       -347     
- Partials         7864       7971       +107     
Files with missing lines Coverage Δ
...erters/src/main/groovy/grails/converters/JSON.java 32.8520% <ø> (+8.3033%) ⬆️
...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 72.8814% <100.0000%> (+2.1917%) ⬆️
... and 51 more

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

Unit tests include the 'core' and 'eventBus' plugins plus, transitively,
whatever those depend on -- which is how the converters plugin arrives.
Nothing depends on XmlGrailsPlugin, so once XML conversion, binding and
rendering moved there, no unit test loaded any of it. Twenty-two specs
in grails-test-suite-web failed as a result, masked until now by the
namedJsonConfigurationRegistry bean failing spec initialization.

Add 'xml' to the default set. Plugin discovery simply does not find it
when the optional module is absent, so this stays conditional. Also
update the malformed-body assertion that named the old JSON parser.
XmlRendererRegistrar registers renderers into the RendererRegistry it
was injected with, but that is not always the instance controllers
resolve: respond found no XML renderer even though the registrar had
run, and resolving the bean by name at afterPropertiesSet time did not
help either, so the instance changes after the registrar completes.

DefaultRendererRegistry autowires every Renderer bean, so contributing
the renderers that way puts them into whichever instance Spring builds,
whenever it builds it. Add an Errors container renderer alongside, since
container renderers are keyed separately and the JSON equivalents are
created inside the registry's own initialize.
The RFC 9457 problem and the Errors serializer both reported
error.getDefaultMessage(). For a Grails constraint that is a template,
not a message: a nullable violation rendered as

    "message": "Property [{0}] of class [{1}] cannot be null"

with the placeholders unsubstituted, in every locale. The marshaller
these paths took over from resolves through the MessageSource, which
since the i18n move onto Spring Boot also honours spring.messages.*.

Resolve each error the same way, falling back to the default message
when no MessageSource is available so the no-argument factory
constructor still works outside an application context.
GrailsJsonMapperCustomizer enumerated the mapping context while the
JsonMapper was being built. Anything injecting Boot's JsonMapper pulls
Jackson auto-configuration into the bean graph ahead of GORM -- since
this change that includes JsonDataBindingSourceCreator, which
MimeTypesConfiguration depends on -- so the enumeration ran too early
and threw:

    MappingContext.getPersistentEntities() cannot be accessed before
    GORM has initialized

That failed the whole application context, not just JSON: the CI
functional suites failed at startup on it, 441 occurrences in one job.

Contribute a Serializers that resolves a type's entity the first time
it is written instead, and walk superclasses so proxies still use their
domain class's metadata. Domain classes registered after the mapper was
built are now picked up too.
@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.

The previous change added the renderers as beans but left
XmlRendererRegistrar in place, so both ran: the registrar's renderers
received the Spring converter supplier and the encoding, the beans
received neither, and whichever registration happened last decided what
respond used.

Delete the registrar and give the beans their dependencies, so the
renderers the registry autowires are the configured ones. Adds a test
that no other bean registers a renderer behind the registry's back.
extendMessageConverters copied the list it was handed. Spring invokes
each WebMvcConfigurer in turn on the same list and installs that
instance on the handler adapter, so a configurer ordered after this one
can still add, remove or reorder converters. The copy froze a list that
was not yet final, which could leave Grails rendering with a different
set from Spring MVC.

Hold the list itself, so reads at response time see everything every
configurer contributed.
The registry bean captured a JsonMapper at creation and fell back to a
plain one whenever Boot's was not yet available. That fallback was
permanent and silently dropped spring.jackson.* settings, the
application's JsonMapperBuilderCustomizer beans, the Grails domain
serializers and the errors serializer -- contradicting the documented
promise that named configurations derive from Boot's configured mapper.

Resolve the mapper when a writer is first needed. If none exists at that
point, say so rather than serializing through a differently configured
one.
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.
@testlens-app

testlens-app Bot commented Sep 1, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 1fb9890
▶️ Tests: 29000 executed
⚪️ Checks: 91/91 completed


Learn more about TestLens at testlens.app/docs.

@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

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants