You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by sb...@apache.org on 2026/09/02 15:05:20 UTC
(grails-core) branch fix/16294-16295-non-public-bean-marshalling created (now dbbfdb538d)
This is an automated email from the ASF dual-hosted git repository.
sbglasius pushed a change to branch fix/16294-16295-non-public-bean-marshalling
in repository https://gitbox.apache.org/repos/asf/grails-core.git
at dbbfdb538d Fix marshalling of non-public classes and Serializable beans
This branch includes the following new commits:
new dbbfdb538d Fix marshalling of non-public classes and Serializable beans
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(grails-core) 01/01: Fix marshalling of non-public classes and Serializable beans
Posted by sb...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
sbglasius pushed a commit to branch fix/16294-16295-non-public-bean-marshalling
in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit dbbfdb538dac189233d91f79f7ae379c53675b25
Author: Søren Berg Glasius <so...@glasius.dk>
AuthorDate: Wed Sep 2 17:04:43 2026 +0200
Fix marshalling of non-public classes and Serializable beans
Marshalling `someObject as JSON` (or `as XML`) failed for two common shapes of
object.
A class that is not public — anonymous, local or package-private — cannot have
its read methods invoked reflectively from another package, even though the
methods themselves are public. All four bean marshallers called
`readMethod.invoke(...)` bare, so handing `as JSON` an anonymous implementation
of a public interface (the usual shape of a Spring Security `UserDetails`) blew
up with an `IllegalAccessException`. The read method is now resolved to the
interface method where one exists and made accessible otherwise. The public
field loops of both `GroovyBeanMarshaller`s failed the same way and are now
made accessible too.
`GenericJavaBeanMarshaller` evaluated `field.canAccess(o)` before the static
check, and `canAccess` throws `IllegalArgumentException` for a static member
when the object is non-null. Any bean declaring `private static final long
serialVersionUID` — nearly every Serializable bean — therefore failed. The
modifier checks now run first so they short-circuit.
Groovy compiles the variables captured by an anonymous class into public
synthetic `Reference` fields. Now that the field loops can read them, they were
emitted as duplicate keys with empty-object values, so synthetic fields are
skipped in all four marshallers.
Fixes #16294
Fixes #16295
---
.../marshaller/json/GenericJavaBeanMarshaller.java | 9 +-
.../marshaller/json/GroovyBeanMarshaller.java | 9 +-
.../marshaller/xml/GenericJavaBeanMarshaller.java | 11 +-
.../marshaller/xml/GroovyBeanMarshaller.java | 9 +-
.../converters/beans/GroovyPersonFactory.groovy | 81 +++++++++
.../web/converters/beans/JavaPersonFactory.java | 61 +++++++
.../beans/PackagePrivateGroovyPerson.groovy | 49 +++++
.../converters/beans/PackagePrivateJavaPerson.java | 44 +++++
.../PackagePrivateStandaloneGroovyBean.groovy | 41 +++++
.../beans/PackagePrivateStandaloneJavaBean.java | 36 ++++
.../grails/web/converters/beans/PublicPerson.java | 35 ++++
.../web/converters/beans/SerializableJavaBean.java | 46 +++++
.../json/NonPublicClassMarshallingSpec.groovy | 197 ++++++++++++++++++++
.../xml/NonPublicClassMarshallingSpec.groovy | 199 +++++++++++++++++++++
14 files changed, 818 insertions(+), 9 deletions(-)
diff --git a/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/GenericJavaBeanMarshaller.java b/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/GenericJavaBeanMarshaller.java
index 03c0752ecb..bee67c9051 100644
--- a/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/GenericJavaBeanMarshaller.java
+++ b/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/GenericJavaBeanMarshaller.java
@@ -25,6 +25,8 @@ import java.lang.reflect.Modifier;
import java.util.List;
import org.springframework.beans.BeanUtils;
+import org.springframework.util.ClassUtils;
+import org.springframework.util.ReflectionUtils;
import grails.converters.JSON;
import grails.persistence.PersistenceMethod;
@@ -64,14 +66,17 @@ public class GenericJavaBeanMarshaller extends IncludeExcludePropertyMarshaller<
if (Modifier.isStatic(readMethod.getModifiers())) continue;
if (readMethod.getAnnotation(PersistenceMethod.class) != null) continue;
if (readMethod.getAnnotation(ControllerMethod.class) != null) continue;
- Object value = readMethod.invoke(o, (Object[]) null);
+ Method invokableMethod = ClassUtils.getInterfaceMethodIfPossible(readMethod, clazz);
+ ReflectionUtils.makeAccessible(invokableMethod);
+ Object value = invokableMethod.invoke(o, (Object[]) null);
writer.key(name);
json.convertAnother(value);
}
}
for (Field field : o.getClass().getDeclaredFields()) {
int modifiers = field.getModifiers();
- if (field.canAccess(o) && Modifier.isPublic(modifiers) && !(Modifier.isStatic(modifiers) || Modifier.isTransient(modifiers))) {
+ if (Modifier.isPublic(modifiers) && !(Modifier.isStatic(modifiers) || Modifier.isTransient(modifiers)) &&
+ !field.isSynthetic() && field.canAccess(o)) {
String name = field.getName();
if (!shouldInclude(includeExcludeSupport, includes, excludes, o, name)) continue;
writer.key(field.getName());
diff --git a/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/GroovyBeanMarshaller.java b/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/GroovyBeanMarshaller.java
index 4bccb38bf9..5342dbca11 100644
--- a/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/GroovyBeanMarshaller.java
+++ b/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/GroovyBeanMarshaller.java
@@ -27,6 +27,8 @@ import java.util.List;
import groovy.lang.GroovyObject;
import org.springframework.beans.BeanUtils;
+import org.springframework.util.ClassUtils;
+import org.springframework.util.ReflectionUtils;
import grails.converters.JSON;
import grails.persistence.PersistenceMethod;
@@ -66,16 +68,19 @@ public class GroovyBeanMarshaller extends IncludeExcludePropertyMarshaller<JSON>
if (Modifier.isStatic(readMethod.getModifiers())) continue;
if (readMethod.getAnnotation(PersistenceMethod.class) != null) continue;
if (readMethod.getAnnotation(ControllerMethod.class) != null) continue;
- Object value = readMethod.invoke(o, (Object[]) null);
+ Method invokableMethod = ClassUtils.getInterfaceMethodIfPossible(readMethod, clazz);
+ ReflectionUtils.makeAccessible(invokableMethod);
+ Object value = invokableMethod.invoke(o, (Object[]) null);
writer.key(name);
json.convertAnother(value);
}
}
for (Field field : clazz.getDeclaredFields()) {
int modifiers = field.getModifiers();
- if (Modifier.isPublic(modifiers) && !(Modifier.isStatic(modifiers) || Modifier.isTransient(modifiers))) {
+ if (Modifier.isPublic(modifiers) && !(Modifier.isStatic(modifiers) || Modifier.isTransient(modifiers)) && !field.isSynthetic()) {
String name = field.getName();
if (!shouldInclude(includeExcludeSupport, includes, excludes, o, name)) continue;
+ ReflectionUtils.makeAccessible(field);
writer.key(name);
json.convertAnother(field.get(o));
}
diff --git a/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/xml/GenericJavaBeanMarshaller.java b/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/xml/GenericJavaBeanMarshaller.java
index be6471e5de..846ae8b74b 100644
--- a/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/xml/GenericJavaBeanMarshaller.java
+++ b/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/xml/GenericJavaBeanMarshaller.java
@@ -24,6 +24,8 @@ import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import org.springframework.beans.BeanUtils;
+import org.springframework.util.ClassUtils;
+import org.springframework.util.ReflectionUtils;
import grails.converters.XML;
import org.grails.web.converters.exceptions.ConverterException;
@@ -46,7 +48,9 @@ public class GenericJavaBeanMarshaller implements ObjectMarshaller<XML> {
Method readMethod = property.getReadMethod();
if (readMethod != null) {
if (Modifier.isStatic(readMethod.getModifiers())) continue;
- Object value = readMethod.invoke(o, (Object[]) null);
+ Method invokableMethod = ClassUtils.getInterfaceMethodIfPossible(readMethod, o.getClass());
+ ReflectionUtils.makeAccessible(invokableMethod);
+ Object value = invokableMethod.invoke(o, (Object[]) null);
xml.startNode(name);
xml.convertAnother(value);
xml.end();
@@ -54,8 +58,9 @@ public class GenericJavaBeanMarshaller implements ObjectMarshaller<XML> {
}
for (Field field : o.getClass().getDeclaredFields()) {
int modifiers = field.getModifiers();
- if (field.canAccess(o) && Modifier.isPublic(modifiers) &&
- !(Modifier.isStatic(modifiers) || Modifier.isTransient(modifiers))) {
+ if (Modifier.isPublic(modifiers) &&
+ !(Modifier.isStatic(modifiers) || Modifier.isTransient(modifiers)) &&
+ !field.isSynthetic() && field.canAccess(o)) {
xml.startNode(field.getName());
xml.convertAnother(field.get(o));
xml.end();
diff --git a/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/xml/GroovyBeanMarshaller.java b/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/xml/GroovyBeanMarshaller.java
index 6b724c01bc..28223afa99 100644
--- a/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/xml/GroovyBeanMarshaller.java
+++ b/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/xml/GroovyBeanMarshaller.java
@@ -27,6 +27,8 @@ import java.util.List;
import groovy.lang.GroovyObject;
import org.springframework.beans.BeanUtils;
+import org.springframework.util.ClassUtils;
+import org.springframework.util.ReflectionUtils;
import grails.converters.XML;
import grails.persistence.Entity;
@@ -66,7 +68,9 @@ public class GroovyBeanMarshaller extends IncludeExcludePropertyMarshaller<XML>
if (Modifier.isStatic(readMethod.getModifiers())) continue;
if (readMethod.getAnnotation(PersistenceMethod.class) != null) continue;
if (readMethod.getAnnotation(ControllerMethod.class) != null) continue;
- Object value = readMethod.invoke(o, (Object[]) null);
+ Method invokableMethod = ClassUtils.getInterfaceMethodIfPossible(readMethod, clazz);
+ ReflectionUtils.makeAccessible(invokableMethod);
+ Object value = invokableMethod.invoke(o, (Object[]) null);
xml.startNode(name);
xml.convertAnother(value);
xml.end();
@@ -74,10 +78,11 @@ public class GroovyBeanMarshaller extends IncludeExcludePropertyMarshaller<XML>
}
for (Field field : o.getClass().getDeclaredFields()) {
int modifiers = field.getModifiers();
- if (Modifier.isPublic(modifiers) && !(Modifier.isStatic(modifiers) || Modifier.isTransient(modifiers))) {
+ if (Modifier.isPublic(modifiers) && !(Modifier.isStatic(modifiers) || Modifier.isTransient(modifiers)) && !field.isSynthetic()) {
String name = field.getName();
if (!shouldInclude(includeExcludeSupport, includes, excludes, o, name)) continue;
if (isEntity && (name.equals(GormProperties.ATTACHED) || name.equals(GormProperties.ERRORS))) continue;
+ ReflectionUtils.makeAccessible(field);
xml.startNode(name);
xml.convertAnother(field.get(o));
xml.end();
diff --git a/grails-converters/src/test/groovy/org/grails/web/converters/beans/GroovyPersonFactory.groovy b/grails-converters/src/test/groovy/org/grails/web/converters/beans/GroovyPersonFactory.groovy
new file mode 100644
index 0000000000..8946f7fd40
--- /dev/null
+++ b/grails-converters/src/test/groovy/org/grails/web/converters/beans/GroovyPersonFactory.groovy
@@ -0,0 +1,81 @@
+/*
+ * 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.beans
+
+import groovy.transform.CompileStatic
+
+/**
+ * Hands out instances of Groovy classes that are not public, but whose read methods are. This is the
+ * shape produced by an anonymous implementation of a public interface inside a Grails controller.
+ */
+@CompileStatic
+class GroovyPersonFactory {
+
+ /**
+ * @return an anonymous implementation of a public interface
+ */
+ static PublicPerson anonymousPerson(String name, int age) {
+ new PublicPerson() {
+
+ @Override
+ String getName() {
+ name
+ }
+
+ @Override
+ int getAge() {
+ age
+ }
+ }
+ }
+
+ /**
+ * @return an anonymous implementation that also exposes a public field
+ */
+ static PublicPerson anonymousPersonWithPublicField(String name, int age, String nick) {
+ new PublicPerson() {
+
+ public String nickname = nick
+
+ @Override
+ String getName() {
+ name
+ }
+
+ @Override
+ int getAge() {
+ age
+ }
+ }
+ }
+
+ /**
+ * @return an instance of a package-private class implementing a public interface
+ */
+ static PublicPerson packagePrivatePerson(String name, int age) {
+ new PackagePrivateGroovyPerson(name, age)
+ }
+
+ /**
+ * @return an instance of a package-private class that implements no interface at all
+ */
+ static Object standalonePerson(String name) {
+ new PackagePrivateStandaloneGroovyBean(name)
+ }
+}
diff --git a/grails-converters/src/test/groovy/org/grails/web/converters/beans/JavaPersonFactory.java b/grails-converters/src/test/groovy/org/grails/web/converters/beans/JavaPersonFactory.java
new file mode 100644
index 0000000000..638e88ce23
--- /dev/null
+++ b/grails-converters/src/test/groovy/org/grails/web/converters/beans/JavaPersonFactory.java
@@ -0,0 +1,61 @@
+/*
+ * 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.beans;
+
+/**
+ * Hands out instances of Java classes that are not public, but whose read methods are.
+ */
+public final class JavaPersonFactory {
+
+ private JavaPersonFactory() {
+ }
+
+ /**
+ * @return an anonymous implementation of a public interface
+ */
+ public static PublicPerson anonymousPerson(final String name, final int age) {
+ return new PublicPerson() {
+
+ @Override
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public int getAge() {
+ return age;
+ }
+ };
+ }
+
+ /**
+ * @return an instance of a package-private class implementing a public interface
+ */
+ public static PublicPerson packagePrivatePerson(String name, int age) {
+ return new PackagePrivateJavaPerson(name, age);
+ }
+
+ /**
+ * @return an instance of a package-private class that implements no interface at all, so the
+ * read method cannot be resolved to a public declaring type
+ */
+ public static Object standalonePerson(String name) {
+ return new PackagePrivateStandaloneJavaBean(name);
+ }
+}
diff --git a/grails-converters/src/test/groovy/org/grails/web/converters/beans/PackagePrivateGroovyPerson.groovy b/grails-converters/src/test/groovy/org/grails/web/converters/beans/PackagePrivateGroovyPerson.groovy
new file mode 100644
index 0000000000..73a618702d
--- /dev/null
+++ b/grails-converters/src/test/groovy/org/grails/web/converters/beans/PackagePrivateGroovyPerson.groovy
@@ -0,0 +1,49 @@
+/*
+ * 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.beans
+
+import groovy.transform.CompileStatic
+import groovy.transform.PackageScope
+
+/**
+ * Package-private Groovy class with public read methods inherited from a public interface.
+ */
+@PackageScope
+@CompileStatic
+class PackagePrivateGroovyPerson implements PublicPerson {
+
+ private final String name
+
+ private final int age
+
+ PackagePrivateGroovyPerson(String name, int age) {
+ this.name = name
+ this.age = age
+ }
+
+ @Override
+ String getName() {
+ name
+ }
+
+ @Override
+ int getAge() {
+ age
+ }
+}
diff --git a/grails-converters/src/test/groovy/org/grails/web/converters/beans/PackagePrivateJavaPerson.java b/grails-converters/src/test/groovy/org/grails/web/converters/beans/PackagePrivateJavaPerson.java
new file mode 100644
index 0000000000..2c2d347688
--- /dev/null
+++ b/grails-converters/src/test/groovy/org/grails/web/converters/beans/PackagePrivateJavaPerson.java
@@ -0,0 +1,44 @@
+/*
+ * 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.beans;
+
+/**
+ * Package-private class with public read methods inherited from a public interface.
+ */
+class PackagePrivateJavaPerson implements PublicPerson {
+
+ private final String name;
+
+ private final int age;
+
+ PackagePrivateJavaPerson(String name, int age) {
+ this.name = name;
+ this.age = age;
+ }
+
+ @Override
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public int getAge() {
+ return age;
+ }
+}
diff --git a/grails-converters/src/test/groovy/org/grails/web/converters/beans/PackagePrivateStandaloneGroovyBean.groovy b/grails-converters/src/test/groovy/org/grails/web/converters/beans/PackagePrivateStandaloneGroovyBean.groovy
new file mode 100644
index 0000000000..96d5a8659e
--- /dev/null
+++ b/grails-converters/src/test/groovy/org/grails/web/converters/beans/PackagePrivateStandaloneGroovyBean.groovy
@@ -0,0 +1,41 @@
+/*
+ * 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.beans
+
+import groovy.transform.CompileStatic
+import groovy.transform.PackageScope
+
+/**
+ * Package-private Groovy class whose only read method is declared on the class itself, so there is
+ * no publicly accessible interface method to fall back on.
+ */
+@PackageScope
+@CompileStatic
+class PackagePrivateStandaloneGroovyBean {
+
+ private final String name
+
+ PackagePrivateStandaloneGroovyBean(String name) {
+ this.name = name
+ }
+
+ String getName() {
+ name
+ }
+}
diff --git a/grails-converters/src/test/groovy/org/grails/web/converters/beans/PackagePrivateStandaloneJavaBean.java b/grails-converters/src/test/groovy/org/grails/web/converters/beans/PackagePrivateStandaloneJavaBean.java
new file mode 100644
index 0000000000..bc84785f0b
--- /dev/null
+++ b/grails-converters/src/test/groovy/org/grails/web/converters/beans/PackagePrivateStandaloneJavaBean.java
@@ -0,0 +1,36 @@
+/*
+ * 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.beans;
+
+/**
+ * Package-private class with a public read method that is declared on the class itself, so there is
+ * no publicly accessible interface method to fall back on.
+ */
+class PackagePrivateStandaloneJavaBean {
+
+ private final String name;
+
+ PackagePrivateStandaloneJavaBean(String name) {
+ this.name = name;
+ }
+
+ public String getName() {
+ return name;
+ }
+}
diff --git a/grails-converters/src/test/groovy/org/grails/web/converters/beans/PublicPerson.java b/grails-converters/src/test/groovy/org/grails/web/converters/beans/PublicPerson.java
new file mode 100644
index 0000000000..71f7f4d383
--- /dev/null
+++ b/grails-converters/src/test/groovy/org/grails/web/converters/beans/PublicPerson.java
@@ -0,0 +1,35 @@
+/*
+ * 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.beans;
+
+/**
+ * A public interface with both abstract and default read methods, mirroring the shape of
+ * interfaces such as Spring Security's {@code UserDetails}. Implementations live in non-public
+ * classes so that the converters have to resolve the interface method to be able to invoke it.
+ */
+public interface PublicPerson {
+
+ String getName();
+
+ int getAge();
+
+ default boolean isActive() {
+ return true;
+ }
+}
diff --git a/grails-converters/src/test/groovy/org/grails/web/converters/beans/SerializableJavaBean.java b/grails-converters/src/test/groovy/org/grails/web/converters/beans/SerializableJavaBean.java
new file mode 100644
index 0000000000..0a3360d396
--- /dev/null
+++ b/grails-converters/src/test/groovy/org/grails/web/converters/beans/SerializableJavaBean.java
@@ -0,0 +1,46 @@
+/*
+ * 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.beans;
+
+import java.io.Serializable;
+
+/**
+ * An ordinary {@code Serializable} Java bean, carrying the {@code private static final
+ * serialVersionUID} that virtually every serializable bean declares, plus a public constant and a
+ * public instance field.
+ */
+public class SerializableJavaBean implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ public static final String KIND = "authority";
+
+ public final String label;
+
+ private final String authority;
+
+ public SerializableJavaBean(String authority, String label) {
+ this.authority = authority;
+ this.label = label;
+ }
+
+ public String getAuthority() {
+ return authority;
+ }
+}
diff --git a/grails-converters/src/test/groovy/org/grails/web/converters/marshaller/json/NonPublicClassMarshallingSpec.groovy b/grails-converters/src/test/groovy/org/grails/web/converters/marshaller/json/NonPublicClassMarshallingSpec.groovy
new file mode 100644
index 0000000000..0d7f21c117
--- /dev/null
+++ b/grails-converters/src/test/groovy/org/grails/web/converters/marshaller/json/NonPublicClassMarshallingSpec.groovy
@@ -0,0 +1,197 @@
+/*
+ * 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.marshaller.json
+
+import java.lang.reflect.Modifier
+
+import spock.lang.Specification
+
+import org.springframework.context.ApplicationContext
+
+import grails.converters.JSON
+import grails.core.DefaultGrailsApplication
+import org.grails.datastore.mapping.keyvalue.mapping.config.KeyValueMappingContext
+import org.grails.datastore.mapping.model.MappingContext
+import org.grails.web.converters.beans.GroovyPersonFactory
+import org.grails.web.converters.beans.JavaPersonFactory
+import org.grails.web.converters.beans.SerializableJavaBean
+import org.grails.web.converters.configuration.ConvertersConfigurationInitializer
+
+/**
+ * Marshalling objects whose class is not public (anonymous, inner or package-private) and ordinary
+ * {@code Serializable} beans.
+ *
+ * @see <a href="https://github.com/apache/grails-core/issues/16294">GitHub issue 16294</a>
+ * @see <a href="https://github.com/apache/grails-core/issues/16295">GitHub issue 16295</a>
+ */
+class NonPublicClassMarshallingSpec extends Specification {
+
+ void setup() {
+ def initializer = new ConvertersConfigurationInitializer()
+ def grailsApplication = new DefaultGrailsApplication()
+ grailsApplication.initialise()
+ def mappingContext = new KeyValueMappingContext('json')
+ grailsApplication.setApplicationContext(Stub(ApplicationContext) {
+ getBean('grailsDomainClassMappingContext', MappingContext) >> mappingContext
+ })
+ grailsApplication.setMappingContext(mappingContext)
+ initializer.grailsApplication = grailsApplication
+ initializer.initialize()
+ }
+
+ void 'an anonymous Groovy class implementing a public interface is marshalled'() {
+ given: 'an anonymous implementation, as handed out by Spring Security style factories'
+ def person = GroovyPersonFactory.anonymousPerson('user', 42)
+
+ expect: 'the class itself is not public, only its read methods are'
+ !Modifier.isPublic(person.getClass().modifiers)
+
+ when:
+ Map json = parse(new JSON(person).toString())
+
+ then: 'the overridden read methods are invoked, not just the interface default method'
+ json == [active: true, age: 42, name: 'user']
+ }
+
+ void 'a public field of an anonymous Groovy class is marshalled'() {
+ given:
+ def person = GroovyPersonFactory.anonymousPersonWithPublicField('user', 42, 'nick')
+
+ expect:
+ !Modifier.isPublic(person.getClass().modifiers)
+
+ when:
+ Map json = parse(new JSON(person).toString())
+
+ then:
+ json == [active: true, age: 42, name: 'user', nickname: 'nick']
+ }
+
+ void 'a package-private Groovy class implementing a public interface is marshalled'() {
+ given:
+ def person = GroovyPersonFactory.packagePrivatePerson('user', 42)
+
+ expect:
+ !Modifier.isPublic(person.getClass().modifiers)
+
+ when:
+ Map json = parse(new JSON(person).toString())
+
+ then:
+ json == [active: true, age: 42, name: 'user']
+ }
+
+ void 'a package-private Groovy class without any interface is marshalled'() {
+ given: 'a read method whose only declaring type is the non-public class itself'
+ def person = GroovyPersonFactory.standalonePerson('user')
+
+ expect:
+ !Modifier.isPublic(person.getClass().modifiers)
+
+ when:
+ Map json = parse(new JSON(person).toString())
+
+ then:
+ json == [name: 'user']
+ }
+
+ void 'an anonymous Java class implementing a public interface is marshalled'() {
+ given:
+ def person = JavaPersonFactory.anonymousPerson('user', 42)
+
+ expect:
+ !Modifier.isPublic(person.getClass().modifiers)
+
+ when:
+ Map json = parse(new JSON(person).toString())
+
+ then:
+ json == [active: true, age: 42, name: 'user']
+ }
+
+ void 'a package-private Java class implementing a public interface is marshalled'() {
+ given:
+ def person = JavaPersonFactory.packagePrivatePerson('user', 42)
+
+ expect:
+ !Modifier.isPublic(person.getClass().modifiers)
+
+ when:
+ Map json = parse(new JSON(person).toString())
+
+ then:
+ json == [active: true, age: 42, name: 'user']
+ }
+
+ void 'a package-private Java class without any interface is marshalled'() {
+ given:
+ def person = JavaPersonFactory.standalonePerson('user')
+
+ expect:
+ !Modifier.isPublic(person.getClass().modifiers)
+
+ when:
+ Map json = parse(new JSON(person).toString())
+
+ then:
+ json == [name: 'user']
+ }
+
+ void 'a Serializable Java bean is marshalled without tripping over its static fields'() {
+ given: 'a bean declaring private static final serialVersionUID, as most Java beans do'
+ def bean = new SerializableJavaBean('ROLE_ADMIN', 'Administrator')
+
+ when:
+ Map json = parse(new JSON(bean).toString())
+
+ then: 'the read method and the public instance field are marshalled'
+ json == [authority: 'ROLE_ADMIN', label: 'Administrator']
+
+ and: 'the static fields are not'
+ !json.containsKey('serialVersionUID')
+ !json.containsKey('KIND')
+ }
+
+ void 'a map of non-public beans and Serializable beans is marshalled as a whole'() {
+ given: 'the object graph of the reported reproducer'
+ def person = GroovyPersonFactory.anonymousPerson('user', 42)
+ def authorities = [new SerializableJavaBean('ROLE_ADMIN', 'Administrator')]
+
+ when:
+ Map json = parse(new JSON([user: person, authorities: authorities]).toString())
+
+ then:
+ json.user == [active: true, age: 42, name: 'user']
+ json.authorities == [[authority: 'ROLE_ADMIN', label: 'Administrator']]
+ }
+
+ private static Map parse(String json) {
+ normalise(JSON.parse(json)) as Map
+ }
+
+ private static Object normalise(Object value) {
+ if (value instanceof Map) {
+ return value.collectEntries { key, item -> [(key.toString()): normalise(item)] }
+ }
+ if (value instanceof List) {
+ return value.collect { normalise(it) }
+ }
+ value
+ }
+}
diff --git a/grails-converters/src/test/groovy/org/grails/web/converters/marshaller/xml/NonPublicClassMarshallingSpec.groovy b/grails-converters/src/test/groovy/org/grails/web/converters/marshaller/xml/NonPublicClassMarshallingSpec.groovy
new file mode 100644
index 0000000000..9874db2c0b
--- /dev/null
+++ b/grails-converters/src/test/groovy/org/grails/web/converters/marshaller/xml/NonPublicClassMarshallingSpec.groovy
@@ -0,0 +1,199 @@
+/*
+ * 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.marshaller.xml
+
+import java.lang.reflect.Modifier
+
+import spock.lang.Specification
+
+import org.springframework.context.ApplicationContext
+
+import grails.converters.XML
+import grails.core.DefaultGrailsApplication
+import org.grails.datastore.mapping.keyvalue.mapping.config.KeyValueMappingContext
+import org.grails.datastore.mapping.model.MappingContext
+import org.grails.web.converters.beans.GroovyPersonFactory
+import org.grails.web.converters.beans.JavaPersonFactory
+import org.grails.web.converters.beans.SerializableJavaBean
+import org.grails.web.converters.configuration.ConvertersConfigurationInitializer
+
+/**
+ * Marshalling objects whose class is not public (anonymous, inner or package-private) and ordinary
+ * {@code Serializable} beans.
+ *
+ * @see <a href="https://github.com/apache/grails-core/issues/16294">GitHub issue 16294</a>
+ * @see <a href="https://github.com/apache/grails-core/issues/16295">GitHub issue 16295</a>
+ */
+class NonPublicClassMarshallingSpec extends Specification {
+
+ void setup() {
+ def initializer = new ConvertersConfigurationInitializer()
+ def grailsApplication = new DefaultGrailsApplication()
+ grailsApplication.initialise()
+ def mappingContext = new KeyValueMappingContext('xml')
+ grailsApplication.setApplicationContext(Stub(ApplicationContext) {
+ getBean('grailsDomainClassMappingContext', MappingContext) >> mappingContext
+ })
+ grailsApplication.setMappingContext(mappingContext)
+ initializer.grailsApplication = grailsApplication
+ initializer.initialize()
+ }
+
+ void 'an anonymous Groovy class implementing a public interface is marshalled'() {
+ given: 'an anonymous implementation, wrapped in a map so the element name is well defined'
+ def person = GroovyPersonFactory.anonymousPerson('user', 42)
+
+ expect: 'the class itself is not public, only its read methods are'
+ !Modifier.isPublic(person.getClass().modifiers)
+
+ when:
+ String xml = new XML([user: person]).toString()
+
+ then: 'the overridden read methods are invoked, not just the interface default method'
+ xml.contains('<name>user</name>')
+ xml.contains('<age>42</age>')
+ xml.contains('<active>true</active>')
+
+ and: 'the synthetic fields capturing the enclosing variables are not marshalled'
+ xml.count('<name>') == 1
+ xml.count('<age>') == 1
+ }
+
+ void 'a public field of an anonymous Groovy class is marshalled'() {
+ given:
+ def person = GroovyPersonFactory.anonymousPersonWithPublicField('user', 42, 'nick')
+
+ expect:
+ !Modifier.isPublic(person.getClass().modifiers)
+
+ when:
+ String xml = new XML([user: person]).toString()
+
+ then: 'the declared public field is marshalled, the synthetic capture fields are not'
+ xml.contains('<name>user</name>')
+ xml.contains('<nickname>nick</nickname>')
+ xml.count('<name>') == 1
+ !xml.contains('<nick>')
+ }
+
+ void 'a package-private Groovy class implementing a public interface is marshalled'() {
+ given:
+ def person = GroovyPersonFactory.packagePrivatePerson('user', 42)
+
+ expect:
+ !Modifier.isPublic(person.getClass().modifiers)
+
+ when:
+ String xml = new XML(person).toString()
+
+ then:
+ xml.contains('<name>user</name>')
+ xml.contains('<age>42</age>')
+ xml.contains('<active>true</active>')
+ }
+
+ void 'a package-private Groovy class without any interface is marshalled'() {
+ given: 'a read method whose only declaring type is the non-public class itself'
+ def person = GroovyPersonFactory.standalonePerson('user')
+
+ expect:
+ !Modifier.isPublic(person.getClass().modifiers)
+
+ when:
+ String xml = new XML(person).toString()
+
+ then:
+ xml.contains('<name>user</name>')
+ }
+
+ void 'an anonymous Java class implementing a public interface is marshalled'() {
+ given:
+ def person = JavaPersonFactory.anonymousPerson('user', 42)
+
+ expect:
+ !Modifier.isPublic(person.getClass().modifiers)
+
+ when:
+ String xml = new XML([user: person]).toString()
+
+ then:
+ xml.contains('<name>user</name>')
+ xml.contains('<age>42</age>')
+ xml.contains('<active>true</active>')
+ }
+
+ void 'a package-private Java class implementing a public interface is marshalled'() {
+ given:
+ def person = JavaPersonFactory.packagePrivatePerson('user', 42)
+
+ expect:
+ !Modifier.isPublic(person.getClass().modifiers)
+
+ when:
+ String xml = new XML(person).toString()
+
+ then:
+ xml.contains('<name>user</name>')
+ xml.contains('<age>42</age>')
+ xml.contains('<active>true</active>')
+ }
+
+ void 'a package-private Java class without any interface is marshalled'() {
+ given:
+ def person = JavaPersonFactory.standalonePerson('user')
+
+ expect:
+ !Modifier.isPublic(person.getClass().modifiers)
+
+ when:
+ String xml = new XML(person).toString()
+
+ then:
+ xml.contains('<name>user</name>')
+ }
+
+ void 'a Serializable Java bean is marshalled without tripping over its static fields'() {
+ given: 'a bean declaring private static final serialVersionUID, as most Java beans do'
+ def bean = new SerializableJavaBean('ROLE_ADMIN', 'Administrator')
+
+ when:
+ String xml = new XML(bean).toString()
+
+ then: 'the read method and the public instance field are marshalled'
+ xml.contains('<authority>ROLE_ADMIN</authority>')
+ xml.contains('<label>Administrator</label>')
+
+ and: 'the static fields are not'
+ !xml.contains('serialVersionUID')
+ !xml.contains('KIND')
+ }
+
+ void 'a map of non-public beans and Serializable beans is marshalled as a whole'() {
+ given: 'the object graph of the reported reproducer'
+ def person = GroovyPersonFactory.anonymousPerson('user', 42)
+ def authorities = [new SerializableJavaBean('ROLE_ADMIN', 'Administrator')]
+
+ when:
+ String xml = new XML([user: person, authorities: authorities]).toString()
+
+ then:
+ xml.contains('<name>user</name>')
+ xml.contains('<authority>ROLE_ADMIN</authority>')
+ }
+}