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


##########
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:
   This moves JSON request binding from `JsonSlurper` to Jackson 3 with no 
switch. I ran both parsers over the same bodies (Groovy 5.1.3, Jackson 3.1.6, 
untyped with `USE_BIG_DECIMAL_FOR_FLOATS` as here). Bodies the previous parser 
bound that now raise `InvalidRequestBodyException`:
   
   | Body | `JsonSlurper` | Jackson 3 |
   |---|---|---|
   | `{"a":1} trailing` | `{a=1}` | rejected (`FAIL_ON_TRAILING_TOKENS`) |
   | `{"a":1,}` | `{a=1}` | rejected |
   | `{"a":01}` | `{a=1}` | rejected |
   | `{"a":"<raw tab>"}` | `{a=\t}` | rejected |
   | `{"a":1}//c` | `{a=1}` | rejected |
   | 1001 nested arrays | parsed | `StreamConstraintsException` |
   | 1001-digit integer | parsed, wrong value | `StreamConstraintsException` |
   
   Number typing matches (`Integer`, `Long`, `BigDecimal`), and 
`9223372036854775808` is now a correct `BigInteger` where the slurper 
overflowed to a negative `Long`, so the stricter answers are the right ones. 
What is missing is the migration. Section 5 of the guide says only that 
malformed bodies continue to raise, which hides that the definition of 
malformed changed. Please list the inputs above in section 5, and treat this 
path like the others: a setting that keeps `JsonSlurper` as the 9.x default 
with a startup deprecation notice, Jackson as the default in 10.



##########
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:
   Deciding the format from `isDefaultConfigurationCustomized` makes the 
response format depend on what a plugin happened to do at startup. An 
application that pulls in a plugin registering one marshaller stays on the 
legacy format; the same application without that plugin switches. Two 
applications on the same Grails version with the same controllers answer 
differently, and neither chose.
   
   With the default set to the legacy converter for 9.x (see the registry 
comment) the heuristic can go: `useSpringJson` is a plain `false` unless set, 
and the warning fires whenever the legacy path renders.



##########
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:
   With this unset, every application that never registered a marshaller gets 
Jackson output from `respond()` the moment it upgrades: dates, non-domain bean 
shape, circular references and the validation error body (guide section 6) all 
change with no code change on their side. The legacy path survives, but only as 
an opt-out an application discovers after its clients break.
   
   Please invert this for Grails 9: default to the legacy converter, log one 
deprecation warning when the legacy path renders (the message in 
`DefaultJsonRenderer` already says what to do), and let applications set 
`grails.web.rendering.json.spring: true` when they are ready. Flip the default 
in Grails 10 and remove the legacy path in the release after that. The RFC 9457 
error body then rides on the same switch, which is where it belongs.



##########
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:
   Two things on these deprecations.
   
   `forRemoval = true` names no release, and the guide says only "until a 
future major release" (section 3, line 145). If the default flips in 10, say 
"removed in Grails 11" here and in the guide so applications can plan.
   
   #16414 keeps `registerObjectMarshaller` as the supported way to override the 
Jackson output of `render ... as JSON` and documents it as taking precedence 
over the mapper, while this PR deprecates it for removal and makes it the 
trigger that keeps `respond()` on the legacy converter. Both target 9.0.x. They 
need one answer on whether a registered marshaller is the migration tool or the 
thing being migrated away from.



##########
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:
   This serializer goes on Boot's shared mapper, so it changes what a plain 
`@RestController`, or any other Jackson user in the application, writes for an 
`Errors` value, and there is no setting to keep Jackson's own representation. 
The domain serializer was scoped to the Grails writers in e8bb2eb0a5 for the 
same reason; please do the same here, or make it opt-in. The `GString` 
serializer above is fine globally, since it only replaces a bean dump.



##########
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:
   XML is not deprecated here; it leaves the default classpath in the same 
release that introduces `grails-xml`. `grails-dependencies/starter-web` still 
lists `grails-converters` and `grails-rest-transforms` but not the new module, 
`@Resource` still defaults to `['json', 'xml']`, and an existing 
`responseFormats = ['json', 'xml']` controller answers 406 to `Accept: 
application/xml` after the upgrade. The branch's own CI shows it: 
`RestApiDocumentFunctionalSpec` in `grails-test-examples/openapi-rest-api` 
fails on this head for exactly that reason, and the four example applications 
that pass only do so because this PR added `implementation 
'org.apache.grails:grails-xml'` to them.
   
   Please add `:grails-xml` to the starter-web `api` list for 9.x and log one 
deprecation warning when the XML converter renders, so the module is optional 
only for applications that remove it. Drop it from the starter in Grails 10, 
and change the `@Resource` default in that same release rather than leaving it 
advertising a format the default classpath cannot render.



##########
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:
   `putIfAbsent` keeps the first MIME type per extension, and 
