codeconsole commented on code in PR #16237:
URL: https://github.com/apache/grails-core/pull/16237#discussion_r4331939579
##########
grails-rest-transforms/src/main/groovy/org/grails/plugins/web/rest/render/json/DefaultJsonRenderer.groovy:
##########
@@ -102,26 +193,282 @@ class DefaultJsonRenderer<T> implements Renderer<T> {
}
/**
- * Subclasses should override to customize JSON response rendering
+ * The named JSON configuration {@code respond} selects, which must be
registered: also for a problem, which Spring's
+ * message converters write whatever the configuration, and for a view, so
that a name that is wrong is found
+ * whatever writes the response.
+ */
+ private String requireRegisteredConfiguration(RenderContext context) {
+ String selectedConfiguration =
context.arguments?.get('jsonConfiguration')?.toString()
+ if (selectedConfiguration && (namedJsonRenderer == null ||
!namedJsonRenderer.contains(selectedConfiguration))) {
+ throw new IllegalArgumentException("Named JSON configuration
[$selectedConfiguration] is not registered.")
+ }
+ selectedConfiguration
+ }
+
+ /**
+ * Subclasses should override to customize JSON response rendering. A
problem is written as Spring MVC writes it,
+ * whatever the renderer's own settings, unless a subclass renders it
itself.
*
* @param object
* @param context
*/
protected void renderJson(T object, RenderContext context) {
+ String selectedConfiguration = requireRegisteredConfiguration(context)
+ // validation errors are written as the problem they make, as a
problem is
+ if (selectedConfiguration && !(object instanceof ProblemDetail ||
object instanceof Errors)) {
+ try {
+ namedJsonRenderer.render(selectedConfiguration, object,
context.writer,
+ getEffectiveIncludes(context),
getEffectiveExcludes(context))
+ }
+ catch (JacksonException | IOException e) {
+ // as respond reports it without a configuration, as Spring
MVC does
+ throw JacksonWriteFailures.reported(e)
+ }
+ return
+ }
+ String springFallbackReason = null
+ if (object instanceof ProblemDetail || canUseSpringConverter(context))
{
+ Object springValue = object
+ if (object instanceof Errors) {
+ springValue = (validationProblemDetailFactory ?:
DEFAULT_PROBLEM_DETAIL_FACTORY)
+ .create((Errors) object, errorsHttpStatus)
+ }
+ MediaType mediaType = springValue instanceof ProblemDetail ?
+ MediaType.parseMediaType(PROBLEM_JSON.name) :
+ MediaType.parseMediaType(resolveMimeType(context).name)
+ // Set the content type before writing: once the writer flushes,
the response is
+ // committed and a later content type change is silently discarded.
+ if (springValue instanceof ProblemDetail) {
+ if (object instanceof Errors) {
+ ProblemDetailResponse.prepareBuilt((ProblemDetail)
springValue, context)
+ }
+ else {
+ ProblemDetailResponse.prepare((ProblemDetail) springValue,
context)
+ }
+
context.setContentType(GrailsWebUtil.getContentType(PROBLEM_JSON.name,
encoding))
+ }
+ springFallbackReason = writeWithSpringConverter(springValue,
mediaType, context)
+ if (springFallbackReason == null) {
+ 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))
+ }
+ }
+
+ reportLegacyFallback(object, springFallbackReason)
JSON converter
+ // A jsonConfiguration argument names a configuration of the JSON
mapper, which the legacy converter does not have
if (namedConfiguration) {
JSON.use(namedConfiguration) {
- converter = object as JSON
+ converter = new JSON(object)
}
} else {
- converter = object as JSON
+ converter = new JSON(object)
}
renderJson(converter, context)
}
+ private void reportLegacyFallback(Object object, String
springFallbackReason) {
+ if (object instanceof ProblemDetail && springFallbackReason != null) {
+ if (problemFallbackReported.compareAndSet(false, true)) {
+ log.warn("A ProblemDetail is written through Spring's message
converters, but ${springFallbackReason}, " +
+ 'so the legacy JSON converter writes it as a bean.')
+ }
+ return
+ }
+ if (!legacyFallbackReported.compareAndSet(false, true)) {
+ return
+ }
+ if (useSpringJson == Boolean.TRUE) {
+ if (springFallbackReason != null || namedConfiguration) {
+ log.warn("Spring JSON rendering is enabled, but
[${object?.getClass()?.name}] is written by the " +
+ 'legacy JSON converter, which is deprecated and will
be removed in Grails 11, because ' +
+ "${springFallbackReason ?: "this renderer uses the
legacy named configuration [${namedConfiguration}]"}.")
+ }
+ return
+ }
+ log.warn('Legacy respond() JSON rendering is deprecated and will be
removed in Grails 11. ' +
+ 'Migrate to Jackson serializers and set
grails.web.rendering.json.spring=true. ' +
+ 'Spring JSON becomes the default in Grails 10.')
+ }
+
+ /**
+ * The includes the projection and the legacy converter apply. A subclass
that carries its own
+ * includes returns them here, so both response paths honour them.
+ */
+ protected List<String> getEffectiveIncludes(RenderContext context) {
+ return context.includes
+ }
+
+ /** The excludes the projection and the legacy converter apply; see {@link
#getEffectiveIncludes}. */
+ protected List<String> getEffectiveExcludes(RenderContext context) {
+ return context.excludes
+ }
+
+ private List<HttpMessageConverter<?>> resolveSpringHttpMessageConverters()
{
+ return springHttpMessageConvertersSupplier?.get() ?: []
+ }
+
+ private boolean canUseSpringConverter(RenderContext context) {
+ if (useSpringJson == Boolean.TRUE &&
!resolveSpringHttpMessageConverters() &&
+ missingConvertersReported.compareAndSet(false, true)) {
+ log.warn('Spring JSON rendering is enabled but no MVC message
converters are available; using legacy JSON.')
+ }
+ return useSpringJson == Boolean.TRUE &&
resolveSpringHttpMessageConverters() && !namedConfiguration
+ }
+
+ /**
+ * @return {@code null} once the value is written, otherwise why the
Spring converters could not write it
+ */
+ private String writeWithSpringConverter(Object object, MediaType
mediaType, RenderContext context) {
+ if (mediaType.type == 'text' && mediaType.subtype == 'json') {
+ mediaType = MediaType.APPLICATION_JSON
+ }
+ Class<?> objectType = object?.getClass() ?: Object
+ HttpMessageConverter<Object> converter =
(HttpMessageConverter<Object>) resolveSpringHttpMessageConverters().find {
+ HttpMessageConverter<?> candidate ->
candidate.canWrite(objectType, mediaType) &&
+ candidate.getSupportedMediaTypes(objectType).any {
MediaType supported ->
+ supported.subtype == 'json' ||
supported.subtype.endsWith('+json')
+ }
+ }
+ if (converter == null) {
+ return "no Spring message converter writes [${objectType.name}] as
${mediaType}"
+ }
+ // A problem is written as Spring MVC writes it, which no projection
changes
+ boolean problem = object instanceof ProblemDetail
+ List<String> includes = problem ? null : getEffectiveIncludes(context)
+ List<String> excludes = problem ? null : getEffectiveExcludes(context)
+
+ // Only adapt the standard converter. Application converter subclasses
and per-type
+ // mappers retain their own serialization contract, and a problem is
written by the converter
+ // itself, with the mapper Spring MVC writes it with, as the XML
converter writes it.
+ boolean grailsMapper = !problem && grailsJsonMapperCustomizer != null
&&
+ converter.getClass() == JacksonJsonHttpMessageConverter &&
+ !((JacksonJsonHttpMessageConverter)
converter).getMappersForType(objectType)
+ if ((includes != null || excludes) && !grailsMapper) {
+ // Only the Grails mapper applies a projection; the legacy
converter applies it otherwise.
+ return 'includes and excludes need the Grails JSON mapper, which
the application\'s message converter replaces'
+ }
+ // Jackson only writes UTF encodings. Use UTF-8 for the intermediate
byte stream;
+ // the servlet writer still applies the configured response encoding.
+ Charset charset = SpringMessageConverters.writeCharset(converter,
encoding)
+ MediaType contentType = new MediaType(mediaType, charset)
+ if (grailsMapper) {
+ JacksonJsonHttpMessageConverter source =
(JacksonJsonHttpMessageConverter) converter
+ JsonProjection projection = JsonProjection.of(object, includes,
excludes)
+ writeWithGrailsMapper(object,
grailsJsonMapperCustomizer.forGrails(source.mapper), projection,
+ prefixOf(source, contentType), context.writer)
+ projection?.reportUnapplied()
+ return null
+ }
+ WriterOutputStream.writeThrough(context.writer, charset) {
OutputStream body ->
+ converter.write(object, contentType, outputMessage(body))
+ }
+ return null
+ }
+
+ /**
+ * Writes a value other than a problem as Spring MVC's converter writes
it, with the isolated Grails mapper: the
+ * converter's prefix is application configuration, and the mapper, which
is derived from the converter's, writes the
+ * wrapper of a root value, and a value a serializer hands to {@link
JsonGenerator#writePOJO(Object)} as the value
+ * itself. One write writes the whole value, so that an object a
serializer writes with {@code writePOJO} is found
+ * when it repeats one being written. The writer is left unflushed, so
that a value that fails part-way leaves the
+ * response uncommitted.
+ */
+ private static void writeWithGrailsMapper(Object value, JsonMapper mapper,
JsonProjection projection, String prefix,
+ Writer output) {
+ ObjectWriter writer =
mapper.writer().without(StreamWriteFeature.AUTO_CLOSE_TARGET)
+ .without(StreamWriteFeature.FLUSH_PASSED_TO_STREAM)
+ if (projection != null) {
+ writer = writer.withAttribute(JsonProjection.ATTRIBUTE, projection)
+ }
+ try {
+ output.write(prefix)
+ writer.writeValue(output, value)
+ }
+ catch (JacksonException | IOException e) {
+ // the converter writes its prefix with its generator, which
reports a failure of the writer as Jackson's
+ throw JacksonWriteFailures.reported(e)
+ }
+ }
+
+ /**
+ * What a converter writes before a value, such as a JSON prefix, which it
has no accessor for: what it writes for a
+ * marker, less the JSON its mapper writes for the marker, which includes
the wrapper of a root value.
+ */
+ private static String prefixOf(JacksonJsonHttpMessageConverter converter,
MediaType contentType) {
Review Comment:
Done in c974ef9d70. The prefix is read once for each converter, on its first
write, and kept in a map with weak keys, so a converter that is no longer used
is not kept, and a `setJsonPrefix` a `WebMvcConfigurer` calls as Spring MVC
starts still applies; section 4 says so. `DefaultJsonRendererSpec` "the prefix
of a converter is read once for the converter, as the configuration it is"
covers it. Since 713e9cecf8 the value itself is written by Spring MVC's
`RequestResponseBodyMethodProcessor`, through a `GrailsMapperJsonConverter`
that carries that prefix.
##########
grails-xml/src/main/groovy/org/apache/grails/xml/aot/XmlRuntimeHints.java:
##########
@@ -0,0 +1,55 @@
+/*
+ * 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.apache.grails.xml.aot;
+
+import java.util.List;
+
+import org.springframework.aot.hint.ExecutableMode;
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+import org.springframework.aot.hint.TypeReference;
+
+import grails.converters.XML;
+import
org.grails.web.converters.configuration.XmlConvertersConfigurationInitializer;
+import org.grails.web.databinding.bindingsource.XmlDataBindingSourceCreator;
+
+/**
+ * Registers the XML converter that {@code encodeAsXML} creates by name, and
the initializer of its configurations.
+ *
+ * <p>The {@code xml} encoder of {@code grails-converters} renders an object
other than text with the XML converter of
+ * this module, which depends on {@code grails-converters}, so it finds the
converter by its name and creates it with
+ * its constructor that takes the object. An ahead-of-time image keeps neither
unless something asks for them, and
+ * without them {@code encodeAsXML} escapes an object as its string, as it
does without this module. The converters'
+ * initializer, when code initializes the converters itself, creates the
initializer of the XML converter by name the
+ * same way, and the data binding registry its default XML binding source
creator.</p>
+ *
+ * @since 9.0
+ */
+public class XmlRuntimeHints implements RuntimeHintsRegistrar {
+
+ @Override
+ public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
+ hints.reflection().registerType(XML.class,
Review Comment:
Fixed in 1a57d26dab with `@SuppressWarnings("removal")` on the class. javac
no longer reports it.
##########
grails-xml/src/main/groovy/org/grails/plugins/web/rest/render/xml/ProblemXml.groovy:
##########
@@ -0,0 +1,413 @@
+/*
+ * 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.xml
+
+import javax.xml.stream.XMLOutputFactory
+import javax.xml.stream.XMLStreamException
+import javax.xml.stream.XMLStreamWriter
+
+import groovy.transform.CompileStatic
+import groovy.transform.PackageScope
+
+import tools.jackson.core.JsonGenerator
+import tools.jackson.core.JsonParser
+import tools.jackson.core.JsonToken
+import tools.jackson.core.StreamWriteConstraints
+import tools.jackson.core.StreamWriteFeature
+import tools.jackson.core.util.JsonGeneratorDelegate
+import tools.jackson.databind.JacksonSerializable
+import tools.jackson.databind.ObjectMapper
+import tools.jackson.databind.SerializationContext
+import tools.jackson.databind.SerializationFeature
+import tools.jackson.databind.exc.MismatchedInputException
+import tools.jackson.databind.jsontype.TypeSerializer
+import tools.jackson.databind.util.RawValue
+import tools.jackson.databind.util.TokenBuffer
+
+import org.springframework.http.ProblemDetail
+
+/**
+ * Writes a problem in the XML format of RFC 9457, as Spring's Jackson XML
converter writes it, for an application without
+ * {@code jackson-dataformat-xml}: a {@code problem} element in the {@code
urn:ietf:rfc:7807} namespace, with the members
+ * of the problem, those of a subclass included, and its extension members, as
a mapper with Spring's mix-in for problems
+ * writes them, every member it writes included, also one whose name it writes
twice, the properties of a bean in no
+ * namespace and the entries of a map in that of the map, as Spring's
converter writes them, but for the members of RFC
+ * 9457, by the getters Spring's mix-in names, and the extension members of
the problem, or of a problem it holds, which
+ * are in the namespace of RFC 9457. It fails, as Spring's
+ * converters fail, for a problem nested deeper than the mapper's {@code
StreamWriteConstraints} allow. A list is written as an element for each of its
values, wrapped in an element of its name where
+ * it is the property of a bean, and a {@code null} as an {@code xsi:nil}
element.
+ *
+ * @since 9.0
+ */
+@PackageScope
+@CompileStatic
+final class ProblemXml {
Review Comment:
Removed in 6eb92d4045, already in Grails 9 rather than in Grails 10. Since
4223262ac3 `respond` hands a problem to the handler that Spring MVC's handler
adapter writes a `ResponseEntity` with, so it no longer reached this class. The
XML renderer now writes a problem as `application/problem+xml` where a
converter writes it in XML, and otherwise as `application/problem+json`, as
Spring MVC answers without `jackson-dataformat-xml`. `XmlProblemDetailSpec`
keeps the comparisons against Spring's converter that do not depend on the
fallback.
##########
grails-doc/src/en/guide/REST/hypermedia/vndError.adoc:
##########
@@ -41,30 +44,62 @@ Content-Type: application/json;charset=ISO-8859-1
}
----
-If you wish to change the format to Vnd.Error then simply register
`grails.rest.render.errors.VndErrorJsonRenderer` bean in
`grails-app/conf/spring/resources.groovy`:
+With `grails.web.rendering.json.spring: true`, which is the default from
Grails 10, `respond` sends the
+errors as an RFC 9457 problem instead, and new applications should use that
response:
Review Comment:
Dropped in 308382daa9.
--
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]