Modernize MIME negotiation and serialization for Spring Boot 4.1 - #16237
Modernize MIME negotiation and serialization for Spring Boot 4.1#16237codeconsole wants to merge 77 commits into
Conversation
Deprecation migration examplesThis comment maps every API deprecated by this PR to its modern replacement. Overloads are grouped where their migration is identical.
1.
|
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.
Why
|
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.
✅ All tests passed ✅🏷️ Commit: 1fb9890 Learn more about TestLens at testlens.app/docs. |
|
This is an extremely large change that I think needs deferred to 8.1 or possibly 9 with feedback from multiple committers to merge. |
|
@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 |
Summary
MediaTypeand integrate negotiation with Spring MVCJsonMapperfor ordinary JSON responses, with legacy compatibility fallbacksrender,respond, and direct serializationgrails-xmlmodule without changing XML plugin descriptorsapplication/problem+json; keep Vnd.Error opt-in and deprecate legacy XML error formatsgrails-spring-hateoasadapter module, deprecate HAL XML, and keep Atom/feed rendering opt-inLines of code
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-docand 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
grails-xmlcontrols application HTTP XML payload support only.NamedJsonConfigurationRegistryreplacesJSON.createNamedConfig(...)andJSON.use(...)for named registration and direct serialization.render value, jsonConfiguration: 'deep'andrespond value, jsonConfiguration: 'deep'select the same registered Jackson configuration.grails-xmland 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 forgrails-web-common,grails-converters,grails-controllers, andgrails-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.