matrei commented on code in PR #16414:
URL: https://github.com/apache/grails-core/pull/16414#discussion_r4205462272
##########
grails-converters/src/main/groovy/grails/converters/JSON.java:
##########
@@ -220,6 +228,26 @@ else if (o instanceof Number) {
}
}
+ /**
+ * @return the JsonMapper this converter writes with
+ * @since 9.0
+ */
+ public JsonMapperSupport getJsonMapper() {
+ return jsonMapper != null ? jsonMapper :
ConvertersConfigurationHolder.getJsonMapper();
+ }
+
+ /**
+ * The JSON object key for a map key, as the JsonMapper writes it: a
{@code String} key as it is, and a
+ * {@code Date} key, for example, in the mapper's date format.
+ *
+ * @param key a non-null map key
+ * @return the JSON object key
+ * @since 9.0
+ */
+ public String formatKey(Object key) {
+ return getJsonMapper().formatKey(key);
Review Comment:
Map keys go through the application mapper's key serializers in legacy mode
too. Grails 8 used `JsonDateFormat.formatKey`, which is `toString()` for
everything except dates. With legacy on:
- a `Date(0)` key, with `spring.jackson.time-zone: America/New_York`,
renders `"1969-12-31T19:00:00.000-05:00"`; Grails 8 wrote
`"1970-01-01T00:00:00.000Z"`
- a `Class` key renders `"java.lang.String"`; Grails 8 wrote `"class
java.lang.String"`
- a key whose class has `@JsonValue` renders the `@JsonValue`; Grails 8
wrote `toString()`
The `Class` and `@JsonValue` cases differ even with a default mapper.
Branching here on `ConvertersConfigurationHolder.isLegacyJson()` to
`JsonDateFormat.formatKey` would restore it. The `Class` key change in the new
mode isn't in the upgrade guide's table either.
##########
grails-converters/src/main/groovy/org/grails/web/converters/configuration/ConvertersConfigurationInitializer.java:
##########
@@ -97,6 +116,8 @@ public void initialize() {
if (applicationContext != null) {
ConvertersConfigurationHolder.setObservationRegistry(
applicationContext.getBeanProvider(ObservationRegistry.class).getIfAvailable(()
-> ObservationRegistry.NOOP));
+ // the application's JsonMapper, or a default one when there is
none, or more than one and none is primary
+
ConvertersConfigurationHolder.setJsonMapper(applicationContext.getBeanProvider(JsonMapper.class).getIfUnique());
Review Comment:
The application's mapper is installed even when
`grails.converters.json.legacy` is true. Legacy text is then written by a
generator copied from it, so ordinary Boot write settings change the output
that is meant to be Grails 8's. Legacy mode with a mapper that has
`INDENT_OUTPUT`, `WRITE_NUMBERS_AS_STRINGS` and `WRITE_BIGDECIMAL_AS_PLAIN`
enabled renders `[n: 42, b: 1E+3]` as:
```
{
"n" : "42",
"b" : "1000"
}
```
Grails 8 wrote `{"n":42,"b":1E+3}`. `toString(false)` is indented as well.
These map to `spring.jackson.serialization.indent-output`,
`spring.jackson.json.write.*` and `spring.jackson.write.*`, which an app may
already have set for its `@RestController`s. Could legacy mode use
`JsonMapperSupport.DEFAULT` (or another fixed mapper) instead? A case with a
non-default mapper in `Grails8JsonRenderingSpec` would cover it.
##########
grails-converters/src/main/groovy/org/grails/web/converters/configuration/ConvertersConfigurationInitializer.java:
##########
@@ -111,26 +132,75 @@ private void initJSONConfiguration() {
List<ObjectMarshaller<JSON>> marshallers = new ArrayList<>();
marshallers.addAll(getPreviouslyConfiguredMarshallers(JSON.class));
- marshallers.add(new
org.grails.web.converters.marshaller.json.ArrayMarshaller());
- marshallers.add(new
org.grails.web.converters.marshaller.json.ByteArrayMarshaller());
- marshallers.add(new
org.grails.web.converters.marshaller.json.CollectionMarshaller());
- marshallers.add(new
org.grails.web.converters.marshaller.json.MapMarshaller());
- marshallers.add(new
org.grails.web.converters.marshaller.json.SimpleEnumMarshaller());
Config grailsConfig = getGrailsConfig();
+ ProxyHandler proxyHandler = getProxyHandler();
+ boolean legacy =
grailsConfig.getProperty(SETTING_CONVERTERS_JSON_LEGACY, Boolean.class, false);
+ ConvertersConfigurationHolder.setLegacyJson(legacy);
+ if (legacy) {
+ LOG.warn(SETTING_CONVERTERS_JSON_LEGACY + " renders JSON as Grails
8 did. It is deprecated, and will be removed in Grails 10.");
+ addLegacyJsonMarshallers(marshallers, grailsConfig, proxyHandler);
+ }
+ else {
+ addJsonMarshallers(marshallers, grailsConfig, proxyHandler);
+ }
- marshallers.add(new
org.grails.web.converters.marshaller.ProxyUnwrappingMarshaller<>());
+ DefaultConverterConfiguration<JSON> cfg = new
DefaultConverterConfiguration<>(marshallers, proxyHandler);
+ cfg.setEncoding(grailsConfig.getProperty(SETTING_CONVERTERS_ENCODING,
"UTF-8"));
+ String defaultCirRefBehaviour =
grailsConfig.getProperty(SETTING_CONVERTERS_CIRCULAR_REFERENCE_BEHAVIOUR,
"DEFAULT");
+
cfg.setCircularReferenceBehaviour(Converter.CircularReferenceBehaviour.valueOf(
+
grailsConfig.getProperty(SETTING_CONVERTERS_JSON_CIRCULAR_REFERENCE_BEHAVIOUR,
String.class,
+ defaultCirRefBehaviour,
Converter.CircularReferenceBehaviour.allowedValues())));
+
+ Boolean defaultPrettyPrint =
grailsConfig.getProperty(SETTING_CONVERTERS_PRETTY_PRINT, Boolean.class, false);
+ Boolean prettyPrint =
grailsConfig.getProperty(SETTING_CONVERTERS_JSON_PRETTY_PRINT, Boolean.class,
defaultPrettyPrint);
+ cfg.setPrettyPrint(prettyPrint);
+
cfg.setCacheObjectMarshallerByClass(grailsConfig.getProperty(SETTING_CONVERTERS_JSON_CACHE_OBJECTS,
Boolean.class, true));
+
+ registerObjectMarshallersFromApplicationContext(cfg, JSON.class);
+ ConvertersConfigurationHolder.setDefaultConfiguration(JSON.class, new
ChainedConverterConfiguration<>(cfg, proxyHandler));
+ }
+
+ private void addJsonMarshallers(List<ObjectMarshaller<JSON>> marshallers,
Config grailsConfig, ProxyHandler proxyHandler) {
if
("javascript".equals(grailsConfig.getProperty(SETTING_CONVERTERS_JSON_DATE,
String.class, "default", Arrays.asList("javascript", "default")))) {
if (LOG.isDebugEnabled()) {
LOG.debug("Using Javascript JSON Date Marshaller.");
}
marshallers.add(new
org.grails.web.converters.marshaller.json.JavascriptDateMarshaller());
}
+ // domain classes, and proxies of them, are claimed first: the
application's JsonMapper may have a serializer for
+ // them, and they are rendered with the converter's includes, excludes
and deep setting
+ marshallers.add(new
org.grails.web.converters.marshaller.ProxyUnwrappingMarshaller<>());
+ marshallers.add(domainClassMarshaller(grailsConfig, proxyHandler));
+ // dates and times and the other single values the application's
JsonMapper has a serializer for are written
+ // by the mapper, as Spring Boot writes them
+ marshallers.add(new
org.grails.web.converters.marshaller.json.JsonMapperValueMarshaller());
Review Comment:
`JsonMapperValueMarshaller` sits ahead of `SimpleEnumMarshaller`. For an
enum with a `@JsonValue` method, Jackson's serializer is a
`JsonValueSerializer`, not an `EnumSerializer`, so `rendersValue` is true and
the mapper renders it. `enum Status { ACTIVE('A'); @JsonValue String getCode()
{ code } }` renders `"A"`, where Grails 8 and legacy render `"ACTIVE"`.
Posting that back binds `null` without an error, because
`SimpleDataBinder.convertStringToEnum` swallows the `IllegalArgumentException`.
That contradicts `upgrading90x.adoc:56` and `defaultRenderers.adoc:93` ("Enums
render by `name()` … so that the JSON binds back"). The `@JsonValue` row in the
table says Grails 8 wrote "bean objects", but for an enum it wrote `name()`.
Either let `SimpleEnumMarshaller` claim enums (other than `Month`) first, or
give enums their own row and drop the binds-back sentence.
##########
grails-converters/src/main/groovy/org/grails/web/converters/jackson/DomainClassSerializer.java:
##########
@@ -0,0 +1,321 @@
+/*
+ * 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.converters.jackson;
+
+import java.lang.reflect.AnnotatedElement;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import tools.jackson.core.JsonGenerator;
+import tools.jackson.databind.JacksonSerializable;
+import tools.jackson.databind.SerializationContext;
+import tools.jackson.databind.jsontype.TypeSerializer;
+import tools.jackson.databind.ser.std.StdSerializer;
+
+import org.springframework.beans.BeanWrapper;
+import org.springframework.beans.BeanWrapperImpl;
+
+import org.grails.core.exceptions.GrailsConfigurationException;
+import org.grails.datastore.mapping.model.PersistentEntity;
+import org.grails.datastore.mapping.model.PersistentProperty;
+import org.grails.datastore.mapping.model.config.GormProperties;
+import org.grails.datastore.mapping.model.types.Association;
+import org.grails.datastore.mapping.model.types.ManyToOne;
+import org.grails.datastore.mapping.model.types.OneToOne;
+import org.grails.datastore.mapping.reflect.NameUtils;
+import org.grails.datastore.mapping.reflect.ReflectionUtils;
+
+/**
+ * Serializes domain class instances as {@code grails.converters.JSON} renders
them: an object of the id, the version
+ * and the class name when they are included, and the persistent properties.
An associated domain class instance is a
+ * reference of its id, such as {@code {"id":1}}, or, when rendering deep, the
instance in full. Embedded instances and
+ * collections of basic values are written in full.
+ *
+ * <p>Property values are written with {@link
JsonGenerator#writePOJO(Object)}, so a mapper writes them as it writes any
+ * other value, and a converter renders them with its marshallers. When
rendering deep, an instance that is already
+ * being written, further up, is written as a reference.
+ *
+ * <p>A property annotated with {@code @JsonIgnore}, or with {@code
@JsonProperty(access = WRITE_ONLY)}, on its field
+ * or getter, is not written. The {@link DomainClassRendering} decides about
the others.
+ *
+ * @since 9.0
+ */
+public final class DomainClassSerializer extends StdSerializer<Object> {
Review Comment:
This doesn't override `unwrappingSerializer(NameTransformer)` or
`isUnwrappingSerializer()`, so `@JsonUnwrapped` on a property holding a domain
instance stops unwrapping. `UnwrappingBeanPropertyWriter` writes the name and
nests the object. `class Wrapper { @JsonUnwrapped Book book; String note }`
renders `{"bookTitle":"G","id":1,"version":null,"note":"n"}` without the module
and `{"book":{"id":1,"bookTitle":"G"},"note":"n"}` with it. I haven't checked
Spring HATEOAS, but `EntityModel<T>` holds its content as `@JsonUnwrapped`, so
`EntityModel<Book>` is likely to change shape too. An unwrapping variant would
fix it: it skips the start and end of the object and passes property names
through the `NameTransformer`.
##########
grails-converters/src/main/groovy/org/grails/web/converters/jackson/DomainClassJacksonModule.java:
##########
@@ -0,0 +1,102 @@
+/*
+ * 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.converters.jackson;
+
+import java.util.Objects;
+import java.util.function.Supplier;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import tools.jackson.core.Version;
+import tools.jackson.databind.BeanDescription;
+import tools.jackson.databind.JacksonModule;
+import tools.jackson.databind.JavaType;
+import tools.jackson.databind.SerializationConfig;
+import tools.jackson.databind.ValueSerializer;
+import tools.jackson.databind.ser.Serializers;
+
+/**
+ * A Jackson module that serializes domain class instances, and proxies of
them, with a {@link DomainClassSerializer},
+ * as {@code grails.converters.JSON} renders them. A Grails application
registers it with the application's
+ * {@code JsonMapper}, so that writing a domain class instance with the
mapper, including returning one from a
+ * Spring MVC controller, renders it as {@code render ... as JSON} does.
+ *
+ * <p>A domain class with its own {@code @JsonSerialize} serializer keeps it.
+ *
+ * @since 9.0
+ */
+public class DomainClassJacksonModule extends JacksonModule {
+
+ private final Supplier<DomainClassRendering> rendering;
+
+ /**
+ * @param rendering how to render domain class instances
+ */
+ public DomainClassJacksonModule(DomainClassRendering rendering) {
+ this(() -> rendering);
+ }
+
+ /**
+ * @param rendering supplies how to render domain class instances, when
the first one is serialized
+ */
+ public DomainClassJacksonModule(Supplier<DomainClassRendering> rendering) {
+ this.rendering = Objects.requireNonNull(rendering, "rendering cannot
be null");
+ }
+
+ @Override
+ public String getModuleName() {
+ return "grails-domain-classes";
+ }
+
+ @Override
+ public Version version() {
+ return Version.unknownVersion();
+ }
+
+ @Override
+ public void setupModule(SetupContext context) {
+ context.addSerializers(new DomainClassSerializers(rendering));
+ }
+
+ private static final class DomainClassSerializers implements Serializers {
+
+ private final Supplier<DomainClassRendering> rendering;
+
+ private volatile DomainClassSerializer serializer;
+
+ private DomainClassSerializers(Supplier<DomainClassRendering>
rendering) {
+ this.rendering = rendering;
+ }
+
+ @Override
+ public ValueSerializer<?> findSerializer(SerializationConfig config,
JavaType type,
+ BeanDescription.Supplier beanDescRef, JsonFormat.Value
formatOverrides) {
+ DomainClassSerializer domainClassSerializer = serializer();
+ return
domainClassSerializer.getRendering().isDomainClass(type.getRawClass()) ?
domainClassSerializer : null;
Review Comment:
Jackson asks module `Serializers` before it looks at `@JsonValue` and
`JacksonSerializable`. In `BeanSerializerFactory._createSerializer2`,
`findSerializerByAnnotations` comes after the module lookup, and only a
class-level `@JsonSerialize` is checked earlier. So a domain class that uses
either of them loses it on the application's mapper. `@Entity class Code {
String code; @JsonValue String jsonValue() { code } }` returned from a
`@RestController` renders `"X1"` without the module and `{"id":2,"code":"X1"}`
with it. Returning `null` here when `beanDescRef.get().findJsonValueAccessor()
!= null`, or when the type implements `JacksonSerializable`, would leave those
to Jackson. Otherwise they should go in the guide next to `@JsonSerialize`.
##########
grails-converters/src/main/groovy/org/grails/web/converters/jackson/DomainClassRendering.java:
##########
@@ -0,0 +1,220 @@
+/*
+ * 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.converters.jackson;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import groovy.lang.GroovyObject;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+import grails.core.GrailsApplication;
+import grails.core.support.proxy.DefaultProxyHandler;
+import grails.core.support.proxy.EntityProxyHandler;
+import grails.core.support.proxy.ProxyHandler;
+import org.grails.core.artefact.DomainClassArtefactHandler;
+import org.grails.datastore.mapping.model.PersistentEntity;
+import org.grails.datastore.mapping.model.PersistentProperty;
+import org.grails.datastore.mapping.model.config.GormProperties;
+import org.grails.datastore.mapping.reflect.ClassPropertyFetcher;
+import org.grails.web.converters.ConverterUtil;
+import org.grails.web.converters.marshaller.ByDatasourceDomainClassFetcher;
+import
org.grails.web.converters.marshaller.ByGrailsApplicationDomainClassFetcher;
+import org.grails.web.converters.marshaller.DomainClassFetcher;
+
+/**
+ * How a {@link DomainClassSerializer} renders domain class instances: whether
it writes the version and the class
+ * name, whether it renders associated domain class instances in full (deep)
or as references of their id, and which
+ * properties it writes.
+ *
+ * <p>The defaults are the converter's JSON defaults ({@code
grails.converters.json.domain.include.version},
+ * {@code grails.converters.json.domain.include.class} and {@code
grails.converters.json.default.deep}), and every
+ * property is written except those named by {@code @JsonIgnoreProperties} on
the domain class or a superclass, as
+ * Jackson leaves them out. GORM adds one to every domain class that names its
own properties, such as
+ * {@code errors} and {@code tenantId}; the version is written when it is
included regardless. Subclasses override the
+ * methods to render differently.
+ *
+ * @since 9.0
+ */
+public class DomainClassRendering {
+
+ private static final ClassValue<Set<String>> IGNORED_PROPERTIES = new
ClassValue<>() {
+ @Override
+ protected Set<String> computeValue(Class<?> type) {
+ Set<String> ignored = new HashSet<>();
+ for (Class<?> current = type; current != null && current !=
Object.class; current = current.getSuperclass()) {
+ JsonIgnoreProperties ignoreProperties =
current.getAnnotation(JsonIgnoreProperties.class);
+ if (ignoreProperties != null &&
!ignoreProperties.allowGetters()) {
+ ignored.addAll(Arrays.asList(ignoreProperties.value()));
+ }
+ }
+ return Set.copyOf(ignored);
+ }
+ };
+
+ private final GrailsApplication grailsApplication;
+
+ private final ProxyHandler proxyHandler;
+
+ private final boolean includeVersion;
+
+ private final boolean includeClass;
+
+ private final boolean deep;
+
+ private final List<DomainClassFetcher> domainClassFetchers;
+
+ /**
+ * @param grailsApplication the application whose domain classes are
rendered
+ * @param proxyHandler unwraps proxied domain class instances
+ * @param includeVersion whether to write the version
+ * @param includeClass whether to write the class name
+ * @param deep whether to render associated domain class instances in
full, rather than as references of their id
+ */
+ public DomainClassRendering(GrailsApplication grailsApplication,
ProxyHandler proxyHandler, boolean includeVersion,
+ boolean includeClass, boolean deep) {
+ this.grailsApplication = grailsApplication;
+ this.proxyHandler = proxyHandler != null ? proxyHandler : new
DefaultProxyHandler();
+ this.includeVersion = includeVersion;
+ this.includeClass = includeClass;
+ this.deep = deep;
+ this.domainClassFetchers = grailsApplication != null ?
+ List.of(new
ByGrailsApplicationDomainClassFetcher(grailsApplication), new
ByDatasourceDomainClassFetcher()) :
+ List.of(new ByDatasourceDomainClassFetcher());
+ }
+
+ /**
+ * @return whether the version is written
+ */
+ public boolean isIncludeVersion() {
+ return includeVersion;
+ }
+
+ /**
+ * @return whether the class name is written, of an instance and of the
references to associated instances
+ */
+ public boolean isIncludeClass() {
+ return includeClass;
+ }
+
+ /**
+ * @return whether associated domain class instances are rendered in full,
rather than as references of their id
+ */
+ public boolean isDeep() {
+ return deep;
+ }
+
+ /**
+ * @param object a domain class instance
+ * @param property the name of one of its properties, or {@code class} or
{@code version}
+ * @return whether the property is written
+ */
+ public boolean includes(Object object, String property) {
+ return GormProperties.VERSION.equals(property) ||
!IGNORED_PROPERTIES.get(object.getClass()).contains(property);
+ }
+
+ /**
+ * @param type a type
+ * @return whether the type is a domain class, or a proxy class of one
+ */
+ public boolean isDomainClass(Class<?> type) {
+ if (grailsApplication == null) {
+ return false;
+ }
+ for (Class<?> current = type; current != null && current !=
Object.class; current = current.getSuperclass()) {
+ if
(grailsApplication.isArtefactOfType(DomainClassArtefactHandler.TYPE,
Review Comment:
`isDomainClass` walks superclasses, but `DomainClassMarshaller.supports`
only matches the exact artefact name, so a non-artefact subclass of a domain
class falls between them. That covers `class BookSub extends Book`, a Spock
`Spy(Book)` or an anonymous subclass. `DomainClassSerializer` isn't in
`rendersValue`'s exclusion list, so `JsonMapperValueMarshaller` claims what
`DomainClassMarshaller` declined. Both `jsonMapper.writeValueAsString(sub)` and
`new JSON(sub).toString()` then fail with `IllegalStateException: No GORM
implementation configured for class [...BookSub]`. Before this PR, the
converter rendered it with `GroovyBeanMarshaller` and Boot's mapper as a bean.
Matching only a name that trims to an artefact (the class itself or its proxy
classes) would leave other subclasses to the bean serializer.
##########
grails-converters/src/main/groovy/org/grails/web/converters/jackson/DomainClassSerializer.java:
##########
@@ -0,0 +1,321 @@
+/*
+ * 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.converters.jackson;
+
+import java.lang.reflect.AnnotatedElement;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import tools.jackson.core.JsonGenerator;
+import tools.jackson.databind.JacksonSerializable;
+import tools.jackson.databind.SerializationContext;
+import tools.jackson.databind.jsontype.TypeSerializer;
+import tools.jackson.databind.ser.std.StdSerializer;
+
+import org.springframework.beans.BeanWrapper;
+import org.springframework.beans.BeanWrapperImpl;
+
+import org.grails.core.exceptions.GrailsConfigurationException;
+import org.grails.datastore.mapping.model.PersistentEntity;
+import org.grails.datastore.mapping.model.PersistentProperty;
+import org.grails.datastore.mapping.model.config.GormProperties;
+import org.grails.datastore.mapping.model.types.Association;
+import org.grails.datastore.mapping.model.types.ManyToOne;
+import org.grails.datastore.mapping.model.types.OneToOne;
+import org.grails.datastore.mapping.reflect.NameUtils;
+import org.grails.datastore.mapping.reflect.ReflectionUtils;
+
+/**
+ * Serializes domain class instances as {@code grails.converters.JSON} renders
them: an object of the id, the version
+ * and the class name when they are included, and the persistent properties.
An associated domain class instance is a
+ * reference of its id, such as {@code {"id":1}}, or, when rendering deep, the
instance in full. Embedded instances and
+ * collections of basic values are written in full.
+ *
+ * <p>Property values are written with {@link
JsonGenerator#writePOJO(Object)}, so a mapper writes them as it writes any
+ * other value, and a converter renders them with its marshallers. When
rendering deep, an instance that is already
+ * being written, further up, is written as a reference.
+ *
+ * <p>A property annotated with {@code @JsonIgnore}, or with {@code
@JsonProperty(access = WRITE_ONLY)}, on its field
+ * or getter, is not written. The {@link DomainClassRendering} decides about
the others.
+ *
+ * @since 9.0
+ */
+public final class DomainClassSerializer extends StdSerializer<Object> {
+
+ private static final String RENDERING_INSTANCES =
DomainClassSerializer.class.getName() + ".instances";
+
+ private static final ClassValue<Set<String>> IGNORED_PROPERTIES = new
ClassValue<>() {
+ @Override
+ protected Set<String> computeValue(Class<?> type) {
+ Set<String> ignored = new HashSet<>();
+ for (Class<?> current = type; current != null && current !=
Object.class; current = current.getSuperclass()) {
+ for (Field field : current.getDeclaredFields()) {
+ if (ignoredForSerialization(field)) {
+ ignored.add(field.getName());
+ }
+ }
+ for (Method method : current.getDeclaredMethods()) {
+ if (ReflectionUtils.isGetter(method.getName(),
method.getParameterTypes()) &&
+ ignoredForSerialization(method)) {
+
ignored.add(NameUtils.getPropertyNameForGetterOrSetter(method.getName()));
+ }
+ }
+ }
+ return Set.copyOf(ignored);
+ }
+ };
+
+ private final DomainClassRendering rendering;
+
+ /**
+ * @param rendering how to render domain class instances
+ */
+ public DomainClassSerializer(DomainClassRendering rendering) {
+ super(Object.class);
+ this.rendering = rendering;
+ }
+
+ /**
+ * @return how this serializer renders domain class instances
+ */
+ public DomainClassRendering getRendering() {
+ return rendering;
+ }
+
+ /**
+ * A value that a mapper writes as a domain class instance rendered in a
particular way, whether or not the mapper
+ * has a {@link DomainClassJacksonModule}.
+ *
+ * @param object a domain class instance
+ * @param rendering how to render it
+ * @return the value to write
+ */
+ public static Object value(Object object, DomainClassRendering rendering) {
+ return new JacksonSerializable.Base() {
+ @Override
+ public void serialize(JsonGenerator generator,
SerializationContext context) {
+ write(object, generator, context, rendering);
+ }
+
+ @Override
+ public void serializeWithType(JsonGenerator generator,
SerializationContext context,
+ TypeSerializer typeSerializer) {
+ serialize(generator, context);
+ }
+ };
+ }
+
+ @Override
+ public void serialize(Object value, JsonGenerator gen,
SerializationContext ctxt) {
+ Object object = rendering.unwrap(value);
+ Set<Object> instances = renderingInstances(ctxt);
+ if (!instances.add(object)) {
+ writeReference(object, entity(object, rendering), gen, rendering);
+ return;
+ }
+ try {
+ write(object, gen, ctxt, rendering);
+ }
+ finally {
+ instances.remove(object);
+ }
+ }
+
+ private static void write(Object value, JsonGenerator gen,
SerializationContext ctxt,
+ DomainClassRendering rendering) {
+ Object object = rendering.unwrap(value);
+ PersistentEntity entity = entity(object, rendering);
+ Set<String> ignored = IGNORED_PROPERTIES.get(object.getClass());
+ gen.writeStartObject(object);
+ if (rendering.isIncludeClass() && rendering.includes(object, "class"))
{
+ gen.writeStringProperty("class", object.getClass().getName());
+ }
+ // a composite key has no identity, and is not written
+ PersistentProperty identity = entity.getIdentity();
+ if (identity != null && !ignored.contains(identity.getName()) &&
rendering.includes(object, identity.getName())) {
+ Object id = rendering.propertyValue(object, identity);
+ if (id != null) {
+ gen.writeName(identity.getName());
+ gen.writePOJO(id);
+ }
+ }
+ if (rendering.isIncludeVersion() &&
!ignored.contains(GormProperties.VERSION) &&
+ rendering.includes(object, GormProperties.VERSION)) {
+ Object version = rendering.propertyValue(object,
entity.getVersion());
+ if (version != null) {
+ gen.writeName(GormProperties.VERSION);
+ gen.writePOJO(version);
+ }
+ }
+ BeanWrapper bean = new BeanWrapperImpl(object);
+ for (PersistentProperty property : entity.getPersistentProperties()) {
+ if (property.equals(entity.getVersion()) ||
ignored.contains(property.getName()) ||
+ !rendering.includes(object, property.getName())) {
+ continue;
+ }
+ gen.writeName(property.getName());
Review Comment:
Property names (and `class`, the id name and `version` above) are written as
they are, so the mapper's `PropertyNamingStrategy` no longer applies to domain
classes on the mapper path. With `spring.jackson.property-naming-strategy:
SNAKE_CASE`, a domain class renders `{"book_title":"G","id":1,"version":null}`
without the module and `{"id":1,"bookTitle":"G"}` with it. The same goes for
`@JsonNaming` and `spring.jackson.mapper.sort-properties-alphabetically`. This
is a mapper setting rather than an annotation, so it isn't covered by the guide
thread. The converter writes through a copy of the same mapper, so applying the
strategy in `serialize()` only, not in the converter's `value()` path, would
keep `render as JSON` unchanged.
##########
grails-converters/src/main/groovy/org/grails/web/converters/configuration/ConvertersConfigurationInitializer.java:
##########
@@ -111,26 +132,75 @@ private void initJSONConfiguration() {
List<ObjectMarshaller<JSON>> marshallers = new ArrayList<>();
marshallers.addAll(getPreviouslyConfiguredMarshallers(JSON.class));
- marshallers.add(new
org.grails.web.converters.marshaller.json.ArrayMarshaller());
- marshallers.add(new
org.grails.web.converters.marshaller.json.ByteArrayMarshaller());
- marshallers.add(new
org.grails.web.converters.marshaller.json.CollectionMarshaller());
- marshallers.add(new
org.grails.web.converters.marshaller.json.MapMarshaller());
- marshallers.add(new
org.grails.web.converters.marshaller.json.SimpleEnumMarshaller());
Config grailsConfig = getGrailsConfig();
+ ProxyHandler proxyHandler = getProxyHandler();
+ boolean legacy =
grailsConfig.getProperty(SETTING_CONVERTERS_JSON_LEGACY, Boolean.class, false);
Review Comment:
For breaking default changes in a major, Grails 8 did what this PR does. The
new behaviour became the default, and an application could restore the old one
with a setting:
- Unconstrained GORM properties became nullable, restored with
`grails.gorm.default.nullable: false` ([upgrade guide
§27](https://github.com/apache/grails-core/blob/3d6db18699d30a459f2dd1a277ad6d3ba61311ee/grails-doc/src/en/guide/upgrading/upgrading80x.adoc#L1448-L1488)).
- Native queries return `java.time` types, restored with
`hibernate.query.native.prefer_jdbc_datetime_types: true` "during a phased
migration"
([§26.5](https://github.com/apache/grails-core/blob/3d6db18699d30a459f2dd1a277ad6d3ba61311ee/grails-doc/src/en/guide/upgrading/upgrading80x.adoc#L1288-L1301)).
- The `Accept` header is honoured for browsers, restored by setting
`grails.mime.disable.accept.header.userAgents`
([§30](https://github.com/apache/grails-core/blob/3d6db18699d30a459f2dd1a277ad6d3ba61311ee/grails-doc/src/en/guide/upgrading/upgrading80x.adoc#L1614-L1646)).
- `defaultViewResolver` and the welcome page are removed, restored with
`grails.web.removeDefaultViewResolverBean: false` and
`grails.web.removeWelcomePageMapping: false` (§31).
The Grails 8 change closest to this one, the enum JSON shape, had no switch
at all
([§8](https://github.com/apache/grails-core/blob/3d6db18699d30a459f2dd1a277ad6d3ba61311ee/grails-doc/src/en/guide/upgrading/upgrading80x.adoc#L307-L330):
"There is no Grails 8 configuration switch that restores the verbose
enum-object shape").
So I'd keep the new output as the 9.0 default, with
`grails.converters.json.legacy: true` as the way back. That holds once the
legacy-mode gaps in my review are fixed, so that the switch really gives Grails
8's text.
Where I agree is the timeline. None of the Grails 8 switches above was
deprecated for removal in the release that added it. Making this one
`forRemoval` and logging a warning from day one leaves less room than we gave
there. I'd keep the setting without a deprecation in 9, or at least not name
Grails 10 for its removal until we know how many applications depend on it.
--
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]