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]