jdaugherty commented on code in PR #16237:
URL: https://github.com/apache/grails-core/pull/16237#discussion_r4246725503


##########
grails-web-databinding/src/main/groovy/org/grails/web/databinding/bindingsource/JsonDataBindingSourceCreator.groovy:
##########
@@ -44,12 +55,58 @@ import org.grails.web.json.JSONObject
  * @see org.grails.databinding.bindingsource.DataBindingSourceCreator
  */
 @CompileStatic
+@Slf4j
 class JsonDataBindingSourceCreator extends 
AbstractRequestBodyDataBindingSourceCreator {
 
     private static final Pattern INDEX_PATTERN = ~/^(\S+)\[(\d+)\]$/
 
+    // Resolved when a request body is first parsed rather than injected. 
Injecting it pulls
+    // Jackson's auto-configuration into this bean's graph, and 
MimeTypesConfiguration depends on
+    // this creator, so Boot's mapper would be built before GORM has 
initialized.
+    @Autowired(required = false)
+    ObjectProvider<JsonMapper> jsonMapperProvider
+
     @Autowired(required = false)
-    JsonSlurper jsonSlurper = new JsonSlurper()
+    ApplicationContext applicationContext
+
+    @Value('${grails.databinding.json.jackson:false}')
+    boolean useJackson = false
+
+    @PostConstruct

Review Comment:
   Two things on the default path. The notice is logged from `@PostConstruct`, 
and `DataBindingGrailsPlugin` declares three beans of this type 
(`JsonDataBindingSourceCreator`, `HalJsonDataBindingSourceCreator`, 
`JsonApiDataBindingSourceCreator`; neither subclass overrides `initialize`), so 
an unconfigured application logs the identical warning three times at startup. 
Please log it once.
   
   Second, 8.0.x had `@Autowired(required = false) JsonSlurper jsonSlurper = 
new JsonSlurper()` here, and line 166 now always constructs its own, so an 
application `JsonSlurper` bean (the only way to get `JsonParserType.LAX`, for 
instance) or a subclass setting the property is silently ignored, and section 5 
does not mention it. Please restore the injection point for the legacy path, or 
document the removal in section 5.



##########
grails-mimetypes/src/main/groovy/org/grails/web/mime/GrailsContentNegotiationStrategy.groovy:
##########
@@ -0,0 +1,127 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.web.mime
+
+import java.util.regex.Pattern
+
+import groovy.transform.CompileStatic
+
+import jakarta.servlet.http.HttpServletRequest
+
+import org.springframework.http.MediaType
+import org.springframework.web.accept.ContentNegotiationStrategy
+import org.springframework.web.context.request.NativeWebRequest
+
+import grails.config.Config
+import grails.config.Settings
+import grails.web.http.HttpHeaders
+import grails.web.mime.MimeType
+import org.grails.web.util.GrailsApplicationAttributes
+import org.grails.web.util.WebUtils
+
+/**
+ * Adapts Grails' configured format aliases and compatibility rules to Spring 
MVC content negotiation.
+ */
+@CompileStatic
+class GrailsContentNegotiationStrategy implements ContentNegotiationStrategy {
+
+    private final MimeType[] mimeTypes
+    private final boolean useAcceptHeader
+    private final boolean useAcceptHeaderXhr
+    private final Pattern disableForUserAgents
+
+    GrailsContentNegotiationStrategy(MimeType[] mimeTypes, Config config) {
+        this.mimeTypes = mimeTypes
+        this.useAcceptHeader = 
config.getProperty(Settings.MIME_USE_ACCEPT_HEADER, Boolean, true)
+        this.useAcceptHeaderXhr = !config.getProperty(
+                Settings.MIME_DISABLE_ACCEPT_HEADER_FOR_USER_AGENTS_XHR,
+                Boolean,
+                false
+        )
+        this.disableForUserAgents = createDisabledUserAgentPattern(config)
+    }
+
+    @Override
+    List<MediaType> resolveMediaTypes(NativeWebRequest webRequest) {
+        HttpServletRequest request = 
webRequest.getNativeRequest(HttpServletRequest)
+        if (request == null) {
+            return [MediaType.ALL]
+        }
+        return resolveMimeTypes(request).collect { MimeType mimeType ->
+            SpringMediaTypeAdapter.toMediaType(mimeType)
+        }
+    }
+
+    /**
+     * @return the MIME types this strategy resolves against, as configured by 
grails.mime.types
+     */
+    MimeType[] getConfiguredMimeTypes() {
+        return mimeTypes
+    }
+
+    MimeType[] resolveMimeTypes(HttpServletRequest request) {
+        String formatOverride
+        if (!WebUtils.isError(request)) {
+            formatOverride = request.getParameter('format')
+        }
+        if (!formatOverride) {
+            formatOverride = 
request.getAttribute(GrailsApplicationAttributes.RESPONSE_FORMAT) as String
+        }
+        if (formatOverride) {
+            MimeType mimeType = mimeTypes.find { MimeType candidate -> 
candidate.extension == formatOverride }
+            return [mimeType ?: mimeTypes[0]] as MimeType[]

Review Comment:
   This is the one remaining behaviour change that nothing gates. 
`response.mimeTypes` now goes through `resolveMimeTypes`, which applies the 
`format` parameter, where 8.0.x's `getMimeTypesInternal` parsed only the Accept 
header and applied the override in `response.format` and 
`getMimeTypesFormatAware`. `MimeTypesApiSupport.withFormatInternal` is 
unchanged: when the format is known but the closure has no block for it, it 
walks `response.mimeTypes` for a match. On 8.0.x that list came from the Accept 
header, so `?format=xml` with `Accept: text/html` and `withFormat { html { } 
json { } }` served the `html` block. On this head the list is `[text/xml]`, 
nothing matches and `withFormat` returns null; I ran that as a 
`ControllerUnitTest` on this head (`result=null format=xml 
mimeTypes=[text/xml]`; the same request with a `'*'` block hits that block). 
The action then falls through to the default view with no model.
   
   Section 8 documents this, but documenting a change is not a migration, and 
`withFormat` is in nearly every Grails application. Please keep the 8.0.x 
semantics in 9.x: leave `response.mimeTypes` on the Accept list, apply the 
override in `getFormat`/`getMimeTypesFormatAware` as before and in 
`resolveMediaTypes` for Spring, and drop the last two paragraphs of section 8. 
If the new fallback behaviour is wanted, it rides the same deprecate-in-9, 
change-in-10 schedule as everything else in this PR.



##########
grails-rest-transforms/src/main/groovy/org/grails/plugins/web/rest/render/json/DefaultJsonRenderer.groovy:
##########
@@ -108,20 +160,164 @@ class DefaultJsonRenderer<T> implements Renderer<T> {
      * @param context
      */
     protected void renderJson(T object, RenderContext context) {
+        String selectedConfiguration = 
context.arguments?.get('jsonConfiguration')?.toString()
+        if (selectedConfiguration) {
+            if (namedJsonRenderer == null || 
!namedJsonRenderer.contains(selectedConfiguration)) {
+                throw new IllegalArgumentException("Named JSON configuration 
[$selectedConfiguration] is not registered.")
+            }
+            namedJsonRenderer.render(selectedConfiguration, object, 
context.writer,
+                    context.includes, context.excludes)
+            return
+        }
+        if (object instanceof ProblemDetail || canUseSpringConverter(context)) 
{
+            Object springValue = object
+            if (object instanceof Errors) {
+                springValue = validationProblemDetailFactory.create((Errors) 
object, errorsHttpStatus)
+            }
+            MediaType mediaType = springValue instanceof ProblemDetail ?
+                    MediaType.parseMediaType(PROBLEM_JSON.name) :
+                    MediaType.parseMediaType(resolveMimeType(context).name)
+            // Set the content type before writing: once the writer flushes, 
the response is
+            // committed and a later content type change is silently discarded.
+            if (springValue instanceof ProblemDetail) {
+                ProblemDetail problem = (ProblemDetail) springValue
+                context.setStatus(HttpStatus.valueOf(problem.status))

Review Comment:
   `HttpStatus.valueOf(int)` throws `IllegalArgumentException` for any code 
without a constant, and `ProblemDetail.forStatus(int)` accepts any int, so 
`respond ProblemDetail.forStatus(499)` (or 299, 599) is a 500. This branch runs 
whatever `grails.web.rendering.json.spring` is set to (line 172), so an 
unconfigured application that adopts the new API reaches it. Spring uses 
`HttpStatusCode.valueOf` here. Please guard with `HttpStatus.resolve` and write 
the raw code when there is no constant (or widen `RenderContext.setStatus`), 
and add the case to `DefaultJsonRendererSpec`.
   
   While here: section 6 of the guide opens with "Grails 9 retains the legacy 
body by default" and never says that a `ProblemDetail` value is written through 
Spring's converters regardless of the setting, although the `respond 
validationProblemDetailFactory.create(book.errors)` example right below always 
produces `application/problem+json`. Please state that in section 6.



##########
grails-doc/src/en/guide/upgrading/upgrading90x.adoc:
##########
@@ -0,0 +1,333 @@
+////
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+////
+
+=== Upgrade Instructions for Grails and Related Dependencies
+
+This guide outlines the changes to review when you upgrade a Grails project 
from Grails 8 to Grails 9.
+
+==== 1. XML Web Support Moves to a Separate Module
+
+XML conversion, request-body binding, codecs, and REST renderers now live in 
the optional
+`grails-xml` module. The web starter still includes it in Grails 9, preserving 
existing XML endpoints.
+JSON-only applications can exclude `org.apache.grails:grails-xml` from their 
starter dependency now.
+In Grails 10 the starter will stop including it and `@Resource` will default 
to JSON only.
+Legacy XML conversion logs one deprecation warning on use and is scheduled for 
removal in Grails 11.

Review Comment:
   Two leftovers from the earlier docs thread. `hal.adoc:132` and 
`vndError.adoc:68` still say "removed in a future major release", and 
`HalXmlRenderer`, `HalXmlCollectionRenderer`, `VndErrorXmlRenderer` and the XML 
`ValidationErrorsMarshaller` carry `@Deprecated(since = '9.0')` with no removal 
release (`forRemoval = false` on the marshaller), while this line says Grails 
11 for all legacy XML conversion; please align them.
   
   These pages still present XML as built in without a `grails-xml` note: 
`REST/renderers/defaultRenderers.adoc:20` (the `grails.rest.render.xml` package 
now lives in grails-xml), 
`REST/restfulControllers/restControllersStepByStep.adoc:91`, 
`REST/domainResources.adoc:41` and `:71`, `REST/RSSAndAtom.adoc:20`, and 
`security/codecs.adoc:107` (`XMLCodec` is now a grails-xml artefact).



##########
grails-doc/src/en/guide/upgrading/upgrading90x.adoc:
##########
@@ -0,0 +1,333 @@
+////
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+////
+
+=== Upgrade Instructions for Grails and Related Dependencies
+
+This guide outlines the changes to review when you upgrade a Grails project 
from Grails 8 to Grails 9.
+
+==== 1. XML Web Support Moves to a Separate Module
+
+XML conversion, request-body binding, codecs, and REST renderers now live in 
the optional
+`grails-xml` module. The web starter still includes it in Grails 9, preserving 
existing XML endpoints.
+JSON-only applications can exclude `org.apache.grails:grails-xml` from their 
starter dependency now.
+In Grails 10 the starter will stop including it and `@Resource` will default 
to JSON only.
+Legacy XML conversion logs one deprecation warning on use and is scheduled for 
removal in Grails 11.
+MIME type negotiation still recognizes `application/xml`, `text/xml`, Atom, 
and other XML media
+types; recognizing a media type does not install an XML serializer.
+
+New REST controllers, RESTful controllers, resources, and scaffolded 
controllers generated by the
+`rest-api` profile advertise JSON only. After adding `grails-xml`, add `xml` 
to the artefact's
+`responseFormats` or `formats` declaration when that endpoint should negotiate 
XML.
+
+Applications that expose XML endpoints or use `grails.converters.XML`, XML 
object marshallers, XML data
+binding, or the XML and Atom renderers need this module. It is supplied 
transitively by the Grails 9
+web starter; applications without that starter, and applications upgrading to 
Grails 10, add it explicitly:
+
+[source,groovy]
+.build.gradle
+----
+dependencies {
+    implementation 'org.apache.grails:grails-xml'
+}
+----
+
+The module preserves the established Grails XML APIs and compatibility 
marshalling for domain-shaped
+payloads, collections, maps, validation errors, named converter 
configurations, and include/exclude
+projections.
+
+Set `grails.web.rendering.xml.spring: true` to write ordinary bean responses 
with Spring Framework's `JacksonXmlHttpMessageConverter`,
+backed by the Spring Boot-managed Jackson XML mapper. As in a plain Spring 
Boot application, that
+converter is registered only when Jackson's XML dataformat is on the 
classpath, so applications that
+want it add the dependency themselves:
+
+[source,groovy]
+.build.gradle
+----
+dependencies {
+    runtimeOnly 'tools.jackson.dataformat:jackson-dataformat-xml'
+}
+----
+
+Grails 9 defaults this setting to `false`: adding the Jackson XML dependency 
alone does not bypass
+registered XML marshallers. Enabling it explicitly selects Spring for ordinary 
beans, even when a
+legacy marshaller is registered. Domain objects, collections, maps, validation 
errors and projections
+continue to use the Grails XML converter; migrate these to DTOs or explicit 
application renderers
+before legacy XML conversion is removed in Grails 11.
+
+Without the Jackson XML dependency, `grails-xml` still renders XML through the 
Grails converter. Applications may also register
+their own Spring XML message converter or OXM/JAXB infrastructure through 
public Spring configuration
+APIs.
+
+This change does not affect Grails plugin descriptors. Plugin metadata 
continues to be generated and
+published in its existing descriptor format; `grails-xml` concerns application 
HTTP payloads only.
+
+==== 2. Opt Into Spring JSON Conversion
+
+Grails 9 preserves the legacy `grails.converters.JSON` converter for 
`respond()` by default and logs one
+deprecation warning when that path renders. Opt into Spring MVC's message 
converters, backed by Boot's
+configured Jackson `JsonMapper`, with `grails.web.rendering.json.spring: 
true`. Grails 10 will enable it
+by default; the legacy `respond()` path is scheduled for removal in Grails 11. 
Jackson can render date
+formats, bean properties, circular references, and validation errors 
differently, so review the JSON
+returned by `respond()` endpoints when you upgrade. Explicit `render value as 
JSON` calls still use the
+legacy converter.
+
+The setting alone determines the default response path: registering a 
marshaller does not implicitly
+change it. `false` retains the legacy converter (with the warning); `true` 
uses Spring's converters
+even while legacy marshallers are registered. Migrate those representations to 
Jackson serializers
+before enabling it. Legacy pretty-print settings do not configure Jackson; use
+`spring.jackson.serialization.indent-output` on the Spring path.
+
+[source,yaml]
+.application.yml
+----
+grails:
+    web:
+        rendering:
+            json:
+                spring: true
+----
+
+Grails selects the first configured MVC `HttpMessageConverter` that can write 
the
+response type and negotiated JSON media type and advertises a JSON media type 
for that class
+(`application/json`, `text/json`, or a `+json` subtype). Generic `*/*` string 
and byte-array converters
+are skipped, so strings remain JSON strings and byte arrays use Jackson's 
base64 JSON representation.
+The same media-family rule applies to XML conversion; a generic text converter 
cannot strip the XML
+string element.
+
+The standard Jackson converter uses an isolated mapper derived from Boot's 
configured `JsonMapper`
+for Grails domain compatibility. Persistent properties, identifiers, 
association identifiers, and
+per-response domain projections follow Grails metadata. On this domain path, 
Jackson property
+annotations (`@JsonIgnore`, `@JsonProperty`, `@JsonInclude`, `@JsonView`, 
`@JsonFormat`), property
+mixins, naming strategies, and transient getters do not define the 
representation. Register an
+explicit serializer or return a DTO when that property model is required. 
Ordinary non-domain beans
+retain Jackson's property rules. Application-provided converter subclasses and 
per-type mapper
+registrations keep their configured behavior and precedence.
+
+Association identifiers use their mapped identity property name (for example 
`uuid`), rather than
+the legacy marshaller's fixed `id` key. A value that leads back to a domain 
object still being
+written, such as an embedded value that points at its owner, is written as the 
legacy converter
+writes it: `{"_ref": "..", "class": "..."}` by default, or as
+`grails.converters.json.circular.reference.behaviour` selects. A cycle made 
only of non-domain beans
+follows Jackson's rules, as it does in a plain Spring MVC controller, and 
fails the response.
+
+Boot's shared mapper is not given the Grails domain serializer, so plain 
Spring `@RestController`
+responses retain Jackson's normal domain property model. Both mappers 
serialize Groovy `GString`
+values as strings. Only the Grails mapper serializes Spring `Errors` as an 
`errors` array containing
+object, field, message, and codes, without rejected values; Grails `respond 
errors` adapts that array into an
+RFC 9457 problem response.
+
+Grails retains the legacy converter path when a renderer sets a legacy named 
converter
+configuration, when a per-response `includes`/`excludes` projection is 
requested and the selected
+converter is not Spring's standard Jackson converter, or when no configured 
MVC converter can write
+the selected type and media type. Explicit application and plugin `Renderer` 
beans continue to take
+precedence over the default renderer.
+
+Jackson conversion uses UTF-8 for intermediate bytes; the response writer 
applies
+`grails.converters.encoding`, so non-UTF response encodings do not corrupt the 
intermediate JSON.
+
+Controller unit tests use Boot's Jackson configuration and the same setting. 
Supply a mapper or
+`JsonMapperBuilderCustomizer` from a nested Spring `@Configuration` class, so 
Boot can see it before
+evaluating its conditional beans. A mapper registered by the deprecated 
`doWithSpring` callback is too
+late to make Boot back off. Named JSON
+configurations work through `render` and `respond` in `ControllerUnitTest`. 
The optional `grails-xml`
+beans are contributed by web test traits when present, without loading the XML 
plugin's dependency
+graph. Web traits automatically include `restResponder`, even when a test 
overrides
+`getIncludePlugins()`, and provide JSON conversion beans and Boot's mapper 
configuration.
+They do not automatically load the controllers, URL mappings or i18n plugins; 
link generation retains
+the curated unit-test setup. Plain `GrailsUnitTest` and `ServiceUnitTest` do 
not load XML or the web
+plugin graph simply because that module is on the classpath.
+Web unit tests build Spring MVC's message converters from the test classpath 
and configuration; see
+<<unitTesting,Unit Testing>>.
+
+==== 3. JSON Converter Customization Moves to Jackson
+
+The `grails.converters.JSON.registerObjectMarshaller(...)` API remains 
supported for
+`render ... as JSON` and converter-based rendering. Registering a marshaller 
does not change the
+`respond()` rendering setting. Marshaller registrations apply to its legacy 
converter path, not to
+Spring's message converters. To customize the Spring path, configure Spring 
Boot's `JsonMapper` so
+the same serializer is used by opted-in `respond`, Spring MVC message 
conversion, and other
+Jackson-based integrations:
+
+[source,groovy]
+----
+import 
org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer
+import tools.jackson.databind.module.SimpleModule
+
+@Bean
+JsonMapperBuilderCustomizer bookJsonCustomizer() {
+    { builder ->
+        builder.addModule(new SimpleModule()
+                .addSerializer(Book, new BookValueSerializer()))
+    } as JsonMapperBuilderCustomizer
+}
+----
+
+`BookValueSerializer` is an application-provided Jackson 
`ValueSerializer<Book>`. Use a module when several
+serializers belong together, or register an individual serializer directly 
through the builder customizer.
+
+For different JSON shapes selected by name, replace 
`JSON.createNamedConfig(...)` with the injectable
+`NamedJsonConfigurationRegistry`. Each registration derives an isolated mapper 
from Spring Boot's configured
+`JsonMapper`, so it retains application-wide Jackson settings without changing 
the shared mapper:
+
+[source,groovy]
+----
+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())
+        }
+    }
+}
+----
+
+Select the configuration per response with `respond`, or with the `json` 
render argument:
+
+[source,groovy]
+----
+respond book, jsonConfiguration: 'deep'
+render json: book, jsonConfiguration: 'deep'
+// Without a name, use the default Jackson configuration:
+render json: [ok: true]
+----
+
+Both accept `includes` and `excludes`. As with the legacy converter, a 
projection applies to the

Review Comment:
   This is wider than the legacy converter, not the same: 
`JSON.setIncludes`/`setExcludes` key by `target.getClass()` and 
`getIncludes(Class)` is an exact lookup, so `respond list, includes: [...]` on 
8.0.x projected nothing on the elements. Applying the projection per element 
type is the better behaviour; please say "unlike the legacy converter, which 
projected only a single root object" rather than "as with".
   
   Separately, the PR description's "Legacy named configurations remain 
supported as a fallback" is not what `jsonConfiguration:` does: 
`DefaultJsonRenderer` (lines 125-133) and `ResponseRenderer.renderNamedJson` 
throw `IllegalArgumentException` for a name only `JSON.createNamedConfig` 
knows. The argument is new, so that is a fine contract; the description should 
just not promise the fallback.



##########
grails-converters/src/main/groovy/org/grails/web/converters/jackson/GrailsJsonMapperCustomizer.java:
##########
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.grails.web.converters.jackson;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+import groovy.lang.GString;
+
+import tools.jackson.databind.JacksonModule;
+import tools.jackson.databind.json.JsonMapper;
+import tools.jackson.databind.module.SimpleModule;
+import tools.jackson.databind.ser.std.ToStringSerializer;
+
+import 
org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer;
+import org.springframework.validation.Errors;
+
+import grails.core.GrailsApplication;
+import grails.core.support.proxy.DefaultProxyHandler;
+import grails.core.support.proxy.ProxyHandler;
+import org.grails.core.artefact.DomainClassArtefactHandler;
+import org.grails.datastore.mapping.model.MappingContext;
+
+/**
+ * Adds Grails-specific serializers to Spring Boot's configured JSON mapper.
+ *
+ * @since 9.0
+ */
+public final class GrailsJsonMapperCustomizer implements 
JsonMapperBuilderCustomizer {
+
+    /** Writer attribute holding the property names to include, as a List or a 
Map keyed by type. */
+    public static final String INCLUDES_ATTRIBUTE = 
GrailsJsonMapperCustomizer.class.getName() + ".includes";
+
+    /** Writer attribute holding the property names to exclude, as a List or a 
Map keyed by type. */
+    public static final String EXCLUDES_ATTRIBUTE = 
GrailsJsonMapperCustomizer.class.getName() + ".excludes";
+
+    private final ConcurrentMap<JsonMapper, JsonMapper> grailsMappers = new 
ConcurrentHashMap<>();
+    private final GrailsApplication grailsApplication;
+    private final ProxyHandler proxyHandler;
+
+    public GrailsJsonMapperCustomizer() {
+        this(null, new DefaultProxyHandler());
+    }
+
+    public GrailsJsonMapperCustomizer(GrailsApplication grailsApplication) {
+        this(grailsApplication, new DefaultProxyHandler());
+    }
+
+    public GrailsJsonMapperCustomizer(GrailsApplication grailsApplication, 
ProxyHandler proxyHandler) {
+        this.grailsApplication = grailsApplication;
+        this.proxyHandler = proxyHandler;
+    }
+
+    private boolean domainArtefact(Class<?> type) {
+        // Decided from the class itself rather than the artefact registry: 
registry lookups need
+        // the Domain handler to have been registered and raise when it has 
not, whereas this holds
+        // as soon as the class is loaded -- which is the point, since GORM is 
not up yet. The flag
+        // lets a proxy be recognised through its domain superclass.
+        return DomainClassArtefactHandler.isDomainClass(type, true);
+    }
+
+    private MappingContext mappingContext() {
+        return this.grailsApplication == null ? null : 
this.grailsApplication.getMappingContext();
+    }
+
+    private boolean booleanProperty(String key, String fallbackKey) {
+        if (this.grailsApplication == null) {
+            return false;
+        }
+        boolean fallback = 
this.grailsApplication.getConfig().getProperty(fallbackKey, Boolean.class, 
false);
+        return this.grailsApplication.getConfig().getProperty(key, 
Boolean.class, fallback);
+    }
+
+    @Override
+    public void customize(JsonMapper.Builder builder) {
+        SimpleModule module = new SimpleModule("grails-json");
+        module.addSerializer(GString.class, ToStringSerializer.instance);
+        // Resolve messages at write time, after the application context is 
ready.
+        module.addSerializer(Errors.class, new SpringErrorsJsonSerializer(

Review Comment:
   Verified: `customize()` adds only the `GString` serializer to Boot's 
builder; `Errors`, the domain serializers and the projection module go on the 
`forGrails` mapper only, and `GrailsJsonMapperCustomizerSpec` checks both 
mappers.



##########
grails-mimetypes/src/main/groovy/org/grails/web/mime/GrailsMimeTypesWebMvcConfigurer.groovy:
##########
@@ -0,0 +1,74 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.web.mime
+
+import groovy.transform.CompileStatic
+
+import grails.web.mime.MimeType
+
+import org.springframework.http.MediaType
+import 
org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
+
+/**
+ * Installs Grails format negotiation as the Spring MVC content negotiation 
strategy.
+ */
+@CompileStatic
+class GrailsMimeTypesWebMvcConfigurer implements WebMvcConfigurer {
+
+    private final GrailsContentNegotiationStrategy contentNegotiationStrategy
+
+    GrailsMimeTypesWebMvcConfigurer(GrailsContentNegotiationStrategy 
contentNegotiationStrategy) {
+        this.contentNegotiationStrategy = contentNegotiationStrategy
+    }
+
+    /**
+     * Exposes the configured strategy to Grails' own format resolution. The 
strategy is deliberately
+     * not a bean of its own: Spring Security adopts any {@link 
org.springframework.web.accept.ContentNegotiationStrategy}
+     * bean it finds, so it is reached through this configurer instead.
+     */
+    GrailsContentNegotiationStrategy getContentNegotiationStrategy() {
+        return contentNegotiationStrategy
+    }
+
+    @Override
+    void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
+        // Contribute the configured format aliases and nothing else. 
Replacing Spring's strategy
+        // list would hand Grails' parser authority over every Spring MVC 
endpoint: it drops media
+        // types absent from grails.mime.types and falls back to the defaults, 
which include */*, so
+        // a request for an unknown type would be answered instead of rejected 
with 406. It would
+        // also disable spring.mvc.contentnegotiation.* and apply the 'format' 
request parameter to
+        // endpoints that never asked for it. Grails' own format resolution 
does not go through the
+        // Spring manager; it uses this configurer's strategy directly.
+        Map<String, MediaType> aliases = [:]
+        for (MimeType mimeType in 
contentNegotiationStrategy.configuredMimeTypes) {
+            String extension = mimeType.extension
+            if (!extension || extension == MimeType.ALL.extension) {
+                continue
+            }
+            MediaType mediaType = SpringMediaTypeAdapter.toMediaType(mimeType)
+            if (mediaType != null && !mediaType.isWildcardType() && 
!mediaType.isWildcardSubtype()) {
+                aliases.putIfAbsent(extension, new MediaType(mediaType.type, 
mediaType.subtype))

Review Comment:
   Verified: `@Order(HIGHEST_PRECEDENCE)` on the configurer bean, an 
`application/*` type replaces a non-application first entry (`xml` is 
`application/xml`, `json` is `application/json`), and the `@EnableWebMvc` case 
shows an `@Order(0)` configurer winning.



##########
grails-rest-transforms/src/main/groovy/org/grails/plugins/web/rest/render/SpringMessageConverters.groovy:
##########
@@ -0,0 +1,60 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.plugins.web.rest.render
+
+import groovy.transform.CompileStatic
+
+import org.springframework.http.converter.HttpMessageConverter
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
+
+/**
+ * Captures the message converters Spring MVC ends up configured with.
+ *
+ * <p>Renderers need the same converter list, in the same order, that the 
handler adapter uses.
+ * Injecting the adapter to read them forces the whole MVC infrastructure to 
be created from a
+ * renderer bean, which risks circular dependencies and defeats lazy startup. 
Spring calls
+ * {@link #extendMessageConverters} once with the final list instead, after 
every
+ * {@code WebMvcConfigurer} has contributed, so the ordering applications 
configure is preserved.</p>
+ *
+ * @since 9.0
+ */
+@CompileStatic
+class SpringMessageConverters implements WebMvcConfigurer {
+
+    private volatile List<HttpMessageConverter<?>> converters = List.of()
+
+    @Override
+    void extendMessageConverters(List<HttpMessageConverter<?>> converters) {

Review Comment:
   Verified: no `WebMvcConfigurer`, a 
`@Qualifier('requestMappingHandlerAdapter') ObjectProvider` read at write time, 
`missingConvertersReported` logs once, and both specs cover the final adapter 
list and the fallback.



##########
grails-converters/src/main/groovy/grails/converters/json/NamedJsonConfigurationRegistry.java:
##########
@@ -0,0 +1,131 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package grails.converters.json;
+
+import java.io.IOException;
+import java.io.Writer;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+
+import tools.jackson.databind.ObjectWriter;
+import tools.jackson.databind.json.JsonMapper;
+
+import org.grails.web.converters.jackson.GrailsJsonMapperCustomizer;
+
+/**
+ * Registry for request-safe named Jackson response configurations.
+ *
+ * @since 9.0
+ */
+public final class NamedJsonConfigurationRegistry {
+
+    private final Supplier<JsonMapper> jsonMapper;
+    private volatile JsonMapper resolvedMapper;
+    private final ConcurrentMap<String, NamedJsonConfiguration> configurations 
= new ConcurrentHashMap<>();
+
+    public NamedJsonConfigurationRegistry(JsonMapper jsonMapper) {
+        this(() -> Objects.requireNonNull(jsonMapper, "jsonMapper"));
+    }
+
+    /**
+     * @param jsonMapper supplies the mapper each configuration derives from, 
resolved when a writer
+     * is first needed. Deferring it means the registry can be created before 
Jackson
+     * auto-configuration has produced Spring Boot's mapper, and still derive 
from that mapper
+     * rather than from a separately configured one. The first successful 
resolution is cached;
+     * a missing mapper is retried on the next write.
+     */
+    public NamedJsonConfigurationRegistry(Supplier<JsonMapper> jsonMapper) {
+        this.jsonMapper = Objects.requireNonNull(jsonMapper, "jsonMapper");
+    }
+
+    public void register(String name, Consumer<NamedJsonConfiguration> 
customizer) {
+        if (name == null || name.isBlank()) {
+            throw new IllegalArgumentException("Named JSON configuration name 
must not be blank.");
+        }
+        Objects.requireNonNull(customizer, "customizer");
+        NamedJsonConfiguration configuration = new 
NamedJsonConfiguration(name);
+        customizer.accept(configuration);
+        configurations.put(name, configuration);
+    }
+
+    public boolean contains(String name) {
+        return configurations.containsKey(name);
+    }
+
+    /** @param name the registered name, or null to use the default Grails 
writer */
+    public ObjectWriter writer(String name) {
+        NamedJsonConfiguration configuration = name == null ? null : 
configurations.get(name);
+        if (name != null && configuration == null) {
+            throw new IllegalArgumentException("Named JSON configuration [" + 
name + "] is not registered.");
+        }
+        JsonMapper mapper = resolveMapper();
+        if (mapper == null) {
+            throw new IllegalStateException("Named JSON configuration [" + 
name +
+                    "] cannot be used: no JsonMapper is available. Spring 
Boot's Jackson " +
+                    "auto-configuration normally provides one.");
+        }
+        return configuration == null ? mapper.writer() : 
configuration.writer(mapper);
+    }
+
+    private JsonMapper resolveMapper() {
+        JsonMapper mapper = resolvedMapper;
+        if (mapper == null) {
+            mapper = jsonMapper.get();
+            if (mapper != null) {
+                resolvedMapper = mapper;
+            }
+        }
+        return mapper;
+    }
+
+    public String writeValueAsString(String name, Object value) {
+        return writer(name).writeValueAsString(value);
+    }
+
+    public void writeValue(String name, Writer output, Object value) throws 
IOException {
+        writer(name).writeValue(output, value);
+    }
+
+    /**
+     * Writes with a per-response include/exclude projection applied on top of 
the named
+     * configuration, so that selecting a configuration does not discard the 
projection.
+     *
+     * @param name the registered configuration
+     * @param output the response writer
+     * @param value the value to write
+     * @param includes property names to include, or null for all
+     * @param excludes property names to exclude, or null for none
+     * @throws IOException if writing fails
+     */
+    public void writeValue(String name, Writer output, Object value,
+            List<String> includes, List<String> excludes) throws IOException {
+        ObjectWriter writer = writer(name);
+        if (includes != null && !includes.isEmpty()) {
+            writer = 
writer.withAttribute(GrailsJsonMapperCustomizer.INCLUDES_ATTRIBUTE, includes);

Review Comment:
   Verified: `ProjectedPropertyWriter` wraps every bean property writer on the 
Grails mapper, matches bean and JSON names, maps and scalars throw before 
anything is written, `respond x, includes:` with the setting on takes the same 
path, and the specs cover it. The `reportUnapplied` warning has no test, and 
section 3's "as with the legacy converter" overstates the parity; see the 
thread on that line.



##########
grails-xml/src/main/groovy/org/grails/plugins/web/rest/render/xml/DefaultXmlRenderer.groovy:
##########
@@ -108,18 +137,67 @@ class DefaultXmlRenderer<T> implements Renderer<T> {
      * @param context
      */
     protected void renderXml(Object object, RenderContext context) {
+        HttpMessageConverter<Object> springConverter = 
findSpringConverter(object, context)
+        if (springConverter != null) {
+            renderWithSpringConverter(springConverter, object, context)
+            return
+        }
+
         XML converter
 
         if (namedConfiguration) {
             XML.use(namedConfiguration) {
-                converter = object as XML
+                converter = new XML(object)
             }
         } else {
-            converter = object as XML
+            converter = new XML(object)
         }
         renderXml(converter, context)
     }
 
+    private HttpMessageConverter<Object> findSpringConverter(Object object, 
RenderContext context) {
+        if (!resolveSpringHttpMessageConverters() || namedConfiguration || 
context.includes || context.excludes) {
+            return null
+        }
+        if (object == null || object instanceof Errors || object instanceof 
Map || object instanceof Collection ||
+                object.getClass().isArray()) {
+            return null
+        }
+        MediaType mediaType = MediaType.parseMediaType((context.acceptMimeType 
?: MimeType.XML).name)
+        return (HttpMessageConverter<Object>) 
resolveSpringHttpMessageConverters().find { HttpMessageConverter<?> converter ->
+            converter.canWrite(object.getClass(), mediaType) &&
+                    converter.getSupportedMediaTypes(object.getClass()).any { 
MediaType supported ->
+                        supported.subtype == 'xml' || 
supported.subtype.endsWith('+xml')
+                    }
+        }
+    }
+
+    private void renderWithSpringConverter(
+            HttpMessageConverter<Object> converter, Object object, 
RenderContext context) {
+        // Write in the configured encoding rather than the converter's 
default so the bytes it
+        // produces and the characters decoded back out agree, and stream them 
through instead of
+        // holding the whole response in memory.
+        Charset charset = Charset.forName(encoding)

Review Comment:
   Verified: UTF-8 intermediate bytes for a Jackson converter, mirroring the 
JSON renderer, with the `café` ISO-8859-1 spec.



##########
grails-xml/src/main/groovy/org/grails/plugins/web/rest/render/xml/DefaultXmlRenderer.groovy:
##########
@@ -108,18 +137,67 @@ class DefaultXmlRenderer<T> implements Renderer<T> {
      * @param context
      */
     protected void renderXml(Object object, RenderContext context) {
+        HttpMessageConverter<Object> springConverter = 
findSpringConverter(object, context)
+        if (springConverter != null) {
+            renderWithSpringConverter(springConverter, object, context)
+            return
+        }
+
         XML converter
 
         if (namedConfiguration) {
             XML.use(namedConfiguration) {
-                converter = object as XML
+                converter = new XML(object)
             }
         } else {
-            converter = object as XML
+            converter = new XML(object)
         }
         renderXml(converter, context)
     }
 
+    private HttpMessageConverter<Object> findSpringConverter(Object object, 
RenderContext context) {
+        if (!resolveSpringHttpMessageConverters() || namedConfiguration || 
context.includes || context.excludes) {

Review Comment:
   Verified: `grails.web.rendering.xml.spring` defaults to false, 
`findSpringConverter` returns null for domain classes, collections, maps, 
errors and projections, and `SpringXmlRendererSpec` covers the marshaller and 
domain cases.



##########
grails-testing-support-web/src/main/groovy/org/grails/testing/spock/WebSetupSpecInterceptor.groovy:
##########
@@ -73,7 +76,14 @@ class WebSetupSpecInterceptor implements IMethodInterceptor {
         GrailsApplication grailsApplication = test.grailsApplication
         Map<String, String> groovyPages = test.views
 
-        test.defineBeans(new ConvertersGrailsPlugin())
+        SpringMessageConverters converters = 
test.applicationContext.getBean(SpringMessageConverters)
+        JsonMapper mapper = 
test.applicationContext.getBeanProvider(JsonMapper).getIfUnique() ?:
+                test.applicationContext.getBean('jacksonJsonMapper', 
JsonMapper)
+        converters.extendMessageConverters([

Review Comment:
   Verified: the web traits build Spring's server defaults, Boot's customizers 
and the test's `WebMvcConfigurer`s, `ControllerUnitTestMessageConvertersSpec` 
writes XML through an application converter, and the non-web traits are 
unchanged (`DEFAULT_INCLUDED_PLUGINS` is still `['core', 'eventBus']`).



##########
grails-rest-transforms/src/main/groovy/org/grails/plugins/web/rest/render/DefaultRendererRegistry.groovy:
##########
@@ -77,13 +80,32 @@ class DefaultRendererRegistry extends 
ClassAndMimeTypeRegistry<Renderer, Rendere
     @Value('${grails.converters.encoding:UTF-8}')
     String encoding = grails.util.GrailsWebUtil.DEFAULT_ENCODING
 
+    @Autowired(required = false)
+    SpringMessageConverters springMessageConverters
+
+    @Autowired(required = false)
+    GrailsJsonMapperCustomizer grailsJsonMapperCustomizer
+
+    @Autowired(required = false)
+    NamedJsonRenderer namedJsonRenderer
+
+    @Autowired(required = false)
+    ValidationProblemDetailFactory validationProblemDetailFactory
+
+    /**
+     * Whether JSON responses are written by Spring's message converters. When 
unset, they are unless
+     * the application customized the legacy {@code grails.converters.JSON} 
converter.
+     */
+    @Value('${grails.web.rendering.json.spring:#{null}}')
+    Boolean useSpringJson

Review Comment:
   Verified: metadata entries in the three owning modules, the plugin applied 
in each, and `generateConfigReference` reading those jars.



##########
grails-doc/src/en/guide/upgrading/upgrading90x.adoc:
##########
@@ -0,0 +1,289 @@
+////
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+////
+
+=== Upgrade Instructions for Grails and Related Dependencies
+
+This guide outlines the changes to review when you upgrade a Grails project 
from Grails 8 to Grails 9.
+
+==== 1. XML Web Support Is Optional
+
+XML conversion, request-body binding, codecs, and REST renderers now live in 
the optional
+`grails-xml` module. Newly generated applications and the web starter no 
longer include that module by
+default. MIME type negotiation still recognizes `application/xml`, `text/xml`, 
Atom, and other XML media
+types; recognizing a media type does not install an XML serializer.
+
+New REST controllers, RESTful controllers, resources, and scaffolded 
controllers generated by the
+`rest-api` profile advertise JSON only. After adding `grails-xml`, add `xml` 
to the artefact's
+`responseFormats` or `formats` declaration when that endpoint should negotiate 
XML.
+
+Applications that expose XML endpoints or use `grails.converters.XML`, XML 
object marshallers, XML data
+binding, or the XML and Atom renderers must add the module explicitly:
+
+[source,groovy]
+.build.gradle
+----
+dependencies {
+    implementation 'org.apache.grails:grails-xml'
+}
+----
+
+The module preserves the established Grails XML APIs and compatibility 
marshalling for domain-shaped
+payloads, collections, maps, validation errors, named converter 
configurations, and include/exclude
+projections.
+
+Ordinary bean responses can also be written by Spring Framework's 
`JacksonXmlHttpMessageConverter`,
+backed by the Spring Boot-managed Jackson XML mapper. As in a plain Spring 
Boot application, that
+converter is registered only when Jackson's XML dataformat is on the 
classpath, so applications that
+want it add the dependency themselves:
+
+[source,groovy]
+.build.gradle
+----
+dependencies {
+    runtimeOnly 'tools.jackson.dataformat:jackson-dataformat-xml'
+}
+----
+
+Without it, `grails-xml` still renders XML through the Grails converter. 
Applications may also register
+their own Spring XML message converter or OXM/JAXB infrastructure through 
public Spring configuration
+APIs.
+
+This change does not affect Grails plugin descriptors. Plugin metadata 
continues to be generated and
+published in its existing descriptor format; `grails-xml` concerns application 
HTTP payloads only.
+
+==== 2. `respond()` Uses Spring JSON Conversion
+
+`respond()` now writes JSON through Spring MVC's message converters, backed by 
Spring Boot's configured
+Jackson `JsonMapper`, instead of the legacy `grails.converters.JSON` 
converter. Jackson can render date
+formats, bean properties, circular references, and validation errors 
differently, so review the JSON
+returned by `respond()` endpoints when you upgrade. Explicit `render value as 
JSON` calls still use the
+legacy converter.
+
+An application that customizes the legacy converter keeps it for `respond()`, 
so that its output does not
+change silently. That is the case once the application, or one of its plugins, 
registers a JSON object
+marshaller with `JSON.registerObjectMarshaller(...)` or 
`JSON.withDefaultConfiguration(...)`, declares an
+`ObjectMarshallerRegisterer` or `JsonRenderer` bean, or sets 
`grails.converters.json.default.deep` or
+`grails.converters.json.date: javascript`. The first such response logs a 
warning. Marshaller registration
+is deprecated for removal (see the next section), so plan to replace those 
marshallers with Jackson
+serializers.
+
+Set `grails.web.rendering.json.spring` to decide explicitly. `false` keeps the 
legacy converter for
+`respond()` without the warning; `true` uses Spring's converters even while 
legacy marshallers are
+registered, for example once Jackson serializers replace them for `respond()` 
but `render ... as JSON`
+calls still rely on them:
+
+[source,yaml]
+.application.yml
+----
+grails:
+    web:
+        rendering:
+            json:
+                spring: false
+----
+
+Grails selects the first configured MVC `HttpMessageConverter` that can write 
the
+response type and negotiated JSON media type and advertises a JSON media type 
for that class
+(`application/json`, `text/json`, or a `+json` subtype). Generic `*/*` string 
and byte-array converters
+are skipped, so strings remain JSON strings and byte arrays use Jackson's 
base64 JSON representation.
+The same media-family rule applies to XML conversion; a generic text converter 
cannot strip the XML
+string element.
+
+The standard Jackson converter uses an isolated mapper derived from Boot's 
configured `JsonMapper`
+for Grails domain compatibility. Persistent properties, identifiers, 
association identifiers, and
+per-response domain projections follow Grails metadata. On this domain path, 
Jackson property
+annotations (`@JsonIgnore`, `@JsonProperty`, `@JsonInclude`, `@JsonView`, 
`@JsonFormat`), property
+mixins, naming strategies, and transient getters do not define the 
representation. Register an
+explicit serializer or return a DTO when that property model is required. 
Ordinary non-domain beans
+retain Jackson's property rules. Application-provided converter subclasses and 
per-type mapper
+registrations keep their configured behavior and precedence.
+
+Boot's shared mapper is not given the Grails domain serializer, so plain 
Spring `@RestController`
+responses retain Jackson's normal domain property model. Both mappers 
serialize Groovy `GString`
+values as strings. Both also serialize Spring `Errors` as an `errors` array 
containing object, field,
+message, and codes, without rejected values; only Grails `respond errors` 
adapts that array into an
+RFC 9457 problem response.
+
+Grails retains the legacy converter path when a named converter configuration 
or per-response
+`includes`/`excludes` projection is requested, or when no configured MVC 
converter can write the
+selected type and media type. Explicit application and plugin `Renderer` beans 
continue to take
+precedence over the default renderer.
+
+Jackson conversion uses UTF-8 for intermediate bytes; the response writer 
applies
+`grails.converters.encoding`, so non-UTF response encodings do not corrupt the 
intermediate JSON.
+
+Controller unit tests use Boot's Jackson configuration and the same setting. A 
test's `doWithSpring`

Review Comment:
   Verified: the guide points at a nested `@Configuration`, and 
`ControllerJsonMapperOverrideSpec` shows Boot backing off.



##########
grails-doc/src/en/guide/upgrading/upgrading90x.adoc:
##########
@@ -0,0 +1,289 @@
+////
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+////
+
+=== Upgrade Instructions for Grails and Related Dependencies
+
+This guide outlines the changes to review when you upgrade a Grails project 
from Grails 8 to Grails 9.
+
+==== 1. XML Web Support Is Optional
+
+XML conversion, request-body binding, codecs, and REST renderers now live in 
the optional
+`grails-xml` module. Newly generated applications and the web starter no 
longer include that module by
+default. MIME type negotiation still recognizes `application/xml`, `text/xml`, 
Atom, and other XML media
+types; recognizing a media type does not install an XML serializer.
+
+New REST controllers, RESTful controllers, resources, and scaffolded 
controllers generated by the
+`rest-api` profile advertise JSON only. After adding `grails-xml`, add `xml` 
to the artefact's
+`responseFormats` or `formats` declaration when that endpoint should negotiate 
XML.
+
+Applications that expose XML endpoints or use `grails.converters.XML`, XML 
object marshallers, XML data
+binding, or the XML and Atom renderers must add the module explicitly:
+
+[source,groovy]
+.build.gradle
+----
+dependencies {
+    implementation 'org.apache.grails:grails-xml'
+}
+----
+
+The module preserves the established Grails XML APIs and compatibility 
marshalling for domain-shaped
+payloads, collections, maps, validation errors, named converter 
configurations, and include/exclude
+projections.
+
+Ordinary bean responses can also be written by Spring Framework's 
`JacksonXmlHttpMessageConverter`,
+backed by the Spring Boot-managed Jackson XML mapper. As in a plain Spring 
Boot application, that
+converter is registered only when Jackson's XML dataformat is on the 
classpath, so applications that
+want it add the dependency themselves:
+
+[source,groovy]
+.build.gradle
+----
+dependencies {
+    runtimeOnly 'tools.jackson.dataformat:jackson-dataformat-xml'
+}
+----
+
+Without it, `grails-xml` still renders XML through the Grails converter. 
Applications may also register
+their own Spring XML message converter or OXM/JAXB infrastructure through 
public Spring configuration
+APIs.
+
+This change does not affect Grails plugin descriptors. Plugin metadata 
continues to be generated and
+published in its existing descriptor format; `grails-xml` concerns application 
HTTP payloads only.
+
+==== 2. `respond()` Uses Spring JSON Conversion
+
+`respond()` now writes JSON through Spring MVC's message converters, backed by 
Spring Boot's configured
+Jackson `JsonMapper`, instead of the legacy `grails.converters.JSON` 
converter. Jackson can render date
+formats, bean properties, circular references, and validation errors 
differently, so review the JSON
+returned by `respond()` endpoints when you upgrade. Explicit `render value as 
JSON` calls still use the
+legacy converter.
+
+An application that customizes the legacy converter keeps it for `respond()`, 
so that its output does not
+change silently. That is the case once the application, or one of its plugins, 
registers a JSON object
+marshaller with `JSON.registerObjectMarshaller(...)` or 
`JSON.withDefaultConfiguration(...)`, declares an
+`ObjectMarshallerRegisterer` or `JsonRenderer` bean, or sets 
`grails.converters.json.default.deep` or

Review Comment:
   Verified: the `JsonRenderer` bean claim went with the heuristic, and neither 
internal class is named any more.



##########
grails-rest-transforms/src/main/groovy/org/grails/plugins/web/rest/render/DefaultRendererRegistry.groovy:
##########
@@ -77,13 +80,32 @@ class DefaultRendererRegistry extends 
ClassAndMimeTypeRegistry<Renderer, Rendere
     @Value('${grails.converters.encoding:UTF-8}')
     String encoding = grails.util.GrailsWebUtil.DEFAULT_ENCODING
 
+    @Autowired(required = false)
+    SpringMessageConverters springMessageConverters
+
+    @Autowired(required = false)
+    GrailsJsonMapperCustomizer grailsJsonMapperCustomizer
+
+    @Autowired(required = false)
+    NamedJsonRenderer namedJsonRenderer
+
+    @Autowired(required = false)
+    ValidationProblemDetailFactory validationProblemDetailFactory
+
+    /**
+     * Whether JSON responses are written by Spring's message converters. When 
unset, they are unless
+     * the application customized the legacy {@code grails.converters.JSON} 
converter.
+     */
+    @Value('${grails.web.rendering.json.spring:#{null}}')

Review Comment:
   Verified at 5ce132c752: `useSpringJson` is 
`@Value('${grails.web.rendering.json.spring:false}')` in 
`DefaultRendererRegistry`, `canUseSpringConverter` requires it, the warning at 
`DefaultJsonRenderer.groovy:200` fires once per registry and names Grails 10 
and 11, and `Errors` take the legacy body unless the setting is true. 
`RespondMethodSpec` and `DefaultJsonRendererSpec` cover it. Thanks.



##########
grails-converters/src/main/groovy/org/grails/web/converters/configuration/ConvertersConfigurationInitializer.java:
##########
@@ -160,6 +156,7 @@ private void initJSONConfiguration() {
         ProxyHandler proxyHandler = getProxyHandler();
         if (grailsConfig.getProperty(SETTING_CONVERTERS_JSON_DEFAULT_DEEP, 
Boolean.class, false)) {
             LOG.debug("Using DeepDomainClassMarshaller as default.");
+            
ConvertersConfigurationHolder.markDefaultConfigurationCustomized(JSON.class);

Review Comment:
   Verified: section 2 points at `spring.jackson.serialization.indent-output`, 
and nothing marks the configuration any more.



##########
grails-rest-transforms/src/main/groovy/org/grails/plugins/web/rest/render/DefaultRendererRegistry.groovy:
##########
@@ -125,6 +156,8 @@ class DefaultRendererRegistry extends 
ClassAndMimeTypeRegistry<Renderer, Rendere
         if (renderer instanceof ContainerRenderer) {
             ContainerRenderer cr = (ContainerRenderer) renderer
             addContainerRenderer(cr.componentType, cr)
+        } else if (renderer instanceof FallbackRenderer) {

Review Comment:
   Only the renderers this registry creates go through `configureJsonRenderer` 
(lines 108 and 123). A renderer bean arrives here through `setRenderers` and 
`addRenderer` and is never configured, so an application `JsonRenderer<Book>` 
bean or a `DefaultJsonRenderer` subclass keeps `useSpringJson = false` 
(`DefaultJsonRenderer.groovy:94`, no `@Value`), has no converters supplier and 
no `namedJsonRenderer`, and owns a separate `legacyFallbackReported`. With 
`grails.web.rendering.json.spring: true` such a bean still renders through the 
legacy converter and logs the warning telling the user to set the property they 
already set, and `respond book, jsonConfiguration: 'x'` through it throws "not 
registered" for a registered name. `DefaultXmlRenderer` beans have the same 
gap: `useSpringXml` is bound, but `springHttpMessageConvertersSupplier` (line 
84) is only set by `XmlGrailsPlugin`, so `findSpringConverter` is always null 
for them.
   
   Section 2 says the setting alone determines the path, and 
`versioningResources.adoc:116` documents `JsonRenderer` beans. Please configure 
renderer beans the same way, either by running `DefaultJsonRenderer` and 
`DefaultXmlRenderer` instances through the configuration in `addRenderer` or by 
autowiring the setting and `SpringMessageConverters` into the renderers, and 
add a test with a `JsonRenderer<Book>` bean and the setting on.



##########
grails-doc/src/en/guide/upgrading/upgrading90x.adoc:
##########
@@ -0,0 +1,289 @@
+////
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+////
+
+=== Upgrade Instructions for Grails and Related Dependencies
+
+This guide outlines the changes to review when you upgrade a Grails project 
from Grails 8 to Grails 9.
+
+==== 1. XML Web Support Is Optional
+
+XML conversion, request-body binding, codecs, and REST renderers now live in 
the optional
+`grails-xml` module. Newly generated applications and the web starter no 
longer include that module by
+default. MIME type negotiation still recognizes `application/xml`, `text/xml`, 
Atom, and other XML media
+types; recognizing a media type does not install an XML serializer.
+
+New REST controllers, RESTful controllers, resources, and scaffolded 
controllers generated by the
+`rest-api` profile advertise JSON only. After adding `grails-xml`, add `xml` 
to the artefact's
+`responseFormats` or `formats` declaration when that endpoint should negotiate 
XML.
+
+Applications that expose XML endpoints or use `grails.converters.XML`, XML 
object marshallers, XML data

Review Comment:
   Verified the pages listed here and `objectMarshallers.adoc`. A few pages 
outside the list still present XML as built in, and the HAL and Vnd.Error pages 
still say "future major release"; see the thread on the guide's section 1.



##########
grails-converters/src/main/groovy/grails/converters/JSON.java:
##########
@@ -423,14 +439,32 @@ public static void use(String cfgName) throws 
ConverterException {
         }
     }
 
+    /**
+     * @deprecated Prefer a Spring Boot {@code JsonMapperBuilderCustomizer} 
that registers a Jackson
+     * {@code SimpleModule} or {@code ValueSerializer}. A registered 
marshaller applies only to the
+     * legacy converter, and keeps {@code respond} on it unless {@code 
grails.web.rendering.json.spring} is set.
+     */
+    @Deprecated(since = "9.0", forRemoval = true)

Review Comment:
   Verified: the five named and default configuration methods say Grails 11, 
`registerObjectMarshaller` is not deprecated, and `JsonCompatibilitySpec` pins 
both. The XML renderers deprecated in grails-xml still name no release; noted 
on the guide's section 1.



##########
grails-converters/src/main/groovy/org/grails/web/converters/jackson/JsonProjection.java:
##########
@@ -0,0 +1,203 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.grails.web.converters.jackson;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import tools.jackson.databind.BeanDescription;
+import tools.jackson.databind.JacksonModule;
+import tools.jackson.databind.SerializationConfig;
+import tools.jackson.databind.introspect.BeanPropertyDefinition;
+import tools.jackson.databind.json.JsonMapper;
+import tools.jackson.databind.module.SimpleModule;
+import tools.jackson.databind.ser.BeanPropertyWriter;
+import tools.jackson.databind.ser.ValueSerializerModifier;
+
+import org.springframework.beans.BeanUtils;
+
+/**
+ * A per-write {@code includes}/{@code excludes} projection, as the {@code 
render} and {@code respond}
+ * arguments request it.
+ *
+ * <p>As with the legacy converter, it applies to the properties of objects of 
the written value's
+ * type, or of its elements' types when the value is a collection or an array. 
It applies to domain
+ * objects and to beans Jackson writes property by property. Values with no 
properties to project,
+ * such as maps and strings, are rejected before anything is written.</p>
+ *
+ * @since 9.0
+ */
+public final class JsonProjection {
+
+    /** Writer attribute holding the projection of one write. */
+    public static final String ATTRIBUTE = JsonProjection.class.getName();
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(JsonProjection.class);
+
+    private final List<String> includes;
+    private final List<String> excludes;
+    private final Set<Class<?>> types;
+    private final Set<Class<?>> applied = ConcurrentHashMap.newKeySet();
+
+    private JsonProjection(List<String> includes, List<String> excludes, 
Set<Class<?>> types) {
+        this.includes = includes;
+        this.excludes = excludes;
+        this.types = types;
+    }
+
+    /**
+     * @param value the value to be written
+     * @param includes property names to include, or null for all
+     * @param excludes property names to exclude, or null for none
+     * @return the projection, or null if neither list names a property
+     * @throws IllegalArgumentException if the value, or an element of it, has 
no properties to project
+     */
+    public static JsonProjection of(Object value, List<String> includes, 
List<String> excludes) {
+        boolean including = includes != null && !includes.isEmpty();
+        boolean excluding = excludes != null && !excludes.isEmpty();
+        if (!including && !excluding) {
+            return null;
+        }
+        Set<Class<?>> types = new LinkedHashSet<>();
+        if (value instanceof Iterable<?> elements) {

Review Comment:
   `of()` walks any `Iterable` root to collect the element types before Jackson 
writes it, so a single-pass `Iterable` (an iterator-backed or cursor-backed 
result) is exhausted here and `IterableSerializer` then writes `[]`. Only 
reachable with `includes`/`excludes` and a non-`Collection` `Iterable`, so low 
severity, but please restrict the walk to `Collection` and arrays and leave 
other iterables to the `reportUnapplied` warning.



##########
grails-rest-transforms/src/main/groovy/org/grails/plugins/web/rest/render/json/DefaultJsonRenderer.groovy:
##########
@@ -108,17 +155,121 @@ class DefaultJsonRenderer<T> implements Renderer<T> {
      * @param context
      */
     protected void renderJson(T object, RenderContext context) {
+        String selectedConfiguration = 
context.arguments?.get('jsonConfiguration')?.toString()
+        if (selectedConfiguration && 
namedJsonRenderer?.contains(selectedConfiguration)) {
+            namedJsonRenderer.render(selectedConfiguration, object, 
context.writer,
+                    context.includes, context.excludes)
+            return
+        }
+        if (!selectedConfiguration && canUseSpringConverter(context)) {
+            Object springValue = object instanceof Errors ?
+                    validationProblemDetailFactory.create((Errors) object, 
errorsHttpStatus) : object
+            MediaType mediaType = object instanceof Errors ?
+                    MediaType.parseMediaType(PROBLEM_JSON.name) :
+                    MediaType.parseMediaType(resolveMimeType(context).name)
+            // Set the content type before writing: once the writer flushes, 
the response is
+            // committed and a later content type change is silently discarded.
+            if (object instanceof Errors) {
+                
context.setContentType(GrailsWebUtil.getContentType(PROBLEM_JSON.name, 
encoding))
+            }
+            if (renderWithSpringConverter(springValue, mediaType, context)) {
+                return
+            }
+            if (object instanceof Errors) {
+                // No converter could write the problem; restore the 
negotiated type for the
+                // legacy converter path below.
+                
context.setContentType(GrailsWebUtil.getContentType(resolveMimeType(context).name,
 encoding))
+            }
+        }
+
         JSON converter
-        if (namedConfiguration) {
-            JSON.use(namedConfiguration) {
-                converter = object as JSON
+        String legacyConfiguration = selectedConfiguration ?: 
namedConfiguration
+        if (legacyConfiguration) {
+            JSON.use(legacyConfiguration) {
+                converter = new JSON(object)
             }
         } else {
-            converter = object as JSON
+            converter = new JSON(object)
         }
         renderJson(converter, context)
     }
 
+    private List<HttpMessageConverter<?>> resolveSpringHttpMessageConverters() 
{
+        List<HttpMessageConverter<?>> supplied = 
springHttpMessageConvertersSupplier?.get()
+        return supplied ?: springHttpMessageConverters
+    }
+
+    private boolean canUseSpringConverter(RenderContext context) {
+        return resolveSpringHttpMessageConverters() && !namedConfiguration &&
+                !context.includes && !context.excludes && springJsonEnabled()
+    }
+
+    private boolean springJsonEnabled() {
+        if (useSpringJson != null) {
+            return useSpringJson
+        }
+        if 
(!ConvertersConfigurationHolder.isDefaultConfigurationCustomized(JSON)) {

Review Comment:
   Verified: no `DefaultConfigurationCustomized` is left in the tree, and the 
field and `configureJsonRenderer` are the only writers of `useSpringJson`. One 
gap remains for renderer beans, which skip `configureJsonRenderer`; see the new 
thread on `addRenderer`.



##########
grails-web-databinding/src/main/groovy/org/grails/web/databinding/bindingsource/JsonDataBindingSourceCreator.groovy:
##########
@@ -48,8 +52,35 @@ class JsonDataBindingSourceCreator extends 
AbstractRequestBodyDataBindingSourceC
 
     private static final Pattern INDEX_PATTERN = ~/^(\S+)\[(\d+)\]$/
 
+    // Resolved when a request body is first parsed rather than injected. 
Injecting it pulls
+    // Jackson's auto-configuration into this bean's graph, and 
MimeTypesConfiguration depends on
+    // this creator, so Boot's mapper would be built before GORM has 
initialized.
     @Autowired(required = false)
-    JsonSlurper jsonSlurper = new JsonSlurper()
+    ObjectProvider<JsonMapper> jsonMapperProvider
+
+    private volatile JsonMapper resolvedJsonMapper
+
+    JsonMapper getJsonMapper() {
+        JsonMapper mapper = this.resolvedJsonMapper
+        if (mapper == null) {
+            mapper = jsonMapperProvider?.getIfAvailable() ?: 
JsonMapper.builder().build()
+            this.resolvedJsonMapper = mapper
+        }
+        return mapper
+    }
+
+    void setJsonMapper(JsonMapper jsonMapper) {
+        this.resolvedJsonMapper = jsonMapper
+    }
+
+    /**
+     * Reads untyped JSON values. Decimals are read as {@link BigDecimal} so 
that binding a
+     * fractional value to a BigDecimal property keeps the digits the request 
sent; reading them
+     * as doubles first would round them before the binder ever saw them.
+     */
+    protected ObjectReader untypedReader() {
+        return 
getJsonMapper().reader().forType(Object).with(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)

Review Comment:
   Verified the switch, the default path (`new JsonSlurper()`, same number 
typing as 8.0.x) and section 5. Two follow-ups on the startup notice and the 
removed `JsonSlurper` injection point are in a new thread on `initialize()`.



##########
grails-mimetypes/src/main/groovy/org/grails/web/mime/DefaultAcceptHeaderParser.groovy:
##########
@@ -123,8 +104,28 @@ class DefaultAcceptHeaderParser implements 
AcceptHeaderParser {
         mimes as MimeType[]
     }
 
-    protected void createMimeTypeAndAddToList(String name, MimeType[] 
mimeConfig, List<MimeType> mimes, Map<String,String> params = null) {
-        def mime = params ? new MimeType(name, params) : new MimeType(name)
+    protected List<MimeType> parseRequestedMimeTypes(String header) {
+        List<MimeType> mimeTypes = []
+
+        for (String token in header.split(',')) {
+            String candidate = token.trim()
+            try {
+                MediaType mediaType = MediaType.parseMediaType(candidate)
+                mimeTypes.add(SpringMediaTypeAdapter.toMimeType(mediaType))
+            }
+            catch (InvalidMediaTypeException ignored) {
+                // Preserve Grails' lenient handling of legacy headers such as 
a trailing semicolon,
+                // a valueless parameter, or an out-of-range/non-numeric 
quality value.
+                mimeTypes.add(new MimeType(candidate))

Review Comment:
   Verified: `split(';', -1)` plus the `i > 0` check. Traced `text/ html;`, 
`;;`, `;`, `;=`, `;q`, `;=1` and `;q=`; none can throw. The spec covers the 
first four.



##########
grails-doc/src/en/guide/upgrading/upgrading90x.adoc:
##########
@@ -0,0 +1,289 @@
+////
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+////
+
+=== Upgrade Instructions for Grails and Related Dependencies
+
+This guide outlines the changes to review when you upgrade a Grails project 
from Grails 8 to Grails 9.
+
+==== 1. XML Web Support Is Optional
+
+XML conversion, request-body binding, codecs, and REST renderers now live in 
the optional
+`grails-xml` module. Newly generated applications and the web starter no 
longer include that module by
+default. MIME type negotiation still recognizes `application/xml`, `text/xml`, 
Atom, and other XML media
+types; recognizing a media type does not install an XML serializer.
+
+New REST controllers, RESTful controllers, resources, and scaffolded 
controllers generated by the
+`rest-api` profile advertise JSON only. After adding `grails-xml`, add `xml` 
to the artefact's

Review Comment:
   Verified: `:grails-xml` is in the starter `api` list, `XML.render` warns 
once (static `AtomicBoolean`) naming Grails 10 and 11, the XML binding creators 
come back as `XmlGrailsPlugin` beans, `@Resource` keeps `['json', 'xml']`, the 
four profile templates are JSON-only, and `openapi-rest-api` passes on the 
starter alone.



##########
grails-mimetypes/src/main/groovy/org/grails/web/mime/GrailsMimeTypesWebMvcConfigurer.groovy:
##########
@@ -0,0 +1,74 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.web.mime
+
+import groovy.transform.CompileStatic
+
+import grails.web.mime.MimeType
+
+import org.springframework.http.MediaType
+import 
org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
+
+/**
+ * Installs Grails format negotiation as the Spring MVC content negotiation 
strategy.
+ */
+@CompileStatic
+class GrailsMimeTypesWebMvcConfigurer implements WebMvcConfigurer {
+
+    private final GrailsContentNegotiationStrategy contentNegotiationStrategy
+
+    GrailsMimeTypesWebMvcConfigurer(GrailsContentNegotiationStrategy 
contentNegotiationStrategy) {
+        this.contentNegotiationStrategy = contentNegotiationStrategy
+    }
+
+    /**
+     * Exposes the configured strategy to Grails' own format resolution. The 
strategy is deliberately
+     * not a bean of its own: Spring Security adopts any {@link 
org.springframework.web.accept.ContentNegotiationStrategy}
+     * bean it finds, so it is reached through this configurer instead.
+     */
+    GrailsContentNegotiationStrategy getContentNegotiationStrategy() {
+        return contentNegotiationStrategy
+    }
+
+    @Override
+    void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
+        // Contribute the configured format aliases and nothing else. 
Replacing Spring's strategy
+        // list would hand Grails' parser authority over every Spring MVC 
endpoint: it drops media
+        // types absent from grails.mime.types and falls back to the defaults, 
which include */*, so
+        // a request for an unknown type would be answered instead of rejected 
with 406. It would
+        // also disable spring.mvc.contentnegotiation.* and apply the 'format' 
request parameter to
+        // endpoints that never asked for it. Grails' own format resolution 
does not go through the
+        // Spring manager; it uses this configurer's strategy directly.
+        Map<String, MediaType> aliases = [:]
+        for (MimeType mimeType in 
contentNegotiationStrategy.configuredMimeTypes) {
+            String extension = mimeType.extension
+            if (!extension || extension == MimeType.ALL.extension) {
+                continue
+            }
+            MediaType mediaType = SpringMediaTypeAdapter.toMediaType(mimeType)
+            if (mediaType != null && !mediaType.isWildcardType() && 
!mediaType.isWildcardSubtype()) {
+                aliases.putIfAbsent(extension, new MediaType(mediaType.type, 
mediaType.subtype))
+            }
+        }
+        if (aliases) {
+            configurer.mediaTypes(aliases)

Review Comment:
   Verified: the `DATA_FORMATS` gate at line 68, and the spec asserts the 
browser formats are not file extensions on the manager.



##########
grails-rest-transforms/src/main/groovy/org/grails/plugins/web/rest/render/json/DefaultJsonRenderer.groovy:
##########
@@ -108,17 +155,121 @@ class DefaultJsonRenderer<T> implements Renderer<T> {
      * @param context
      */
     protected void renderJson(T object, RenderContext context) {
+        String selectedConfiguration = 
context.arguments?.get('jsonConfiguration')?.toString()
+        if (selectedConfiguration && 
namedJsonRenderer?.contains(selectedConfiguration)) {
+            namedJsonRenderer.render(selectedConfiguration, object, 
context.writer,
+                    context.includes, context.excludes)
+            return
+        }
+        if (!selectedConfiguration && canUseSpringConverter(context)) {
+            Object springValue = object instanceof Errors ?
+                    validationProblemDetailFactory.create((Errors) object, 
errorsHttpStatus) : object
+            MediaType mediaType = object instanceof Errors ?
+                    MediaType.parseMediaType(PROBLEM_JSON.name) :
+                    MediaType.parseMediaType(resolveMimeType(context).name)
+            // Set the content type before writing: once the writer flushes, 
the response is
+            // committed and a later content type change is silently discarded.
+            if (object instanceof Errors) {
+                
context.setContentType(GrailsWebUtil.getContentType(PROBLEM_JSON.name, 
encoding))
+            }
+            if (renderWithSpringConverter(springValue, mediaType, context)) {
+                return
+            }
+            if (object instanceof Errors) {
+                // No converter could write the problem; restore the 
negotiated type for the
+                // legacy converter path below.
+                
context.setContentType(GrailsWebUtil.getContentType(resolveMimeType(context).name,
 encoding))
+            }
+        }
+
         JSON converter
-        if (namedConfiguration) {
-            JSON.use(namedConfiguration) {
-                converter = object as JSON
+        String legacyConfiguration = selectedConfiguration ?: 
namedConfiguration
+        if (legacyConfiguration) {
+            JSON.use(legacyConfiguration) {
+                converter = new JSON(object)
             }
         } else {
-            converter = object as JSON
+            converter = new JSON(object)
         }
         renderJson(converter, context)
     }
 
+    private List<HttpMessageConverter<?>> resolveSpringHttpMessageConverters() 
{
+        List<HttpMessageConverter<?>> supplied = 
springHttpMessageConvertersSupplier?.get()
+        return supplied ?: springHttpMessageConverters
+    }
+
+    private boolean canUseSpringConverter(RenderContext context) {
+        return resolveSpringHttpMessageConverters() && !namedConfiguration &&
+                !context.includes && !context.excludes && springJsonEnabled()
+    }
+
+    private boolean springJsonEnabled() {
+        if (useSpringJson != null) {
+            return useSpringJson
+        }
+        if 
(!ConvertersConfigurationHolder.isDefaultConfigurationCustomized(JSON)) {
+            return true
+        }
+        if (legacyFallbackReported.compareAndSet(false, true)) {
+            log.warn('respond() renders JSON with the legacy 
grails.converters.JSON converter because the ' +
+                    'application customizes it, for example with 
JSON.registerObjectMarshaller or an ' +
+                    'ObjectMarshallerRegisterer bean. Legacy marshaller 
registration is deprecated for removal: ' +
+                    'replace it with Jackson serializers and set 
grails.web.rendering.json.spring to true, or set it ' +
+                    'to false to keep the legacy converter.')
+        }
+        return false
+    }
+
+    private boolean renderWithSpringConverter(Object object, MediaType 
mediaType, RenderContext context) {
+        Class<?> objectType = object?.getClass() ?: Object
+        HttpMessageConverter<Object> converter = 
(HttpMessageConverter<Object>) resolveSpringHttpMessageConverters().find {
+            HttpMessageConverter<?> candidate -> 
candidate.canWrite(objectType, mediaType) &&
+                    candidate.getSupportedMediaTypes(objectType).any { 
MediaType supported ->

Review Comment:
   Verified: `text/json` is mapped to `application/json` for `canWrite` and 
`write` only, the response keeps the negotiated type, and 
`ControllerTextJsonSpec` covers `respond`.



##########
grails-converters/src/main/groovy/org/grails/web/converters/jackson/GrailsDomainJsonSerializer.java:
##########
@@ -0,0 +1,161 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.grails.web.converters.jackson;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+import tools.jackson.core.JacksonException;
+import tools.jackson.core.JsonGenerator;
+import tools.jackson.databind.SerializationContext;
+import tools.jackson.databind.ValueSerializer;
+
+import org.springframework.beans.BeanWrapper;
+import org.springframework.beans.BeanWrapperImpl;
+
+import grails.core.support.proxy.EntityProxyHandler;
+import grails.core.support.proxy.ProxyHandler;
+import org.grails.core.util.IncludeExcludeSupport;
+import org.grails.datastore.mapping.model.PersistentEntity;
+import org.grails.datastore.mapping.model.PersistentProperty;
+import org.grails.datastore.mapping.model.types.Association;
+import org.grails.datastore.mapping.model.types.ManyToOne;
+import org.grails.datastore.mapping.model.types.OneToOne;
+
+/** Serializes a mapped Grails domain type using its persistent metadata. */
+final class GrailsDomainJsonSerializer extends ValueSerializer<Object> {
+
+    // Stateless, and consulted once per property of every serialized object.
+    private static final IncludeExcludeSupport<String> INCLUDE_EXCLUDE_SUPPORT 
= new IncludeExcludeSupport<>();
+
+    private final PersistentEntity entity;
+    private final ProxyHandler proxyHandler;
+    private final boolean includeVersion;
+    private final boolean includeClass;
+
+    GrailsDomainJsonSerializer(PersistentEntity entity, ProxyHandler 
proxyHandler,
+            boolean includeVersion, boolean includeClass) {
+        this.entity = entity;
+        this.proxyHandler = proxyHandler;
+        this.includeVersion = includeVersion;
+        this.includeClass = includeClass;
+    }
+
+    @Override
+    public void serialize(Object value, JsonGenerator generator, 
SerializationContext context) throws JacksonException {
+        Object unwrapped = proxyHandler.unwrapIfProxy(value);

Review Comment:
   Verified: `unwrapped.getClass() != entity.getJavaClass()` re-dispatches 
through `context.writeValue`, and the narrowed-proxy spec checks the subclass 
properties and `class`.



##########
grails-rest-transforms/src/main/groovy/org/grails/plugins/web/rest/render/json/DefaultJsonRenderer.groovy:
##########
@@ -108,17 +155,121 @@ class DefaultJsonRenderer<T> implements Renderer<T> {
      * @param context
      */
     protected void renderJson(T object, RenderContext context) {
+        String selectedConfiguration = 
context.arguments?.get('jsonConfiguration')?.toString()
+        if (selectedConfiguration && 
namedJsonRenderer?.contains(selectedConfiguration)) {
+            namedJsonRenderer.render(selectedConfiguration, object, 
context.writer,
+                    context.includes, context.excludes)
+            return
+        }
+        if (!selectedConfiguration && canUseSpringConverter(context)) {
+            Object springValue = object instanceof Errors ?

Review Comment:
   Verified the status, media type and `instance` handling and both specs. Two 
follow-ups are in a new thread on line 184: `HttpStatus.valueOf` for a 
non-standard status code, and section 6 not saying this path ignores the 
setting.



##########
grails-converters/src/main/groovy/org/grails/web/converters/jackson/GrailsDomainJsonSerializer.java:
##########
@@ -0,0 +1,161 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.grails.web.converters.jackson;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+import tools.jackson.core.JacksonException;
+import tools.jackson.core.JsonGenerator;
+import tools.jackson.databind.SerializationContext;
+import tools.jackson.databind.ValueSerializer;
+
+import org.springframework.beans.BeanWrapper;
+import org.springframework.beans.BeanWrapperImpl;
+
+import grails.core.support.proxy.EntityProxyHandler;
+import grails.core.support.proxy.ProxyHandler;
+import org.grails.core.util.IncludeExcludeSupport;
+import org.grails.datastore.mapping.model.PersistentEntity;
+import org.grails.datastore.mapping.model.PersistentProperty;
+import org.grails.datastore.mapping.model.types.Association;
+import org.grails.datastore.mapping.model.types.ManyToOne;
+import org.grails.datastore.mapping.model.types.OneToOne;
+
+/** Serializes a mapped Grails domain type using its persistent metadata. */
+final class GrailsDomainJsonSerializer extends ValueSerializer<Object> {
+
+    // Stateless, and consulted once per property of every serialized object.
+    private static final IncludeExcludeSupport<String> INCLUDE_EXCLUDE_SUPPORT 
= new IncludeExcludeSupport<>();
+
+    private final PersistentEntity entity;
+    private final ProxyHandler proxyHandler;
+    private final boolean includeVersion;
+    private final boolean includeClass;
+
+    GrailsDomainJsonSerializer(PersistentEntity entity, ProxyHandler 
proxyHandler,
+            boolean includeVersion, boolean includeClass) {
+        this.entity = entity;
+        this.proxyHandler = proxyHandler;
+        this.includeVersion = includeVersion;
+        this.includeClass = includeClass;
+    }
+
+    @Override
+    public void serialize(Object value, JsonGenerator generator, 
SerializationContext context) throws JacksonException {
+        Object unwrapped = proxyHandler.unwrapIfProxy(value);
+        BeanWrapper bean = new BeanWrapperImpl(unwrapped);
+        List<String> includes = properties(context, 
GrailsJsonMapperCustomizer.INCLUDES_ATTRIBUTE, unwrapped.getClass());
+        List<String> excludes = properties(context, 
GrailsJsonMapperCustomizer.EXCLUDES_ATTRIBUTE, unwrapped.getClass());
+
+        generator.writeStartObject();
+        if (includeClass && shouldInclude(includes, excludes, "class")) {
+            generator.writeStringProperty("class", entity.getName());
+        }
+        // An unsaved instance has neither yet; the legacy marshaller leaves 
them out rather than writing null
+        writePropertyIfSet(entity.getIdentity(), bean, generator, context, 
includes, excludes);
+        if (includeVersion) {
+            writePropertyIfSet(entity.getVersion(), bean, generator, context, 
includes, excludes);
+        }
+        for (PersistentProperty property : entity.getPersistentProperties()) {
+            if (!property.equals(entity.getVersion())) {
+                writeProperty(property, bean, generator, context, includes, 
excludes);
+            }
+        }
+        generator.writeEndObject();
+    }
+
+    private void writePropertyIfSet(PersistentProperty property, BeanWrapper 
bean, JsonGenerator generator,
+            SerializationContext context, List<String> includes, List<String> 
excludes) throws JacksonException {
+        if (property != null && bean.getPropertyValue(property.getName()) != 
null) {
+            writeProperty(property, bean, generator, context, includes, 
excludes);
+        }
+    }
+
+    private void writeProperty(PersistentProperty property, BeanWrapper bean, 
JsonGenerator generator,
+            SerializationContext context, List<String> includes, List<String> 
excludes) throws JacksonException {
+        if (property == null || !shouldInclude(includes, excludes, 
property.getName())) {
+            return;
+        }
+        Object propertyValue = bean.getPropertyValue(property.getName());
+        generator.writeName(property.getName());
+        if (property instanceof Association association && 
!association.isEmbedded() &&
+                (property instanceof OneToOne || property instanceof 
ManyToOne)) {
+            writeAssociationReference(propertyValue, 
association.getAssociatedEntity(), generator, context);
+        }
+        else if (property instanceof Association association && 
!association.isEmbedded() &&
+                propertyValue instanceof Collection<?> collection) {
+            generator.writeStartArray();
+            for (Object associated : collection) {
+                writeAssociationReference(associated, 
association.getAssociatedEntity(), generator, context);
+            }
+            generator.writeEndArray();
+        }
+        else if (property instanceof Association association && 
!association.isEmbedded() &&
+                propertyValue instanceof Map<?, ?> map) {
+            generator.writeStartObject();
+            for (Map.Entry<?, ?> entry : map.entrySet()) {
+                generator.writeName(String.valueOf(entry.getKey()));
+                writeAssociationReference(entry.getValue(), 
association.getAssociatedEntity(), generator, context);
+            }
+            generator.writeEndObject();
+        }
+        else {
+            context.writeValue(generator, propertyValue);

Review Comment:
   Verified: the in-progress map is a per-call `SerializationContext` attribute 
(`IdentityHashMap`, removed in `finally`), so it is per write and thread-safe; 
`_ref`, `EXCEPTION`, `INSERT_NULL` and `PATH` match the legacy converter, and 
the behaviour specs cover each. `IGNORE` writes `null` where the legacy writer 
left a dangling key, which is better, but section 2 should say so.



##########
grails-xml/src/main/groovy/org/grails/plugins/xml/XmlGrailsPlugin.groovy:
##########
@@ -0,0 +1,94 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.plugins.xml
+
+import groovy.transform.CompileStatic
+
+import org.springframework.beans.factory.BeanRegistrar
+import org.springframework.beans.factory.BeanRegistry
+import org.springframework.core.env.Environment
+
+import grails.converters.XML
+import grails.plugins.Plugin
+import grails.util.GrailsUtil
+import org.grails.plugins.codecs.XMLCodec
+import org.grails.web.converters.configuration.ObjectMarshallerRegisterer
+import org.grails.plugins.web.rest.render.SpringMessageConverters
+import org.grails.plugins.web.rest.render.xml.DefaultXmlRenderer
+import org.grails.web.gsp.io.GrailsConventionGroovyPageLocator
+import 
org.grails.web.converters.configuration.XmlConvertersConfigurationInitializer
+import org.grails.web.converters.marshaller.xml.ValidationErrorsMarshaller
+import org.grails.web.databinding.bindingsource.HalXmlDataBindingSourceCreator
+import org.grails.web.databinding.bindingsource.XmlDataBindingSourceCreator
+
+/**
+ * Provides optional XML conversion, rendering, and request binding support.
+ *
+ * @since 9.0
+ */
+@CompileStatic
+class XmlGrailsPlugin extends Plugin {
+
+    def version = GrailsUtil.getGrailsVersion()
+    def dependsOn = [converters: version, dataBinding: version, restResponder: 
version]
+    def providedArtefacts = [XMLCodec]
+
+    private static <T extends DefaultXmlRenderer> T configure(T renderer, 
Environment environment,
+            SpringMessageConverters converters) {
+        renderer.encoding = 
environment.getProperty('grails.converters.encoding', 'UTF-8')
+        if (converters != null) {
+            renderer.springHttpMessageConvertersSupplier = 
converters::getConverters
+        }
+        return renderer
+    }
+
+    @Override
+    BeanRegistrar beanRegistrar() {
+        return { BeanRegistry registry, Environment environment ->
+            registry.registerBean('xmlErrorsMarshaller', 
ValidationErrorsMarshaller)
+            registry.registerBean('xmlConvertersConfigurationInitializer', 
XmlConvertersConfigurationInitializer)
+            registry.registerBean('xmlDataBindingSourceCreator', 
XmlDataBindingSourceCreator)
+            registry.registerBean('halXmlDataBindingSourceCreator', 
HalXmlDataBindingSourceCreator)
+            // Contributed as Renderer beans, which DefaultRendererRegistry 
autowires: registering
+            // them from a bean that holds a registry reference can write into 
an instance nothing
+            // reads, because the harness rebuilds that singleton.
+            registry.registerBean('xmlRenderer', DefaultXmlRenderer) {

Review Comment:
   Verified: only `FallbackRenderer` beans go through `addDefaultRenderer`; the 
only other registry change is the Errors and XML container registrations moving 
to grails-xml beans with the same keys. Both specs cover the interface and 
`Object` renderer cases.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to