`MimeType.createDefaults()` lists `text/xml` before `application/xml`, so the 
`xml` alias Grails registers is `text/xml`. This configurer carries no order, 
so it runs after Spring's defaults and after Boot's 
`WebMvcAutoConfigurationAdapter` (`@Order(0)`, which applies 
`spring.mvc.contentnegotiation.media-types.*`), and 
`ContentNegotiationConfigurer.mediaTypes` is a `putAll`, so the Grails value 
overwrites both.
   
   Result: with `favor-parameter=true`, a `@GetMapping(produces = 
"application/xml")` that answered `?format=xml` on 8.0.x now gets 406 
(`text/xml` is not compatible with `application/xml`), and 
`spring.mvc.contentnegotiation.media-types.xml=application/xml` does not help 
because it is overwritten. Section 8 says the `spring.mvc.contentnegotiation.*` 
properties continue to work.
   
   Please let Boot's and the user's aliases win (register with high precedence, 
or only fill in extensions that are absent) and prefer the `application/*` type 
where an extension maps to several. `GrailsContentNegotiationStrategySpec` uses 
a hand-built list with `xml → application/xml`, so the real defaults are 
untested.



##########
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:
   `WebMvcConfigurer.extendMessageConverters(List)` is `@Deprecated(since = 
"7.0", forRemoval = true)` in the Spring this PR targets, and the replacement 
`configureMessageConverters(HttpMessageConverters.ServerBuilder)` cannot 
observe the final list. When the hook is never called (a 
`WebMvcConfigurationSupport` subclass that is not 
`DelegatingWebMvcConfiguration`, or the method's removal), the list stays 
empty, `canUseSpringConverter` returns false before `springJsonEnabled()` is 
consulted, and every `respond` silently uses the legacy converter even with 
`grails.web.rendering.json.spring=true`. Please resolve the converters from the 
`RequestMappingHandlerAdapter` (or Boot's `HttpMessageConverters`) through an 
`ObjectProvider` at write time, and log once when Spring JSON is enabled but no 
converters are available.



##########
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:
   The `includes`/`excludes` attributes set here, and by `render json:` and 
`respond x, jsonConfiguration:`, are read only by `GrailsDomainJsonSerializer`. 
For any value that is not a mapped domain class they are silently ignored: 
`render json: new Credentials(user: 'a', password: 'b'), excludes: 
['password']` writes the password. Section 3 says both arguments are accepted, 
with no domain-only caveat, and the `NamedJsonRenderer` javadoc says an 
implementation that quietly ignores the projection is unacceptable. Meanwhile 
`respond x, includes: [...]` without a name goes to the legacy converter, so 
the projection switches serializers depending on whether a configuration was 
named. Please either apply the projection to non-domain values on the Jackson 
paths (a `BeanSerializerModifier` or a filter) or reject it loudly, and say 
which in section 3.



##########
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:
   The harness converter list is hard-coded to byte-array, string and Jackson 
JSON. Production renders through whatever Spring MVC installed, so a 
`ControllerUnitTest` diverges from runtime as soon as the application adds 
`jackson-dataformat-xml` (section 1 tells it how): production writes XML 
through `JacksonXmlHttpMessageConverter`, the unit test through the Grails XML 
converter, with different element names. An application `WebMvcConfigurer` or 
Boot `HttpMessageConverters` customisation is likewise never applied in tests, 
and section 2 says unit tests use Boot's Jackson configuration and the same 
setting, with no caveat. Please build the list from Boot's 
`HttpMessageConverters` (or at least add the XML converter when present), or 
document the divergence in `unitTesting.adoc`.



##########
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:
   On 8.0.x the registry installed the XML renderer with `addDefaultRenderer`, 
consulted only after the class chain and the interfaces. As a `Renderer` bean 
it now goes through `addRenderer` → `addToRegisteredObjects(Object, …)`, and 
the registry lookup walks the class chain up to and including `Object` before 
it looks at interfaces or defaults. An application `XmlRenderer` registered for 
an interface the domain class implements (the shape 
`DefaultRendererRegistrySpec` uses with `CharSequence`) therefore loses to this 
bean for `respond book` as XML, and an application renderer for `Object` shares 
the bucket with it, so whichever definition Spring collected first wins. 
Section 2 says application and plugin `Renderer` beans continue to take 
precedence over the default renderer. Please route a non-container renderer 
whose target is `Object` through `addDefaultRenderer` (or mark this bean as the 
default) and add a precedence case to `XmlGrailsPluginSpec`.



##########
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:
   `grails.web.rendering.json.spring` has no Spring configuration metadata: no 
`additional-spring-configuration-metadata.json` mentions it, 
grails-rest-transforms applies neither the `configuration-metadata` convention 
plugin nor an overlay, and `generateConfigReference` in grails-doc does not 
scan this module, so the key is missing from the Application Properties 
reference and from IDE completion. Every settable key in this repository is 
documented that way; please add the entry in the owning module.



##########
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:
   The REST chapter still presents XML as built in, which contradicts this 
section: `REST/domainResources.adoc:73` (`formats=['json', 'xml']` as the 
canonical example), 
`REST/restfulControllers/extendingRestfulController.adoc:25` and `:64`, 
`REST/hypermedia/hal.adoc:89` and `:100` directly above the new deprecation 
paragraph, `REST/versioningResources.adoc:122`, `REST/openApi.adoc:584-588` 
(`text/xml`, "the first media type Grails configures"), 
`theWebLayer/contentNegotiation.adoc:115` (says `render books as XML` needs 
`grails-converters`), and `REST/binding.adoc:93-102` (XML and HAL XML binding 
sources "supported by the core framework", although 
`DefaultDataBindingSourceRegistry.initialize()` no longer registers them). Each 
page needs a note that the module is required, or the samples changed to 
`['json']`. `REST/renderers/objectMarshallers.adoc:32-33` also says "deprecated 
for removal in Grails 9", which reads as removal in 9, while this file says a 
future major release.



##########
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:
   A `JsonRenderer` bean does not keep `respond()` on the legacy converter. 
`markDefaultConfigurationCustomized` is called only from `JSON` and 
`ConvertersConfigurationInitializer`, nothing inspects renderer beans, an 
application `JsonRenderer` only takes precedence for its own type, and a 
subclass of `DefaultJsonRenderer` that does not override `renderJson` inherits 
the Spring path. Please drop it from this list or implement the detection. In 
the same sentence, `ObjectMarshallerRegisterer` lives in 
`org.grails.web.converters.configuration`, and `InvalidRequestBodyException` 
(line 225) in `org.grails.web.databinding.bindingsource`; user-facing docs are 
not supposed to name internal classes.



##########
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:
   `text/json` is one of Grails' two default JSON media types and is in this 
renderer's own `mimeTypes`, but Spring's Jackson converter supports only 
`application/json` and `application/*+json`, so `canWrite(type, text/json)` is 
false, no converter is found, and the response goes to the legacy converter 
with no log line. The same endpoint renders with Jackson for `Accept: 
application/json` and with `grails.converters.JSON` for `Accept: text/json` 
(`?format=json` is fine, it resolves to the first alias). Section 2 lists 
`text/json` as a selectable family. Please normalise `text/json` to 
`application/json` for converter selection, or at least log the fallback, and 
add a `text/json` case to the respond specs.



##########
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:
   Blocking. A token Spring rejects is handed to the `MimeType(String)` 
constructor unchanged, and that constructor does `tokenWithArgs[1..-1]` after 
`split(';')` without checking the size. `String.split` drops a trailing empty 
segment, so a header such as `Accept: text/ html;` (any token with an illegal 
character and an empty tail after the `;`) ends in `IndexOutOfBoundsException` 
from `MimeType.groovy:70`, which propagates out of `resolveMimeTypes` into 
`response.format`, `withFormat` and `respond` as a 500. The old parser split 
the parameters itself and passed only the bare name, so the same header was 
ignored.
   
   Verified: `MediaType.parseMediaType('text/ html;')` throws 
`InvalidMediaTypeException`, and `['text/ html'][1..-1]` throws on Groovy 
5.1.3. Please guard the size in the constructor (or strip the name to its first 
segment here) and add the case to `AcceptHeaderParserSpec`; this is 
request-controlled input.



##########
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:
   Only `Errors` is special-cased, so the section 6 example, `respond 
validationProblemDetailFactory.create(book.errors)`, takes the ordinary path: 
no status is set and the media type is the negotiated `application/json`. The 
response is 200 with a body that says `"status":422`, where the guide says it 
has status 422 and `application/problem+json`. Spring MVC sets the status and 
`instance` for a `ProblemDetail` return value; `respond` does neither. Please 
handle a `ProblemDetail` value like `Errors` (status from `problem.status`, 
`application/problem+json`) and add a test for `respond` of a `ProblemDetail`; 
none exists.



##########
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:
   The JSON renderer uses UTF-8 for the intermediate bytes when the converter 
is a Jackson one, because Jackson honours only the UTF encodings and writes 
UTF-8 for anything else. This copy decodes with the configured 
`grails.converters.encoding` on both sides, and the comment above says the 
opposite. With `grails.converters.encoding: ISO-8859-1` and 
`jackson-dataformat-xml` on the classpath, `respond` of a bean containing 
`café` as XML writes UTF-8 bytes that are decoded as ISO-8859-1, so the body 
contains `café`. `XmlGrailsPluginSpec` wires ISO-8859-1 but never renders 
through a Jackson converter with it. Please mirror the JSON branch and add that 
case.



##########
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:
   Everything registered here becomes a file extension on Spring's 
`ContentNegotiationManager`, and `AbstractMessageConverterMethodProcessor` adds 
`getAllFileExtensions()` to its safe-extension set (spring-webmvc 7.0.9, line 
147). Spring deliberately leaves `js` and `html` out of that set and 
special-cases `html`, because the reflected-file-download check adds 
`Content-Disposition: inline;filename=f.txt` when a path like `/api/users/x.js` 
is answered with JSON. After this change `html`, `js`, `css`, `pdf`, `form`, 
`multipartform` and `hal` are all registered, so that header is no longer added 
for those suffixes on every `@RestController` in a Grails application. Please 
register only the data-format aliases (`json`, `xml`, `hal`, `atom`, `rss`, 
`csv`, `text`), or document the trade-off.



##########
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:
   The JSON side falls back to the legacy converter, with a warning and the 
`grails.web.rendering.json.spring` override, once the application registered a 
marshaller. This method has neither: the XML initializer never calls 
`markDefaultConfigurationCustomized(XML)`, nothing here checks it, and there is 
no XML setting. An application that adds `jackson-dataformat-xml`, as section 1 
suggests, and keeps `XML.registerObjectMarshaller(Book) { ... }` has that 
marshaller ignored by `respond book` without a log line, while `render book as 
XML` still honours it. The same path sends GORM entities to Boot's plain 
`XmlMapper`, with nothing like `GrailsDomainSerializers` to drop the GORM trait 
properties, which is what section 1 ("compatibility marshalling for 
domain-shaped payloads") says does not happen. Please mirror the JSON renderer: 
mark XML as customised in the XML initializer, consult it plus an explicit 
setting here, and either exclude persistent entities from the Spring path or 
document
  that they go through Jackson XML.



##########
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:
   The serializer is bound to the entity resolved from the class Jackson 
dispatched on, which for a proxy is the proxy's superclass, and after 
`unwrapIfProxy` it keeps using that `entity`. A proxy of `Animal` whose target 
is a `Dog` is written with `Animal`'s properties and `"class":"Animal"`; the 
subclass properties vanish. That is what a lazy polymorphic to-one gives you 
(`respond parent.pet`, or the proxy inside a list or map). The legacy 
`DomainClassMarshaller` unwraps first and then resolves the class from the 
unwrapped value, so it wrote the `Dog` shape. Please re-resolve the entity when 
`unwrapped.getClass() != entity.getJavaClass()` (or delegate to 
`context.findValueSerializer(unwrapped.getClass())`) and add a narrowed-proxy 
case to the serializer specs.



##########
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:
   Embedded and other non-association persistent values go to Jackson with no 
reference tracking. An embedded value that points back at its owner (`@Entity 
Person { Address address; static embedded = ['address'] }` with `Address { 
Person owner }`) recurses until `StreamConstraintsException: Document nesting 
depth (501) exceeds the maximum allowed`, a 500. The legacy converter wrote 
`{"owner":{"_ref":"..","class":"Person"}}` through its reference stack and 
`circular.reference.behaviour`. Section 2 only says circular references can 
render differently. Please either keep a reference stack in a context attribute 
and write the legacy `_ref`, or state in section 2 that a cycle through an 
embedded or plain bean property is an error on this path.



##########
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:
   `default.deep` and `date: javascript` mark the configuration as customised, 
but `grails.converters.json.pretty.print` (and the `default.pretty.print` 
fallback), `json.circular.reference.behaviour` and `grails.converters.encoding` 
do not, so an application with pretty printing on loses it on `respond()` 
without the warning and without a mention in section 2 (it would need 
`spring.jackson.serialization.indent-output`). 
`ConvertersConfigurationHolder.setDefaultConfiguration(JSON, cfg)`, which a 
plugin can call, never marks either. Please mark on those settings too, or list 
pretty printing among the section 2 differences.



##########
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:
   A mapper declared in a test's `doWithSpring` is not used. I ran a 
`ControllerUnitTest` on this head with `probeMapper(InstanceFactoryBean, 
JsonMapper.builder().propertyNamingStrategy(SNAKE_CASE).build(), JsonMapper)` 
in `doWithSpring`: `respond` wrote `{"someValue":"x"}` and the context held 
both `jacksonJsonMapper` and `probeMapper`, because the DSL registers after 
Boot's `@ConditionalOnMissingBean` was evaluated and Boot's bean is `@Primary`. 
The same bean from a nested `@Configuration` class wrote `{"some_value":"x"}` 
with Boot's mapper backed off. `doWithSpring()` on the test traits is also 
deprecated since 8.0. Please point this sentence at a `beans` block or a nested 
configuration class, and add that case to `ControllerJsonSerializationSpec` (it 
currently covers only a customizer from a configuration class).



-- 
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