diff --git a/.github/workflows/release-publish-docs.yml b/.github/workflows/release-publish-docs.yml
index b4820647804..73f1ae939dc 100644
--- a/.github/workflows/release-publish-docs.yml
+++ b/.github/workflows/release-publish-docs.yml
@@ -25,7 +25,7 @@ permissions: { }
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
JAVA_DISTRIBUTION: liberica
- JAVA_VERSION: 21.0.7
+ JAVA_VERSION: 21.0.12
TARGET_BRANCH: ${{ github.ref_name }}
VERSION: ${{ inputs.version }}
jobs:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e6762cb9b84..b044f6d0830 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -24,7 +24,7 @@ env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GRAILS_PUBLISH_RELEASE: 'true'
JAVA_DISTRIBUTION: liberica
- JAVA_VERSION: 21.0.7 # this must be a specific version for reproducible builds, keep it synced with .sdkmanrc and verification container
+ JAVA_VERSION: 21.0.12 # this must be a specific version for reproducible builds, keep it synced with .sdkmanrc and verification container
JAVA_VERSION_MICRONAUT: 25.0.3 # the Grails-Micronaut "island" (grails-micronaut, grails-micronaut-bom) is built against Micronaut 5 which targets JVM 25 bytecode. Keep this synced with the secondary JDK installed in etc/bin/Dockerfile and the JDK_25_HOME branch in etc/bin/verify-reproducible.sh.
PROJECT_DESC: >
Grails is a powerful Groovy-based web application framework for the JVM,
diff --git a/.sdkmanrc b/.sdkmanrc
index 508f113b0c1..8a8e9095b8d 100644
--- a/.sdkmanrc
+++ b/.sdkmanrc
@@ -5,7 +5,7 @@
# $JAVA_VERSION_MICRONAUT in release.yml; for local verification, install that JDK 25
# alongside this one (sdk install java -librca) and follow the dual-JDK
# instructions in RELEASE.md "Manual Verification: Reproducible Jar Files".
-java=21.0.7-librca
+java=21.0.12-librca
# Keep gradle version synced with gradle.properties (gradleToolingApiVersion).
# Update the gradle-bootstrap project to propagate the version to all gradle-wrapper.properties files.
gradle=9.6.0
diff --git a/dependencies.gradle b/dependencies.gradle
index 29bc2978822..ad18672d592 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -126,8 +126,8 @@ ext {
'sitemesh.version' : '2.6.0',
'scribejava.version' : '8.3.3',
'spock.version' : '2.4-groovy-5.0',
- 'starter-sitemesh.version' : '3.3.0-M3',
- 'spring-webmvc-sitemesh.version': '3.3.0-M3',
+ 'starter-sitemesh.version' : '3.3.0-SNAPSHOT',
+ 'spring-webmvc-sitemesh.version': '3.3.0-SNAPSHOT',
// Spring Boot 4 no longer manages spring-retry; pin it here so the
// grails-shell-cli SpringRetryCompilerAutoConfiguration's unversioned
// reference resolves and consumer apps using @Retryable get a known version.
diff --git a/etc/bin/Dockerfile b/etc/bin/Dockerfile
index fe376a6da81..345396ad874 100644
--- a/etc/bin/Dockerfile
+++ b/etc/bin/Dockerfile
@@ -17,7 +17,7 @@
# run this from the root of the project
# `docker build -t grails:testing -f etc/bin/Dockerfile . && docker run -it --rm -v $(pwd):/home/groovy/project grails:testing bash`
# Keep java version synced with .sdkmanrc and .github/workflows/release.yml ($JAVA_VERSION)
-FROM bellsoft/liberica-openjdk-debian:21.0.7
+FROM bellsoft/liberica-openjdk-debian:21.0.12
USER root
RUN apt-get update && apt-get install -y ca-certificates curl unzip coreutils libdigest-sha-perl gpg vim sudo psmisc locales groovy rsync nano
diff --git a/grails-common/src/main/groovy/org/grails/aot/RegistrableTypes.java b/grails-common/src/main/groovy/org/grails/aot/RegistrableTypes.java
new file mode 100644
index 00000000000..840b2b7c54b
--- /dev/null
+++ b/grails-common/src/main/groovy/org/grails/aot/RegistrableTypes.java
@@ -0,0 +1,251 @@
+/*
+ * 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.aot;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.asm.ClassReader;
+import org.springframework.asm.ClassVisitor;
+import org.springframework.asm.ConstantDynamic;
+import org.springframework.asm.FieldVisitor;
+import org.springframework.asm.Handle;
+import org.springframework.asm.Label;
+import org.springframework.asm.MethodVisitor;
+import org.springframework.asm.SpringAsmInfo;
+import org.springframework.asm.Type;
+import org.springframework.util.ClassUtils;
+
+/**
+ * Decides whether a type found by scanning can be registered for reflection.
+ *
+ *
Registering one that cannot be loaded here does not degrade at run time -- it fails the build,
+ * because the image analysis parses what it is asked to keep. The framework compiles against
+ * optional integrations, so a scan of its own packages finds classes an application need not be able
+ * to load: the JSP support without the JSP API, the GSP compiler's task without Ant, a datastore
+ * without its driver.
+ *
+ *
Two questions are asked, because either alone lets one of those through. Whether the type and
+ * the class declaring it load covers a closure whose enclosing class extends something absent, which
+ * the closure's own bytecode need not name -- it reaches it through invokedynamic. Whether the types
+ * its bytecode names load covers the opposite case, a class that loads cleanly while a method body
+ * names something absent, because loading resolves signatures and not bodies.
+ *
+ * @since 8.0
+ */
+public final class RegistrableTypes {
+
+ private RegistrableTypes() {
+ }
+
+ /**
+ * Whether the type, and the class declaring it, load here.
+ *
+ * @param className the binary name of the type
+ * @param classLoader the loader to resolve against
+ * @return whether the type can be registered
+ */
+ public static boolean loads(String className, @Nullable ClassLoader classLoader) {
+ int declaring = className.indexOf('$');
+ if (declaring > 0 && !ClassUtils.isPresent(className.substring(0, declaring), classLoader)) {
+ return false;
+ }
+ return ClassUtils.isPresent(className, classLoader);
+ }
+
+ /**
+ * Whether every type named in the given bytecode loads here, following the declaration and the
+ * bodies of the methods.
+ *
+ * @param bytecode the class file to read; closed by this method
+ * @param classLoader the loader to resolve against
+ * @return whether the type can be registered
+ */
+ public static boolean referencesLoad(InputStream bytecode, @Nullable ClassLoader classLoader) {
+ Set referenced = new LinkedHashSet<>();
+ try (InputStream input = bytecode) {
+ new ClassReader(input).accept(new ReferenceCollector(referenced),
+ ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
+ }
+ catch (IOException | RuntimeException ex) {
+ return false;
+ }
+ for (String name : referenced) {
+ if (!ClassUtils.isPresent(name, classLoader)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /** Collects the types a class names, in its own declaration and in the bodies of its methods. */
+ private static final class ReferenceCollector extends ClassVisitor {
+
+ private final Set referenced;
+
+ private ReferenceCollector(Set referenced) {
+ super(SpringAsmInfo.ASM_VERSION);
+ this.referenced = referenced;
+ }
+
+ private void add(@Nullable String internalName) {
+ if (internalName == null) {
+ return;
+ }
+ if (internalName.startsWith("[")) {
+ // an array names its element type, which is the class that has to be there
+ addType(Type.getType(internalName));
+ return;
+ }
+ String className = ClassUtils.convertResourcePathToClassName(internalName);
+ // the JDK is always present, and skipping it keeps this to the classes that can be absent
+ if (!className.startsWith("java.") && !className.startsWith("jdk.")) {
+ referenced.add(className);
+ }
+ }
+
+ /** Adds a type, reducing an array to the element type it is an array of. */
+ private void addType(@Nullable Type type) {
+ if (type == null) {
+ return;
+ }
+ Type element = type;
+ while (element.getSort() == Type.ARRAY) {
+ element = element.getElementType();
+ }
+ if (element.getSort() == Type.OBJECT) {
+ add(element.getInternalName());
+ }
+ }
+
+ /** Adds every type a method descriptor names: what it takes and what it gives back. */
+ private void addMethodDescriptor(@Nullable String descriptor) {
+ if (descriptor == null) {
+ return;
+ }
+ for (Type argument : Type.getArgumentTypes(descriptor)) {
+ addType(argument);
+ }
+ addType(Type.getReturnType(descriptor));
+ }
+
+ /** Adds whichever constant carries a type: a class literal, a handle, or a method type. */
+ private void addConstant(@Nullable Object constant) {
+ if (constant instanceof Type type) {
+ if (type.getSort() == Type.METHOD) {
+ addMethodDescriptor(type.getDescriptor());
+ }
+ else {
+ addType(type);
+ }
+ }
+ else if (constant instanceof Handle handle) {
+ add(handle.getOwner());
+ addMethodDescriptor(handle.getDesc());
+ }
+ else if (constant instanceof ConstantDynamic dynamic) {
+ addType(Type.getType(dynamic.getDescriptor()));
+ add(dynamic.getBootstrapMethod().getOwner());
+ for (int i = 0; i < dynamic.getBootstrapMethodArgumentCount(); i++) {
+ addConstant(dynamic.getBootstrapMethodArgument(i));
+ }
+ }
+ }
+
+ @Override
+ public void visit(int version, int access, String name, String signature, String superName,
+ String[] interfaces) {
+ add(superName);
+ if (interfaces != null) {
+ for (String each : interfaces) {
+ add(each);
+ }
+ }
+ }
+
+ @Override
+ public FieldVisitor visitField(int access, String name, String descriptor, String signature,
+ Object value) {
+ addType(Type.getType(descriptor));
+ return null;
+ }
+
+ @Override
+ public MethodVisitor visitMethod(int access, String name, String descriptor, String signature,
+ String[] exceptions) {
+ addMethodDescriptor(descriptor);
+ if (exceptions != null) {
+ for (String each : exceptions) {
+ add(each);
+ }
+ }
+ return new MethodVisitor(SpringAsmInfo.ASM_VERSION) {
+ @Override
+ public void visitTypeInsn(int opcode, String type) {
+ add(type);
+ }
+
+ @Override
+ public void visitMethodInsn(int opcode, String owner, String methodName,
+ String methodDescriptor, boolean isInterface) {
+ add(owner);
+ addMethodDescriptor(methodDescriptor);
+ }
+
+ @Override
+ public void visitFieldInsn(int opcode, String owner, String fieldName,
+ String fieldDescriptor) {
+ add(owner);
+ addType(Type.getType(fieldDescriptor));
+ }
+
+ @Override
+ public void visitLdcInsn(Object value) {
+ // a class literal, which names a type without ever calling anything on it
+ addConstant(value);
+ }
+
+ @Override
+ public void visitInvokeDynamicInsn(String methodName, String methodDescriptor,
+ Handle bootstrap, Object... arguments) {
+ // how a Groovy call site names what it dispatches on
+ addMethodDescriptor(methodDescriptor);
+ addConstant(bootstrap);
+ for (Object argument : arguments) {
+ addConstant(argument);
+ }
+ }
+
+ @Override
+ public void visitMultiANewArrayInsn(String descriptor, int dimensions) {
+ addType(Type.getType(descriptor));
+ }
+
+ @Override
+ public void visitTryCatchBlock(Label start, Label end, Label handler, String type) {
+ add(type);
+ }
+ };
+ }
+ }
+}
diff --git a/grails-common/src/test/groovy/org/grails/aot/RegistrableTypesSpec.groovy b/grails-common/src/test/groovy/org/grails/aot/RegistrableTypesSpec.groovy
new file mode 100644
index 00000000000..3319fa77ddc
--- /dev/null
+++ b/grails-common/src/test/groovy/org/grails/aot/RegistrableTypesSpec.groovy
@@ -0,0 +1,194 @@
+/*
+ * 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.aot
+
+import org.springframework.asm.ClassWriter
+import org.springframework.asm.Handle
+import org.springframework.asm.Label
+import org.springframework.asm.MethodVisitor
+import org.springframework.asm.Opcodes
+import org.springframework.asm.Type
+
+import spock.lang.Specification
+
+/**
+ * Covers which scanned types may be registered for reflection. Registering one that cannot be loaded
+ * fails the image build rather than degrading at run time, which is why this is asked at all.
+ */
+class RegistrableTypesSpec extends Specification {
+
+ ClassLoader loader = getClass().classLoader
+
+ private InputStream bytecodeOf(Class> type) {
+ loader.getResourceAsStream(type.name.replace('.', '/') + '.class')
+ }
+
+ void 'a type that loads may be registered'() {
+ expect:
+ RegistrableTypes.loads('java.lang.String', loader)
+ }
+
+ void 'a type that is absent may not'() {
+ expect:
+ !RegistrableTypes.loads('com.example.NotOnTheClasspath', loader)
+ }
+
+ void 'a nested type whose declaring class is absent may not'() {
+ expect: 'this is the closure whose enclosing class extends something absent, which the ' +
+ 'closure reaches through invokedynamic and so never names itself'
+ !RegistrableTypes.loads('com.example.Missing$_run_closure1', loader)
+ }
+
+ void 'a nested type whose declaring class loads may be'() {
+ expect:
+ RegistrableTypes.loads(Outer.Inner.name, loader)
+ }
+
+ void 'bytecode naming only types that load may be registered'() {
+ expect:
+ RegistrableTypes.referencesLoad(bytecodeOf(Outer), loader)
+ }
+
+ void 'bytecode is rejected when it cannot be read'() {
+ expect:
+ !RegistrableTypes.referencesLoad(new ByteArrayInputStream('not a class'.bytes), loader)
+ }
+
+ void 'a class loader without the framework on it accepts none of its types'() {
+ given: 'a bootstrap-only loader, which still has the JDK but nothing else'
+ ClassLoader empty = new URLClassLoader(new URL[0], null)
+
+ expect:
+ !RegistrableTypes.loads(RegistrableTypes.name, empty)
+ RegistrableTypes.loads('java.lang.String', empty)
+ }
+
+ void 'bytecode is rejected wherever the absent type is named'() {
+ expect: 'each of these names it once and nowhere else, so each stands or falls on its own'
+ !RegistrableTypes.referencesLoad(namingAbsent(shape), loader)
+
+ where:
+ shape << Shape.values()
+ }
+
+ void 'the same shapes naming a type that loads are accepted'() {
+ expect: 'so the rejections above are the absent type and not the shape it was named in'
+ RegistrableTypes.referencesLoad(naming('java/lang/Number', shape), loader)
+
+ where:
+ shape << Shape.values()
+ }
+
+ /** The places a class file can name a type, one per shape. */
+ private enum Shape {
+ FIELD_TYPE, ARRAY_FIELD_TYPE, RETURN_TYPE, PARAMETER_TYPE, THROWN_TYPE,
+ CLASS_LITERAL, CAUGHT_TYPE, CALL_ARGUMENT_TYPE, INVOKEDYNAMIC_ARGUMENT
+ }
+
+ private InputStream namingAbsent(Shape shape) {
+ naming('com/example/Absent', shape)
+ }
+
+ /**
+ * A class naming the given type in one place only.
+ *
+ *
Written rather than compiled because the point is the single mention: a fixture compiled
+ * from source names its own package, its supertypes and whatever the compiler adds, and would
+ * pass or fail for reasons other than the one under test.
+ */
+ private InputStream naming(String internalName, Shape shape) {
+ ClassWriter writer = new ClassWriter(0)
+ writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC | Opcodes.ACC_ABSTRACT,
+ 'org/grails/aot/Written', null, 'java/lang/Object', null)
+ String descriptor = "L${internalName};"
+
+ switch (shape) {
+ case Shape.FIELD_TYPE ->
+ writer.visitField(Opcodes.ACC_PRIVATE, 'held', descriptor, null, null).visitEnd()
+ case Shape.ARRAY_FIELD_TYPE ->
+ writer.visitField(Opcodes.ACC_PRIVATE, 'held', "[[${descriptor}", null, null).visitEnd()
+ case Shape.RETURN_TYPE ->
+ writer.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_ABSTRACT,
+ 'give', "()${descriptor}", null, null).visitEnd()
+ case Shape.PARAMETER_TYPE ->
+ writer.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_ABSTRACT,
+ 'take', "(${descriptor})V", null, null).visitEnd()
+ case Shape.THROWN_TYPE ->
+ writer.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_ABSTRACT,
+ 'fail', '()V', null, [internalName] as String[]).visitEnd()
+ default -> withBody(writer, internalName, descriptor, shape)
+ }
+
+ writer.visitEnd()
+ new ByteArrayInputStream(writer.toByteArray())
+ }
+
+ private void withBody(ClassWriter writer, String internalName, String descriptor, Shape shape) {
+ MethodVisitor method = writer.visitMethod(Opcodes.ACC_PUBLIC, 'body', '()V', null, null)
+ method.visitCode()
+ switch (shape) {
+ case Shape.CLASS_LITERAL -> {
+ method.visitLdcInsn(Type.getObjectType(internalName))
+ method.visitInsn(Opcodes.POP)
+ }
+ case Shape.CAUGHT_TYPE -> {
+ Label start = new Label(), end = new Label(), handler = new Label()
+ method.visitTryCatchBlock(start, end, handler, internalName)
+ method.visitLabel(start)
+ method.visitLabel(end)
+ method.visitLabel(handler)
+ method.visitInsn(Opcodes.POP)
+ }
+ case Shape.CALL_ARGUMENT_TYPE -> {
+ // the owner is present; only what the call takes is not
+ method.visitInsn(Opcodes.ACONST_NULL)
+ method.visitInsn(Opcodes.ACONST_NULL)
+ method.visitMethodInsn(Opcodes.INVOKEVIRTUAL, 'java/lang/Object',
+ 'equals', "(${descriptor})Z", false)
+ method.visitInsn(Opcodes.POP)
+ }
+ case Shape.INVOKEDYNAMIC_ARGUMENT -> {
+ Handle bootstrap = new Handle(Opcodes.H_INVOKESTATIC,
+ 'java/lang/invoke/LambdaMetafactory', 'metafactory',
+ '(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;' +
+ 'Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;' +
+ 'Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)' +
+ 'Ljava/lang/invoke/CallSite;',
+ false)
+ method.visitInvokeDynamicInsn('run', '()Ljava/lang/Runnable;', bootstrap,
+ Type.getType('()V'), bootstrap, Type.getType("(${descriptor})V"))
+ method.visitInsn(Opcodes.POP)
+ }
+ default -> throw new IllegalArgumentException("${shape} has no body")
+ }
+ method.visitInsn(Opcodes.RETURN)
+ method.visitMaxs(3, 1)
+ method.visitEnd()
+ }
+
+ static class Outer {
+
+ String describe() {
+ new Inner().toString()
+ }
+
+ static class Inner {
+ }
+ }
+}
diff --git a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/aot/ControllerRuntimeHints.java b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/aot/ControllerRuntimeHints.java
new file mode 100644
index 00000000000..6ff7b7fceab
--- /dev/null
+++ b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/aot/ControllerRuntimeHints.java
@@ -0,0 +1,69 @@
+/*
+ * 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.controllers.aot;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.aot.hint.MemberCategory;
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+
+/**
+ * Registers the controller API a request is dispatched through.
+ *
+ *
A controller action reaches these through Groovy's dynamic dispatch, which reads a type's
+ * declared methods to choose an overload. An ahead-of-time image keeps only the members something
+ * asks for, so without these hints the methods are absent and dispatch fails at the point of use --
+ * on the request that first takes that path, rather than at start-up.
+ *
+ *
Recording this here rather than leaving it to a tracing agent matters because the agent only
+ * ever sees the paths a developer happened to exercise: the method check below is reached only by
+ * POST, PUT and DELETE, so a walk of an application's pages never records it and the failure
+ * appears the first time someone submits a form.
+ *
+ * @since 8.0
+ */
+public class ControllerRuntimeHints implements RuntimeHintsRegistrar {
+
+ /**
+ * Types Groovy dispatches on while handling a request. Named as strings, and registered only
+ * when present, so this stays correct for an application that does not use every plugin.
+ */
+ private static final String[] DISPATCHED_TYPES = {
+ "grails.artefact.Controller",
+ "grails.artefact.controller.support.AllowedMethodsHelper",
+ "grails.artefact.controller.support.RequestForwarder",
+ "grails.artefact.controller.support.ResponseRedirector",
+ "grails.artefact.controller.support.ResponseRenderer",
+ "grails.artefact.controller.RestResponder",
+ // a view asking who is logged in reaches the request's principal, and Groovy makes that
+ // call on the interface rather than the implementation the container supplies
+ "java.security.Principal"
+ };
+
+ @Override
+ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
+ for (String type : DISPATCHED_TYPES) {
+ hints.reflection().registerTypeIfPresent(classLoader, type,
+ MemberCategory.INVOKE_DECLARED_METHODS,
+ MemberCategory.INVOKE_PUBLIC_METHODS,
+ MemberCategory.INVOKE_DECLARED_CONSTRUCTORS);
+ }
+ }
+}
diff --git a/grails-controllers/src/main/resources/META-INF/spring/aot.factories b/grails-controllers/src/main/resources/META-INF/spring/aot.factories
new file mode 100644
index 00000000000..a8cb0eb8035
--- /dev/null
+++ b/grails-controllers/src/main/resources/META-INF/spring/aot.factories
@@ -0,0 +1,2 @@
+org.springframework.aot.hint.RuntimeHintsRegistrar=\
+org.grails.plugins.web.controllers.aot.ControllerRuntimeHints
diff --git a/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/aot/ControllerRuntimeHintsSpec.groovy b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/aot/ControllerRuntimeHintsSpec.groovy
new file mode 100644
index 00000000000..8467a537142
--- /dev/null
+++ b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/aot/ControllerRuntimeHintsSpec.groovy
@@ -0,0 +1,73 @@
+/*
+ * 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.controllers.aot
+
+import grails.artefact.controller.support.AllowedMethodsHelper
+
+import org.springframework.aot.hint.MemberCategory
+import org.springframework.aot.hint.RuntimeHints
+import org.springframework.aot.hint.TypeReference
+import spock.lang.Specification
+
+/**
+ * Covers the controller API surviving into an ahead-of-time image. Without these hints the methods
+ * are stripped and dispatch fails on the request that first takes the path, which for the method
+ * check below means the first form submission rather than start-up.
+ */
+class ControllerRuntimeHintsSpec extends Specification {
+
+ RuntimeHints hints = new RuntimeHints()
+
+ void setup() {
+ new ControllerRuntimeHints().registerHints(hints, getClass().classLoader)
+ }
+
+ private boolean registered(Class> type) {
+ def hint = hints.reflection().getTypeHint(TypeReference.of(type))
+ hint != null && hint.memberCategories.contains(MemberCategory.INVOKE_DECLARED_METHODS)
+ }
+
+ void 'the method check a form submission reaches is registered'() {
+ expect: 'reached only by POST, PUT and DELETE, so a walk of an application never records it'
+ registered(AllowedMethodsHelper)
+ }
+
+ void 'the controller trait Groovy dispatches through is registered'() {
+ expect:
+ registered(grails.artefact.Controller)
+ }
+
+ void 'the response and forwarding support types are registered'() {
+ expect:
+ registered(grails.artefact.controller.support.ResponseRenderer)
+ registered(grails.artefact.controller.support.ResponseRedirector)
+ registered(grails.artefact.controller.support.RequestForwarder)
+ }
+
+ void 'a type absent from the classpath is skipped rather than failing the build'() {
+ given:
+ RuntimeHints empty = new RuntimeHints()
+
+ when: 'no class loader can resolve the named types'
+ new ControllerRuntimeHints().registerHints(empty, new URLClassLoader(new URL[0], null))
+
+ then:
+ noExceptionThrown()
+ }
+}
diff --git a/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy b/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy
index 434245a18a4..fe126b45870 100644
--- a/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy
+++ b/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy
@@ -18,13 +18,20 @@
*/
package grails.boot
+import java.lang.management.ManagementFactory
+
import groovy.transform.CompileStatic
import groovy.transform.MapConstructor
-import groovy.transform.stc.ClosureParams
-import groovy.transform.stc.SimpleType
+import groovy.util.logging.Slf4j
import org.springframework.boot.Banner
+import org.springframework.boot.ansi.Ansi8BitColor
+import org.springframework.boot.ansi.AnsiColor
+import org.springframework.boot.ansi.AnsiElement
+import org.springframework.boot.ansi.AnsiOutput
import org.springframework.boot.SpringBootVersion
+import org.springframework.aot.AotDetector
+import org.springframework.core.NativeDetector
import org.springframework.core.SpringVersion
import org.springframework.core.env.Environment
import org.springframework.core.io.ClassPathResource
@@ -36,6 +43,7 @@ import grails.util.BuildSettings
*
* @since 7.1
*/
+@Slf4j
@CompileStatic
@MapConstructor(noArg = true)
class GrailsBanner implements Banner {
@@ -43,6 +51,37 @@ class GrailsBanner implements Banner {
private static final int FALLBACK_BANNER_WIDTH = 0
private static final String DEFAULT_BANNER_FILE = 'grails-banner.txt'
+ private static final String ART_COLOR_PROPERTY = 'grails.banner.art.color'
+
+ private static final String MARK_DISPLAY_PROPERTY = 'grails.banner.mark.display'
+
+ private static final String MARK_TEXT_PROPERTY = 'grails.banner.mark.text'
+
+ private static final String MARK_COLOR_PROPERTY = 'grails.banner.mark.color'
+
+ /** Bright yellow, so the mark reads as its own thing rather than the last line of the art. */
+ private static final String DEFAULT_MARK_COLOR = '226'
+
+ /** What an application was started as, strongest first. */
+ private static final String NATIVE_MARK = 'NATIVE'
+ private static final String CACHE_MARK = 'AOT CACHE'
+ private static final String AOT_MARK = 'AOT'
+
+ /** One of the 256 colours a terminal offers: the amber the framework is shown in. */
+ private static final String DEFAULT_ART_COLOR = '214'
+
+ private static final String NO_COLOR = 'none'
+
+ private static final String ORDER_PROPERTY = 'grails.banner.versions.order'
+
+ private static final String EXCLUDE_PROPERTY = 'grails.banner.versions.exclude'
+
+ private static final String INCLUDE_PROPERTY = 'grails.banner.versions.include'
+
+ /** Everywhere an application names a version option, and so everywhere one can be misspelt. */
+ private static final List VERSION_PROPERTIES =
+ [ORDER_PROPERTY, EXCLUDE_PROPERTY, INCLUDE_PROPERTY].asImmutable()
+
String bannerFile = DEFAULT_BANNER_FILE
int bannerPaddingTop = 1
int bannerPaddingBottom = 1
@@ -63,15 +102,190 @@ class GrailsBanner implements Banner {
bannerPaddingTop.times { out.println() }
if (shouldDisplayArt(environment)) {
def art = createBannerArt(environment)
+ // measured before colouring, so the escapes do not count towards the width the
+ // versions below are centred on
bannerWidth = longestLineLength(art) ?: FALLBACK_BANNER_WIDTH
- out.println(art)
+ out.println(colour(art, environment))
artPaddingBottom.times { out.println() }
}
- if (shouldDisplayVersions(environment)) {
+ printMark(environment, out, bannerWidth)
+ boolean displayVersions = shouldDisplayVersions(environment)
+ if (displayVersions) {
createVersionsFormatter().format(createBannerVersions(environment), bannerWidth)
.forEach { out.println(it) }
}
bannerPaddingBottom.times { out.println() }
+ if (displayVersions) {
+ warnAboutUnrecognisedVersions(environment)
+ }
+ }
+
+ /**
+ * Says which of the version options an application configured were not recognised.
+ *
+ *
An option that names nothing is dropped, and was dropped without a word -- so a name with
+ * a typo in it reads as a banner that quietly ignores what it was told, and the only way to
+ * find out is to notice a line that is not there.
+ *
+ *
Said after the banner rather than while one is being built. A line logged partway through
+ * arrives between the mark and the versions, which is the very thing reading a version without
+ * initialising the library it belongs to was for.
+ */
+ protected void warnAboutUnrecognisedVersions(Environment env) {
+ List unrecognised = unrecognisedVersionOptions(env)
+ if (unrecognised) {
+ log.warn('Ignoring unknown banner version option(s) {}; the known options are {}',
+ unrecognised, VersionOption.values()*.key)
+ }
+ }
+
+ /**
+ * The configured version options that name none of {@link VersionOption}, in the order they
+ * were written.
+ *
+ *
Which list an option belongs to is not asked. An option is named by an application to say
+ * that it wants it or does not, and moving one between the shown-by-default set and the
+ * asked-for set is a decision of this class -- so an application that asked for a version that
+ * has since become one of the defaults said something recognisable, and is told nothing.
+ */
+ protected List unrecognisedVersionOptions(Environment env) {
+ List known = VersionOption.values()*.key
+ VERSION_PROPERTIES.collectMany { String property ->
+ readVersionOptions(env, property).findAll { String option -> !(option in known) }
+ }.unique()
+ }
+
+ /**
+ * Marks how the application was started, centred under the art and above the versions.
+ *
+ *
Only where it is worth saying: an image, a JVM given a cache to read, or one running bean
+ * definitions that were generated. An ordinary start says nothing.
+ *
+ *
Written plainly, and deliberately: a banner is printed on the thread that is starting the
+ * application, which cannot get on until this returns. Anything drawn over time here is time the
+ * application is not starting, and an image that starts in two thirds of a second should not
+ * spend a third of it on its own announcement.
+ */
+ protected void printMark(Environment environment, PrintStream out, int bannerWidth) {
+ if (!environment.getProperty(MARK_DISPLAY_PROPERTY, Boolean, true)) {
+ return
+ }
+ String mark = resolveMark(environment)
+ if (!mark) {
+ return
+ }
+ String label = spaced(mark)
+ String indent = ' ' * Math.max(0, (bannerWidth - label.length()).intdiv(2))
+ AnsiElement colour = resolveMarkColour(environment)
+ out.println(indent + (colour == null ? label : AnsiOutput.toString(colour, label, AnsiColor.DEFAULT)))
+ }
+
+ /**
+ * What to say, strongest first, or nothing where an application started the ordinary way.
+ *
+ *
An image has already done all of it. A cache means the JDK was handed what a previous run
+ * worked out. Generated bean definitions are the smaller half of the same idea, and worth
+ * saying on their own because an application can be run either with them or without.
+ */
+ protected String resolveMark(Environment environment) {
+ String configured = environment.getProperty(MARK_TEXT_PROPERTY, String)
+ if (configured != null) {
+ return configured.trim() ?: null
+ }
+ if (isNativeImage()) {
+ return NATIVE_MARK
+ }
+ if (readsAotCache()) {
+ return CACHE_MARK
+ }
+ AotDetector.useGeneratedArtifacts() ? AOT_MARK : null
+ }
+
+ /**
+ * Whether the JDK was given a cache to read.
+ *
+ *
Asked of the arguments the JVM was started with rather than of the cache: a JVM handed one
+ * it cannot use declines it and starts as it would have anyway, and this is the banner rather
+ * than a diagnostic.
+ */
+ protected boolean readsAotCache() {
+ ManagementFactory.runtimeMXBean.inputArguments.any { String argument ->
+ argument.startsWith('-XX:AOTCache=') || argument.startsWith('-XX:AOTMode=')
+ }
+ }
+
+ /**
+ * Spaced so it reads as a mark rather than a word.
+ *
+ *
Spaced as written rather than shouted: the marks this draws are already upper case, and
+ * an application that says what it wants the mark to be gets what it said. A name whose case
+ * is part of it -- CRaC -- keeps it.
+ */
+ private static String spaced(String word) {
+ word.toCharArray().join(' ')
+ }
+
+ /** Whether this is running as an image. A seam, so the mark can be covered without being one. */
+ protected boolean isNativeImage() {
+ NativeDetector.inNativeImage()
+ }
+
+ /**
+ * The art in the configured colour, or as it stands where none is wanted.
+ *
+ *
{@link AnsiOutput} writes the escapes only where colour has been enabled, so this is the
+ * same string on a terminal that cannot colour, in a redirected log, and wherever an application
+ * has turned colour off.
+ */
+ protected String colour(String art, Environment environment) {
+ AnsiElement colour = resolveArtColour(environment)
+ colour == null ? art : AnsiOutput.toString(colour, art, AnsiColor.DEFAULT)
+ }
+
+ /**
+ * The colour to show the art in, read from {@code grails.banner.art.color}.
+ *
+ *
Takes a number for one of the 256 colours a terminal offers, a name for one of the eight
+ * it has always had ({@code red}, {@code bright_blue}), or {@code none} to leave the art as it
+ * stands. A value that is neither falls back to the default rather than failing to start over
+ * the colour of a banner.
+ */
+ protected AnsiElement resolveArtColour(Environment environment) {
+ resolveColour(environment, ART_COLOR_PROPERTY, DEFAULT_ART_COLOR)
+ }
+
+ /**
+ * The colour to show the mark in, read from {@code grails.banner.mark.color}.
+ *
+ *
Its own colour rather than the art's, and brighter, so that what an application was started
+ * as is not read as the last line of the drawing above it. Takes the same values as
+ * {@code grails.banner.art.color}.
+ */
+ protected AnsiElement resolveMarkColour(Environment environment) {
+ resolveColour(environment, MARK_COLOR_PROPERTY, DEFAULT_MARK_COLOR)
+ }
+
+ private AnsiElement resolveColour(Environment environment, String property, String fallback) {
+ String configured = environment.getProperty(property, String, fallback)
+ if (!configured || configured.equalsIgnoreCase(NO_COLOR)) {
+ return null
+ }
+ if (configured.isInteger()) {
+ int code = configured.toInteger()
+ // A terminal offers 256 of them, and anything else writes an escape it does not
+ // understand -- which shows as the escape itself, printed into the banner.
+ return code in 0..255 ? Ansi8BitColor.foreground(code) : defaultColour(fallback)
+ }
+ try {
+ return AnsiColor.valueOf(configured.toUpperCase())
+ }
+ catch (IllegalArgumentException ignored) {
+ return defaultColour(fallback)
+ }
+ }
+
+ private static AnsiElement defaultColour(String fallback) {
+ Ansi8BitColor.foreground(fallback.toInteger())
}
/**
@@ -104,12 +318,11 @@ class GrailsBanner implements Banner {
* @param env the current env
* @return a map of version labels to version values
*/
- @SuppressWarnings('GrMethodMayBeStatic')
protected Map createBannerVersions(Environment env) {
def defaultIncluded = (DefaultVersionOption.values()).collect { it.key }
- def sortOrder = findConfiguredVersions(env, 'grails.banner.versions.order') { it in VersionOption.values()*.key }
- def configExcluded = findConfiguredVersions(env, 'grails.banner.versions.exclude') { it in DefaultVersionOption.values()*.key }
- def configIncluded = findConfiguredVersions(env, 'grails.banner.versions.include') { it in OptionalVersionOption.values()*.key }
+ def sortOrder = findConfiguredVersions(env, ORDER_PROPERTY)
+ def configExcluded = findConfiguredVersions(env, EXCLUDE_PROPERTY)
+ def configIncluded = findConfiguredVersions(env, INCLUDE_PROPERTY)
def includedVersions = defaultIncluded
.tap { removeAll(configExcluded) }
.tap { addAll(configIncluded) }
@@ -129,7 +342,10 @@ class GrailsBanner implements Banner {
}
}
}
- includedVersions.collectEntries { key ->
+ // A library that is not there, or that records no version, is left out rather than shown
+ // as unknown. That is what lets a version be on by default: an application without Spring
+ // Security says nothing about it, instead of saying it does not know.
+ Map versions = includedVersions.collectEntries { key ->
switch (VersionOption.fromString(key)) {
case VersionOption.APP:
[(env.getProperty('info.app.name') ?: 'app'): env.getProperty('info.app.version') ?: 'unknown']
@@ -152,52 +368,136 @@ class GrailsBanner implements Banner {
case VersionOption.SPRING_SECURITY:
['Spring Security': findVersion('org.springframework.security.core.SpringSecurityCoreVersion')]
break
+ case VersionOption.CONTAINER:
+ findContainerVersion()
+ break
case VersionOption.TOMCAT:
- ['Tomcat': findVersion('org.apache.catalina.util.ServerInfo')]
+ ['Tomcat': findTomcatVersion()]
break
case VersionOption.JETTY:
- ['Jetty': findVersion('org.eclipse.jetty.util.Jetty')]
+ ['Jetty': findJettyVersion()]
break
case VersionOption.UNDERTOW:
- ['Undertow': findVersion('io.undertow.Undertow')]
+ ['Undertow': findUndertowVersion()]
break
default:
null
}
} as Map
+ versions.findAll { String label, String version -> version != null }
+ }
+
+ /**
+ * The servlet container the application is running on, and the version it records.
+ *
+ *
An application runs on one container: choosing another is done by excluding the starter
+ * for this one, so two are not on the classpath together. They are therefore tried in the order
+ * they are commonly used and the first one found is the answer -- an application on Tomcat
+ * never goes looking for Jetty.
+ *
+ *
On a container that records no version, or on none of these, this is empty and the banner
+ * leaves the line out rather than saying it does not know.
+ */
+ protected Map findContainerVersion() {
+ String tomcat = findTomcatVersion()
+ if (tomcat != null) {
+ return ['Tomcat': tomcat]
+ }
+ String jetty = findJettyVersion()
+ if (jetty != null) {
+ return ['Jetty': jetty]
+ }
+ String undertow = findUndertowVersion()
+ if (undertow != null) {
+ return ['Undertow': undertow]
+ }
+ return [:]
}
/**
- * Finds the implementation version of the specified class.
+ * Tomcat's version, read from the resource it ships rather than only from its manifest.
*
- * @param className the fully qualified class name
- * @return the implementation version, or 'unknown' if not found
+ *
A resource survives being repackaged into an executable jar or built into an image, where
+ * the manifest's attributes are no longer attached to the package -- which is why the manifest
+ * route reads as nothing in exactly the two places a version is most worth having.
*/
- private static String findVersion(String className) {
+ protected String findTomcatVersion() {
+ findVersionInResource('org/apache/catalina/util/ServerInfo.properties', 'server.number')
+ ?: findVersion('org.apache.catalina.util.ServerInfo')
+ }
+
+ protected String findJettyVersion() {
+ findVersion('org.eclipse.jetty.util.Jetty')
+ }
+
+ protected String findUndertowVersion() {
+ findVersion('io.undertow.Undertow')
+ }
+
+ /**
+ * A version a library records in a resource it ships, read without loading any of its classes.
+ *
+ *
The manifest route only works while a jar is a plain entry on the classpath. Repackaged
+ * into an executable jar its attributes are no longer attached to the package, and an image has
+ * no jars at all -- which is why a container version read that way reads as nothing in exactly
+ * the two places it is most worth having. A resource is still a resource in both.
+ *
+ * @param resource the classpath location of the resource to read
+ * @param key the property within it that carries the version
+ * @return the version, or {@code null} where the resource or the property is absent
+ */
+ protected static String findVersionInResource(String resource, String key) {
+ InputStream stream = GrailsBanner.classLoader.getResourceAsStream(resource)
+ if (stream == null) {
+ return null
+ }
try {
- def pkg = Class.forName(className).package
- return pkg?.implementationVersion ?: 'unknown'
+ Properties properties = new Properties()
+ stream.withCloseable { properties.load(it) }
+ return properties.getProperty(key)
+ }
+ catch (IOException ignored) {
+ return null
+ }
+ }
+
+ /**
+ * The version a library records in the manifest of the jar it ships in.
+ *
+ *
Loaded without being initialised. A version is read about a library rather than
+ * from it, and running a static initialiser to find one lets the library do whatever it
+ * does on the way -- Spring Security logs a line of its own from there, which arrived in the
+ * middle of the banner, between the mark and the very versions it was being read for. The
+ * manifest is attached to the package when the class is loaded, and loading is all this
+ * needs.
+ *
+ * @param className the fully qualified name of a class the library ships
+ * @return the version, or {@code null} where the class is absent or records none
+ */
+ protected static String findVersion(String className) {
+ try {
+ Package pkg = Class.forName(className, false, GrailsBanner.classLoader).package
+ return pkg?.implementationVersion
} catch (ClassNotFoundException ignore) {
- return 'unknown'
+ return null
}
}
/**
- * Finds the configured versions from the environment.
+ * The version options configured under the given property that name something.
*
* @param env the current environment
* @param propertyName the property name to look for
- * @param filter the filter closure
- * @return a list of configured versions
+ * @return the options that name a {@link VersionOption}, in the order they were written
*/
- private static List findConfiguredVersions(
- Environment env,
- String propertyName,
- @ClosureParams(
- value = SimpleType,
- options = ['java.lang.String']
- ) Closure filter) {
- env.getProperty(propertyName, List, [] as List).findAll(filter)
+ private static List findConfiguredVersions(Environment env, String propertyName) {
+ List known = VersionOption.values()*.key
+ readVersionOptions(env, propertyName).findAll { String option -> option in known }
+ }
+
+ /** What an application wrote under the given property, or nothing where it wrote none. */
+ private static List readVersionOptions(Environment env, String propertyName) {
+ env.getProperty(propertyName, List, [] as List)
}
/**
@@ -323,6 +623,7 @@ class GrailsBanner implements Banner {
SPRING_BOOT,
SPRING,
SPRING_SECURITY,
+ CONTAINER,
TOMCAT,
JETTY,
UNDERTOW
@@ -352,7 +653,9 @@ class GrailsBanner implements Banner {
GRAILS,
GROOVY,
SPRING_BOOT,
- SPRING
+ SPRING,
+ SPRING_SECURITY,
+ CONTAINER
final String key
@@ -363,10 +666,13 @@ class GrailsBanner implements Banner {
/**
* Enumeration of optional version options.
+ *
+ *
The container being run is shown by default under {@code container}, which is the one an
+ * application is on. These name a particular container instead, for an application that wants
+ * to be told about one whether or not it is the one serving.
*/
@CompileStatic
enum OptionalVersionOption {
- SPRING_SECURITY,
TOMCAT,
JETTY,
UNDERTOW
diff --git a/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy b/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy
index 297ae6046fd..c37840bfb5a 100644
--- a/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy
+++ b/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy
@@ -29,6 +29,12 @@ import org.springframework.beans.factory.config.ConfigurableBeanFactory
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory
import org.springframework.beans.factory.support.BeanDefinitionRegistry
import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor
+import org.springframework.context.annotation.AnnotationConfigUtils
+import org.springframework.aot.AotDetector
+import org.springframework.beans.factory.support.RootBeanDefinition
+import org.springframework.beans.factory.config.BeanDefinition
+import org.springframework.context.annotation.CommonAnnotationBeanPostProcessor
+import org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor
import org.springframework.beans.factory.support.BeanRegistryAdapter
import org.springframework.context.ApplicationContext
import org.springframework.context.ApplicationContextAware
@@ -238,6 +244,8 @@ class GrailsApplicationPostProcessor implements BeanDefinitionRegistryPostProces
@Override
void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException {
+ registerAnnotationConfigProcessorsForGeneratedArtifacts(registry)
+
def springConfig = new DefaultRuntimeSpringConfiguration()
def application = grailsApplication
Holders.setGrailsApplication(application)
@@ -248,7 +256,12 @@ class GrailsApplicationPostProcessor implements BeanDefinitionRegistryPostProces
pluginManager.doRuntimeConfiguration(springConfig)
}
- if (loadExternalBeans) {
+ // Running on generated artifacts these beans are already registered: the application's own
+ // definitions were read while the artifacts were being generated and what they declared was
+ // written out as code. Reading them again would register them a second time, and reading
+ // the Groovy one means compiling a script -- which an image cannot do at all, so an
+ // application that has a spring/resources.groovy did not start.
+ if (loadExternalBeans && !AotDetector.useGeneratedArtifacts()) {
// now allow overriding via application
def context = application.mainContext
@@ -322,6 +335,46 @@ class GrailsApplicationPostProcessor implements BeanDefinitionRegistryPostProces
}
}
+ /**
+ * Restores the processors that read the injection annotations, when running on artifacts
+ * generated ahead of time.
+ *
+ *
Generating those artifacts normally makes these unnecessary: the generator reads the
+ * annotations itself and writes the field and method access into the code it emits, which is why
+ * a controller or a tag library arrives fully injected without them. It can only do that for a
+ * bean whose implementation it can see, and a bean contributed as an interface built by a
+ * supplier hides it -- the link generator is declared as {@code LinkGenerator} and built by a
+ * closure, so the {@code @Autowired} field on the implementation is generated for by nobody.
+ * Nothing fails at start-up; the first page that follows a link does.
+ *
+ *
Only the two that inject are restored. Registering the whole set would bring back the
+ * processor that reads configuration classes, and reading them again in a context whose
+ * configuration has already been generated makes a second definition for beans the generated
+ * code has already contributed.
+ *
+ *
They are registered under the names Spring uses itself, so a context that already has them
+ * keeps what it has, and a context running without generated artifacts is untouched.
+ */
+ protected static void registerAnnotationConfigProcessorsForGeneratedArtifacts(BeanDefinitionRegistry registry) {
+ if (!AotDetector.useGeneratedArtifacts()) {
+ return
+ }
+ registerInfrastructureBean(registry, AnnotationConfigUtils.AUTOWIRED_ANNOTATION_PROCESSOR_BEAN_NAME,
+ AutowiredAnnotationBeanPostProcessor)
+ registerInfrastructureBean(registry, AnnotationConfigUtils.COMMON_ANNOTATION_PROCESSOR_BEAN_NAME,
+ CommonAnnotationBeanPostProcessor)
+ }
+
+ private static void registerInfrastructureBean(BeanDefinitionRegistry registry, String beanName,
+ Class> beanClass) {
+ if (registry.containsBeanDefinition(beanName)) {
+ return
+ }
+ RootBeanDefinition definition = new RootBeanDefinition(beanClass)
+ definition.role = BeanDefinition.ROLE_INFRASTRUCTURE
+ registry.registerBeanDefinition(beanName, definition)
+ }
+
@Override
void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
BeanFactory parentBeanFactory = beanFactory.getParentBeanFactory()
diff --git a/grails-core/src/main/groovy/grails/boot/config/GrailsAutoConfiguration.groovy b/grails-core/src/main/groovy/grails/boot/config/GrailsAutoConfiguration.groovy
index 6e800e43982..3ce991577b5 100644
--- a/grails-core/src/main/groovy/grails/boot/config/GrailsAutoConfiguration.groovy
+++ b/grails-core/src/main/groovy/grails/boot/config/GrailsAutoConfiguration.groovy
@@ -26,6 +26,8 @@ import org.springframework.aop.config.AopConfigUtils
import org.springframework.context.ApplicationContext
import org.springframework.context.ApplicationContextAware
import org.springframework.context.annotation.Bean
+import org.springframework.aot.AotDetector
+import org.springframework.beans.factory.config.SingletonBeanRegistry
import org.springframework.core.io.support.PathMatchingResourcePatternResolver
import grails.boot.config.tools.ClassPathScanner
@@ -34,6 +36,7 @@ import grails.core.GrailsApplication
import grails.core.GrailsApplicationClass
import org.apache.grails.core.plugins.PluginDiscovery
import org.grails.spring.aop.autoproxy.GroovyAwareAspectJAwareAdvisorAutoProxyCreator
+import org.grails.spring.beans.aot.ArtefactClassesBeanFactoryInitializationAotProcessor
import org.grails.spring.aop.autoproxy.GroovyAwareInfrastructureAdvisorAutoProxyCreator
/**
@@ -78,6 +81,11 @@ class GrailsAutoConfiguration implements GrailsApplicationClass, ApplicationCont
* @return The classes that constitute the Grails application
*/
Collection classes() {
+ Collection written = artefactsWrittenDownAheadOfTime()
+ if (written != null) {
+ return written
+ }
+
if (limitScanningToApplication()) {
return ApplicationArtefactScanner.scanApplicationClasses(getClass(), packageNames())
}
@@ -88,6 +96,29 @@ class GrailsAutoConfiguration implements GrailsApplicationClass, ApplicationCont
return classes
}
+ /**
+ * The artefacts written down while the application's code was generated, or {@code null} where
+ * nothing was written down and they are to be found the usual ways.
+ *
+ *
Both usual ways need something an image does not have: one walks the classpath, the other
+ * reads a list the compile-time transform builds as it goes, which is empty in anything the
+ * transform did not itself compile. So an image found no artefacts at all, and an application
+ * could only start by naming its own -- a list to keep in step with itself forever after.
+ *
+ *
They were found while the code was generated, on an ordinary JVM where both ways work, and
+ * left here.
+ */
+ protected Collection artefactsWrittenDownAheadOfTime() {
+ if (applicationContext == null || !AotDetector.useGeneratedArtifacts()) {
+ return null
+ }
+ Object written = applicationContext.autowireCapableBeanFactory instanceof SingletonBeanRegistry
+ ? ((SingletonBeanRegistry) applicationContext.autowireCapableBeanFactory)
+ .getSingleton(ArtefactClassesBeanFactoryInitializationAotProcessor.BEAN_NAME)
+ : null
+ written instanceof Class[] ? Arrays.asList((Class[]) written) : null
+ }
+
/**
* Whether classpath scanning should be limited to the application and not dependent JAR files. Users can override this method to enable more broad scanning
* at the cost of startup time.
diff --git a/grails-core/src/main/groovy/grails/boot/config/GrailsEnvironmentPostProcessor.java b/grails-core/src/main/groovy/grails/boot/config/GrailsEnvironmentPostProcessor.java
index 2650bb300be..ee823f1a5e7 100644
--- a/grails-core/src/main/groovy/grails/boot/config/GrailsEnvironmentPostProcessor.java
+++ b/grails-core/src/main/groovy/grails/boot/config/GrailsEnvironmentPostProcessor.java
@@ -22,6 +22,7 @@
import java.util.Collection;
import java.util.Collections;
import java.util.List;
+import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -32,6 +33,7 @@
import org.springframework.core.Ordered;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.EnumerablePropertySource;
+import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.MutablePropertySources;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.Resource;
@@ -74,8 +76,15 @@ public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE + 15;
}
+ /** Where Spring Boot reads whether to colour its output. */
+ private static final String ANSI_ENABLED = "spring.output.ansi.enabled";
+
+ /** Set in an image, and in nothing else. */
+ private static final String IMAGE_CODE = "org.graalvm.nativeimage.imagecode";
+
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
+ colourTheOutputOfAnImageThatHasATerminal(environment);
try {
PluginDiscovery pluginDiscovery = bootstrapContext.get(PluginDiscovery.class);
if (pluginDiscovery == null) {
@@ -92,6 +101,43 @@ public void postProcessEnvironment(ConfigurableEnvironment environment, SpringAp
}
}
+ /**
+ * Colours the output of an image running at a terminal, which it otherwise cannot tell it has.
+ *
+ *
Spring Boot decides by asking for the console, and an image answers that it has none even
+ * when it is being watched at a terminal. So the same application whose start-up is coloured
+ * under {@code bootRun} arrives plain once it is built, for a reason that has nothing to do with
+ * the terminal it is running at.
+ *
+ *
What the environment names as the terminal is read instead, which an image does carry.
+ * That does not distinguish output being watched from output being redirected -- nothing in an
+ * image does, which is the whole difficulty -- so a shell that redirects to a file still gets
+ * the escapes. It does distinguish a shell from the places that name no terminal at all: a
+ * build, a container, a service manager, where the output is only ever read later and stays
+ * plain. An application that has said either way is left alone.
+ */
+ private void colourTheOutputOfAnImageThatHasATerminal(ConfigurableEnvironment environment) {
+ if (!isImage() || environment.containsProperty(ANSI_ENABLED)) {
+ return;
+ }
+ String terminal = terminal();
+ if (terminal == null || terminal.isEmpty() || "dumb".equals(terminal)) {
+ return;
+ }
+ environment.getPropertySources().addLast(new MapPropertySource("grails.ansi.output",
+ Map.of(ANSI_ENABLED, "always")));
+ }
+
+ /** Whether this is running as an image, which only a run can answer. */
+ protected boolean isImage() {
+ return System.getProperty(IMAGE_CODE) != null;
+ }
+
+ /** What the environment names as the terminal, if it names one. */
+ protected String terminal() {
+ return System.getenv("TERM");
+ }
+
/**
* Loads plugin configuration files ({@code plugin.yml} or {@code plugin.groovy})
* in the topologically sorted order and adds them to the environment's property sources.
diff --git a/grails-core/src/main/groovy/org/grails/plugins/CoreGrailsPlugin.groovy b/grails-core/src/main/groovy/org/grails/plugins/CoreGrailsPlugin.groovy
index c65796ecde2..687382b77d9 100644
--- a/grails-core/src/main/groovy/org/grails/plugins/CoreGrailsPlugin.groovy
+++ b/grails-core/src/main/groovy/org/grails/plugins/CoreGrailsPlugin.groovy
@@ -21,6 +21,7 @@ package org.grails.plugins
import groovy.transform.CompileStatic
import org.springframework.aop.config.AopConfigUtils
+import org.springframework.aot.AotDetector
import org.springframework.beans.factory.BeanRegistrar
import org.springframework.beans.factory.BeanRegistry
import org.springframework.beans.factory.config.CustomEditorConfigurer
@@ -29,6 +30,9 @@ import org.springframework.beans.factory.xml.XmlBeanDefinitionReader
import org.springframework.boot.autoconfigure.AutoConfiguration
import org.springframework.boot.autoconfigure.AutoConfigureOrder
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration
+import org.springframework.context.ApplicationContext
+import org.springframework.context.ConfigurableApplicationContext
+import org.springframework.context.annotation.AnnotationConfigUtils
import org.springframework.context.annotation.ConfigurationClassPostProcessor
import org.springframework.context.support.GenericApplicationContext
import org.springframework.context.support.PropertySourcesPlaceholderConfigurer
@@ -118,6 +122,31 @@ class CoreGrailsPlugin extends Plugin {
}
}
+ /**
+ * Whether the context already has a processor that parses configuration classes.
+ *
+ *
Spring registers one under a well-known name as part of setting up annotation
+ * configuration, which is every application context that reads annotations. A context assembled
+ * without that step -- a test slice registering this plugin's beans on a bare registry -- has
+ * none, and is what the plugin's own processor is for.
+ */
+ private static boolean hasConfigurationClassPostProcessor(GrailsApplication application) {
+ hasBeanDefinition(application, AnnotationConfigUtils.CONFIGURATION_ANNOTATION_PROCESSOR_BEAN_NAME)
+ }
+
+ /**
+ * Whether the context already has a definition under this name.
+ *
+ *
These registrars run after the {@code doWithSpring} drain, so a plugin that has already
+ * declared a bean under one of these names has declared the one that should stand: registering
+ * over it replaces something chosen for the application with the general case.
+ */
+ private static boolean hasBeanDefinition(GrailsApplication application, String beanName) {
+ ApplicationContext context = application.mainContext
+ context instanceof ConfigurableApplicationContext &&
+ ((ConfigurableApplicationContext) context).beanFactory.containsBeanDefinition(beanName)
+ }
+
/**
* Scans the packages named by {@code grails.spring.bean.packages}. Contributed here rather
* than through {@code doWithSpring}'s {@code grailsContext:component-scan} element, which
@@ -129,8 +158,27 @@ class CoreGrailsPlugin extends Plugin {
GrailsApplication application = grailsApplication
Config config = application.config
- // enable post-processing of @Configuration beans defined by plugins
- registry.registerBean('grailsConfigurationClassPostProcessor', ConfigurationClassPostProcessor)
+ // enable post-processing of @Configuration beans defined by plugins. An AOT-optimized
+ // context has no ConfigurationClassPostProcessor of its own: the configuration classes
+ // were parsed at build time and their beans are already in the generated initializer,
+ // so registering one here would parse them a second time. A context that annotation
+ // configuration has already been set up on has one of its own, which sees the plugin
+ // definitions because they are registered ahead of it; a second processor over the same
+ // registry parses everything a second time, and while code is being generated the two of
+ // them write out the same import-aware post-processor twice, so one registration
+ // replaces the other on every start.
+ //
+ // "Registered ahead of it" is what makes standing down safe, and it is a property of how
+ // the application started rather than of this registry. GrailsEarlyPluginRegistrationPostProcessor
+ // is added with addBeanFactoryPostProcessor and so runs before Spring's own processor,
+ // and it runs whenever PluginDiscovery was promoted to the bean factory -- which
+ // GrailsBootstrapRegistryInitializer does, from spring.factories, for every
+ // SpringApplication. A context assembled without SpringApplication would have Spring's
+ // processor already finished by the time these definitions arrive, and would need this
+ // one; it would also not be a Grails application started any supported way.
+ if (!AotDetector.useGeneratedArtifacts() && !hasConfigurationClassPostProcessor(application)) {
+ registry.registerBean('grailsConfigurationClassPostProcessor', ConfigurationClassPostProcessor)
+ }
registry.registerBean('grailsBeanOverrideConfigurer', MapBasedSmartPropertyOverrideConfigurer) {
it.supplier {
@@ -189,7 +237,12 @@ class CoreGrailsPlugin extends Plugin {
}
}
- registry.registerBean('proxyHandler', DefaultProxyHandler)
+ // The GORM implementations register a proxy handler that knows how to unwrap their own
+ // proxies; this is the one for an application that has none. Registering it over theirs
+ // left a Hibernate application unwrapping Hibernate proxies with the general case.
+ if (!hasBeanDefinition(application, 'proxyHandler')) {
+ registry.registerBean('proxyHandler', DefaultProxyHandler)
+ }
// an abstract parent definition, which registerBean cannot express since it always
// takes a class; third-party plugins inherit their search locations from it
diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/AbstractResourceLocatorPostProcessor.java b/grails-core/src/main/groovy/org/grails/spring/beans/AbstractResourceLocatorPostProcessor.java
index 7149a707ece..b53bec5af07 100644
--- a/grails-core/src/main/groovy/org/grails/spring/beans/AbstractResourceLocatorPostProcessor.java
+++ b/grails-core/src/main/groovy/org/grails/spring/beans/AbstractResourceLocatorPostProcessor.java
@@ -26,7 +26,9 @@
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor;
import org.springframework.beans.factory.support.GenericBeanDefinition;
+import org.springframework.context.aot.AbstractAotProcessor;
import org.springframework.core.PriorityOrdered;
+import org.springframework.core.SpringProperties;
/**
* Registers {@code abstractGrailsResourceLocator}, the abstract parent definition that
@@ -61,10 +63,25 @@ public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) t
}
GenericBeanDefinition definition = new GenericBeanDefinition();
definition.setAbstract(true);
- definition.getPropertyValues().add("searchLocations", this.searchLocations);
+ definition.getPropertyValues().add("searchLocations", searchLocationsToInherit());
registry.registerBeanDefinition(BEAN_NAME, definition);
}
+ /**
+ * The locations to be inherited, which while code is being generated are none.
+ *
+ *
These are directories on the machine this runs on, and a child definition merges them in.
+ * Generating code for that child writes them into it, so an application would carry the
+ * directory it was built in and look for its resources there -- a path that says where it was
+ * built and, wherever it then runs, is not where its resources are. A generated application
+ * reads them from its own contents instead, which is what is left when there is nowhere named
+ * to look.
+ */
+ private List searchLocationsToInherit() {
+ return SpringProperties.getFlag(AbstractAotProcessor.AOT_PROCESSING) ?
+ List.of() : this.searchLocations;
+ }
+
/**
* Ordered first, because a child definition naming this one as its parent cannot be merged
* until it exists, and merging happens as soon as anything resolves beans by type.
diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/AbstractBeanDefinitionExcludeFilter.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/AbstractBeanDefinitionExcludeFilter.java
new file mode 100644
index 00000000000..58808c10676
--- /dev/null
+++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/AbstractBeanDefinitionExcludeFilter.java
@@ -0,0 +1,48 @@
+/*
+ * 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.spring.beans.aot;
+
+import org.springframework.beans.factory.aot.BeanRegistrationExcludeFilter;
+import org.springframework.beans.factory.support.RegisteredBean;
+
+/**
+ * Keeps abstract bean definitions out of ahead-of-time processing.
+ *
+ *
An abstract definition is a template: it carries property values for children to inherit
+ * and is never instantiated. Spring's bean-definition code generator has no representation for
+ * that — it emits neither the abstract flag nor a bean class, so the definition is regenerated
+ * as a concrete bean of type {@code Object} carrying the template's properties, which fails as
+ * soon as the context applies them.
+ *
+ *
Children are unaffected: ahead-of-time processing generates them from their merged
+ * definition, so inherited values are already folded in and no parent is needed at runtime.
+ * A definition contributed dynamically still finds its parent, because the post-processor that
+ * registers the template runs during refresh in an ahead-of-time context as it does in any other.
+ *
+ * @since 8.0
+ * @see org.grails.spring.beans.AbstractResourceLocatorPostProcessor
+ */
+public class AbstractBeanDefinitionExcludeFilter implements BeanRegistrationExcludeFilter {
+
+ @Override
+ public boolean isExcludedFromAotProcessing(RegisteredBean registeredBean) {
+ return registeredBean.getMergedBeanDefinition().isAbstract();
+ }
+
+}
diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessor.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessor.java
new file mode 100644
index 00000000000..1415d88e56b
--- /dev/null
+++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessor.java
@@ -0,0 +1,155 @@
+/*
+ * 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.spring.beans.aot;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+import javax.lang.model.element.Modifier;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.aot.generate.GeneratedMethod;
+import org.springframework.beans.factory.aot.BeanFactoryInitializationAotContribution;
+import org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor;
+import org.springframework.beans.factory.aot.BeanFactoryInitializationCode;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.javapoet.CodeBlock;
+
+import grails.core.GrailsApplication;
+import grails.plugins.GrailsPlugin;
+import grails.plugins.GrailsPluginManager;
+
+/**
+ * Writes down the artefacts an application is made of, while they can still be found.
+ *
+ *
They are found two ways, and an image has neither. The classpath is scanned for the classes
+ * under the application's own packages, which needs a classpath to walk; and a list the compile-time
+ * transform builds as it goes is consulted, which lives in a static field of the transform and is
+ * therefore empty in anything the transform did not itself compile.
+ *
+ *
So an image found no controllers, no domain classes and no URL mappings, and the application
+ * failed to start on the first bean that wanted one. The only way an application could start was to
+ * override {@code classes()} and list its own artefacts by hand, which is a thing to keep in step
+ * with the application forever after.
+ *
+ *
Generation runs on an ordinary JVM with the full classpath, where both ways work. What they
+ * found is written into the generated code here, and read back by {@link
+ * grails.boot.config.GrailsAutoConfiguration#classes()}.
+ *
+ * @since 8.0
+ */
+public class ArtefactClassesBeanFactoryInitializationAotProcessor implements BeanFactoryInitializationAotProcessor {
+
+ /**
+ * Where the classes are left for {@code classes()} to find. A singleton rather than a bean
+ * definition, because it is read while the definitions are still being contributed.
+ */
+ public static final String BEAN_NAME = "grailsArtefactClasses";
+
+ @Override
+ @Nullable
+ public BeanFactoryInitializationAotContribution processAheadOfTime(ConfigurableListableBeanFactory beanFactory) {
+ List> artefacts = artefactsOf(beanFactory);
+ if (artefacts.isEmpty()) {
+ return null;
+ }
+ return (generationContext, beanFactoryInitializationCode) ->
+ contribute(artefacts, beanFactoryInitializationCode);
+ }
+
+ /**
+ * The artefacts the application was found to be made of, or none if this is not a Grails
+ * application context -- a plain Spring one being generated has no {@code grailsApplication}.
+ */
+ private List> artefactsOf(ConfigurableListableBeanFactory beanFactory) {
+ List> artefacts = new ArrayList<>();
+ Object application = beanFactory.getSingleton(GrailsApplication.APPLICATION_ID);
+ if (!(application instanceof GrailsApplication grailsApplication)) {
+ return artefacts;
+ }
+ Class>[] allClasses = grailsApplication.getAllClasses();
+ if (allClasses == null) {
+ return artefacts;
+ }
+ Set> providedByPlugins = providedByPlugins(beanFactory);
+ for (Class> artefact : allClasses) {
+ if (artefact != null && isNamed(artefact) && !providedByPlugins.contains(artefact)) {
+ artefacts.add(artefact);
+ }
+ }
+ return artefacts;
+ }
+
+ /**
+ * The artefacts the plugins bring with them, which are not the application's to declare.
+ *
+ *
They are registered from the plugins on every start, so writing them down here would have
+ * the application claim them as its own -- and a codec or a tag library registered twice, once
+ * as a plugin's and once as the application's, is not the same as registered once.
+ */
+ private Set> providedByPlugins(ConfigurableListableBeanFactory beanFactory) {
+ Set> provided = new LinkedHashSet<>();
+ Object manager = beanFactory.getSingleton(GrailsPluginManager.BEAN_NAME);
+ if (!(manager instanceof GrailsPluginManager pluginManager)) {
+ return provided;
+ }
+ for (GrailsPlugin plugin : pluginManager.getAllPlugins()) {
+ Class>[] artefacts = plugin.getProvidedArtefacts();
+ if (artefacts != null) {
+ provided.addAll(Arrays.asList(artefacts));
+ }
+ }
+ return provided;
+ }
+
+ /**
+ * Whether the class can be written down and read back. One generated as the application ran --
+ * a proxy, or a script compiled from a string -- has a name that resolves to nothing next time.
+ */
+ private boolean isNamed(Class> artefact) {
+ return !artefact.isSynthetic() && !artefact.isAnonymousClass() &&
+ !artefact.isLocalClass() && artefact.getCanonicalName() != null;
+ }
+
+ private void contribute(List> artefacts, BeanFactoryInitializationCode beanFactoryInitializationCode) {
+ GeneratedMethod method = beanFactoryInitializationCode.getMethods()
+ .add("registerArtefactClasses", builder -> {
+ builder.addJavadoc("Register the artefacts this application is made of.");
+ builder.addModifiers(Modifier.PUBLIC, Modifier.STATIC);
+ builder.addParameter(ConfigurableListableBeanFactory.class,
+ BeanFactoryInitializationCode.BEAN_FACTORY_VARIABLE);
+ builder.addStatement("$L.registerSingleton($S, $L)",
+ BeanFactoryInitializationCode.BEAN_FACTORY_VARIABLE, BEAN_NAME, arrayOf(artefacts));
+ });
+ beanFactoryInitializationCode.addInitializer(method.toMethodReference());
+ }
+
+ /** The classes as an array literal, in the order they were found, so a run reads as a build did. */
+ private CodeBlock arrayOf(List> artefacts) {
+ CodeBlock.Builder array = CodeBlock.builder().add("new $T[] {", Class.class);
+ for (int i = 0; i < artefacts.size(); i++) {
+ array.add(i == 0 ? "$T.class" : ", $T.class", artefacts.get(i));
+ }
+ return array.add("}").build();
+ }
+}
diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/AutowireModeBeanRegistrationAotProcessor.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/AutowireModeBeanRegistrationAotProcessor.java
new file mode 100644
index 00000000000..82a52b4b084
--- /dev/null
+++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/AutowireModeBeanRegistrationAotProcessor.java
@@ -0,0 +1,92 @@
+/*
+ * 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.spring.beans.aot;
+
+import java.util.function.Predicate;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.aot.generate.GenerationContext;
+import org.springframework.beans.factory.aot.BeanRegistrationAotContribution;
+import org.springframework.beans.factory.aot.BeanRegistrationAotProcessor;
+import org.springframework.beans.factory.aot.BeanRegistrationCode;
+import org.springframework.beans.factory.aot.BeanRegistrationCodeFragments;
+import org.springframework.beans.factory.aot.BeanRegistrationCodeFragmentsDecorator;
+import org.springframework.beans.factory.support.AbstractBeanDefinition;
+import org.springframework.beans.factory.support.RegisteredBean;
+import org.springframework.beans.factory.support.RootBeanDefinition;
+import org.springframework.javapoet.CodeBlock;
+
+/**
+ * Carries a bean's autowire mode into the code generated for it ahead of time.
+ *
+ *
Grails registers much of what it contributes as autowired by name: a tag library, a controller
+ * or an interceptor takes its collaborators from beans of the same name, without annotating them.
+ * The generator writes out most of a definition but not this, so a bean rebuilt from generated code
+ * arrives with those collaborators unset -- a tag library holding null where it expects a message
+ * source, and a link generator holding null where it expects the URL mappings. Nothing fails at
+ * start-up; the first page that reaches one of them does.
+ *
+ *
The annotated injection is unaffected either way, because the generator resolves that itself
+ * and writes the field and method access into the instance supplier. This only restores what
+ * convention supplies.
+ *
+ * @since 8.0
+ */
+public class AutowireModeBeanRegistrationAotProcessor implements BeanRegistrationAotProcessor {
+
+ @Override
+ @Nullable
+ public BeanRegistrationAotContribution processAheadOfTime(RegisteredBean registeredBean) {
+ int autowireMode = autowireModeOf(registeredBean);
+ if (autowireMode == AbstractBeanDefinition.AUTOWIRE_NO) {
+ return null;
+ }
+ return BeanRegistrationAotContribution.withCustomCodeFragments(
+ codeFragments -> new AutowireModeCodeFragments(codeFragments, autowireMode));
+ }
+
+ private int autowireModeOf(RegisteredBean registeredBean) {
+ RootBeanDefinition definition = registeredBean.getMergedBeanDefinition();
+ return definition.getAutowireMode();
+ }
+
+ /** Appends the assignment the generator leaves out to the properties it does write. */
+ private static final class AutowireModeCodeFragments extends BeanRegistrationCodeFragmentsDecorator {
+
+ private final int autowireMode;
+
+ private AutowireModeCodeFragments(BeanRegistrationCodeFragments delegate, int autowireMode) {
+ super(delegate);
+ this.autowireMode = autowireMode;
+ }
+
+ @Override
+ public CodeBlock generateSetBeanDefinitionPropertiesCode(GenerationContext generationContext,
+ BeanRegistrationCode beanRegistrationCode, RootBeanDefinition beanDefinition,
+ Predicate attributeFilter) {
+ CodeBlock properties = super.generateSetBeanDefinitionPropertiesCode(generationContext,
+ beanRegistrationCode, beanDefinition, attributeFilter);
+ return CodeBlock.builder()
+ .add(properties)
+ .addStatement("$L.setAutowireMode($L)", BEAN_DEFINITION_VARIABLE, this.autowireMode)
+ .build();
+ }
+ }
+}
diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/BeanRegistrarRuntimeHints.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/BeanRegistrarRuntimeHints.java
new file mode 100644
index 00000000000..361358200f2
--- /dev/null
+++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/BeanRegistrarRuntimeHints.java
@@ -0,0 +1,73 @@
+/*
+ * 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.spring.beans.aot;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.aot.hint.MemberCategory;
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+import org.springframework.beans.factory.BeanRegistrar;
+import org.springframework.beans.factory.BeanRegistry;
+import org.springframework.beans.factory.ListableBeanFactory;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.BeanDefinitionRegistry;
+
+/**
+ * Registers the registry a plugin declares its beans against.
+ *
+ *
A plugin declares them in a closure, and Groovy resolves every call in a closure where the call
+ * is written rather than when the closure is compiled. So each call on the registry is made
+ * reflectively, and an image keeps a method for that only when something has asked it to.
+ *
+ *
Nothing does: the registry belongs to Spring, an application never names it, and the closures
+ * that call it are registered as closures rather than for what they call. The image then refuses the
+ * first call and the context does not start, reporting a method of an interface that appears nowhere
+ * in the application -- a plugin declaring a bean with a specification, which most of them do.
+ *
+ *
The same is true of the registry the older bean DSL is handed: a plugin that still declares
+ * its beans that way asks whether one is already registered, and that call is made the same
+ * reflective way. It failed later than the others, once a datastore came to be configured.
+ *
+ *
The types are named here rather than scanned for, because they belong to Spring and are few.
+ *
+ * @since 8.0
+ */
+public class BeanRegistrarRuntimeHints implements RuntimeHintsRegistrar {
+
+ /** The registry, what hands a plugin to it, and the types its calls pass through. */
+ private static final Class>[] TYPES = {
+ BeanRegistrar.class,
+ BeanRegistry.class,
+ BeanRegistry.Spec.class,
+ BeanRegistry.SupplierContext.class,
+ // The registry the older bean DSL is handed, which the plugins that still use it call the
+ // same way: asking whether a bean is already there, registering one, naming an alias.
+ BeanDefinitionRegistry.class,
+ ListableBeanFactory.class,
+ ConfigurableListableBeanFactory.class
+ };
+
+ @Override
+ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
+ for (Class> type : TYPES) {
+ hints.reflection().registerType(type, MemberCategory.INVOKE_DECLARED_METHODS);
+ }
+ }
+}
diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHints.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHints.java
new file mode 100644
index 00000000000..67b5e522947
--- /dev/null
+++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHints.java
@@ -0,0 +1,90 @@
+/*
+ * 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.spring.beans.aot;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.aot.hint.MemberCategory;
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.core.env.ConfigurableEnvironment;
+import org.springframework.core.env.Environment;
+import org.springframework.core.env.PropertyResolver;
+import org.springframework.core.io.ResourceLoader;
+
+import grails.config.Config;
+import grails.config.ConfigMap;
+import grails.core.GrailsApplication;
+import grails.core.GrailsClass;
+import grails.plugins.GrailsPlugin;
+import grails.plugins.GrailsPluginManager;
+
+/**
+ * Registers the interfaces a plugin reaches through dynamically: the framework's own, and the
+ * Spring ones it is handed.
+ *
+ *
A plugin descriptor is Groovy, and much of it is written without static compilation -- so
+ * reading a configuration value, asking the application for its artefacts, or asking the plugin
+ * manager about a plugin are all calls resolved where they are written rather than when the
+ * descriptor is compiled. Each is therefore made reflectively.
+ *
+ *
An image keeps a method for that only when something has asked it to, and an application never
+ * names these itself: they are the framework's, called from the framework's own descriptors. The
+ * image refuses the call where it is made, and what it reports is a method of an interface that
+ * appears nowhere in the application -- the last of them stopping a context from starting on
+ * {@code ConfigMap.getProperty}, which is how nearly every plugin reads its settings.
+ *
+ *
The Spring interfaces are here for the same reason rather than a different one: a descriptor is
+ * given the environment and the context and calls them the same dynamic way, and an application does
+ * not name them either. Reading a setting from the environment stopped a Hibernate application from
+ * starting in exactly the way reading one from the configuration stopped every application.
+ *
+ *
Only the interfaces are named. What implements them is reached through them, and registering
+ * the implementations would be registering most of the framework.
+ *
+ * @since 8.0
+ */
+public class GrailsApiRuntimeHints implements RuntimeHintsRegistrar {
+
+ /** What a descriptor written without static compilation calls on the framework. */
+ private static final Class>[] TYPES = {
+ Config.class,
+ ConfigMap.class,
+ GrailsApplication.class,
+ GrailsClass.class,
+ GrailsPlugin.class,
+ GrailsPluginManager.class,
+ // What a descriptor is handed by Spring and calls the same way
+ Environment.class,
+ ConfigurableEnvironment.class,
+ PropertyResolver.class,
+ ApplicationContext.class,
+ ConfigurableApplicationContext.class,
+ ResourceLoader.class
+ };
+
+ @Override
+ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
+ for (Class> type : TYPES) {
+ hints.reflection().registerType(type, MemberCategory.INVOKE_DECLARED_METHODS);
+ }
+ }
+}
diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsBannerRuntimeHints.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsBannerRuntimeHints.java
new file mode 100644
index 00000000000..c0167de49e4
--- /dev/null
+++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsBannerRuntimeHints.java
@@ -0,0 +1,70 @@
+/*
+ * 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.spring.beans.aot;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.aot.hint.MemberCategory;
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+import org.springframework.aot.hint.TypeReference;
+
+import grails.boot.GrailsBanner;
+
+/**
+ * Keeps the banner's option enums usable in an image, including the ones an application has not
+ * turned on.
+ *
+ *
A Groovy enum builds its constants in a static initialiser that reaches its own constructor
+ * through the metaclass rather than calling it. An image keeps a constructor only when something
+ * has asked it to, so the enum initialises to
+ * {@code Could not find matching constructor for: ...OptionalVersionOption(String, Integer)} --
+ * thrown while printing the banner, before anything the application wrote has run.
+ *
+ *
Which enums are reached depends on configuration: the optional ones are touched only where
+ * {@code grails.banner.versions.include} names something. So an image traced with nothing included
+ * carries no record of them, and an application that later asks for the Tomcat version gets a
+ * start-up failure out of a banner setting. They are registered here rather than left to a trace
+ * for that reason -- all three are small, and the alternative is metadata that depends on which
+ * options happened to be on the day it was collected.
+ *
+ * @since 8.0
+ */
+public class GrailsBannerRuntimeHints implements RuntimeHintsRegistrar {
+
+ private static final Class>[] OPTION_TYPES = {
+ GrailsBanner.VersionOption.class,
+ GrailsBanner.DefaultVersionOption.class,
+ GrailsBanner.OptionalVersionOption.class
+ };
+
+ @Override
+ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
+ for (Class> type : OPTION_TYPES) {
+ hints.reflection().registerType(type,
+ MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
+ MemberCategory.INVOKE_DECLARED_METHODS,
+ MemberCategory.ACCESS_DECLARED_FIELDS);
+ // The name Groovy looks up before deciding an enum has no metaclass of its own. It is
+ // not a class anyone writes, so it is named rather than referenced.
+ hints.reflection().registerType(
+ TypeReference.of("groovy.runtime.metaclass." + type.getName() + "MetaClass"));
+ }
+ }
+}
diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHints.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHints.java
new file mode 100644
index 00000000000..4928450914d
--- /dev/null
+++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHints.java
@@ -0,0 +1,156 @@
+/*
+ * 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.spring.beans.aot;
+
+import java.io.IOException;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.aot.hint.MemberCategory;
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
+import org.springframework.core.io.support.ResourcePatternResolver;
+import org.springframework.core.type.classreading.CachingMetadataReaderFactory;
+import org.springframework.core.type.classreading.MetadataReaderFactory;
+import org.springframework.util.ClassUtils;
+
+import org.grails.aot.RegistrableTypes;
+
+/**
+ * Registers the framework closures Groovy dispatches through.
+ *
+ *
Calling a closure goes through {@code doCall}, and Groovy reads its parameter types
+ * reflectively to choose an overload. In an ahead-of-time image those types are stripped unless
+ * something asks for them, and the call fails where the closure is used rather than at start-up.
+ * The framework ships thousands of closures across its plugins, so naming them individually would
+ * be neither complete nor stable.
+ *
+ *
They are found here instead, while the hints are being written. That happens during the build,
+ * on an ordinary JVM with the full classpath, so scanning is available -- it is only the image that
+ * cannot do it. A closure whose own bytecode does not resolve is skipped: the GSP compiler's Ant
+ * task is on the compile classpath but Ant is not on the runtime one, and registering it would make
+ * the image analysis parse a class whose supertype is absent, failing the build.
+ *
+ *
Where the plugins are listed is carried into the image as well, since that is read to find
+ * them at all and an image carries a resource only when it has been asked to.
+ *
+ * @since 8.0
+ */
+public class GrailsClosureRuntimeHints implements RuntimeHintsRegistrar {
+
+ private static final Log logger = LogFactory.getLog(GrailsClosureRuntimeHints.class);
+
+ /**
+ * Where the plugins are listed, read to find them at all. Written out rather than taken from
+ * {@code FactoriesLoaderSupport}, whose constant is a Groovy property and so not visible here.
+ */
+ private static final String PLUGIN_LISTING = "META-INF/grails.factories";
+
+ /**
+ * Where the framework's closures are found. The first two cover its own packages; the third
+ * covers plugin descriptors, which a plugin may declare in any package of its choosing -- the
+ * asset pipeline names its own {@code asset.pipeline}, and its bean definitions are closures
+ * the container calls while the context is built.
+ */
+ private static final String[] PATTERNS = {
+ ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "grails/**/*_closure*.class",
+ ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "org/grails/**/*_closure*.class",
+ ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "**/*GrailsPlugin$*_closure*.class"
+ };
+
+ @Override
+ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
+ hints.resources().registerPattern(PLUGIN_LISTING);
+ ClassLoader loader = (classLoader != null) ? classLoader : ClassUtils.getDefaultClassLoader();
+ ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(loader);
+ MetadataReaderFactory metadataReaderFactory = new CachingMetadataReaderFactory(resolver);
+ int registered = 0;
+ for (String pattern : PATTERNS) {
+ Resource[] resources;
+ try {
+ resources = resolver.getResources(pattern);
+ }
+ catch (IOException ex) {
+ logger.warn("Unable to scan for Grails closures matching " + pattern, ex);
+ continue;
+ }
+ for (Resource resource : resources) {
+ String className = classNameOf(metadataReaderFactory, resource);
+ if (className != null && registrable(className, resource, loader)) {
+ hints.reflection().registerTypeIfPresent(loader, className,
+ MemberCategory.INVOKE_DECLARED_METHODS,
+ MemberCategory.INVOKE_DECLARED_CONSTRUCTORS);
+ registered++;
+ }
+ }
+ }
+ logger.debug("Registered " + registered + " Grails closures for reflection");
+ }
+
+ /**
+ * Whether the closure can be kept. Both questions are asked: the GSP compiler's task extends an
+ * Ant type absent at run time and its closures reach it through invokedynamic, so nothing in
+ * their own bytecode reveals it, while the JSP closures load cleanly and name an absent class in
+ * a method body.
+ */
+ private boolean registrable(String className, Resource resource, ClassLoader loader) {
+ if (!RegistrableTypes.loads(className, loader) || !enclosingLoads(className, loader)) {
+ return false;
+ }
+ try {
+ return RegistrableTypes.referencesLoad(resource.getInputStream(), loader);
+ }
+ catch (IOException ex) {
+ return false;
+ }
+ }
+
+ /**
+ * Whether the class the closure was written inside can be loaded.
+ *
+ *
A closure loads without it -- it extends {@code Closure} and nothing else -- so asking only
+ * about the closure lets one through whose surroundings are absent. Registering it makes the
+ * image analyse it, and analysing a closure means reading the method it was written in: the test
+ * support ships closures written inside a Spock specification, and an application that does not
+ * test with Spock has no {@code spock.lang.Specification} for the image to read, which fails the
+ * build rather than the closure.
+ */
+ boolean enclosingLoads(String className, ClassLoader loader) {
+ int closure = className.indexOf("$_");
+ if (closure < 0) {
+ return true;
+ }
+ return RegistrableTypes.loads(className.substring(0, closure), loader);
+ }
+
+ @Nullable
+ private String classNameOf(MetadataReaderFactory factory, Resource resource) {
+ try {
+ return factory.getMetadataReader(resource).getClassMetadata().getClassName();
+ }
+ catch (IOException | RuntimeException ex) {
+ return null;
+ }
+ }
+
+}
diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsResourceRuntimeHints.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsResourceRuntimeHints.java
new file mode 100644
index 00000000000..6eb63af240d
--- /dev/null
+++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsResourceRuntimeHints.java
@@ -0,0 +1,99 @@
+/*
+ * 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.spring.beans.aot;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+import org.springframework.core.SpringProperties;
+import org.springframework.util.StringUtils;
+
+/**
+ * The resources a Grails application reads by name at run time, which an image cannot prove are
+ * reached.
+ *
+ *
An image includes what it can prove is used. A compiled asset is asked for by request path and
+ * a message bundle by locale, so nothing in the code names either, and an image built without them
+ * starts and then serves every page with a missing stylesheet and an untranslated string.
+ *
+ *
Registered as hints rather than passed as {@code -H:IncludeResources}: that option is
+ * experimental and GraalVM now warns it will require unlocking, while hints are the supported way
+ * and are what Spring writes the image's resource configuration from.
+ *
+ *
Bounded to where those two are actually looked for. A pattern that matched every properties
+ * file at every depth would carry the whole classpath into the image -- every dependency's
+ * configuration, and whatever else happened to be packaged beside it -- to keep the few that are
+ * message bundles.
+ *
+ * @since 8.0
+ */
+public class GrailsResourceRuntimeHints implements RuntimeHintsRegistrar {
+
+ /**
+ * Where a message bundle is looked for: the root of the classpath, which is what
+ * {@code PluginAwareResourceBundleMessageSource} scans and where a plugin's own bundles land.
+ * Not below it, because nothing reads them there.
+ */
+ private static final String MESSAGE_BUNDLES = "*.properties";
+
+ /** Compiled assets, which asset-pipeline serves from the classpath by the path asked for. */
+ private static final String[] ASSETS = { "assets/*", "assets/**" };
+
+ /**
+ * Where an application says what else it reads by name, as a comma-separated list of patterns.
+ * Read as a property rather than from configuration because this runs while the code is being
+ * generated rather than while the application is running, so it is set on the task that does
+ * the generating:
+ *
+ *
+ */
+ public static final String ADDITIONAL_PATTERNS_PROPERTY = "grails.aot.resource-patterns";
+
+ @Override
+ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
+ for (String pattern : ASSETS) {
+ hints.resources().registerPattern(pattern);
+ }
+ hints.resources().registerPattern(MESSAGE_BUNDLES);
+ for (String pattern : additionalPatterns()) {
+ hints.resources().registerPattern(pattern);
+ }
+ }
+
+ /**
+ * What the application asked for on top of the above, or none where it asked for nothing.
+ *
+ *
Tokenized rather than split, so that a list written with spaces after its commas is the
+ * list it looks like: a pattern carrying a leading space matches nothing, and does so silently,
+ * leaving out exactly the resource the application asked to keep.
+ */
+ private String[] additionalPatterns() {
+ String configured = SpringProperties.getProperty(ADDITIONAL_PATTERNS_PROPERTY);
+ if (!StringUtils.hasText(configured)) {
+ return new String[0];
+ }
+ return StringUtils.tokenizeToStringArray(configured, ",");
+ }
+
+}
diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/GroovyExtensionModuleRuntimeHints.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GroovyExtensionModuleRuntimeHints.java
new file mode 100644
index 00000000000..c5c07fc72b5
--- /dev/null
+++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GroovyExtensionModuleRuntimeHints.java
@@ -0,0 +1,152 @@
+/*
+ * 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.spring.beans.aot;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.LinkedHashSet;
+import java.util.Properties;
+import java.util.Set;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.aot.hint.MemberCategory;
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
+import org.springframework.core.io.support.ResourcePatternResolver;
+import org.springframework.util.ClassUtils;
+import org.springframework.util.StringUtils;
+
+import org.grails.aot.RegistrableTypes;
+
+/**
+ * Registers the classes that extend types Groovy did not declare.
+ *
+ *
A module declares them in a descriptor, and Groovy calls the method it finds there through the
+ * metaclass rather than as a call written against the class -- so {@code request.forwardURI} reaches
+ * a static method on an extension class reflectively. An image keeps that method only when asked,
+ * and nothing asks: the extension class is named in a descriptor rather than in any code.
+ *
+ *
The image then refuses the call where it is made. The framework extends the servlet request,
+ * the response, the session and more this way, and those are reached while rendering a page, so what
+ * fails is a request rather than the start-up -- most visibly the error page, which is the one page
+ * that renders when something has already gone wrong.
+ *
+ *
Read from the descriptors while the hints are written, so a module added later is covered
+ * without being named here, whether it belongs to the framework, a plugin or the application.
+ *
+ *
The descriptors themselves are carried into the image too, along with the table of methods
+ * Groovy adds to every type. Both are read as the runtime starts, and an image carries a resource
+ * only when it has been asked to: without them Groovy cannot build its metaclasses at all, and
+ * fails before any application code runs.
+ *
+ * @since 8.0
+ */
+public class GroovyExtensionModuleRuntimeHints implements RuntimeHintsRegistrar {
+
+ private static final Log logger = LogFactory.getLog(GroovyExtensionModuleRuntimeHints.class);
+
+ private static final String DESCRIPTOR_NAME = "org.codehaus.groovy.runtime.ExtensionModule";
+
+ /** Both places Groovy reads module descriptors from. */
+ private static final String[] DESCRIPTORS = {
+ ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "META-INF/services/" + DESCRIPTOR_NAME,
+ ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "META-INF/groovy/" + DESCRIPTOR_NAME
+ };
+
+ /** The two kinds of extension a descriptor names: one extends instances, the other the type. */
+ private static final String[] CLASS_PROPERTIES = { "extensionClasses", "staticExtensionClasses" };
+
+ /**
+ * What the Groovy runtime reads as it starts: the table of the methods it adds to every type,
+ * the version it reports, and the descriptors naming the extensions above.
+ */
+ private static final String[] RESOURCES = {
+ "META-INF/dgminfo",
+ "META-INF/groovy-release-info.properties",
+ "META-INF/services/" + DESCRIPTOR_NAME,
+ "META-INF/groovy/" + DESCRIPTOR_NAME
+ };
+
+ @Override
+ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
+ for (String resource : RESOURCES) {
+ hints.resources().registerPattern(resource);
+ }
+ ClassLoader loader = (classLoader != null) ? classLoader : ClassUtils.getDefaultClassLoader();
+ ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(loader);
+ Set extensionClasses = new LinkedHashSet<>();
+ for (String descriptor : DESCRIPTORS) {
+ collectFrom(resolver, descriptor, extensionClasses);
+ }
+ int registered = 0;
+ for (String className : extensionClasses) {
+ if (RegistrableTypes.loads(className, loader)) {
+ hints.reflection().registerTypeIfPresent(loader, className,
+ MemberCategory.INVOKE_DECLARED_METHODS);
+ registered++;
+ }
+ }
+ logger.debug("Registered " + registered + " Groovy extension classes for reflection");
+ }
+
+ private void collectFrom(ResourcePatternResolver resolver, String descriptor, Set collected) {
+ Resource[] resources;
+ try {
+ resources = resolver.getResources(descriptor);
+ }
+ catch (IOException ex) {
+ logger.warn("Unable to read Groovy extension modules from " + descriptor, ex);
+ return;
+ }
+ for (Resource resource : resources) {
+ Properties module = read(resource);
+ if (module == null) {
+ continue;
+ }
+ for (String property : CLASS_PROPERTIES) {
+ for (String className : StringUtils.commaDelimitedListToStringArray(
+ module.getProperty(property, ""))) {
+ String trimmed = className.trim();
+ if (!trimmed.isEmpty()) {
+ collected.add(trimmed);
+ }
+ }
+ }
+ }
+ }
+
+ /** A descriptor that cannot be read names nothing, which is what an unreadable one contributes. */
+ @Nullable
+ private Properties read(Resource resource) {
+ try (InputStream input = resource.getInputStream()) {
+ Properties module = new Properties();
+ module.load(input);
+ return module;
+ }
+ catch (IOException | RuntimeException ex) {
+ logger.warn("Unable to read Groovy extension module " + resource, ex);
+ return null;
+ }
+ }
+}
diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/VarargsBeanRegistrationAotProcessor.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/VarargsBeanRegistrationAotProcessor.java
new file mode 100644
index 00000000000..6f5add205ac
--- /dev/null
+++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/VarargsBeanRegistrationAotProcessor.java
@@ -0,0 +1,180 @@
+/*
+ * 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.spring.beans.aot;
+
+import java.lang.reflect.Array;
+import java.lang.reflect.Executable;
+import java.util.Collection;
+import java.util.List;
+import java.util.function.Predicate;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.aot.generate.GenerationContext;
+import org.springframework.beans.factory.aot.BeanRegistrationAotContribution;
+import org.springframework.beans.factory.aot.BeanRegistrationAotProcessor;
+import org.springframework.beans.factory.aot.BeanRegistrationCode;
+import org.springframework.beans.factory.aot.BeanRegistrationCodeFragments;
+import org.springframework.beans.factory.aot.BeanRegistrationCodeFragmentsDecorator;
+import org.springframework.beans.factory.config.ConstructorArgumentValues;
+import org.springframework.beans.factory.config.ConstructorArgumentValues.ValueHolder;
+import org.springframework.beans.factory.support.RegisteredBean;
+import org.springframework.beans.factory.support.RootBeanDefinition;
+import org.springframework.javapoet.CodeBlock;
+import org.springframework.util.ClassUtils;
+
+/**
+ * Gathers a variable-argument constructor argument into the array it feeds, ahead of time.
+ *
+ *
A bean declared through the plugin DSL passes its arguments positionally, and a constructor
+ * that ends in a variable-argument parameter is called the way the language allows: one value where
+ * the parameter is an array, or a collection where it is an array of that element type. Building the
+ * bean, Spring adapts the argument to the parameter. Reading the definition to generate code for it,
+ * Spring does not: it looks the argument up by the parameter's type, and a lone {@code String} does
+ * not answer to {@code String[]}.
+ *
+ *
The argument is then missed and resolved as a dependency instead, and an array of a type nobody
+ * publishes as a bean resolves to an empty array rather than failing. So the bean is built, and
+ * built wrong: a datastore that maps no classes, or a servlet registration with no URL mapping,
+ * which then falls back to mapping everything. Nothing is logged, and the bean that goes wrong is
+ * rarely the one that reports it -- the first symptom is a page that 404s or a domain class that
+ * says it is not one.
+ *
+ *
Gathering the argument into an array here means the generator writes out {@code new String[]
+ * {"*.gsp"}}, which the lookup does find. Only an argument that is already usable as the array is
+ * left alone, and an argument that would need its elements converted is left to the resolution that
+ * exists today rather than guessed at here.
+ *
+ * @since 8.0
+ */
+public class VarargsBeanRegistrationAotProcessor implements BeanRegistrationAotProcessor {
+
+ @Override
+ @Nullable
+ public BeanRegistrationAotContribution processAheadOfTime(RegisteredBean registeredBean) {
+ Executable executable = resolveExecutable(registeredBean);
+ if (executable == null || !executable.isVarArgs()) {
+ return null;
+ }
+ Class>[] parameterTypes = executable.getParameterTypes();
+ RootBeanDefinition beanDefinition = registeredBean.getMergedBeanDefinition();
+ Object gathered = gatherTrailingArgument(beanDefinition.getConstructorArgumentValues(), parameterTypes);
+ if (gathered == null) {
+ return null;
+ }
+ return BeanRegistrationAotContribution.withCustomCodeFragments(
+ codeFragments -> new VarargsCodeFragments(codeFragments, gathered));
+ }
+
+ /**
+ * The constructor or factory method the generator will write the call to.
+ *
+ *
Resolution reads the bean class and its members, so a bean whose class cannot be resolved
+ * fails here rather than at the point of use. It is not this processor's place to report that:
+ * generation carries on and fails where it means something.
+ */
+ @Nullable
+ private Executable resolveExecutable(RegisteredBean registeredBean) {
+ try {
+ return registeredBean.resolveConstructorOrFactoryMethod();
+ }
+ catch (Throwable ignored) {
+ return null;
+ }
+ }
+
+ /**
+ * The trailing argument as the array its parameter takes, or {@code null} to leave it alone.
+ *
+ *
Only the straightforward reading is handled: one argument per parameter, the last of them
+ * standing for the variable-argument array. An argument list that spreads several values across
+ * that parameter, or that is indexed rather than positional, is left as it is.
+ */
+ @Nullable
+ private Object gatherTrailingArgument(ConstructorArgumentValues arguments, Class>[] parameterTypes) {
+ if (!arguments.getIndexedArgumentValues().isEmpty()) {
+ return null;
+ }
+ List supplied = arguments.getGenericArgumentValues();
+ if (supplied.size() != parameterTypes.length) {
+ return null;
+ }
+ Class> arrayType = parameterTypes[parameterTypes.length - 1];
+ if (!arrayType.isArray()) {
+ return null;
+ }
+ Object value = supplied.get(supplied.size() - 1).getValue();
+ if (value == null || ClassUtils.isAssignableValue(arrayType, value)) {
+ return null;
+ }
+ Class> componentType = arrayType.getComponentType();
+ Collection> elements = value instanceof Collection> collection ? collection : List.of(value);
+ return toArray(elements, componentType);
+ }
+
+ /**
+ * The elements as an array of the component type, or {@code null} if any of them is not already
+ * one. Converting an element is the resolution step's job, and guessing at it here would turn a
+ * missed argument into a wrong one.
+ */
+ @Nullable
+ private Object toArray(Collection> elements, Class> componentType) {
+ for (Object element : elements) {
+ if (element == null || !ClassUtils.isAssignableValue(componentType, element)) {
+ return null;
+ }
+ }
+ Object array = Array.newInstance(componentType, elements.size());
+ int index = 0;
+ for (Object element : elements) {
+ Array.set(array, index++, element);
+ }
+ return array;
+ }
+
+ /** Writes the definition out with the gathered argument in place of the one supplied. */
+ private static final class VarargsCodeFragments extends BeanRegistrationCodeFragmentsDecorator {
+
+ private final Object gathered;
+
+ private VarargsCodeFragments(BeanRegistrationCodeFragments delegate, Object gathered) {
+ super(delegate);
+ this.gathered = gathered;
+ }
+
+ @Override
+ public CodeBlock generateSetBeanDefinitionPropertiesCode(GenerationContext generationContext,
+ BeanRegistrationCode beanRegistrationCode, RootBeanDefinition beanDefinition,
+ Predicate attributeFilter) {
+ return super.generateSetBeanDefinitionPropertiesCode(generationContext, beanRegistrationCode,
+ withGatheredArgument(beanDefinition), attributeFilter);
+ }
+
+ /**
+ * A copy carrying the gathered argument, so the definition the context is running on keeps
+ * the argument it was given and only the generated code differs.
+ */
+ private RootBeanDefinition withGatheredArgument(RootBeanDefinition beanDefinition) {
+ RootBeanDefinition copy = new RootBeanDefinition(beanDefinition);
+ List supplied = copy.getConstructorArgumentValues().getGenericArgumentValues();
+ supplied.get(supplied.size() - 1).setValue(this.gathered);
+ return copy;
+ }
+ }
+}
diff --git a/grails-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/grails-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json
index ec339dce9a7..d6119f9aa04 100644
--- a/grails-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json
+++ b/grails-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json
@@ -2,15 +2,38 @@
"properties": [
{
"name": "grails.banner.art.display",
- "description": "Whether to display the Grails banner art.",
+ "description": "Whether to display the Grails banner art.",
"type": "java.lang.Boolean",
"defaultValue": true
},
{
- "name": "grails.banner.art.file",
- "description": "The file path on the classpath to the Grails banner art to display.",
- "type": "java.lang.String",
- "defaultValue": "grails-banner.txt"
+ "name": "grails.banner.art.file",
+ "description": "The file path on the classpath to the Grails banner art to display.",
+ "type": "java.lang.String",
+ "defaultValue": "grails-banner.txt"
+ },
+ {
+ "name": "grails.banner.art.color",
+ "type": "java.lang.String",
+ "description": "The colour to show the banner art in, where the terminal colours output at all. A number selects one of the 256 colours a terminal offers; a name selects one of the eight it has always had, such as red or bright_blue; none leaves the art uncoloured. A value that is neither falls back to the default.",
+ "defaultValue": "214"
+ },
+ {
+ "name": "grails.banner.mark.display",
+ "type": "java.lang.Boolean",
+ "description": "Whether to say under the banner art how the application was started: NATIVE for an image, AOT CACHE for a JVM given a cache to read, AOT for one running generated bean definitions. An ordinary start says nothing.",
+ "defaultValue": true
+ },
+ {
+ "name": "grails.banner.mark.color",
+ "type": "java.lang.String",
+ "description": "The colour to show the mark in, separate from the art so it is not read as part of it. Takes the same values as grails.banner.art.color: a number for one of the 256 colours a terminal offers, a name for one of the eight it has always had, or none.",
+ "defaultValue": "226"
+ },
+ {
+ "name": "grails.banner.mark.text",
+ "type": "java.lang.String",
+ "description": "Says this instead of what was detected. Shown spaced and in upper case, and an empty value shows nothing."
},
{
"name": "grails.banner.versions.display",
@@ -56,11 +79,23 @@
{
"name": "grails.i18n.localeResolver",
"values": [
- { "value": "session", "description": "Resolve the locale from the HTTP session (mutable; ?lang= switching works). The default." },
- { "value": "cookie", "description": "Resolve the locale from a cookie (mutable; ?lang= switching works)." },
- { "value": "acceptHeader", "description": "Resolve the locale from the Accept-Language header (read-only; ?lang= is ignored)." },
- { "value": "fixed", "description": "Use a fixed locale from grails.i18n.default.locale, falling back to the JVM default (read-only; ?lang= is ignored)." }
+ {
+ "value": "session",
+ "description": "Resolve the locale from the HTTP session (mutable; ?lang= switching works). The default."
+ },
+ {
+ "value": "cookie",
+ "description": "Resolve the locale from a cookie (mutable; ?lang= switching works)."
+ },
+ {
+ "value": "acceptHeader",
+ "description": "Resolve the locale from the Accept-Language header (read-only; ?lang= is ignored)."
+ },
+ {
+ "value": "fixed",
+ "description": "Use a fixed locale from grails.i18n.default.locale, falling back to the JVM default (read-only; ?lang= is ignored)."
+ }
]
}
]
-}
\ No newline at end of file
+}
diff --git a/grails-core/src/main/resources/META-INF/native-image/org.apache.grails/grails-core/native-image.properties b/grails-core/src/main/resources/META-INF/native-image/org.apache.grails/grails-core/native-image.properties
new file mode 100644
index 00000000000..966754ebc3c
--- /dev/null
+++ b/grails-core/src/main/resources/META-INF/native-image/org.apache.grails/grails-core/native-image.properties
@@ -0,0 +1,30 @@
+#
+# 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.
+#
+# Both of these read their surroundings the first time they are touched, so an image that
+# initialized them while it was being built would answer for the machine that built it.
+#
+# - Groovy's plugin factory decides how it reaches the JDK it is running on, and an image
+# initialized at build time carries a decision made against the build's JDK. Nothing then
+# starts: its metaclass registry throws a NullPointerException before any application code runs.
+# - Grails' build settings resolve the project directory, which exists only where the build ran.
+#
+# Args rather than hints because when a class is initialized is a property of the image, and
+# nothing in the Spring hint model expresses it.
+Args = --initialize-at-run-time=org.codehaus.groovy.vmplugin.VMPluginFactory \
+ --initialize-at-run-time=grails.util.BuildSettings
diff --git a/grails-core/src/main/resources/META-INF/spring/aot.factories b/grails-core/src/main/resources/META-INF/spring/aot.factories
new file mode 100644
index 00000000000..fe2a8c4bea9
--- /dev/null
+++ b/grails-core/src/main/resources/META-INF/spring/aot.factories
@@ -0,0 +1,17 @@
+org.springframework.beans.factory.aot.BeanRegistrationExcludeFilter=\
+org.grails.spring.beans.aot.AbstractBeanDefinitionExcludeFilter
+
+org.springframework.aot.hint.RuntimeHintsRegistrar=\
+org.grails.spring.beans.aot.BeanRegistrarRuntimeHints,\
+org.grails.spring.beans.aot.GrailsApiRuntimeHints,\
+org.grails.spring.beans.aot.GrailsBannerRuntimeHints,\
+org.grails.spring.beans.aot.GrailsClosureRuntimeHints,\
+org.grails.spring.beans.aot.GrailsResourceRuntimeHints,\
+org.grails.spring.beans.aot.GroovyExtensionModuleRuntimeHints
+
+org.springframework.beans.factory.aot.BeanRegistrationAotProcessor=\
+org.grails.spring.beans.aot.AutowireModeBeanRegistrationAotProcessor,\
+org.grails.spring.beans.aot.VarargsBeanRegistrationAotProcessor
+
+org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor=\
+org.grails.spring.beans.aot.ArtefactClassesBeanFactoryInitializationAotProcessor
diff --git a/grails-core/src/test/groovy/grails/boot/GrailsBannerColourSpec.groovy b/grails-core/src/test/groovy/grails/boot/GrailsBannerColourSpec.groovy
new file mode 100644
index 00000000000..13321c4ae2c
--- /dev/null
+++ b/grails-core/src/test/groovy/grails/boot/GrailsBannerColourSpec.groovy
@@ -0,0 +1,156 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package grails.boot
+
+import org.springframework.boot.ansi.Ansi8BitColor
+import org.springframework.boot.ansi.AnsiColor
+import org.springframework.boot.ansi.AnsiOutput
+import org.springframework.core.env.MapPropertySource
+import org.springframework.core.env.StandardEnvironment
+import spock.lang.Specification
+
+/**
+ * Covers the banner art being shown in the framework's colour.
+ *
+ *
The escapes are written only where colour has been enabled, and the width the versions beneath
+ * are centred on is measured before they are added -- otherwise they would count towards it and
+ * push the versions off centre by as many characters as the colour cost.
+ */
+class GrailsBannerColourSpec extends Specification {
+
+ GrailsBanner banner = new GrailsBanner()
+
+ StandardEnvironment environment = new StandardEnvironment()
+
+ void cleanup() {
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.DETECT)
+ }
+
+ private void configured(String colour) {
+ environment.propertySources.addFirst(
+ new MapPropertySource('test', ['grails.banner.art.color': colour]))
+ }
+
+ private String printed() {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream()
+ banner.printBanner(environment, GrailsBannerColourSpec, new PrintStream(bytes))
+ bytes.toString()
+ }
+
+ void 'the art is the framework amber where nothing is configured'() {
+ given:
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
+
+ expect:
+ banner.colour('art', environment)
+ .startsWith(AnsiOutput.encode(Ansi8BitColor.foreground(214)))
+ }
+
+ void 'the art is left as it stands where colour is off'() {
+ given:
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.NEVER)
+
+ expect: 'a redirected log, or an application that has turned colour off'
+ banner.colour('art', environment) == 'art'
+ }
+
+ void 'an application chooses one of the 256 colours by number'() {
+ given:
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
+ configured('45')
+
+ expect:
+ banner.colour('art', environment)
+ .startsWith(AnsiOutput.encode(Ansi8BitColor.foreground(45)))
+ }
+
+ void 'an application chooses one of the eight by name'() {
+ given:
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
+ configured('bright_blue')
+
+ expect: 'read without regard to case, as configuration is written either way'
+ banner.colour('art', environment)
+ .startsWith(AnsiOutput.encode(AnsiColor.BRIGHT_BLUE))
+ }
+
+ void 'an application asks for no colour at all'() {
+ given:
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
+ configured('none')
+
+ expect: 'colour everywhere else, and a banner left as it stands'
+ banner.colour('art', environment) == 'art'
+ }
+
+ void 'a colour that means nothing falls back rather than failing to start'() {
+ given:
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
+ configured('chartreuse')
+
+ expect:
+ banner.colour('art', environment)
+ .startsWith(AnsiOutput.encode(Ansi8BitColor.foreground(214)))
+ }
+
+ void 'a number outside the 256 a terminal has falls back'() {
+ given:
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
+
+ expect: 'anything else writes an escape the terminal does not understand, which it then shows'
+ banner.colour('art', configuredWith('999'))
+ .startsWith(AnsiOutput.encode(Ansi8BitColor.foreground(214)))
+ banner.colour('art', configuredWith('-1'))
+ .startsWith(AnsiOutput.encode(Ansi8BitColor.foreground(214)))
+ }
+
+ /** A fresh environment each time, so the two values above do not fight over one property source. */
+ private StandardEnvironment configuredWith(String colour) {
+ StandardEnvironment fresh = new StandardEnvironment()
+ fresh.propertySources.addFirst(new MapPropertySource('test', ['grails.banner.art.color': colour]))
+ fresh
+ }
+
+ void 'the banner carries the colour through to what is printed'() {
+ given:
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
+
+ expect:
+ printed().contains(AnsiOutput.encode(Ansi8BitColor.foreground(214)))
+ }
+
+ void 'the versions stay where they were before the art was coloured'() {
+ given:
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.NEVER)
+ List plain = versionLines(printed())
+
+ when:
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
+ List coloured = versionLines(printed().replaceAll(/\[[0-9;]*m/, ''))
+
+ then: 'the width is measured before the escapes are added, so they do not count towards it'
+ coloured == plain
+ !plain.isEmpty()
+ }
+
+ /** The centred lines beneath the art, which is where a mismeasured width would show. */
+ private List versionLines(String output) {
+ output.readLines().findAll { it.contains('Grails:') || it.contains('Spring') }
+ }
+}
diff --git a/grails-core/src/test/groovy/grails/boot/GrailsBannerNativeMarkSpec.groovy b/grails-core/src/test/groovy/grails/boot/GrailsBannerNativeMarkSpec.groovy
new file mode 100644
index 00000000000..6640184ac9a
--- /dev/null
+++ b/grails-core/src/test/groovy/grails/boot/GrailsBannerNativeMarkSpec.groovy
@@ -0,0 +1,217 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package grails.boot
+
+import org.springframework.boot.ansi.Ansi8BitColor
+import org.springframework.boot.ansi.AnsiOutput
+import org.springframework.core.env.MapPropertySource
+import org.springframework.core.env.StandardEnvironment
+import spock.lang.Specification
+
+/**
+ * Covers the banner saying how an application was started, under the art and above the versions.
+ *
+ *
Written plainly. A banner is printed on the thread that is starting the application, so
+ * anything drawn over time here is time the application is not starting.
+ */
+class GrailsBannerNativeMarkSpec extends Specification {
+
+ /** A banner told what it was started as, so the marks can be covered without being one. */
+ static class StartedAs extends GrailsBanner {
+
+ boolean image
+ boolean cache
+
+ @Override
+ protected boolean isNativeImage() {
+ image
+ }
+
+ @Override
+ protected boolean readsAotCache() {
+ cache
+ }
+ }
+
+ StandardEnvironment environment = new StandardEnvironment()
+
+ void cleanup() {
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.DETECT)
+ System.clearProperty('spring.aot.enabled')
+ }
+
+ private void configured(Map properties) {
+ environment.propertySources.addFirst(new MapPropertySource('test', properties))
+ }
+
+ private String printed(GrailsBanner banner) {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream()
+ banner.printBanner(environment, GrailsBannerNativeMarkSpec, new PrintStream(bytes))
+ bytes.toString()
+ }
+
+ private String plain(GrailsBanner banner) {
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.NEVER)
+ printed(banner)
+ }
+
+ void 'an ordinary start says nothing'() {
+ expect: 'no image, no cache, and bean definitions worked out as it went'
+ String output = plain(new StartedAs())
+ !output.contains('N A T I V E')
+ !output.contains('A O T')
+ }
+
+ void 'an image says so'() {
+ expect:
+ plain(new StartedAs(image: true)).contains('N A T I V E')
+ }
+
+ void 'a run given a cache to read says so'() {
+ expect:
+ plain(new StartedAs(cache: true)).contains('A O T C A C H E')
+ }
+
+ void 'a run on generated bean definitions says so'() {
+ given: 'the smaller half of the same idea, and a thing an application can be run without'
+ System.setProperty('spring.aot.enabled', 'true')
+
+ expect:
+ plain(new StartedAs()).contains('A O T')
+ }
+
+ void 'an image outranks a cache, which outranks generated definitions'() {
+ given:
+ System.setProperty('spring.aot.enabled', 'true')
+
+ expect: 'an image has already done all of it, so it is the only thing worth saying'
+ plain(new StartedAs(image: true, cache: true)).contains('N A T I V E')
+ }
+
+ void 'the mark sits under the art and above the versions'() {
+ when:
+ String output = plain(new StartedAs(image: true))
+
+ then:
+ output.indexOf('N A T I V E') > output.indexOf('grails.apache.org')
+ output.indexOf('N A T I V E') < output.indexOf('JVM')
+ }
+
+ void 'the mark is centred on the art'() {
+ when:
+ List lines = plain(new StartedAs(image: true)).readLines()
+ String mark = lines.find { it.contains('N A T I V E') }
+ int artWidth = lines*.length().max()
+
+ then: 'the same space either side, give or take the odd character'
+ int leading = mark.length() - mark.stripLeading().length()
+ int trailing = artWidth - mark.length()
+ Math.abs(leading - trailing) <= 1
+ }
+
+ void 'nothing is drawn over, wherever it is read'() {
+ when: 'a terminal, a redirected log, or an application that has turned colour off'
+ String output = plain(new StartedAs(image: true))
+
+ then: 'one word, once'
+ output.count('N A T I V E') == 1
+
+ and: 'nothing drawn over -- a return only ever ends a line, never rewinds one'
+ // A banner is printed with println, so the line ending is the platform's: \n where
+ // this is read on Linux or macOS, \r\n on Windows. What over-drawing looks like is a
+ // return on its own, so that is what this asks about, on every platform alike.
+ !output.replace('\r\n', '\n').contains('\r')
+ }
+
+ void 'an application can say something else'() {
+ expect:
+ configured(['grails.banner.mark.text': 'Leyden'])
+ plain(new StartedAs(cache: true)).contains('L e y d e n')
+ }
+
+ void 'a mark is spaced as it was written rather than shouted'() {
+ expect: 'a name whose case is part of it keeps it -- CRaC is not CRAC'
+ configured(['grails.banner.mark.text': 'CRaC'])
+ plain(new StartedAs()).contains('C R a C')
+ }
+
+ void 'the marks it works out for itself are still upper case'() {
+ expect: 'they are written that way, rather than made that way'
+ plain(new StartedAs(image: true)).contains('N A T I V E')
+ plain(new StartedAs(cache: true)).contains('A O T C A C H E')
+ }
+
+ void 'the mark is a different colour from the art'() {
+ given:
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
+
+ when: 'the art amber and the mark bright yellow, so one is not read as part of the other'
+ String output = printed(new StartedAs(image: true))
+ String markLine = output.readLines().find { it.contains('N A T I V E') }
+
+ then:
+ markLine.contains(AnsiOutput.encode(Ansi8BitColor.foreground(226)))
+ !markLine.contains(AnsiOutput.encode(Ansi8BitColor.foreground(214)))
+ }
+
+ void 'an application can choose the colour the mark is shown in'() {
+ given:
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
+ configured(['grails.banner.mark.color': '39'])
+
+ expect:
+ printed(new StartedAs(image: true)).readLines()
+ .find { it.contains('N A T I V E') }
+ .contains(AnsiOutput.encode(Ansi8BitColor.foreground(39)))
+ }
+
+ void 'spring security is a default rather than something to ask for'() {
+ expect: 'so an application with it on the classpath shows it without configuring anything'
+ GrailsBanner.DefaultVersionOption.values()*.key.contains('spring-security')
+ !GrailsBanner.OptionalVersionOption.values()*.key.contains('spring-security')
+ }
+
+ void 'the container served on is a default, and naming a particular one stays optional'() {
+ expect: 'every application has a container, so it is shown without being asked for'
+ GrailsBanner.DefaultVersionOption.values()*.key.contains('container')
+
+ and: 'and the specific ones remain, for an application that wants one whatever is serving'
+ GrailsBanner.OptionalVersionOption.values()*.key == ['tomcat', 'jetty', 'undertow']
+ }
+
+ void 'a library that is not there is left out rather than shown as unknown'() {
+ expect: 'jetty and undertow are not on this classpath; asking for them says nothing'
+ configured(['grails.banner.versions.include': 'jetty,undertow'])
+ String output = plain(new StartedAs())
+ !output.contains('Jetty')
+ !output.contains('Undertow')
+ }
+
+ void 'an application can turn spring security off'() {
+ expect: 'it is a default now, so exclude is what removes it'
+ configured(['grails.banner.versions.exclude': 'spring-security'])
+ !plain(new StartedAs()).contains('Spring Security')
+ }
+
+ void 'an application can turn the mark off altogether'() {
+ expect:
+ configured(['grails.banner.mark.display': false])
+ !plain(new StartedAs(image: true)).contains('N A T I V E')
+ }
+}
diff --git a/grails-core/src/test/groovy/grails/boot/GrailsBannerVersionLookupSpec.groovy b/grails-core/src/test/groovy/grails/boot/GrailsBannerVersionLookupSpec.groovy
new file mode 100644
index 00000000000..f018478bedb
--- /dev/null
+++ b/grails-core/src/test/groovy/grails/boot/GrailsBannerVersionLookupSpec.groovy
@@ -0,0 +1,226 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package grails.boot
+
+import org.springframework.core.env.MapPropertySource
+import org.springframework.core.env.StandardEnvironment
+import spock.lang.Specification
+
+/**
+ * Covers reading an optional library's version without setting the library going.
+ *
+ *
A version is read about a library, not from it. Initialising the class to
+ * find one lets it do whatever it does on the way: Spring Security logs
+ * {@code You are running with Spring Security Core ...} from its static initialiser, and that line
+ * arrived in the middle of the banner -- between the mark and the very versions it was being read
+ * for -- because the lookup had initialised it.
+ */
+class GrailsBannerVersionLookupSpec extends Specification {
+
+ /**
+ * Where the record is kept, so that reading it is not itself what sets {@link Noisy} going.
+ * Asking the class under test whether it was initialised initialises it.
+ */
+ static class Record {
+
+ static boolean noisyRan = false
+ }
+
+ /** Stands in for a library that does something when it starts. */
+ static class Noisy {
+
+ static {
+ Record.noisyRan = true
+ }
+ }
+
+ void 'reading a version does not set the library going'() {
+ when:
+ GrailsBanner.findVersion(Noisy.name)
+
+ then: 'loaded, but its static initialiser has not run'
+ !Record.noisyRan
+
+ and: 'and it really would have run, had the class been initialised'
+ Class.forName(Noisy.name, true, getClass().classLoader)
+ Record.noisyRan
+ }
+
+ void 'a library that is not there has no version rather than an error'() {
+ expect: 'nothing, so the banner leaves it out rather than saying it does not know'
+ GrailsBanner.findVersion('com.example.NotOnTheClasspath') == null
+ }
+
+ void 'a library with no version in its manifest has none'() {
+ expect: 'test classes carry no Implementation-Version, and that is not a failure'
+ GrailsBanner.findVersion(Noisy.name) == null
+ }
+
+ void 'a version recorded in a manifest is read'() {
+ expect: 'Spock ships one, so this proves the lookup reads rather than always saying unknown'
+ GrailsBanner.findVersion(Specification.name) ==~ /\d+\..*/
+ }
+
+ void 'a version recorded in a resource is read'() {
+ expect: 'the route a container version takes, which a manifest cannot take out of a jar'
+ GrailsBanner.findVersionInResource('grails/boot/version-in-a-resource.properties',
+ 'server.number') == '1.2.3'
+ }
+
+ void 'a resource that is not there has no version rather than an error'() {
+ expect:
+ GrailsBanner.findVersionInResource('grails/boot/no-such-resource.properties',
+ 'server.number') == null
+ }
+
+ void 'a resource that records no such version has none'() {
+ expect: 'a library may ship the file and still not answer this question'
+ GrailsBanner.findVersionInResource('grails/boot/version-in-a-resource.properties',
+ 'server.unrecorded') == null
+ }
+
+ /** A banner told which containers are there, and remembering which it was asked about. */
+ static class Serving extends GrailsBanner {
+
+ Map present = [:]
+
+ List asked = []
+
+ @Override
+ protected String findTomcatVersion() {
+ asked << 'tomcat'
+ present['tomcat']
+ }
+
+ @Override
+ protected String findJettyVersion() {
+ asked << 'jetty'
+ present['jetty']
+ }
+
+ @Override
+ protected String findUndertowVersion() {
+ asked << 'undertow'
+ present['undertow']
+ }
+ }
+
+ private static StandardEnvironment configured(Map properties = [:]) {
+ StandardEnvironment environment = new StandardEnvironment()
+ environment.propertySources.addFirst(new MapPropertySource('test', properties))
+ environment
+ }
+
+ void 'the container being served on is shown, named as itself'() {
+ expect:
+ new Serving(present: ['tomcat': '1.1.1']).findContainerVersion() == ['Tomcat': '1.1.1']
+ }
+
+ void 'the ones it cannot also be running on are not looked for'() {
+ given: 'an application serves on one container, so the second is not a question worth asking'
+ Serving banner = new Serving(present: ['tomcat': '1.1.1', 'jetty': '2.2.2'])
+
+ when:
+ Map container = banner.findContainerVersion()
+
+ then: 'the most commonly used of the two, and only that one shown'
+ container == ['Tomcat': '1.1.1']
+
+ and: 'and only that one asked about'
+ banner.asked == ['tomcat']
+ }
+
+ void 'the next one is tried when the one before it is not there'() {
+ expect:
+ new Serving(present: ['jetty': '2.2.2']).findContainerVersion() == ['Jetty': '2.2.2']
+ new Serving(present: ['undertow': '3.3.3']).findContainerVersion() == ['Undertow': '3.3.3']
+ }
+
+ void 'an application on none of them says nothing rather than that it does not know'() {
+ when:
+ Serving banner = new Serving()
+
+ then:
+ banner.findContainerVersion().isEmpty()
+
+ and: 'having asked about each, since any of them could have been the one'
+ banner.asked == ['tomcat', 'jetty', 'undertow']
+ }
+
+ void 'the container is shown without an application asking for it'() {
+ expect:
+ new Serving(present: ['tomcat': '1.1.1']).createBannerVersions(configured())['Tomcat'] == '1.1.1'
+ }
+
+ void 'an application can leave the container out'() {
+ expect: 'one key covers whichever container it is, so this is how it is turned off'
+ !new Serving(present: ['tomcat': '1.1.1'])
+ .createBannerVersions(configured(['grails.banner.versions.exclude': 'container']))
+ .containsKey('Tomcat')
+ }
+
+ void 'naming the container as well as being shown it by default shows it once'() {
+ when: 'an application that asked for tomcat before it was shown by default keeps working'
+ Serving banner = new Serving(present: ['tomcat': '1.1.1'])
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream()
+ banner.printBanner(configured(['grails.banner.versions.include': 'tomcat']),
+ GrailsBannerVersionLookupSpec, new PrintStream(bytes))
+
+ then:
+ bytes.toString().count('Tomcat') == 1
+ }
+
+ void 'an option that has moved between the lists is still recognised'() {
+ expect: 'spring-security was asked for before it was shown by default, and an application ' +
+ 'that still asks for it named something this understands'
+ new Serving().unrecognisedVersionOptions(
+ configured(['grails.banner.versions.include': 'spring-security'])).isEmpty()
+ }
+
+ void 'an option shown by default can be named in either list'() {
+ expect: 'which list an option belongs to is this class\'s decision to change, so naming one ' +
+ 'is not a mistake an application made'
+ new Serving().unrecognisedVersionOptions(configured([
+ 'grails.banner.versions.include': 'container',
+ 'grails.banner.versions.exclude': 'tomcat'])).isEmpty()
+ }
+
+ void 'an option that names nothing is reported'() {
+ expect: 'dropped silently, a typo reads as a banner ignoring what it was told, and the only ' +
+ 'way to find out is to notice a line that is not there'
+ new Serving().unrecognisedVersionOptions(
+ configured(['grails.banner.versions.include': 'spring-securty'])) == ['spring-securty']
+ }
+
+ void 'every place an option can be written is checked'() {
+ expect:
+ new Serving().unrecognisedVersionOptions(configured([
+ 'grails.banner.versions.order': 'grails,ordr',
+ 'grails.banner.versions.exclude': 'excloode',
+ 'grails.banner.versions.include': 'includ'])) == ['ordr', 'excloode', 'includ']
+ }
+
+ void 'an application that named them all correctly is told nothing'() {
+ expect:
+ new Serving().unrecognisedVersionOptions(configured([
+ 'grails.banner.versions.order': 'grails,groovy',
+ 'grails.banner.versions.exclude': 'app',
+ 'grails.banner.versions.include': 'jetty'])).isEmpty()
+ }
+}
diff --git a/grails-core/src/test/groovy/grails/boot/config/GrailsApplicationPostProcessorAnnotationConfigSpec.groovy b/grails-core/src/test/groovy/grails/boot/config/GrailsApplicationPostProcessorAnnotationConfigSpec.groovy
new file mode 100644
index 00000000000..7789f062c17
--- /dev/null
+++ b/grails-core/src/test/groovy/grails/boot/config/GrailsApplicationPostProcessorAnnotationConfigSpec.groovy
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package grails.boot.config
+
+import org.springframework.aot.AotDetector
+import org.springframework.beans.factory.support.BeanDefinitionRegistry
+import org.springframework.beans.factory.support.DefaultListableBeanFactory
+import org.springframework.beans.factory.support.RootBeanDefinition
+import org.springframework.context.annotation.AnnotationConfigUtils
+import spock.lang.Specification
+
+/**
+ * Covers the processors that read the injection annotations being restored when running on
+ * artifacts generated ahead of time.
+ *
+ *
The generator resolves the annotations itself for a bean whose implementation it can see, but a
+ * bean contributed as an interface built by a supplier hides that implementation, and the annotated
+ * members on it are then injected by nobody.
+ */
+class GrailsApplicationPostProcessorAnnotationConfigSpec extends Specification {
+
+ private static final String AOT_KEY = 'spring.aot.enabled'
+
+ BeanDefinitionRegistry registry = new DefaultListableBeanFactory()
+
+ /** Restores the property, so the environment other specs observe is unchanged. */
+ private void withGeneratedArtifacts(boolean enabled, Closure body) {
+ String previous = System.getProperty(AOT_KEY)
+ try {
+ enabled ? System.setProperty(AOT_KEY, 'true') : System.clearProperty(AOT_KEY)
+ body.call()
+ }
+ finally {
+ previous == null ? System.clearProperty(AOT_KEY) : System.setProperty(AOT_KEY, previous)
+ }
+ }
+
+ private void register() {
+ GrailsApplicationPostProcessor.registerAnnotationConfigProcessorsForGeneratedArtifacts(registry)
+ }
+
+ void 'the injection processors are registered when running on generated artifacts'() {
+ when:
+ withGeneratedArtifacts(true) { register() }
+
+ then:
+ registry.containsBeanDefinition(AnnotationConfigUtils.AUTOWIRED_ANNOTATION_PROCESSOR_BEAN_NAME)
+ registry.containsBeanDefinition(AnnotationConfigUtils.COMMON_ANNOTATION_PROCESSOR_BEAN_NAME)
+ }
+
+ void 'the processor that reads configuration classes is not'() {
+ when:
+ withGeneratedArtifacts(true) { register() }
+
+ then: 'reading them again in a context whose configuration is already generated makes a ' +
+ 'second definition for beans the generated code has contributed'
+ !registry.containsBeanDefinition(AnnotationConfigUtils.CONFIGURATION_ANNOTATION_PROCESSOR_BEAN_NAME)
+ }
+
+ void 'nothing is registered without generated artifacts'() {
+ when:
+ withGeneratedArtifacts(false) { register() }
+
+ then:
+ !registry.containsBeanDefinition(AnnotationConfigUtils.AUTOWIRED_ANNOTATION_PROCESSOR_BEAN_NAME)
+ !registry.containsBeanDefinition(AnnotationConfigUtils.COMMON_ANNOTATION_PROCESSOR_BEAN_NAME)
+ }
+
+ void 'a context that already has them keeps what it has'() {
+ given:
+ RootBeanDefinition existing = new RootBeanDefinition(String)
+ registry.registerBeanDefinition(
+ AnnotationConfigUtils.AUTOWIRED_ANNOTATION_PROCESSOR_BEAN_NAME, existing)
+
+ when:
+ withGeneratedArtifacts(true) { register() }
+
+ then:
+ registry.getBeanDefinition(
+ AnnotationConfigUtils.AUTOWIRED_ANNOTATION_PROCESSOR_BEAN_NAME).is(existing)
+ }
+}
diff --git a/grails-core/src/test/groovy/grails/boot/config/GrailsEnvironmentPostProcessorAnsiSpec.groovy b/grails-core/src/test/groovy/grails/boot/config/GrailsEnvironmentPostProcessorAnsiSpec.groovy
new file mode 100644
index 00000000000..97bedd9ac8d
--- /dev/null
+++ b/grails-core/src/test/groovy/grails/boot/config/GrailsEnvironmentPostProcessorAnsiSpec.groovy
@@ -0,0 +1,114 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package grails.boot.config
+
+import org.springframework.boot.bootstrap.BootstrapRegistry.InstanceSupplier
+import org.springframework.boot.bootstrap.DefaultBootstrapContext
+import org.springframework.core.env.MapPropertySource
+import org.springframework.core.env.StandardEnvironment
+import spock.lang.Specification
+
+import org.apache.grails.core.plugins.DefaultPluginDiscovery
+import org.apache.grails.core.plugins.PluginDiscovery
+
+/**
+ * Covers an image colouring its output when it is being watched at a terminal.
+ *
+ *
Spring Boot decides by asking for the console, and an image answers that it has none even when
+ * it has one -- so the same application whose start-up is coloured under bootRun arrives plain once
+ * it is built. What the environment names as the terminal is read instead, which an image does
+ * carry. It cannot tell output being watched from output being redirected -- nothing in an image
+ * can -- but it does tell a shell from a build, a container or a service manager.
+ */
+class GrailsEnvironmentPostProcessorAnsiSpec extends Specification {
+
+ private static final String ANSI = 'spring.output.ansi.enabled'
+
+ StandardEnvironment environment = new StandardEnvironment()
+
+ /**
+ * Runs the post-processor the way Spring Boot runs it, rather than reaching past it for the one
+ * method under test: whether an image colours its output is only worth knowing about through the
+ * hook that decides it.
+ */
+ private String ansiAfter(boolean image, String terminal) {
+ DefaultBootstrapContext bootstrapContext = new DefaultBootstrapContext()
+ bootstrapContext.register(PluginDiscovery, InstanceSupplier.of(noPlugins()))
+ new Processor(bootstrapContext, image, terminal).postProcessEnvironment(environment, null)
+ environment.getProperty(ANSI)
+ }
+
+ /** Plugin discovery that finds none, so the phase this shares with completes and does nothing. */
+ private static PluginDiscovery noPlugins() {
+ DefaultPluginDiscovery discovery = new DefaultPluginDiscovery(new Class>[0])
+ discovery.loadPluginsFromClasspath = false
+ discovery
+ }
+
+ void 'an image at a terminal colours its output'() {
+ expect:
+ ansiAfter(true, 'xterm-256color') == 'always'
+ }
+
+ void 'an image where nothing names a terminal stays plain'() {
+ expect: 'a build, a container or a service manager, whose output is only ever read later'
+ ansiAfter(true, null) == null
+ }
+
+ void 'a terminal that cannot colour is left alone'() {
+ expect:
+ ansiAfter(true, 'dumb') == null
+ }
+
+ void 'running on a JVM is left to Spring Boot'() {
+ expect: 'where asking for the console works, and answers for pipes too'
+ ansiAfter(false, 'xterm-256color') == null
+ }
+
+ void 'an application that has said either way keeps what it said'() {
+ given:
+ environment.propertySources.addFirst(new MapPropertySource('test', [(ANSI): 'never']))
+
+ expect:
+ ansiAfter(true, 'xterm-256color') == 'never'
+ }
+
+ /** Stands in for the two things only a run can answer. */
+ static class Processor extends GrailsEnvironmentPostProcessor {
+
+ private final boolean image
+ private final String terminal
+
+ Processor(DefaultBootstrapContext bootstrapContext, boolean image, String terminal) {
+ super(bootstrapContext)
+ this.image = image
+ this.terminal = terminal
+ }
+
+ @Override
+ protected boolean isImage() {
+ image
+ }
+
+ @Override
+ protected String terminal() {
+ terminal
+ }
+ }
+}
diff --git a/grails-core/src/test/groovy/org/grails/plugins/CoreGrailsPluginAotSpec.groovy b/grails-core/src/test/groovy/org/grails/plugins/CoreGrailsPluginAotSpec.groovy
new file mode 100644
index 00000000000..32395f089a5
--- /dev/null
+++ b/grails-core/src/test/groovy/org/grails/plugins/CoreGrailsPluginAotSpec.groovy
@@ -0,0 +1,186 @@
+/*
+ * 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
+
+import org.springframework.aot.AotDetector
+import org.springframework.aot.generate.ClassNameGenerator
+import org.springframework.aot.generate.DefaultGenerationContext
+import org.springframework.aot.generate.InMemoryGeneratedFiles
+import org.springframework.beans.factory.BeanRegistrar
+import org.springframework.beans.factory.support.BeanRegistryAdapter
+import org.springframework.beans.factory.support.GenericBeanDefinition
+import org.springframework.beans.factory.support.RootBeanDefinition
+import org.springframework.context.annotation.AnnotationConfigUtils
+import org.springframework.context.aot.ApplicationContextAotGenerator
+import org.springframework.context.support.GenericApplicationContext
+import org.springframework.core.SpringProperties
+import org.springframework.javapoet.ClassName
+
+import grails.core.DefaultGrailsApplication
+import grails.core.GrailsApplication
+import grails.plugins.DefaultGrailsPluginManager
+import grails.plugins.GrailsPlugin
+import grails.plugins.GrailsPluginManager
+import org.apache.grails.core.plugins.DefaultPluginDiscovery
+import spock.lang.Specification
+
+/**
+ * Covers the core plugin's behaviour under Spring's ahead-of-time processing: the bean definitions
+ * it contributes must be expressible as generated code, and the configuration class post-processor
+ * it registers must stand down where the context already has its bean definitions generated.
+ */
+class CoreGrailsPluginAotSpec extends Specification {
+
+ GenericApplicationContext context = new GenericApplicationContext()
+
+ void cleanup() {
+ SpringProperties.setProperty(AotDetector.AOT_ENABLED, null)
+ context.close()
+ }
+
+ /**
+ * Mirrors the registrar phase of {@code GrailsApplicationPostProcessor}: every enabled plugin's
+ * {@link BeanRegistrar} applied against the registry through the same adapter the runtime uses.
+ */
+ private void applyCorePluginRegistrar() {
+ GrailsApplication application = new DefaultGrailsApplication()
+ application.applicationContext = context
+ application.initialise()
+
+ def discovery = new DefaultPluginDiscovery([CoreGrailsPlugin] as Class>[])
+ discovery.loadPluginsFromClasspath = false
+ discovery.init(context.environment)
+
+ GrailsPluginManager pluginManager = new DefaultGrailsPluginManager(application, discovery)
+ context.beanFactory.registerSingleton(GrailsApplication.APPLICATION_ID, application)
+ context.beanFactory.registerSingleton(GrailsPluginManager.BEAN_NAME, pluginManager)
+ pluginManager.loadPlugins()
+
+ for (GrailsPlugin plugin : pluginManager.allPlugins) {
+ BeanRegistrar registrar = plugin.beanRegistrar
+ if (registrar != null) {
+ new BeanRegistryAdapter(context, context, context.environment, registrar.getClass())
+ .register(registrar)
+ }
+ }
+ }
+
+ void 'the configuration class post-processor is registered when generated artifacts are not in use'() {
+ given:
+ SpringProperties.setProperty(AotDetector.AOT_ENABLED, 'false')
+
+ when:
+ applyCorePluginRegistrar()
+
+ then: 'plugin-contributed @Configuration beans still need parsing at runtime'
+ context.containsBeanDefinition('grailsConfigurationClassPostProcessor')
+ }
+
+ void 'the configuration class post-processor is withheld when generated artifacts are in use'() {
+ given: 'the flag an AOT-optimized application is started with'
+ SpringProperties.setProperty(AotDetector.AOT_ENABLED, 'true')
+
+ when:
+ applyCorePluginRegistrar()
+
+ then: 'the configuration classes were parsed at build time, so parsing them again would ' +
+ 'collide with the definitions already generated'
+ !context.containsBeanDefinition('grailsConfigurationClassPostProcessor')
+ }
+
+ void 'a second configuration class post-processor is not added to a context that has one'() {
+ given: 'the processor Spring registers as part of setting up annotation configuration'
+ SpringProperties.setProperty(AotDetector.AOT_ENABLED, 'false')
+ AnnotationConfigUtils.registerAnnotationConfigProcessors(context)
+
+ when:
+ applyCorePluginRegistrar()
+
+ then: 'the one already there sees the plugin definitions, which are registered ahead of it; ' +
+ 'a second parses the same registry again and, while code is being generated, writes ' +
+ 'out the same import-aware post-processor a second time'
+ !context.containsBeanDefinition('grailsConfigurationClassPostProcessor')
+ context.containsBeanDefinition(AnnotationConfigUtils.CONFIGURATION_ANNOTATION_PROCESSOR_BEAN_NAME)
+ }
+
+ void "a datastore's own proxy handler is not replaced"() {
+ given: 'what a GORM implementation declares from doWithSpring, which runs earlier'
+ SpringProperties.setProperty(AotDetector.AOT_ENABLED, 'false')
+ context.registerBeanDefinition('proxyHandler', new RootBeanDefinition(String))
+
+ when:
+ applyCorePluginRegistrar()
+
+ then: 'it knows how to unwrap that datastore\'s proxies, and this one does not'
+ context.getBeanDefinition('proxyHandler').beanClassName == String.name
+ }
+
+ void 'the proxy handler is registered where no datastore declared one'() {
+ given:
+ SpringProperties.setProperty(AotDetector.AOT_ENABLED, 'false')
+
+ when:
+ applyCorePluginRegistrar()
+
+ then:
+ context.containsBeanDefinition('proxyHandler')
+ }
+
+ void 'the core plugin bean definitions can be generated ahead of time'() {
+ given:
+ SpringProperties.setProperty(AotDetector.AOT_ENABLED, 'false')
+ applyCorePluginRegistrar()
+
+ and:
+ def generationContext = new DefaultGenerationContext(
+ new ClassNameGenerator(ClassName.get('org.grails.aot.test', 'CoreAotTest')),
+ new InMemoryGeneratedFiles())
+
+ when: 'the context is processed exactly as the processAot build task processes it'
+ new ApplicationContextAotGenerator().processAheadOfTime(context, generationContext)
+
+ then: 'no definition holds a value the generator cannot express as code -- a live instance ' +
+ 'passed as a constructor argument or property value would fail here'
+ noExceptionThrown()
+ }
+
+ void 'a definition holding a live instance fails generation'() {
+ given:
+ SpringProperties.setProperty(AotDetector.AOT_ENABLED, 'false')
+ applyCorePluginRegistrar()
+
+ and: 'the shape this plugin must avoid: an already-constructed object as a constructor argument'
+ def definition = new GenericBeanDefinition()
+ definition.beanClass = StringBuilder
+ definition.constructorArgumentValues.addIndexedArgumentValue(0, new DefaultGrailsApplication())
+ context.registerBeanDefinition('holdsALiveInstance', definition)
+
+ and:
+ def generationContext = new DefaultGenerationContext(
+ new ClassNameGenerator(ClassName.get('org.grails.aot.test', 'LiveInstanceAotTest')),
+ new InMemoryGeneratedFiles())
+
+ when:
+ new ApplicationContextAotGenerator().processAheadOfTime(context, generationContext)
+
+ then: 'proving the preceding check is capable of failing'
+ Exception e = thrown()
+ e.message.contains('holdsALiveInstance')
+ }
+}
diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/AbstractResourceLocatorPostProcessorSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/AbstractResourceLocatorPostProcessorSpec.groovy
new file mode 100644
index 00000000000..de2e1d2dd73
--- /dev/null
+++ b/grails-core/src/test/groovy/org/grails/spring/beans/AbstractResourceLocatorPostProcessorSpec.groovy
@@ -0,0 +1,96 @@
+/*
+ * 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.spring.beans
+
+import org.springframework.beans.factory.support.BeanDefinitionRegistry
+import org.springframework.beans.factory.support.DefaultListableBeanFactory
+import org.springframework.beans.factory.support.RootBeanDefinition
+import org.springframework.context.aot.AbstractAotProcessor
+import spock.lang.Specification
+
+/**
+ * Covers the search locations a resource locator inherits.
+ *
+ *
They are directories on the machine this runs on, and a child definition merges them in.
+ * Generating code for that child writes them into it, so an application would carry the directory it
+ * was built in and look there for its resources -- a path that says where it was built and, wherever
+ * it runs, is not where its resources are.
+ */
+class AbstractResourceLocatorPostProcessorSpec extends Specification {
+
+ BeanDefinitionRegistry registry = new DefaultListableBeanFactory()
+
+ void cleanup() {
+ System.clearProperty(AbstractAotProcessor.AOT_PROCESSING)
+ }
+
+ private void whileGeneratingCode(boolean generating) {
+ generating ? System.setProperty(AbstractAotProcessor.AOT_PROCESSING, 'true')
+ : System.clearProperty(AbstractAotProcessor.AOT_PROCESSING)
+ }
+
+ private List registeredSearchLocations() {
+ registry.getBeanDefinition(AbstractResourceLocatorPostProcessor.BEAN_NAME)
+ .propertyValues.getPropertyValue('searchLocations').value as List
+ }
+
+ void 'the locations are inherited on an ordinary start'() {
+ given:
+ whileGeneratingCode(false)
+
+ when:
+ new AbstractResourceLocatorPostProcessor(['/base']).postProcessBeanDefinitionRegistry(registry)
+
+ then:
+ registeredSearchLocations() == ['/base']
+ }
+
+ void 'no location is inherited while code is being generated'() {
+ given:
+ whileGeneratingCode(true)
+
+ when:
+ new AbstractResourceLocatorPostProcessor(['/base']).postProcessBeanDefinitionRegistry(registry)
+
+ then: 'a generated application reads its resources from its own contents, and the directory ' +
+ 'it was built in belongs to the machine that built it'
+ registeredSearchLocations().isEmpty()
+ }
+
+ void 'the definition is abstract, so it is inherited rather than built'() {
+ when:
+ new AbstractResourceLocatorPostProcessor(['/base']).postProcessBeanDefinitionRegistry(registry)
+
+ then:
+ registry.getBeanDefinition(AbstractResourceLocatorPostProcessor.BEAN_NAME).abstract
+ }
+
+ void 'a definition that is already registered is kept'() {
+ given:
+ RootBeanDefinition existing = new RootBeanDefinition()
+ existing.abstract = true
+ registry.registerBeanDefinition(AbstractResourceLocatorPostProcessor.BEAN_NAME, existing)
+
+ when:
+ new AbstractResourceLocatorPostProcessor(['/base']).postProcessBeanDefinitionRegistry(registry)
+
+ then: 'which is how an application overrides where its resources are looked for'
+ registry.getBeanDefinition(AbstractResourceLocatorPostProcessor.BEAN_NAME).is(existing)
+ }
+}
diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/AbstractBeanDefinitionExcludeFilterSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/AbstractBeanDefinitionExcludeFilterSpec.groovy
new file mode 100644
index 00000000000..c0b0ee9de52
--- /dev/null
+++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/AbstractBeanDefinitionExcludeFilterSpec.groovy
@@ -0,0 +1,79 @@
+/*
+ * 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.spring.beans.aot
+
+import spock.lang.Specification
+
+import org.springframework.beans.factory.support.DefaultListableBeanFactory
+import org.springframework.beans.factory.support.GenericBeanDefinition
+import org.springframework.beans.factory.support.RegisteredBean
+
+import org.grails.spring.beans.AbstractResourceLocatorPostProcessor
+
+class AbstractBeanDefinitionExcludeFilterSpec extends Specification {
+
+ DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory()
+ AbstractBeanDefinitionExcludeFilter filter = new AbstractBeanDefinitionExcludeFilter()
+
+ void 'an abstract definition is excluded from AOT processing'() {
+ given: 'a classless template definition carrying only inherited property values'
+ def definition = new GenericBeanDefinition()
+ definition.abstract = true
+ definition.propertyValues.add('searchLocations', ['/some/location'])
+ beanFactory.registerBeanDefinition('abstractParent', definition)
+
+ expect:
+ filter.isExcludedFromAotProcessing(RegisteredBean.of(beanFactory, 'abstractParent'))
+ }
+
+ void 'a concrete definition is left to AOT processing'() {
+ given:
+ def definition = new GenericBeanDefinition()
+ definition.beanClass = String
+ beanFactory.registerBeanDefinition('concrete', definition)
+
+ expect:
+ !filter.isExcludedFromAotProcessing(RegisteredBean.of(beanFactory, 'concrete'))
+ }
+
+ void 'a child inheriting from an abstract parent is left to AOT processing'() {
+ given: 'the parent template and a child naming it'
+ def parent = new GenericBeanDefinition()
+ parent.abstract = true
+ parent.propertyValues.add('searchLocations', ['/some/location'])
+ beanFactory.registerBeanDefinition('abstractParent', parent)
+
+ def child = new GenericBeanDefinition()
+ child.beanClass = StringBuilder
+ child.parentName = 'abstractParent'
+ beanFactory.registerBeanDefinition('child', child)
+
+ expect: 'the child is generated from its merged definition, so it needs no parent at runtime'
+ !filter.isExcludedFromAotProcessing(RegisteredBean.of(beanFactory, 'child'))
+ }
+
+ void 'the resource locator template the core plugin contributes is excluded'() {
+ given:
+ new AbstractResourceLocatorPostProcessor(['/base']).postProcessBeanDefinitionRegistry(beanFactory)
+
+ expect:
+ filter.isExcludedFromAotProcessing(
+ RegisteredBean.of(beanFactory, AbstractResourceLocatorPostProcessor.BEAN_NAME))
+ }
+}
diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessorSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessorSpec.groovy
new file mode 100644
index 00000000000..dd2813dacc0
--- /dev/null
+++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessorSpec.groovy
@@ -0,0 +1,163 @@
+/*
+ * 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.spring.beans.aot
+
+import org.springframework.aot.generate.ClassNameGenerator
+import org.springframework.aot.generate.DefaultGenerationContext
+import org.springframework.aot.generate.GeneratedFiles
+import org.springframework.aot.generate.InMemoryGeneratedFiles
+import org.springframework.context.aot.ApplicationContextAotGenerator
+import org.springframework.context.support.GenericApplicationContext
+import org.springframework.javapoet.ClassName
+import spock.lang.Specification
+
+import grails.core.DefaultGrailsApplication
+import grails.core.GrailsApplication
+import grails.plugins.GrailsPlugin
+import grails.plugins.GrailsPluginManager
+
+/**
+ * Covers the artefacts an application is made of being written down while they can still be found.
+ *
+ *
They are found by walking the classpath and by reading a list the compile-time transform builds
+ * as it goes, and an image has neither -- so it found no controllers, no domain classes and no URL
+ * mappings, and the application failed to start on the first bean that wanted one. The only way to
+ * start was for an application to name its own artefacts by hand.
+ */
+class ArtefactClassesBeanFactoryInitializationAotProcessorSpec extends Specification {
+
+ GenericApplicationContext context = new GenericApplicationContext()
+
+ void cleanup() {
+ context.close()
+ }
+
+ private String generatedSourceFor(Class>... artefacts) {
+ GrailsApplication application = new DefaultGrailsApplication(artefacts)
+ application.applicationContext = context
+ application.initialise()
+ context.beanFactory.registerSingleton(GrailsApplication.APPLICATION_ID, application)
+
+ InMemoryGeneratedFiles generatedFiles = new InMemoryGeneratedFiles()
+ DefaultGenerationContext generationContext = new DefaultGenerationContext(
+ new ClassNameGenerator(ClassName.get('com.example', 'Subject')), generatedFiles)
+ new ApplicationContextAotGenerator().processAheadOfTime(context, generationContext)
+ generationContext.writeGeneratedContent()
+
+ generatedFiles.getGeneratedFiles(GeneratedFiles.Kind.SOURCE)
+ .keySet()
+ .collect { generatedFiles.getGeneratedFileContent(GeneratedFiles.Kind.SOURCE, it) }
+ .join('\n')
+ }
+
+ void 'the artefacts are written into the generated code'() {
+ when:
+ String generated = generatedSourceFor(DemoController, DemoService)
+
+ then: 'so that an image has them without the application naming them itself'
+ generated.contains('registerSingleton("grailsArtefactClasses"')
+ generated.contains('DemoController.class')
+ generated.contains('DemoService.class')
+ }
+
+ void 'the registration is run as the bean factory is initialized'() {
+ when:
+ String generated = generatedSourceFor(DemoController)
+
+ then: 'they are read while the definitions are still being contributed, so they have to be ' +
+ 'there before any of them is'
+ generated.contains('registerArtefactClasses')
+ }
+
+ void 'a context that is not a Grails application contributes nothing'() {
+ given:
+ def processor = new ArtefactClassesBeanFactoryInitializationAotProcessor()
+
+ expect: 'a plain Spring application being generated has no grailsApplication to ask'
+ processor.processAheadOfTime(context.beanFactory) == null
+ }
+
+ void 'the classes that constitute the application are what is written down'() {
+ when: 'a Grails application holds these as the classes it is made of'
+ String generated = generatedSourceFor(DemoController, DemoService)
+
+ then: 'which is what classes() answers with, so a run reads what a build found'
+ generated.count('.class') >= 2
+ }
+
+ void 'an application with no artefacts contributes nothing'() {
+ given:
+ GrailsApplication application = new DefaultGrailsApplication()
+ application.applicationContext = context
+ application.initialise()
+ context.beanFactory.registerSingleton(GrailsApplication.APPLICATION_ID, application)
+
+ expect: 'writing an empty array down would say the application has none, which is different ' +
+ 'from not having looked'
+ new ArtefactClassesBeanFactoryInitializationAotProcessor()
+ .processAheadOfTime(context.beanFactory) == null
+ }
+
+ void "what the plugins bring with them is not written down"() {
+ given: "a plugin manager whose plugins provide one of the classes the application holds"
+ GrailsApplication application = new DefaultGrailsApplication(DemoController, DemoService)
+ application.applicationContext = context
+ application.initialise()
+ context.beanFactory.registerSingleton(GrailsApplication.APPLICATION_ID, application)
+ context.beanFactory.registerSingleton(GrailsPluginManager.BEAN_NAME,
+ Stub(GrailsPluginManager) {
+ getAllPlugins() >> ([Stub(GrailsPlugin) {
+ getProvidedArtefacts() >> ([DemoService] as Class[])
+ }] as GrailsPlugin[])
+ })
+
+ when:
+ def contribution = new ArtefactClassesBeanFactoryInitializationAotProcessor()
+ .processAheadOfTime(context.beanFactory)
+
+ then: "they are registered from the plugins on every start, and a codec or tag library " +
+ "registered twice is not the same as registered once"
+ contribution != null
+
+ and:
+ def written = writtenBy(contribution)
+ written.contains(DemoController)
+ !written.contains(DemoService)
+ }
+
+ /** The classes the contribution would write, read back off the code it generates. */
+ private List writtenBy(contribution) {
+ InMemoryGeneratedFiles files = new InMemoryGeneratedFiles()
+ DefaultGenerationContext generation = new DefaultGenerationContext(
+ new ClassNameGenerator(ClassName.get('com.example', 'Written')), files)
+ new ApplicationContextAotGenerator().processAheadOfTime(context, generation)
+ generation.writeGeneratedContent()
+ String source = files.getGeneratedFiles(GeneratedFiles.Kind.SOURCE)
+ .keySet()
+ .collect { files.getGeneratedFileContent(GeneratedFiles.Kind.SOURCE, it) }
+ .join('\n')
+ [DemoController, DemoService].findAll { source.contains(it.simpleName + '.class') }
+ }
+
+ static class DemoController {
+ }
+
+ static class DemoService {
+ }
+}
diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/AutowireModeBeanRegistrationAotProcessorSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/AutowireModeBeanRegistrationAotProcessorSpec.groovy
new file mode 100644
index 00000000000..f08b4db347d
--- /dev/null
+++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/AutowireModeBeanRegistrationAotProcessorSpec.groovy
@@ -0,0 +1,70 @@
+/*
+ * 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.spring.beans.aot
+
+import org.springframework.beans.factory.support.AbstractBeanDefinition
+import org.springframework.beans.factory.support.DefaultListableBeanFactory
+import org.springframework.beans.factory.support.RegisteredBean
+import org.springframework.beans.factory.support.RootBeanDefinition
+import spock.lang.Specification
+
+/**
+ * Covers a bean's autowire mode reaching the code generated for it.
+ *
+ *
Grails registers much of what it contributes as autowired by name, and the generator writes out
+ * most of a definition but not that, so a bean rebuilt from generated code would arrive with those
+ * collaborators unset. Nothing fails at start-up; the first request that reaches one does.
+ */
+class AutowireModeBeanRegistrationAotProcessorSpec extends Specification {
+
+ DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory()
+
+ AutowireModeBeanRegistrationAotProcessor processor = new AutowireModeBeanRegistrationAotProcessor()
+
+ private RegisteredBean register(String name, int autowireMode) {
+ RootBeanDefinition definition = new RootBeanDefinition(Collaborating)
+ definition.autowireMode = autowireMode
+ beanFactory.registerBeanDefinition(name, definition)
+ RegisteredBean.of(beanFactory, name)
+ }
+
+ void 'a bean autowired by name is contributed to'() {
+ expect:
+ processor.processAheadOfTime(register('byName', AbstractBeanDefinition.AUTOWIRE_BY_NAME)) != null
+ }
+
+ void 'a bean autowired by type is contributed to'() {
+ expect:
+ processor.processAheadOfTime(register('byType', AbstractBeanDefinition.AUTOWIRE_BY_TYPE)) != null
+ }
+
+ void 'a bean that is not autowired is left alone'() {
+ expect: 'contributing to every bean would put a redundant assignment in every definition'
+ processor.processAheadOfTime(register('plain', AbstractBeanDefinition.AUTOWIRE_NO)) == null
+ }
+
+ static class Collaborating {
+
+ String collaborator
+
+ void setCollaborator(String collaborator) {
+ this.collaborator = collaborator
+ }
+ }
+}
diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/BeanRegistrarRuntimeHintsSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/BeanRegistrarRuntimeHintsSpec.groovy
new file mode 100644
index 00000000000..e0204de73ca
--- /dev/null
+++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/BeanRegistrarRuntimeHintsSpec.groovy
@@ -0,0 +1,85 @@
+/*
+ * 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.spring.beans.aot
+
+import java.util.function.Consumer
+
+import org.springframework.aot.hint.MemberCategory
+import org.springframework.aot.hint.RuntimeHints
+import org.springframework.aot.hint.TypeHint
+import org.springframework.aot.hint.TypeReference
+import org.springframework.beans.factory.BeanRegistrar
+import org.springframework.beans.factory.BeanRegistry
+import org.springframework.beans.factory.ListableBeanFactory
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory
+import org.springframework.beans.factory.support.BeanDefinitionRegistry
+import spock.lang.Specification
+
+/**
+ * Covers the registry a plugin declares its beans against being callable from an image.
+ *
+ *
A plugin declares them in a closure, so every call on the registry is made reflectively. An
+ * image keeps a method for that only when asked, nothing else asks for these, and the failure is a
+ * context that does not start naming an interface the application never mentions.
+ */
+class BeanRegistrarRuntimeHintsSpec extends Specification {
+
+ RuntimeHints hints = new RuntimeHints()
+
+ void setup() {
+ new BeanRegistrarRuntimeHints().registerHints(hints, getClass().classLoader)
+ }
+
+ private TypeHint hintFor(Class> type) {
+ hints.reflection().getTypeHint(TypeReference.of(type))
+ }
+
+ void 'the registry a plugin declares its beans against can be called'() {
+ expect:
+ hintFor(BeanRegistry)?.memberCategories?.contains(MemberCategory.INVOKE_DECLARED_METHODS)
+ }
+
+ void 'the specification a plugin configures a bean through can be called'() {
+ expect: 'registerBean(name, type) { ... } calls onto it for every bean so declared'
+ hintFor(BeanRegistry.Spec)?.memberCategories?.contains(MemberCategory.INVOKE_DECLARED_METHODS)
+ hintFor(BeanRegistry.SupplierContext)?.memberCategories?.contains(MemberCategory.INVOKE_DECLARED_METHODS)
+ }
+
+ void 'what hands a plugin to the registry can be called'() {
+ expect:
+ hintFor(BeanRegistrar)?.memberCategories?.contains(MemberCategory.INVOKE_DECLARED_METHODS)
+ }
+
+ void 'the registry the older bean DSL is handed can be called'() {
+ expect: 'a plugin that still declares its beans that way asks whether one is already there'
+ hintFor(BeanDefinitionRegistry)?.memberCategories?.contains(MemberCategory.INVOKE_DECLARED_METHODS)
+ hintFor(ListableBeanFactory)?.memberCategories?.contains(MemberCategory.INVOKE_DECLARED_METHODS)
+ hintFor(ConfigurableListableBeanFactory)?.memberCategories
+ ?.contains(MemberCategory.INVOKE_DECLARED_METHODS)
+ }
+
+ void 'the call a plugin actually makes is covered'() {
+ given: 'the three-argument form, which is what a bean with a specification uses'
+ def registerBean = BeanRegistry.getMethod('registerBean', String, Class, Consumer)
+
+ expect: 'declared methods covers it, so the image keeps it'
+ registerBean.declaringClass == BeanRegistry
+ hintFor(BeanRegistry).memberCategories.contains(MemberCategory.INVOKE_DECLARED_METHODS)
+ }
+}
diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHintsSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHintsSpec.groovy
new file mode 100644
index 00000000000..2ff8daf8fb1
--- /dev/null
+++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHintsSpec.groovy
@@ -0,0 +1,88 @@
+/*
+ * 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.spring.beans.aot
+
+import org.springframework.aot.hint.MemberCategory
+import org.springframework.aot.hint.RuntimeHints
+import org.springframework.aot.hint.TypeReference
+import spock.lang.Specification
+
+import org.springframework.context.ApplicationContext
+import org.springframework.core.env.Environment
+import org.springframework.core.env.PropertyResolver
+
+import grails.config.Config
+import grails.config.ConfigMap
+import grails.core.GrailsApplication
+import grails.plugins.GrailsPluginManager
+
+/**
+ * Covers the framework's own interfaces being callable from an image.
+ *
+ *
A plugin descriptor is Groovy written largely without static compilation, so reading a setting
+ * or asking the application about its artefacts is resolved where the call is written and made
+ * reflectively. An application never names these interfaces itself, so nothing else asks an image to
+ * keep them, and a context stopped starting on ConfigMap.getProperty -- how nearly every plugin
+ * reads its settings.
+ */
+class GrailsApiRuntimeHintsSpec extends Specification {
+
+ RuntimeHints hints = new RuntimeHints()
+
+ void setup() {
+ new GrailsApiRuntimeHints().registerHints(hints, getClass().classLoader)
+ }
+
+ private boolean invocable(Class> type) {
+ def hint = hints.reflection().getTypeHint(TypeReference.of(type))
+ hint != null && hint.memberCategories.contains(MemberCategory.INVOKE_DECLARED_METHODS)
+ }
+
+ void 'the configuration a plugin reads its settings from can be called'() {
+ expect:
+ invocable(ConfigMap)
+ invocable(Config)
+ }
+
+ void 'what a plugin asks about the application can be called'() {
+ expect:
+ invocable(GrailsApplication)
+ }
+
+ void 'what a plugin asks about the plugins can be called'() {
+ expect:
+ invocable(GrailsPluginManager)
+ }
+
+ void 'what Spring hands a plugin can be called'() {
+ expect: 'a descriptor is given these and calls them the same dynamic way'
+ invocable(PropertyResolver)
+ invocable(Environment)
+ invocable(ApplicationContext)
+ }
+
+ void 'the call that stopped a context from starting is covered'() {
+ given: 'the form a plugin uses to read a setting with a type and a default'
+ def getProperty = ConfigMap.getMethod('getProperty', String, Class, Object)
+
+ expect:
+ getProperty.declaringClass == ConfigMap
+ invocable(ConfigMap)
+ }
+}
diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsBannerRuntimeHintsSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsBannerRuntimeHintsSpec.groovy
new file mode 100644
index 00000000000..7545a368bfa
--- /dev/null
+++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsBannerRuntimeHintsSpec.groovy
@@ -0,0 +1,79 @@
+/*
+ * 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.spring.beans.aot
+
+import org.springframework.aot.hint.MemberCategory
+import org.springframework.aot.hint.RuntimeHints
+import org.springframework.aot.hint.TypeHint
+import org.springframework.aot.hint.TypeReference
+import spock.lang.Specification
+
+import grails.boot.GrailsBanner
+
+/**
+ * Covers the banner's option enums surviving into an image.
+ *
+ *
A Groovy enum reaches its own constructor through the metaclass to build its constants, so an
+ * image that kept no constructor for it fails in the static initialiser -- while printing the
+ * banner, before the application has run. The optional enum is the one that bites: it is touched
+ * only where {@code grails.banner.versions.include} names something, so a traced image carries no
+ * record of it and turning a version on turns the application off.
+ */
+class GrailsBannerRuntimeHintsSpec extends Specification {
+
+ RuntimeHints hints = new RuntimeHints()
+
+ void setup() {
+ new GrailsBannerRuntimeHints().registerHints(hints, getClass().classLoader)
+ }
+
+ private TypeHint hintFor(Class> type) {
+ hints.reflection().getTypeHint(TypeReference.of(type))
+ }
+
+ void 'every option enum can be constructed'() {
+ expect: 'the constants are built by a static initialiser that goes through the constructor'
+ hintFor(type)?.memberCategories?.contains(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS)
+
+ where:
+ type << [GrailsBanner.VersionOption, GrailsBanner.DefaultVersionOption,
+ GrailsBanner.OptionalVersionOption]
+ }
+
+ void 'the optional enum is registered though nothing reaches it unless it is configured'() {
+ expect: 'an image traced with no versions included would otherwise have no record of it'
+ hintFor(GrailsBanner.OptionalVersionOption) != null
+ }
+
+ void 'the enums can be read and called'() {
+ expect: 'values() and the key each constant carries'
+ hintFor(type)?.memberCategories?.contains(MemberCategory.INVOKE_DECLARED_METHODS)
+ hintFor(type)?.memberCategories?.contains(MemberCategory.ACCESS_DECLARED_FIELDS)
+
+ where:
+ type << [GrailsBanner.VersionOption, GrailsBanner.DefaultVersionOption,
+ GrailsBanner.OptionalVersionOption]
+ }
+
+ void 'the metaclass name Groovy looks up is registered'() {
+ expect: 'looked up before Groovy decides the enum has no metaclass of its own'
+ hints.reflection().getTypeHint(TypeReference.of(
+ "groovy.runtime.metaclass.${GrailsBanner.OptionalVersionOption.name}MetaClass")) != null
+ }
+}
diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHintsSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHintsSpec.groovy
new file mode 100644
index 00000000000..c489ed120d7
--- /dev/null
+++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHintsSpec.groovy
@@ -0,0 +1,101 @@
+/*
+ * 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.spring.beans.aot
+
+import org.springframework.aot.hint.MemberCategory
+import org.springframework.aot.hint.RuntimeHints
+import org.springframework.aot.hint.predicate.RuntimeHintsPredicates
+import org.springframework.aot.hint.TypeReference
+import spock.lang.Specification
+
+/**
+ * Covers the framework's closures being found while the hints are written, rather than named one by
+ * one. Groovy reads {@code doCall}'s parameter types to choose an overload, so a closure missing
+ * from an image fails where it is used rather than at start-up.
+ */
+class GrailsClosureRuntimeHintsSpec extends Specification {
+
+ RuntimeHints hints = new RuntimeHints()
+
+ private List registeredTypes() {
+ hints.reflection().typeHints().collect { it.type.name }
+ }
+
+ void 'the framework closures on the classpath are registered'() {
+ when:
+ new GrailsClosureRuntimeHints().registerHints(hints, getClass().classLoader)
+
+ then: 'this module alone ships plenty, so a scan that found nothing would be broken'
+ registeredTypes().count { it.contains('_closure') } > 0
+ }
+
+ void 'a registered closure can have its parameter types read'() {
+ given:
+ new GrailsClosureRuntimeHints().registerHints(hints, getClass().classLoader)
+
+ when:
+ def closure = registeredTypes().find { it.contains('_closure') }
+ def hint = hints.reflection().getTypeHint(TypeReference.of(closure))
+
+ then: 'that is the access Groovy needs to select an overload'
+ hint.memberCategories.contains(MemberCategory.INVOKE_DECLARED_METHODS)
+ }
+
+ void 'only framework and plugin descriptor closures are registered'() {
+ when:
+ new GrailsClosureRuntimeHints().registerHints(hints, getClass().classLoader)
+
+ then: 'a plugin may sit in any package, but nothing else should be swept up'
+ registeredTypes().every {
+ it.startsWith('grails.') || it.startsWith('org.grails.') || it.contains('GrailsPlugin$')
+ }
+ }
+
+ void 'where the plugins are listed is carried'() {
+ given:
+ new GrailsClosureRuntimeHints().registerHints(hints, getClass().classLoader)
+
+ expect: 'read to find the plugins at all, and an image carries a resource only when asked'
+ RuntimeHintsPredicates.resource().forResource('META-INF/grails.factories').test(hints)
+ }
+
+ void 'a closure is skipped when the class it was written inside cannot be loaded'() {
+ given:
+ def registrar = new GrailsClosureRuntimeHints()
+ ClassLoader loader = getClass().classLoader
+
+ expect: 'analysing a closure means reading the method it was written in, so a closure whose ' +
+ 'surroundings are absent fails the build rather than being left out of it'
+ !registrar.enclosingLoads('com.example.NotHere$_someMethod_closure1', loader)
+
+ and: 'one written inside a class that is present is kept'
+ registrar.enclosingLoads('org.grails.config.NavigableMap$_flattenKeys_closure3', loader)
+
+ and: 'a class that is not a closure has no surroundings to ask about'
+ registrar.enclosingLoads('org.grails.config.NavigableMap', loader)
+ }
+
+ void 'a class loader that resolves nothing yields no hints rather than failing'() {
+ when:
+ new GrailsClosureRuntimeHints().registerHints(hints, new URLClassLoader(new URL[0], null))
+
+ then:
+ noExceptionThrown()
+ }
+}
diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsResourceRuntimeHintsSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsResourceRuntimeHintsSpec.groovy
new file mode 100644
index 00000000000..c2a4c9f4470
--- /dev/null
+++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsResourceRuntimeHintsSpec.groovy
@@ -0,0 +1,100 @@
+/*
+ * 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.spring.beans.aot
+
+import org.springframework.aot.hint.RuntimeHints
+import org.springframework.core.SpringProperties
+import spock.lang.Specification
+
+/**
+ * Covers the resources an application reads by name surviving into an image, and only those.
+ *
+ *
An asset is asked for by request path and a message bundle by locale, so nothing in the code
+ * names either and an image built without them serves every page with a missing stylesheet and an
+ * untranslated string. What is registered has to be bounded all the same: a pattern matching every
+ * properties file at every depth carries the whole classpath into the image to keep the few that
+ * are bundles.
+ */
+class GrailsResourceRuntimeHintsSpec extends Specification {
+
+ RuntimeHints hints = new RuntimeHints()
+
+ void cleanup() {
+ SpringProperties.setProperty(GrailsResourceRuntimeHints.ADDITIONAL_PATTERNS_PROPERTY, null)
+ }
+
+ /**
+ * What was registered. Spring records the parent directory of each pattern alongside it, so
+ * these are what was asked for and not only what this class named.
+ */
+ private Set registeredPatterns() {
+ new GrailsResourceRuntimeHints().registerHints(hints, getClass().classLoader)
+ hints.resources().resourcePatternHints().toList()
+ .collectMany { it.includes }
+ .collect { it.pattern } as Set
+ }
+
+ void 'the compiled assets are registered'() {
+ expect: 'asset-pipeline serves them from the classpath by the path that was asked for'
+ registeredPatterns().containsAll(['assets/*', 'assets/**'])
+ }
+
+ void 'the message bundles at the root of the classpath are registered'() {
+ expect: 'which is what PluginAwareResourceBundleMessageSource scans, and where a plugin ' +
+ "shipping its own bundle lands them"
+ '*.properties' in registeredPatterns()
+ }
+
+ void 'every properties file on the classpath is not'() {
+ expect: 'nothing reads a bundle below the root, and registering the pattern would carry ' +
+ 'every dependency configuration into the image to keep the few that are bundles'
+ !('**/*.properties' in registeredPatterns())
+ }
+
+ void 'an application says what else it reads by name'() {
+ given:
+ SpringProperties.setProperty(GrailsResourceRuntimeHints.ADDITIONAL_PATTERNS_PROPERTY,
+ 'db/migration/**,templates/*.vm')
+
+ expect: 'read as a property because this runs while the code is being generated'
+ registeredPatterns().containsAll(['db/migration/**', 'templates/*.vm'])
+ }
+
+ void 'a list written with spaces after its commas is the list it looks like'() {
+ given:
+ SpringProperties.setProperty(GrailsResourceRuntimeHints.ADDITIONAL_PATTERNS_PROPERTY,
+ 'db/migration/**, templates/*.vm ,')
+
+ expect: 'a pattern carrying a leading space matches nothing, and does so silently'
+ registeredPatterns().containsAll(['db/migration/**', 'templates/*.vm'])
+ !registeredPatterns().any { it != it.trim() }
+ }
+
+ void 'an application that says nothing gets what it would have'() {
+ given:
+ SpringProperties.setProperty(GrailsResourceRuntimeHints.ADDITIONAL_PATTERNS_PROPERTY, blank)
+
+ expect:
+ registeredPatterns().containsAll(['assets/*', 'assets/**', '*.properties'])
+ !('db/migration/**' in registeredPatterns())
+
+ where:
+ blank << [null, '', ' ']
+ }
+}
diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/GroovyExtensionModuleRuntimeHintsSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GroovyExtensionModuleRuntimeHintsSpec.groovy
new file mode 100644
index 00000000000..d73e1b14617
--- /dev/null
+++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GroovyExtensionModuleRuntimeHintsSpec.groovy
@@ -0,0 +1,115 @@
+/*
+ * 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.spring.beans.aot
+
+import org.springframework.aot.hint.MemberCategory
+import org.springframework.aot.hint.RuntimeHints
+import org.springframework.aot.hint.TypeReference
+import org.springframework.aot.hint.predicate.RuntimeHintsPredicates
+import spock.lang.Specification
+
+/**
+ * Covers the classes that extend types Groovy did not declare being callable from an image.
+ *
+ *
A module names them in a descriptor rather than in any code, and Groovy calls them through the
+ * metaclass, so nothing else asks an image to keep them. The failure is a request rather than the
+ * start-up: the framework extends the servlet request and response this way, and those are reached
+ * while a page is rendered.
+ */
+class GroovyExtensionModuleRuntimeHintsSpec extends Specification {
+
+ RuntimeHints hints = new RuntimeHints()
+
+ void setup() {
+ new GroovyExtensionModuleRuntimeHints().registerHints(hints, getClass().classLoader)
+ }
+
+ private Set registeredTypes() {
+ hints.reflection().typeHints().collect { it.type.name } as Set
+ }
+
+ private boolean invocable(String className) {
+ invocableIn(hints, className)
+ }
+
+ private boolean invocableIn(RuntimeHints target, String className) {
+ def hint = target.reflection().getTypeHint(TypeReference.of(className))
+ hint != null && hint.memberCategories.contains(MemberCategory.INVOKE_DECLARED_METHODS)
+ }
+
+ void 'the extensions the framework declares are registered'() {
+ expect: 'these are named only in a descriptor, so nothing else would ask for them'
+ registeredTypes().any { it.endsWith('Extension') }
+ }
+
+ void 'both kinds of extension a descriptor names are registered'() {
+ given: 'a descriptor of this spec\'s own, so the assertion does not rest on what happens to ' +
+ 'be on the classpath of the module the registrar lives in'
+ File directory = File.createTempDir()
+ new File(directory, 'META-INF/services').mkdirs()
+ new File(directory, 'META-INF/services/org.codehaus.groovy.runtime.ExtensionModule').text = '''
+ moduleName=spec-module
+ moduleVersion=1.0
+ extensionClasses=java.lang.StringBuilder
+ staticExtensionClasses=java.lang.StringBuffer
+ '''.stripIndent()
+ RuntimeHints declared = new RuntimeHints()
+
+ when:
+ new GroovyExtensionModuleRuntimeHints().registerHints(declared,
+ new URLClassLoader([directory.toURI().toURL()] as URL[], getClass().classLoader))
+
+ then: 'one extends instances and the other the type; a call is made through either'
+ invocableIn(declared, 'java.lang.StringBuilder')
+ invocableIn(declared, 'java.lang.StringBuffer')
+
+ cleanup:
+ directory.deleteDir()
+ }
+
+ void 'every extension named by a descriptor can have its methods called'() {
+ expect: 'registering the type without its methods still leaves the call refused'
+ registeredTypes().findAll { it.endsWith('Extension') }.every { invocable(it) }
+ }
+
+ void 'what the Groovy runtime reads as it starts is carried'() {
+ expect: 'without these it cannot build its metaclasses, and fails before any application code'
+ resourceRegistered('META-INF/dgminfo')
+ resourceRegistered('META-INF/groovy-release-info.properties')
+
+ and: 'and the descriptors naming the extensions, which is how it finds them'
+ resourceRegistered('META-INF/services/org.codehaus.groovy.runtime.ExtensionModule')
+ resourceRegistered('META-INF/groovy/org.codehaus.groovy.runtime.ExtensionModule')
+ }
+
+ private boolean resourceRegistered(String resource) {
+ RuntimeHintsPredicates.resource().forResource(resource).test(hints)
+ }
+
+ void 'a class loader that resolves nothing yields no hints rather than failing'() {
+ given:
+ RuntimeHints empty = new RuntimeHints()
+
+ when:
+ new GroovyExtensionModuleRuntimeHints().registerHints(empty, new URLClassLoader(new URL[0], null))
+
+ then:
+ noExceptionThrown()
+ }
+}
diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/VarargsBeanRegistrationAotProcessorSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/VarargsBeanRegistrationAotProcessorSpec.groovy
new file mode 100644
index 00000000000..f24d672738d
--- /dev/null
+++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/VarargsBeanRegistrationAotProcessorSpec.groovy
@@ -0,0 +1,162 @@
+/*
+ * 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.spring.beans.aot
+
+import org.springframework.aot.generate.ClassNameGenerator
+import org.springframework.aot.generate.DefaultGenerationContext
+import org.springframework.aot.generate.GeneratedFiles
+import org.springframework.aot.generate.InMemoryGeneratedFiles
+import org.springframework.beans.factory.config.RuntimeBeanReference
+import org.springframework.beans.factory.support.AbstractBeanDefinition
+import org.springframework.beans.factory.support.RootBeanDefinition
+import org.springframework.context.aot.ApplicationContextAotGenerator
+import org.springframework.context.support.GenericApplicationContext
+import org.springframework.javapoet.ClassName
+import spock.lang.Specification
+
+/**
+ * Covers a variable-argument constructor argument being gathered into the array it feeds.
+ *
+ *
Spring adapts the argument when it builds the bean, but not when it reads the definition to
+ * generate code for it: it looks the argument up by the parameter's type, misses a value that is not
+ * already the array, and resolves it as a dependency instead -- which for an array type is an empty
+ * array. The bean is then built with nothing where its arguments should be, and says so much later.
+ *
+ *
These read the code that is actually generated, because the failure this guards against is one
+ * where every bean is still registered and still built.
+ */
+class VarargsBeanRegistrationAotProcessorSpec extends Specification {
+
+ GenericApplicationContext context = new GenericApplicationContext()
+
+ void cleanup() {
+ context.close()
+ }
+
+ /** The generated source for a context holding one bean with the given constructor arguments. */
+ private String generatedSourceFor(Class> beanClass, List
+ *
+ *
Which Java did the compiling is described, by {@link #getJavaLauncher()}. Which Groovy is
+ * not, and this stays uncacheable until it is.
+ *
+ *
Compiling them again costs seconds. Getting this wrong costs an afternoon.
+ *
* @author David Estes
* @since 4.0
*/
@CompileStatic
-@CacheableTask
+@DisableCachingByDefault(because = 'What a forked compiler produces is not described by this task\'s inputs')
abstract class GroovyPageForkCompileTask extends AbstractCompile {
@Input
@@ -98,9 +111,8 @@ abstract class GroovyPageForkCompileTask extends AbstractCompile {
* builds -- which shows up as an {@code UnsupportedClassVersionError} at the moment a page is
* first rendered, long after the build called itself successful.
*
- *
Nested rather than internal because this task is cacheable: the Java that did the
- * compiling is part of what the result is, and an entry produced by one must not be handed to
- * a build asking for another.
+ *
Nested rather than internal because the Java that did the compiling is part of what the
+ * result is: pages built by one are not left standing when the build asks for another.
*/
@Nested
abstract Property getJavaLauncher()
diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/GroovyPagePlugin.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/GroovyPagePlugin.groovy
index 1f46366f9be..fa980893baa 100644
--- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/GroovyPagePlugin.groovy
+++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/GroovyPagePlugin.groovy
@@ -22,12 +22,19 @@ import groovy.transform.CompileStatic
import org.gradle.api.Plugin
import org.gradle.api.Project
+import org.gradle.api.plugins.BasePlugin
+import org.gradle.api.tasks.Sync
+import org.gradle.api.tasks.util.PatternFilterable
+import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.CopySpec
import org.gradle.api.file.Directory
import org.gradle.api.file.DuplicatesStrategy
import org.gradle.api.file.FileCollection
import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.api.provider.Provider
+import org.gradle.api.provider.ValueSource
+import org.gradle.api.provider.ValueSourceParameters
+import org.gradle.api.provider.ValueSourceSpec
import org.gradle.api.tasks.SourceSet
import org.gradle.api.tasks.SourceSetOutput
import org.gradle.api.tasks.TaskContainer
@@ -36,6 +43,7 @@ import org.gradle.api.tasks.bundling.War
import org.gradle.jvm.toolchain.JavaLauncher
import org.gradle.jvm.toolchain.JavaToolchainService
+import org.grails.gradle.plugin.scaffolding.GenerateScaffoldedViewsTask
import org.grails.gradle.plugin.util.SourceSets
/**
@@ -79,13 +87,65 @@ class GroovyPagePlugin implements Plugin {
JavaToolchainService toolchains = project.extensions.getByType(JavaToolchainService)
Provider launcher = toolchains.launcherFor(javaExtension.toolchain)
+ // A scaffolded controller has no views of its own, so they are expanded from their
+ // templates and compiled with the rest. They are staged together rather than compiled
+ // separately, because a second compilation writes a second gsp/views.properties and the
+ // archive tasks discard duplicates, losing the views one of them lists.
+ //
+ // Only a project that scaffolds pays for this. Staging copies the views, and pointing the
+ // compilation at the copy would change what every other project compiles for no reason.
+ Directory appViews = project.layout.projectDirectory.dir('grails-app/views')
+ boolean scaffolds = scaffoldsAnyController(project).get()
+
+ Directory viewsToCompile = appViews
+ if (scaffolds) {
+ Provider stagedViews = project.layout.buildDirectory.dir('generated/views')
+ def generateScaffoldedViews = tasks.register(
+ 'generateScaffoldedViews', GenerateScaffoldedViewsTask) { GenerateScaffoldedViewsTask it ->
+ it.group = BasePlugin.BUILD_GROUP
+ it.description = 'Expands the views of scaffolded controllers so they can be precompiled'
+ // the classes directory is written by more than one task, so the dependency is stated
+ // against the compilation rather than inferred from the directory
+ it.dependsOn(tasks.named('compileJava'))
+ ['compileGroovy', 'copyAstClasses'].each { String name ->
+ if (project.tasks.findByName(name)) {
+ it.dependsOn(tasks.named(name))
+ }
+ }
+ it.classesDirs.from(classesDirs)
+ it.templateClasspath.from(project.configurations.named('compileClasspath'))
+ it.templateOverrides.from(
+ project.fileTree(project.layout.projectDirectory.dir('src/main/templates/scaffolding'))
+ .matching { PatternFilterable p -> p.include('*.gsp') })
+ it.applicationViews.from(
+ project.fileTree(appViews)
+ .matching { PatternFilterable p -> p.include('**/*.gsp') })
+ it.outputDirectory.set(project.layout.buildDirectory.dir('generated/scaffolded-views'))
+ }
+
+ tasks.register('stageGroovyPages', Sync) { Sync it ->
+ it.description = 'Collects the application and scaffolded views for GSP compilation'
+ it.into(stagedViews)
+ it.from(appViews)
+ it.from(generateScaffoldedViews)
+ // the application's own page wins, matching how the view resolvers are ordered
+ it.duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+ }
+ viewsToCompile = stagedViews.get()
+ }
+
def compileGroovyPages = tasks.register('compileGroovyPages', GroovyPageForkCompileTask) {
it.destinationDirectory.set(destDir)
it.tmpDirPath = getTmpDirPath(project)
- it.source = project.layout.projectDirectory.dir('grails-app/views')
+ // the setter takes a directory rather than a provider: it has to set both srcDir
+ // and the SourceTask inputs, and setting srcDir alone compiles nothing
+ it.source = viewsToCompile
it.serverpath.set('/WEB-INF/grails-app/views/')
it.classpath = allClasspath
it.javaLauncher.convention(launcher)
+ if (scaffolds) {
+ it.dependsOn(tasks.named('stageGroovyPages'))
+ }
}
def compileWebappGroovyPages = tasks.register('compileWebappGroovyPages', GroovyPageForkCompileTask) {
@@ -145,6 +205,46 @@ class GroovyPagePlugin implements Plugin {
}
}
+ /**
+ * Whether any controller in this project is scaffolded, which decides whether the views are
+ * staged before they are compiled. The answer is needed while the build is being configured,
+ * before anything has been compiled, so it is read from the sources.
+ *
+ *
Read through a {@link ValueSource} rather than by opening the files here. Gradle re-runs a
+ * value source on every build and invalidates the configuration cache when its answer changes,
+ * so a controller that becomes scaffolded rebuilds the graph that generates its views. Read
+ * directly, the answer would be an undeclared input: settled once, cached, and wrong from then
+ * on.
+ *
+ *
The match is deliberately loose. {@link GenerateScaffoldedViewsTask} reads the real
+ * annotation from the compiled class, so a false positive here costs a staging copy and a
+ * generation task that writes nothing -- while a false negative costs a view that is missing
+ * from the artifact, found by whoever opens that page.
+ */
+ protected Provider scaffoldsAnyController(Project project) {
+ project.providers.of(ScaffoldedControllers) { ValueSourceSpec spec ->
+ spec.parameters.controllers.from(
+ project.fileTree(project.layout.projectDirectory.dir('grails-app/controllers'))
+ .matching { PatternFilterable p -> p.include('**/*.groovy') })
+ }
+ }
+
+ /** Reads the controller sources for the mark of a scaffolded one, as a tracked build input. */
+ abstract static class ScaffoldedControllers implements ValueSource {
+
+ interface Parameters extends ValueSourceParameters {
+
+ ConfigurableFileCollection getControllers()
+
+ }
+
+ @Override
+ Boolean obtain() {
+ parameters.controllers.files.any { File controller -> controller.text.contains('@Scaffold') }
+ }
+
+ }
+
protected FileCollection resolveClassesDirs(SourceSetOutput output, Project project) {
output?.classesDirs ?: project.files(project.layout.buildDirectory.dir('classes/main'))
}
diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/GenerateNativeMetadataTaskSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/GenerateNativeMetadataTaskSpec.groovy
new file mode 100644
index 00000000000..6a26597ecd9
--- /dev/null
+++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/GenerateNativeMetadataTaskSpec.groovy
@@ -0,0 +1,182 @@
+/*
+ * 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.gradle.plugin.aot
+
+import java.util.jar.JarEntry
+import java.util.jar.JarOutputStream
+
+import groovy.json.JsonSlurper
+import spock.lang.Specification
+import spock.lang.TempDir
+
+import org.gradle.api.Project
+import org.gradle.testfixtures.ProjectBuilder
+
+class GenerateNativeMetadataTaskSpec extends Specification {
+
+ @TempDir
+ File projectDir
+
+ private File classesDir
+ private File pageClassesDir
+
+ void setup() {
+ classesDir = new File(projectDir, 'classes')
+ pageClassesDir = new File(projectDir, 'gsp-classes')
+ classesDir.mkdirs()
+ pageClassesDir.mkdirs()
+ }
+
+ private void writeClass(File root, String path) {
+ File target = new File(root, path + '.class')
+ target.parentFile.mkdirs()
+ target.bytes = new byte[]{ (byte) 0xCA, (byte) 0xFE, (byte) 0xBA, (byte) 0xBE }
+ }
+
+ /** Writes the manifest the GSP compiler produces, mapping a view to the class it compiled to. */
+ private void writeViewsManifest(File root, Map views) {
+ File manifest = new File(root, GenerateNativeMetadataTask.VIEWS_MANIFEST)
+ manifest.parentFile.mkdirs()
+ Properties properties = new Properties()
+ views.each { String uri, String pageClass -> properties.setProperty(uri, pageClass) }
+ manifest.withOutputStream { properties.store(it, null) }
+ }
+
+ /** A jar shaped like the one a plugin publishes, carrying its own pages. */
+ private File writePluginJar(String name, Map views) {
+ File jar = new File(projectDir, name)
+ Properties properties = new Properties()
+ views.each { String uri, String pageClass -> properties.setProperty(uri, pageClass) }
+ new JarOutputStream(jar.newOutputStream()).withCloseable { JarOutputStream out ->
+ out.putNextEntry(new JarEntry(GenerateNativeMetadataTask.VIEWS_MANIFEST))
+ properties.store(out, null)
+ out.closeEntry()
+ views.values().each { String pageClass ->
+ out.putNextEntry(new JarEntry(pageClass + '.class'))
+ out.write([0xCA, 0xFE, 0xBA, 0xBE] as byte[])
+ out.closeEntry()
+ }
+ }
+ jar
+ }
+
+ private GenerateNativeMetadataTask task(List classpath = []) {
+ Project project = ProjectBuilder.builder().withProjectDir(projectDir).build()
+ project.tasks.register('generateNativeMetadata', GenerateNativeMetadataTask) {
+ GenerateNativeMetadataTask it ->
+ it.classesDirs.from(classesDir)
+ it.pageClassesDirs.from(pageClassesDir)
+ it.pageClasspath.from(classpath)
+ it.outputDirectory.set(new File(projectDir, 'out'))
+ }
+ project.tasks.named('generateNativeMetadata', GenerateNativeMetadataTask).get()
+ }
+
+ private List recordedTypes(GenerateNativeMetadataTask task) {
+ task.generate()
+ File file = new File(task.outputDirectory.get().asFile, GenerateNativeMetadataTask.METADATA_PATH)
+ file.exists() ? new JsonSlurper().parse(file).reflection.collect { it.type } : []
+ }
+
+ void 'the application classes are recorded'() {
+ given:
+ writeClass(classesDir, 'com/example/UserController')
+ writeClass(classesDir, 'com/example/User')
+
+ expect:
+ recordedTypes(task()).containsAll(['com.example.UserController', 'com.example.User'])
+ }
+
+ void 'the closures an artefact declares are recorded'() {
+ given: 'Groovy reads doCall reflectively to pick an overload, so these are reached too'
+ writeClass(classesDir, 'com/example/BootStrap$_closure1')
+
+ expect:
+ recordedTypes(task()).contains('com.example.BootStrap$_closure1')
+ }
+
+ void 'a page is recorded under the class its view compiled to'() {
+ given:
+ writeViewsManifest(pageClassesDir, ['/WEB-INF/grails-app/views/index.gsp': 'gsp_app_index_gsp'])
+ writeClass(pageClassesDir, 'gsp_app_index_gsp')
+
+ expect:
+ recordedTypes(task()).contains('gsp_app_index_gsp')
+ }
+
+ void 'the closures a page declares are recorded'() {
+ given:
+ writeViewsManifest(pageClassesDir, ['/WEB-INF/grails-app/views/index.gsp': 'gsp_app_index_gsp'])
+ writeClass(pageClassesDir, 'gsp_app_index_gsp')
+ writeClass(pageClassesDir, 'gsp_app_index_gsp$_run_closure1')
+
+ expect:
+ recordedTypes(task()).contains('gsp_app_index_gsp$_run_closure1')
+ }
+
+ void 'a page class left behind by an earlier build is not recorded'() {
+ given: 'the manifest is the record of this build, the directory is not'
+ writeViewsManifest(pageClassesDir, ['/WEB-INF/grails-app/views/index.gsp': 'gsp_app_index_gsp'])
+ writeClass(pageClassesDir, 'gsp_app_index_gsp')
+ writeClass(pageClassesDir, 'gsp_app_removed_gsp')
+
+ expect:
+ !recordedTypes(task()).contains('gsp_app_removed_gsp')
+ }
+
+ void 'the pages a plugin contributes are recorded'() {
+ given: 'an application renders these as readily as its own, and they are not in its build'
+ File pluginJar = writePluginJar('fields-plugin.jar',
+ ['/WEB-INF/grails-app/views/_fields/default/_field.gsp': 'gsp_fields_field_gsp'])
+
+ expect:
+ recordedTypes(task([pluginJar])).contains('gsp_fields_field_gsp')
+ }
+
+ void 'an artifact carrying no pages is passed over'() {
+ given:
+ File plainJar = new File(projectDir, 'plain.jar')
+ new JarOutputStream(plainJar.newOutputStream()).withCloseable { JarOutputStream out ->
+ out.putNextEntry(new JarEntry('com/example/Plain.class'))
+ out.write([0xCA, 0xFE, 0xBA, 0xBE] as byte[])
+ out.closeEntry()
+ }
+ writeClass(classesDir, 'com/example/UserController')
+
+ when:
+ def types = recordedTypes(task([plainJar]))
+
+ then:
+ noExceptionThrown()
+ types == ['com.example.UserController']
+ }
+
+ void 'every recorded type is registered for the access Grails makes of it'() {
+ given:
+ writeClass(classesDir, 'com/example/UserController')
+
+ when:
+ def task = task()
+ task.generate()
+ def entries = new JsonSlurper()
+ .parse(new File(task.outputDirectory.get().asFile, GenerateNativeMetadataTask.METADATA_PATH))
+ .reflection
+
+ then: 'an action is invoked by name, and a domain class has its properties read'
+ entries.every { it.allDeclaredMethods && it.allDeclaredFields && it.allDeclaredConstructors }
+ }
+}
diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/TraceNativeMetadataTaskSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/TraceNativeMetadataTaskSpec.groovy
new file mode 100644
index 00000000000..bcda3058af7
--- /dev/null
+++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/TraceNativeMetadataTaskSpec.groovy
@@ -0,0 +1,345 @@
+/*
+ * 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.gradle.plugin.aot
+
+import java.net.CookieHandler
+import java.net.CookieManager
+
+import org.gradle.api.GradleException
+import org.gradle.api.Project
+import org.gradle.testfixtures.ProjectBuilder
+import spock.lang.Specification
+import spock.lang.TempDir
+
+/**
+ * Covers the run that records what an image will need.
+ *
+ *
The agent itself is GraalVM's and is not exercised here: what is worth covering is what the
+ * task asks the application for, because the metadata is only ever as complete as that.
Launched through a script standing in for GraalVM's java: the agent is asked for on the
+ * command line, and a JDK without one stops at load -- which is the thing the task refuses in
+ * advance, and is covered separately below.
+ */
+ private TraceNativeMetadataTask task(List paths, List forms, String behaviour = 'ok') {
+ File application = new File(temporaryFolder, 'application')
+ application.mkdirs()
+ File archive = new File(application, 'traced.jar')
+ archive.text = 'stands in for the archive'
+ requestedFile = new File(temporaryFolder, 'requested.txt')
+ requestedFile.text = ''
+ metadata = new File(temporaryFolder, 'metadata')
+
+ // A java with the agent beside it, which is what the task looks for before it starts.
+ File home = new File(temporaryFolder, 'graalvm')
+ new File(home, 'bin').mkdirs()
+ new File(home, 'lib').mkdirs()
+ new File(home, 'lib/libnative-image-agent.dylib').text = 'stands in for the agent'
+ File script = new File(home, 'bin/java')
+ script.text = """#!/bin/sh
+port=""
+for arg in "\$@"; do
+ case "\$arg" in
+ --server.port=*) port="\${arg#--server.port=}" ;;
+ esac
+done
+exec '${new File(System.getProperty('java.home'), 'bin/java').absolutePath}' \\
+ -cp '${System.getProperty('java.class.path')}' \\
+ ${TracedRunFixture.name} "\$port" '${requestedFile.absolutePath}' ${behaviour}
+"""
+ script.setExecutable(true)
+
+ TraceNativeMetadataTask task = project.tasks.create("trace${paths.size()}${forms.size()}${behaviour}".replaceAll('-', ''), TraceNativeMetadataTask)
+ task.archiveFile.set(archive)
+ task.javaExecutable.set(script.absolutePath)
+ task.jvmArguments.set([])
+ task.paths.set(paths)
+ task.forms.set(forms)
+ task.port.set(18077)
+ task.startTimeoutSeconds.set(60)
+ task.outputDirectory.set(metadata)
+ task
+ }
+
+ void 'every path named is asked for'() {
+ given:
+ TraceNativeMetadataTask task = task(['/', '/login'], [])
+
+ when:
+ task.trace()
+
+ then: 'the agent records what ran, so what is not asked for is not in the image'
+ requested.contains('GET /')
+ requested.contains('GET /login')
+ }
+
+ void 'a form is submitted with the fields it declares'() {
+ given:
+ TraceNativeMetadataTask task = task([], ['/create'])
+
+ when:
+ task.trace()
+
+ then: 'to the action the form names, rather than to the page it was read from'
+ String posted = requested.find { it.startsWith('POST /save') }
+ posted != null
+
+ and: 'carrying the value a field already had, which is how a security token is carried'
+ posted.contains('_csrf=a-token')
+
+ and: 'and what a ticked checkbox sends -- the field whose absence means nothing is converted'
+ posted.contains('published=on')
+
+ and: 'every other field the form declares'
+ posted.contains('title=')
+ posted.contains('secret=')
+
+ and: 'but not the button that submits it'
+ !posted.contains('go=')
+ }
+
+ void 'a form can be told what to put in it'() {
+ given: 'a form that authenticates is only worth submitting with credentials that work'
+ TraceNativeMetadataTask task = task([], ['/create?title=a-known-title'])
+
+ when:
+ task.trace()
+
+ then:
+ String posted = requested.find { it.startsWith('POST /save') }
+ posted.contains('title=a-known-title')
+
+ and: 'while the rest of the form is still filled in'
+ posted.contains('_csrf=a-token')
+ posted.contains('published=on')
+ }
+
+ void 'a value for a field the form does not have is not sent'() {
+ given:
+ TraceNativeMetadataTask task = task([], ['/create?nosuchfield=x'])
+
+ when:
+ task.trace()
+
+ then: 'rather than posting something the application will ignore and calling it covered'
+ String posted = requested.find { it.startsWith('POST /save') }
+ !posted.contains('nosuchfield')
+ }
+
+ void 'the form with the most fields is the one submitted'() {
+ given: 'a page carrying a layout search form ahead of its own'
+ TraceNativeMetadataTask task = task([], ['/create'])
+
+ when:
+ task.trace()
+
+ then: 'taking the first would report a submission having recorded none of the binding'
+ requested.any { it.startsWith('POST /save') }
+ !requested.any { it.startsWith('POST /search') }
+ }
+
+ void 'a form can be named where the wanted one is not the fullest'() {
+ given:
+ TraceNativeMetadataTask task = task([], ['/create#searchForm'])
+
+ when:
+ task.trace()
+
+ then:
+ requested.any { it.startsWith('POST /search') }
+ !requested.any { it.startsWith('POST /save') }
+ }
+
+ void 'naming a form that is not on the page fails the trace'() {
+ given: 'rather than falling back to another and reporting that one as covered'
+ TraceNativeMetadataTask task = task([], ['/create#nosuchform'])
+
+ when:
+ task.trace()
+
+ then:
+ GradleException e = thrown()
+ e.message.contains("no form with id 'nosuchform'")
+ }
+
+ void 'a page behind a login is reached because the form is submitted first'() {
+ given: 'the form listed after the path it makes reachable, to show the order is not the list'
+ TraceNativeMetadataTask task = task(['/protected'], ['/login?username=admin&password=s3cret'])
+
+ when:
+ task.trace()
+
+ then: 'the credentials given reach the application'
+ requested.any { it.startsWith('POST /authenticate') && it.contains('username=admin') }
+
+ and: 'and the protected page answers with itself rather than with the login page'
+ requested.any { it == 'GET /protected' }
+ !requested.any { it == 'GET /login' && requested.indexOf(it) > requested.findIndexOf { it.startsWith('POST /authenticate') } }
+ }
+
+ void 'a page answered from somewhere else is reported rather than counted'() {
+ given: 'asked for without the login form that makes it reachable'
+ TraceNativeMetadataTask task = task(['/protected'], [])
+
+ when:
+ task.trace()
+
+ then: 'the redirect is followed, so what the agent recorded is the login page'
+ requested.any { it == 'GET /login' }
+ }
+
+ void 'a form page answered from the login page does not count as tracing the form'() {
+ given: 'only the protected form listed, so nothing has made it reachable'
+ TraceNativeMetadataTask task = task([], ['/protected-form'])
+
+ when:
+ task.trace()
+
+ then: 'the page it lands on has a form of its own, so every check after the redirect passes'
+ GradleException e = thrown()
+ e.message.contains('/protected-form')
+ e.message.contains('answered from /login')
+
+ and: 'and the login form it landed on was not submitted under the protected page\'s name'
+ !requested.any { it.startsWith('POST /authenticate') }
+
+ and: 'nor was the binding it was listed for ever exercised'
+ !requested.any { it.startsWith('POST /protected-save') }
+ }
+
+ void 'and is traced once the form that makes it reachable is listed'() {
+ given:
+ TraceNativeMetadataTask task = task([],
+ ['/login?username=admin&password=s3cret', '/protected-form'])
+
+ when:
+ task.trace()
+
+ then: 'the form on the page that was asked for, rather than the one on the way to it'
+ requested.any { it.startsWith('POST /protected-save') && it.contains('secretTitle=') }
+ }
+
+ void 'a form on a page that has none fails the trace'() {
+ given:
+ TraceNativeMetadataTask task = task([], ['/'])
+
+ when:
+ task.trace()
+
+ then: 'listed to be submitted and never submitted is a gap, not a success'
+ GradleException e = thrown()
+ e.message.contains('no form on the page')
+ }
+
+ void 'a path that answers with an error fails the trace'() {
+ given: 'because what would be recorded is the error page rather than the page'
+ TraceNativeMetadataTask task = task(['/broken'], [])
+
+ when:
+ task.trace()
+
+ then:
+ GradleException e = thrown()
+ e.message.contains('did not answer with the page')
+ e.message.contains('/broken')
+ }
+
+ void 'a form that fails to submit fails the trace'() {
+ given:
+ TraceNativeMetadataTask task = task([], ['/create'], 'save-fails')
+
+ when:
+ task.trace()
+
+ then: 'a save that 500s during tracing records the failure and nothing of the save'
+ GradleException e = thrown()
+ e.message.contains('did not answer with the page')
+ }
+
+ void 'the cookie handler the build had is put back after a trace'() {
+ given: 'a daemon outlives the build, so what this sets is handed to every task after it'
+ CookieHandler inherited = new CookieManager()
+ CookieHandler.default = inherited
+ TraceNativeMetadataTask task = task(['/'], [])
+
+ when:
+ task.trace()
+
+ then:
+ CookieHandler.default.is(inherited)
+
+ cleanup:
+ CookieHandler.default = null
+ }
+
+ void 'and after one that failed'() {
+ given:
+ CookieHandler inherited = new CookieManager()
+ CookieHandler.default = inherited
+ TraceNativeMetadataTask task = task(['/broken'], [])
+
+ when:
+ task.trace()
+
+ then:
+ thrown(GradleException)
+ CookieHandler.default.is(inherited)
+
+ cleanup:
+ CookieHandler.default = null
+ }
+
+ void 'a java without the agent is refused before the application is started'() {
+ given: 'the agent ships with GraalVM, so the JDK running the build is usually not one'
+ File plain = new File(temporaryFolder, 'plain-jdk/bin/java')
+ plain.parentFile.mkdirs()
+ plain.text = '#!/bin/sh\nexit 0\n'
+ plain.setExecutable(true)
+ TraceNativeMetadataTask task = task(['/'], [])
+ task.javaExecutable.set(plain.absolutePath)
+
+ when:
+ task.trace()
+
+ then: 'named, rather than left as a message about a library path from a JVM that would not load'
+ GradleException e = thrown()
+ e.message.contains('native-image-agent')
+ e.message.contains('GraalVM')
+
+ and: 'and nothing was run'
+ requested.isEmpty()
+ }
+}
diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/TracedRunFixture.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/TracedRunFixture.groovy
new file mode 100644
index 00000000000..ae9d71e43dc
--- /dev/null
+++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/TracedRunFixture.groovy
@@ -0,0 +1,138 @@
+/*
+ * 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.gradle.plugin.aot
+
+import java.util.concurrent.CountDownLatch
+
+import com.sun.net.httpserver.HttpExchange
+import com.sun.net.httpserver.HttpServer
+
+/**
+ * An application for the trace to record: it serves forms, guards a page behind a login, and writes
+ * down what it was asked for.
+ *
+ *
The form is the point. A page can be fetched by anything; only submitting one shows whether the
+ * fields a form declares are the fields that arrive.
+ *
+ *
Two of the shapes here are the ones a trace gets wrong rather than the ones it gets right: a
+ * page carrying a layout form ahead of its own, and a page that answers with a redirect to the login
+ * form until the login form has been submitted.
+ */
+class TracedRunFixture {
+
+ /** What a page carrying a layout form ahead of its own looks like. */
+ private static final String TWO_FORMS = '''
+
+ '''
+
+ private static final String LOGIN_FORM = '''
+ '''
+
+ /** A form only a signed-in visitor is shown, which is what makes reaching it worth checking. */
+ private static final String PROTECTED_FORM = '''
+ '''
+
+ static void main(String[] args) {
+ int port = Integer.parseInt(args[0])
+ File requested = new File(args[1])
+ String behaviour = args.length > 2 ? args[2] : 'ok'
+
+ HttpServer server = HttpServer.create(new InetSocketAddress('localhost', port), 0)
+ server.createContext('/') { HttpExchange exchange ->
+ String path = exchange.requestURI.path
+ String method = exchange.requestMethod
+ String body = method == 'POST' ? exchange.requestBody.getText('UTF-8') : ''
+ requested << "${method} ${path}${body ? ' ' + body : ''}\n"
+ boolean signedIn = exchange.requestHeaders.getFirst('Cookie')?.contains('session=in')
+
+ int status = 200
+ String response = 'served'
+ if (path == '/broken') {
+ status = 500
+ response = 'no'
+ }
+ else if (path == '/create') {
+ response = TWO_FORMS
+ }
+ else if (path == '/login') {
+ response = LOGIN_FORM
+ }
+ else if (path == '/authenticate') {
+ exchange.responseHeaders.add('Set-Cookie', 'session=in; Path=/')
+ status = 302
+ exchange.responseHeaders.add('Location', '/')
+ response = ''
+ }
+ else if (path == '/protected-form') {
+ // the shape that made a redirected form page count as traced: the page it lands on
+ // has a form of its own, so everything after the redirect succeeds
+ if (!signedIn) {
+ status = 302
+ exchange.responseHeaders.add('Location', '/login')
+ response = ''
+ }
+ else {
+ response = PROTECTED_FORM
+ }
+ }
+ else if (path == '/protected') {
+ // answered from the login page until the login form has been submitted, which is
+ // what makes asking for it before submitting that form record the wrong page
+ if (!signedIn) {
+ status = 302
+ exchange.responseHeaders.add('Location', '/login')
+ response = ''
+ }
+ else {
+ response = 'the protected page'
+ }
+ }
+ else if (path == '/save' && behaviour == 'save-fails') {
+ status = 500
+ response = 'no'
+ }
+
+ byte[] bytes = response.bytes
+ exchange.sendResponseHeaders(status, bytes.length ?: -1)
+ if (bytes.length) {
+ exchange.responseBody.withCloseable { it.write(bytes) }
+ }
+ else {
+ exchange.responseBody.close()
+ }
+ }
+ server.start()
+ println 'Started Application in 0.05 seconds'
+ System.out.flush()
+ new CountDownLatch(1).await()
+ }
+}
diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/TrainAotCacheTaskSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/TrainAotCacheTaskSpec.groovy
new file mode 100644
index 00000000000..35a68516fb6
--- /dev/null
+++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/TrainAotCacheTaskSpec.groovy
@@ -0,0 +1,337 @@
+/*
+ * 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.gradle.plugin.aot
+
+import org.gradle.api.GradleException
+import org.gradle.api.Project
+import org.gradle.testfixtures.ProjectBuilder
+import spock.lang.Specification
+import spock.lang.TempDir
+
+/**
+ * Covers the run that writes down what starting the application needs.
+ *
+ *
The cache is written as the training JVM exits, so a run that never started, or one that was
+ * killed, leaves nothing behind -- and a build that carried on regardless would ship an application
+ * with no cache and no indication that the speed it was built for is absent.
+ */
+class TrainAotCacheTaskSpec extends Specification {
+
+ @TempDir
+ File temporaryFolder
+
+ Project project = ProjectBuilder.builder().build()
+
+ /** Where the fixture writes the paths it was asked for, so the spec can read them back. */
+ private File requestedFile
+
+ List getRequested() {
+ requestedFile?.isFile() ? requestedFile.readLines().findAll { it } : []
+ }
+
+ private static Properties describedBy(TrainAotCacheTask task) {
+ Properties properties = new Properties()
+ task.metadataFile.get().asFile.withInputStream { properties.load(it) }
+ properties
+ }
+
+ private static String sha256Of(File file) {
+ java.security.MessageDigest.getInstance('SHA-256').digest(file.bytes).encodeHex().toString()
+ }
+
+ /**
+ * A task whose training run really starts, serves and stops.
+ *
+ *
Launched through a script standing in for the JVM: the run is given
+ * {@code -XX:AOTCacheOutput}, which a JDK before 25 refuses outright, and a test of this task
+ * should not also be a test of which JDK the build is running on.
+ */
+ private TrainAotCacheTask servingTask(List paths, boolean announcesItself = true) {
+ File application = new File(temporaryFolder, 'serving')
+ application.mkdirs()
+ new File(application, 'served.jar').text = 'stands in for the archive, and is what is digested'
+ requestedFile = new File(temporaryFolder, 'requested.txt')
+ requestedFile.text = ''
+
+ File script = new File(temporaryFolder, 'fake-jvm.sh')
+ script.text = """#!/bin/sh
+cache=""
+port=""
+for arg in "\$@"; do
+ case "\$arg" in
+ -XX:AOTCacheOutput=*) cache="\${arg#-XX:AOTCacheOutput=}" ;;
+ --server.port=*) port="\${arg#--server.port=}" ;;
+ esac
+done
+exec '${new File(System.getProperty('java.home'), 'bin/java').absolutePath}' \\
+ -cp '${System.getProperty('java.class.path')}' \\
+ ${TrainingRunFixture.name} "\$cache" "\$port" '${requestedFile.absolutePath}' ${announcesItself ? 'loud' : 'quiet'}
+"""
+ script.setExecutable(true)
+
+ TrainAotCacheTask task = project.tasks.create('trainServing', TrainAotCacheTask)
+ task.applicationDirectory.set(application)
+ task.archiveFileName.set('served.jar')
+ task.cacheFile.set(new File(application, 'demo.aot'))
+ task.metadataFile.set(new File(application, 'aot-cache.properties'))
+ task.javaExecutable.set(script.absolutePath)
+ task.javaVersion.set('25.0.1+9')
+ task.javaVendor.set('A Vendor')
+ task.jvmArguments.set(['-Dspring.aot.enabled=true'])
+ task.paths.set(paths)
+ task.port.set(18098)
+ task.startTimeoutSeconds.set(60)
+ task
+ }
+
+ private TrainAotCacheTask task(String archiveName, List arguments) {
+ File application = new File(temporaryFolder, 'application')
+ application.mkdirs()
+ new File(application, archiveName).text = 'not a real archive'
+ TrainAotCacheTask task = project.tasks.create('trainAotCache', TrainAotCacheTask)
+ task.applicationDirectory.set(application)
+ task.archiveFileName.set(archiveName)
+ task.cacheFile.set(new File(application, 'demo.aot'))
+ task.metadataFile.set(new File(application, 'aot-cache.properties'))
+ task.javaExecutable.set(new File(System.getProperty('java.home'), 'bin/java').absolutePath)
+ task.javaVersion.set('25.0.1+9')
+ task.javaVendor.set('A Vendor')
+ task.jvmArguments.set(arguments)
+ task.paths.set([])
+ task.port.set(18099)
+ task.startTimeoutSeconds.set(5)
+ task
+ }
+
+ void 'a variable the daemon kept but emptied is not handed to the run'() {
+ given: 'a run that writes down the environment it was given, and ends'
+ File application = new File(temporaryFolder, 'environment')
+ application.mkdirs()
+ new File(application, 'reports.jar').text = 'stands in for the archive'
+ File seen = new File(temporaryFolder, 'seen-environment.txt')
+ File script = new File(temporaryFolder, 'reporting-jvm.sh')
+ script.text = """#!/bin/sh
+env > '${seen.absolutePath}'
+exit 1
+"""
+ script.setExecutable(true)
+ TrainAotCacheTask task = project.tasks.create('trainEnvironment', TrainAotCacheTask)
+ task.applicationDirectory.set(application)
+ task.archiveFileName.set('reports.jar')
+ task.cacheFile.set(new File(application, 'demo.aot'))
+ task.metadataFile.set(new File(application, 'aot-cache.properties'))
+ task.javaExecutable.set(script.absolutePath)
+ task.javaVersion.set('25.0.1+9')
+ task.javaVendor.set('A Vendor')
+ task.jvmArguments.set([])
+ task.paths.set([])
+ task.port.set(18096)
+ task.startTimeoutSeconds.set(5)
+
+ and: 'which this build gives the test worker, because an environment cannot be written'
+ assert System.getenv().containsKey('GRAILS_TRAINING_LEFTOVER')
+ assert !System.getenv('GRAILS_TRAINING_LEFTOVER')
+
+ when:
+ task.train()
+
+ then:
+ thrown(GradleException)
+
+ and: 'Spring Boot binds an environment variable over the application configuration, so an ' +
+ 'empty one left behind by a daemon replaces a configured value with nothing'
+ !seen.readLines().any { String line -> line.startsWith('GRAILS_TRAINING_LEFTOVER=') }
+
+ and: 'while the environment the build was actually given still arrives'
+ seen.readLines().any { String line -> line.startsWith('PATH=') }
+ }
+
+ void 'a run that never starts serving fails the build'() {
+ given: 'an archive that is not one, so the run ends immediately'
+ TrainAotCacheTask task = task('not-an-archive.jar', [])
+
+ when:
+ task.train()
+
+ then: 'rather than leaving a build to carry on and ship an application with no cache'
+ GradleException e = thrown()
+ e.message.contains('ended before it started serving')
+ }
+
+ void 'what the run printed is where the failure says it is'() {
+ given:
+ TrainAotCacheTask task = task('not-an-archive.jar', [])
+
+ when:
+ task.train()
+
+ then:
+ GradleException e = thrown()
+ new File(e.message.replaceAll(/(?s).*is in /, '').trim()).isFile()
+ }
+
+ void 'a run that could not start is reported with what it said was wrong'() {
+ given: 'a run that prints why it could not start, the way Spring Boot does, and ends'
+ File application = new File(temporaryFolder, 'failing')
+ application.mkdirs()
+ new File(application, 'failing.jar').text = 'stands in for the archive'
+ File script = new File(temporaryFolder, 'failing-jvm.sh')
+ script.text = '''#!/bin/sh
+echo "***************************"
+echo "APPLICATION FAILED TO START"
+echo "***************************"
+echo ""
+echo "Description:"
+echo ""
+echo "Invalid value for configuration property grails.mongodb.url, originating from System Environment Property GRAILS_MONGODB_URL"
+exit 1
+'''
+ script.setExecutable(true)
+ TrainAotCacheTask task = project.tasks.create('trainFailing', TrainAotCacheTask)
+ task.applicationDirectory.set(application)
+ task.archiveFileName.set('failing.jar')
+ task.cacheFile.set(new File(application, 'demo.aot'))
+ task.metadataFile.set(new File(application, 'aot-cache.properties'))
+ task.javaExecutable.set(script.absolutePath)
+ task.javaVersion.set('25.0.1+9')
+ task.javaVendor.set('A Vendor')
+ task.jvmArguments.set([])
+ task.paths.set([])
+ task.port.set(18097)
+ task.startTimeoutSeconds.set(5)
+
+ when:
+ task.train()
+
+ then: 'the reason is in the failure, rather than only a file to go and read'
+ GradleException e = thrown()
+ e.message.contains('ended before it started serving')
+ e.message.contains('grails.mongodb.url')
+ e.message.contains('GRAILS_MONGODB_URL')
+
+ and: 'without the box it was printed in'
+ !e.message.contains('****')
+ }
+
+ void 'a run that starts is exercised and described'() {
+ given: 'a run that says it started, serves what it is asked for, and stops when asked'
+ TrainAotCacheTask task = servingTask(['/', '/login', '/missing'])
+
+ when:
+ task.train()
+
+ then: 'the cache the run left behind is described by what it was made from'
+ Properties described = describedBy(task)
+ described.'application.archive' == 'served.jar'
+ described.'application.sha256' == sha256Of(new File(task.applicationDirectory.get().asFile, 'served.jar'))
+ described.'training.paths' == '/ /login /missing'
+ described.'training.arguments' == '-Dspring.aot.enabled=true'
+ described.'java.runtime.version' == '25.0.1+9'
+ described.'java.vendor' == 'A Vendor'
+
+ and: 'every path was asked for, including the one that answered with an error'
+ requested == ['/', '/login', '/missing']
+ }
+
+ void 'a path that is not a URI is skipped rather than failing the build'() {
+ given: 'a leading slash left off, which makes a URI with no valid authority'
+ TrainAotCacheTask task = servingTask(['login', '/'])
+
+ when:
+ task.train()
+
+ then: 'a typo in a list that exists to make the next start quicker does not fail a build'
+ noExceptionThrown()
+
+ and: 'the paths that are URIs were still asked for'
+ requested == ['/']
+ }
+
+ void 'a run that says nothing is found by its port'() {
+ given: 'an application that has turned off the log line Spring Boot starts with'
+ TrainAotCacheTask task = servingTask(['/'], false)
+
+ when:
+ task.train()
+
+ then: 'a message an application may reword or silence is not the only way to know it is up'
+ noExceptionThrown()
+ requested == ['/']
+ }
+
+ void 'a run is refused where it could not be asked to stop'() {
+ given: 'Windows, where a child process can only be killed, and a killed run writes no cache'
+ String os = System.getProperty('os.name')
+ System.setProperty('os.name', 'Windows 11')
+ TrainAotCacheTask task = task('not-an-archive.jar', [])
+
+ when:
+ task.train()
+
+ then: 'said before the run rather than after it, as "no cache was written"'
+ GradleException e = thrown()
+ e.message.contains('can only be killed')
+
+ cleanup:
+ System.setProperty('os.name', os)
+ }
+
+ void 'a run is refused on a JDK that has no cache to write'() {
+ given: 'the toolchain the training would run on, which is not the one running the build'
+ TrainAotCacheTask task = task('not-an-archive.jar', [])
+ task.javaVersion.set('21.0.12+10')
+
+ when:
+ task.train()
+
+ then: 'said as the wrong JDK, rather than as an application that ended before it served'
+ GradleException e = thrown()
+ e.message.contains('Java 25 or later')
+ e.message.contains('21.0.12+10')
+ }
+
+ void 'a JDK that can write one is not refused'() {
+ given:
+ TrainAotCacheTask task = task('not-an-archive.jar', [])
+ task.javaVersion.set(version)
+
+ when:
+ task.train()
+
+ then: 'it gets as far as running, which is where this spec stops caring'
+ GradleException e = thrown()
+ !e.message.contains('Java 25 or later')
+
+ where:
+ version << ['25.0.1+9', '26', '31.0.2+7']
+ }
+
+ void 'a version that cannot be read is left to the run to answer for'() {
+ given: 'refusing over an unreadable version would fail a build on a capable JDK'
+ TrainAotCacheTask task = task('not-an-archive.jar', [])
+ task.javaVersion.set('a vendor string nobody parsed')
+
+ when:
+ task.train()
+
+ then:
+ GradleException e = thrown()
+ !e.message.contains('Java 25 or later')
+ }
+}
diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/TrainingRunFixture.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/TrainingRunFixture.groovy
new file mode 100644
index 00000000000..89e7af2a8bb
--- /dev/null
+++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/TrainingRunFixture.groovy
@@ -0,0 +1,66 @@
+/*
+ * 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.gradle.plugin.aot
+
+import java.util.concurrent.CountDownLatch
+
+import com.sun.net.httpserver.HttpExchange
+import com.sun.net.httpserver.HttpServer
+
+/**
+ * Stands in for the application a training run starts, so the run can be exercised for real.
+ *
+ *
It does the three things {@link TrainAotCacheTask} depends on and nothing else: it says it
+ * started the way Spring Boot says it, it answers on the paths it is asked for and writes down
+ * which, and it writes its cache while shutting down rather than when told to stop -- which is what
+ * makes a killed run leave nothing behind.
+ *
+ *
Run by the script the spec writes, not by {@code java -jar}: a real JVM would have to
+ * understand {@code -XX:AOTCacheOutput}, which ties the test to the JDK the build happens to run
+ * on.
+ */
+class TrainingRunFixture {
+
+ static void main(String[] args) {
+ File cache = new File(args[0])
+ int port = Integer.parseInt(args[1])
+ File requested = new File(args[2])
+
+ HttpServer server = HttpServer.create(new InetSocketAddress('localhost', port), 0)
+ server.createContext('/') { HttpExchange exchange ->
+ requested << "${exchange.requestURI.path}\n"
+ byte[] body = 'served'.bytes
+ int status = exchange.requestURI.path == '/missing' ? 404 : 200
+ exchange.sendResponseHeaders(status, body.length)
+ exchange.responseBody.withCloseable { it.write(body) }
+ }
+ server.start()
+
+ Runtime.runtime.addShutdownHook(new Thread({
+ cache.text = 'a cache is what a run leaves behind as it goes'
+ }))
+
+ if (args.length < 4 || args[3] != 'quiet') {
+ println 'Started Application in 0.05 seconds'
+ System.out.flush()
+ }
+
+ new CountDownLatch(1).await()
+ }
+}
diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/AssetClasspathPackagingSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/AssetClasspathPackagingSpec.groovy
new file mode 100644
index 00000000000..fc1ca20b2d1
--- /dev/null
+++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/AssetClasspathPackagingSpec.groovy
@@ -0,0 +1,47 @@
+/*
+ * 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.gradle.plugin.core
+
+import org.gradle.testkit.runner.BuildResult
+
+/**
+ * Covers compiled assets being readable from an executable jar.
+ *
+ *
The asset pipeline packages them at the root of the archive, which is where a war serves its
+ * web content from. An executable jar has no web content: assets are read off the classpath, and
+ * its classpath is {@code BOOT-INF/classes}. The same bytes at the same place, in a jar rather than
+ * a war, are packaged but unreachable -- the page renders and every asset on it is a 404.
+ */
+class AssetClasspathPackagingSpec extends GradleSpecification {
+
+ void 'compiled assets are packaged where an executable jar reads its classpath'() {
+ given:
+ setupTestResourceProject('asset-classpath-packaging')
+
+ when:
+ BuildResult result = executeTask('inspectAssetPackaging')
+
+ then:
+ result.output.contains('ON_CLASSPATH=true')
+ result.output.contains('ON_CLASSPATH_SVG=true')
+
+ and: 'every compiled asset, not a sample of them'
+ result.output.contains('ASSET_COUNT=2')
+ }
+}
diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/GrailsNativeImageDefaultsSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/GrailsNativeImageDefaultsSpec.groovy
new file mode 100644
index 00000000000..e95110ef648
--- /dev/null
+++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/GrailsNativeImageDefaultsSpec.groovy
@@ -0,0 +1,72 @@
+/*
+ * 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.gradle.plugin.core
+
+import org.gradle.testkit.runner.BuildResult
+
+/**
+ * Covers what an application gets for building a native image, and what it does not get for not
+ * building one.
+ *
+ *
These are reactions to the plugin the application applied, not a setting it has to find.
+ * Applying GraalVM's plugin is already how an application says it wants an image; nothing here is
+ * reached without it, so an application that has no use for any of it pays nothing and sees no
+ * change.
+ *
+ * @since 8.0
+ * @see GrailsGradlePlugin#configureNativeImage
+ */
+class GrailsNativeImageDefaultsSpec extends GradleSpecification {
+
+ void 'an application that builds no image is left as it was'() {
+ given:
+ setupTestResourceProject('native-defaults-off')
+
+ when:
+ BuildResult result = executeTask('inspectDefaults')
+
+ then: 'invokedynamic stays off, as it is for every application that did not ask for it'
+ result.output.contains('INDY=false')
+
+ and: 'and nothing an image would want has been added'
+ result.output.contains('HAS_NATIVE_EXTENSION=false')
+ }
+
+ void 'definitions are generated for the environment the application will run in'() {
+ given: 'the plugin that asks for generated definitions is what this reacts to'
+ setupTestResourceProject('native-defaults-aot')
+
+ when:
+ BuildResult result = executeTask('inspectAot')
+
+ then: 'development declares reloadable beans, which cannot be written out as code'
+ result.output.contains('GRAILS_ENV=production')
+ }
+
+ void 'an application that builds an image gets what an image needs'() {
+ given:
+ setupTestResourceProject('native-defaults-on')
+
+ when:
+ BuildResult result = executeTask('inspectDefaults')
+
+ then: 'a classic call site defines a class as it runs, which an image has no way to do'
+ result.output.contains('INDY=true')
+ }
+}
diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/scaffolding/GenerateScaffoldedViewsTaskSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/scaffolding/GenerateScaffoldedViewsTaskSpec.groovy
new file mode 100644
index 00000000000..33ffe0736c5
--- /dev/null
+++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/scaffolding/GenerateScaffoldedViewsTaskSpec.groovy
@@ -0,0 +1,205 @@
+/*
+ * 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.gradle.plugin.scaffolding
+
+import java.util.jar.JarEntry
+import java.util.jar.JarOutputStream
+
+import groovyjarjarasm.asm.AnnotationVisitor
+import groovyjarjarasm.asm.ClassWriter
+import groovyjarjarasm.asm.Opcodes
+import groovyjarjarasm.asm.Type
+import spock.lang.Specification
+import spock.lang.TempDir
+
+import org.gradle.api.Project
+import org.gradle.testfixtures.ProjectBuilder
+
+class GenerateScaffoldedViewsTaskSpec extends Specification {
+
+ @TempDir
+ File projectDir
+
+ private File classesDir
+ private File templateJar
+
+ void setup() {
+ classesDir = new File(projectDir, 'classes')
+ classesDir.mkdirs()
+ templateJar = new File(projectDir, 'templates.jar')
+ writeTemplateJar(templateJar, [
+ index : 'list of ${propertyName} for ${className}',
+ create: 'create ${className}',
+ edit : 'edit ${className}',
+ show : 'show ${className}'])
+ }
+
+ /** A jar shaped like the one the scaffolding plugin publishes. */
+ private void writeTemplateJar(File jar, Map templates) {
+ new JarOutputStream(jar.newOutputStream()).withCloseable { JarOutputStream out ->
+ templates.each { String name, String body ->
+ out.putNextEntry(new JarEntry("META-INF/templates/scaffolding/${name}.gsp"))
+ out.write(body.bytes)
+ out.closeEntry()
+ }
+ }
+ }
+
+ /**
+ * Writes a class carrying {@code @Scaffold}, so the task reads a real annotation rather than a
+ * stand-in for one.
+ */
+ private void writeController(String controllerName, String domainClassName) {
+ ClassWriter writer = new ClassWriter(0)
+ writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, "com/example/${controllerName}", null,
+ 'java/lang/Object', null)
+ AnnotationVisitor annotation = writer.visitAnnotation(
+ 'Lgrails/plugin/scaffolding/annotation/Scaffold;', true)
+ annotation.visit('value', Type.getObjectType("com/example/${domainClassName}"))
+ annotation.visitEnd()
+ writer.visitEnd()
+ File target = new File(classesDir, "com/example/${controllerName}.class")
+ target.parentFile.mkdirs()
+ target.bytes = writer.toByteArray()
+ }
+
+ private void writePlainController(String controllerName) {
+ ClassWriter writer = new ClassWriter(0)
+ writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, "com/example/${controllerName}", null,
+ 'java/lang/Object', null)
+ writer.visitEnd()
+ File target = new File(classesDir, "com/example/${controllerName}.class")
+ target.parentFile.mkdirs()
+ target.bytes = writer.toByteArray()
+ }
+
+ private GenerateScaffoldedViewsTask task(List overrides = [], List views = []) {
+ Project project = ProjectBuilder.builder().withProjectDir(projectDir).build()
+ project.tasks.register('generateScaffoldedViews', GenerateScaffoldedViewsTask) {
+ GenerateScaffoldedViewsTask it ->
+ it.classesDirs.from(classesDir)
+ it.templateClasspath.from(templateJar)
+ it.templateOverrides.from(overrides)
+ it.applicationViews.from(views)
+ it.outputDirectory.set(new File(projectDir, 'out'))
+ }
+ project.tasks.named('generateScaffoldedViews', GenerateScaffoldedViewsTask).get()
+ }
+
+ private File generated(GenerateScaffoldedViewsTask task, String path) {
+ new File(task.outputDirectory.get().asFile, path)
+ }
+
+ void 'a scaffolded controller gets the full set of views'() {
+ given:
+ writeController('UserController', 'User')
+ def task = task()
+
+ when:
+ task.generate()
+
+ then:
+ ['index', 'create', 'edit', 'show'].every { generated(task, "user/${it}.gsp").exists() }
+ }
+
+ void 'the naming the templates read is substituted'() {
+ given:
+ writeController('UserController', 'User')
+ def task = task()
+
+ when:
+ task.generate()
+
+ then:
+ generated(task, 'user/index.gsp').text == 'list of user for User'
+ }
+
+ void 'a controller without the annotation is left alone'() {
+ given:
+ writePlainController('PlainController')
+ def task = task()
+
+ when:
+ task.generate()
+
+ then:
+ !generated(task, 'plain').exists()
+ }
+
+ void 'the domain class named by the annotation drives the naming, not the controller'() {
+ given: 'a controller whose name does not match the domain it scaffolds'
+ writeController('AccountController', 'Person')
+ def task = task()
+
+ when:
+ task.generate()
+
+ then:
+ generated(task, 'account/index.gsp').text == 'list of person for Person'
+ }
+
+ void 'an application template overrides the one a plugin contributes'() {
+ given:
+ writeController('UserController', 'User')
+ File overrides = new File(projectDir, 'templates')
+ overrides.mkdirs()
+ File custom = new File(overrides, 'index.gsp')
+ custom.text = 'custom ${className}'
+ def task = task([custom])
+
+ when:
+ task.generate()
+
+ then:
+ generated(task, 'user/index.gsp').text == 'custom User'
+ }
+
+ void 'a view the application declares is not generated over'() {
+ given: 'the application writes its own index page'
+ writeController('UserController', 'User')
+ File views = new File(projectDir, 'grails-app/views/user')
+ views.mkdirs()
+ File declared = new File(views, 'index.gsp')
+ declared.text = 'hand written'
+ def task = task([], [declared])
+
+ when:
+ task.generate()
+
+ then: 'the runtime prefers the declared page, so generating one would only shadow it'
+ !generated(task, 'user/index.gsp').exists()
+
+ and: 'the views it does not declare are still generated'
+ generated(task, 'user/create.gsp').exists()
+ }
+
+ void 'a stale view from a previous run does not survive'() {
+ given:
+ writeController('UserController', 'User')
+ def task = task()
+ task.generate()
+ File stale = generated(task, 'gone/index.gsp')
+ stale.parentFile.mkdirs()
+ stale.text = 'stale'
+
+ when:
+ task.generate()
+
+ then:
+ !stale.exists()
+ }
+}
diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/GroovyPageToolchainSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/GroovyPageToolchainSpec.groovy
index e975429943e..c471523dfe4 100644
--- a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/GroovyPageToolchainSpec.groovy
+++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/GroovyPageToolchainSpec.groovy
@@ -98,7 +98,7 @@ class GroovyPageToolchainSpec extends Specification {
}
void 'the Java that did the compiling is part of what the result is'() {
- given: 'the task is cacheable, so an entry built by one Java must not be reused by another'
+ given: 'pages built by one Java must not be left standing when the build asks for another'
Project project = projectWithPages()
expect: 'declared as an input, which is what keeps the two apart'
diff --git a/grails-gradle/plugins/src/test/resources/test-projects/asset-classpath-packaging/build.gradle b/grails-gradle/plugins/src/test/resources/test-projects/asset-classpath-packaging/build.gradle
new file mode 100644
index 00000000000..d6c459512a7
--- /dev/null
+++ b/grails-gradle/plugins/src/test/resources/test-projects/asset-classpath-packaging/build.gradle
@@ -0,0 +1,42 @@
+// The asset pipeline plugin is not on the test classpath, and this does not need it: the wiring
+// under test reacts to the assetCompile task, so a stand-in that produces compiled assets the same
+// way exercises the same path.
+plugins {
+ id 'org.apache.grails.gradle.grails-web'
+}
+
+group = 'org.example.test'
+
+grails {
+ bom = null
+ cliAutoProvision = false
+}
+
+springBoot {
+ mainClass = 'org.example.test.Application'
+}
+
+def compiledAssets = layout.buildDirectory.dir('assets')
+
+tasks.register('assetCompile') {
+ outputs.dir(compiledAssets)
+ doLast {
+ def dir = compiledAssets.get().asFile
+ dir.mkdirs()
+ new File(dir, 'application-d41d8c.css').text = 'body { }'
+ new File(dir, 'grails-527fa9.svg').text = ''
+ }
+}
+
+tasks.register('inspectAssetPackaging') {
+ dependsOn 'bootJar'
+ doLast {
+ def archive = tasks.named('bootJar', Jar).get().archiveFile.get().asFile
+ def entries = new java.util.zip.ZipFile(archive).withCloseable { zip ->
+ zip.entries().findAll { !it.directory }.collect { it.name } as Set
+ }
+ println "ON_CLASSPATH=${entries.contains('BOOT-INF/classes/assets/application-d41d8c.css')}"
+ println "ON_CLASSPATH_SVG=${entries.contains('BOOT-INF/classes/assets/grails-527fa9.svg')}"
+ println "ASSET_COUNT=${entries.count { it.startsWith('BOOT-INF/classes/assets/') }}"
+ }
+}
diff --git a/grails-gradle/plugins/src/test/resources/test-projects/asset-classpath-packaging/gradle.properties b/grails-gradle/plugins/src/test/resources/test-projects/asset-classpath-packaging/gradle.properties
new file mode 100644
index 00000000000..52f4d78c7c2
--- /dev/null
+++ b/grails-gradle/plugins/src/test/resources/test-projects/asset-classpath-packaging/gradle.properties
@@ -0,0 +1,2 @@
+grailsVersion=__PROJECT_VERSION__
+org.gradle.jvmargs=-Xmx1g
diff --git a/grails-gradle/plugins/src/test/resources/test-projects/asset-classpath-packaging/settings.gradle b/grails-gradle/plugins/src/test/resources/test-projects/asset-classpath-packaging/settings.gradle
new file mode 100644
index 00000000000..922d6e337a1
--- /dev/null
+++ b/grails-gradle/plugins/src/test/resources/test-projects/asset-classpath-packaging/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'asset-classpath-packaging'
diff --git a/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-aot/build.gradle b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-aot/build.gradle
new file mode 100644
index 00000000000..f85514c8a29
--- /dev/null
+++ b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-aot/build.gradle
@@ -0,0 +1,10 @@
+plugins {
+ id 'org.apache.grails.gradle.grails-app'
+ id 'org.springframework.boot.aot'
+}
+
+tasks.register('inspectAot') {
+ doLast {
+ println "GRAILS_ENV=${tasks.named('processAot').get().systemProperties['grails.env']}"
+ }
+}
diff --git a/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-aot/gradle.properties b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-aot/gradle.properties
new file mode 100644
index 00000000000..35c332fb874
--- /dev/null
+++ b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-aot/gradle.properties
@@ -0,0 +1 @@
+grailsVersion=__PROJECT_VERSION__
diff --git a/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-aot/grails-app/conf/application.yml b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-aot/grails-app/conf/application.yml
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-aot/settings.gradle b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-aot/settings.gradle
new file mode 100644
index 00000000000..04f5f689a93
--- /dev/null
+++ b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-aot/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'native-defaults-aot'
diff --git a/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-off/build.gradle b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-off/build.gradle
new file mode 100644
index 00000000000..aa04269ab4e
--- /dev/null
+++ b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-off/build.gradle
@@ -0,0 +1,12 @@
+plugins {
+ id 'org.apache.grails.gradle.grails-app'
+}
+
+// An application that builds neither an image nor a cache applies neither plugin, and so should
+// find nothing has been decided on its behalf.
+tasks.register('inspectDefaults') {
+ doLast {
+ println "INDY=${project.extensions.getByName('grails').indy.get()}"
+ println "HAS_NATIVE_EXTENSION=${project.extensions.findByName('graalvmNative') != null}"
+ }
+}
diff --git a/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-off/gradle.properties b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-off/gradle.properties
new file mode 100644
index 00000000000..35c332fb874
--- /dev/null
+++ b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-off/gradle.properties
@@ -0,0 +1 @@
+grailsVersion=__PROJECT_VERSION__
diff --git a/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-off/grails-app/conf/application.yml b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-off/grails-app/conf/application.yml
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-off/settings.gradle b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-off/settings.gradle
new file mode 100644
index 00000000000..0a4b1dcc6ac
--- /dev/null
+++ b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-off/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'native-defaults-off'
diff --git a/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-on/build.gradle b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-on/build.gradle
new file mode 100644
index 00000000000..8c7467cf904
--- /dev/null
+++ b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-on/build.gradle
@@ -0,0 +1,10 @@
+plugins {
+ id 'org.apache.grails.gradle.grails-app'
+ id 'org.graalvm.buildtools.native' version '1.1.7'
+}
+
+tasks.register('inspectDefaults') {
+ doLast {
+ println "INDY=${project.extensions.getByName('grails').indy.get()}"
+ }
+}
diff --git a/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-on/gradle.properties b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-on/gradle.properties
new file mode 100644
index 00000000000..35c332fb874
--- /dev/null
+++ b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-on/gradle.properties
@@ -0,0 +1 @@
+grailsVersion=__PROJECT_VERSION__
diff --git a/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-on/grails-app/conf/application.yml b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-on/grails-app/conf/application.yml
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-on/settings.gradle b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-on/settings.gradle
new file mode 100644
index 00000000000..8012c888a5d
--- /dev/null
+++ b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-on/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'native-defaults-on'
diff --git a/grails-gsp/core/src/main/groovy/org/grails/gsp/aot/PrecompiledPageRuntimeHints.java b/grails-gsp/core/src/main/groovy/org/grails/gsp/aot/PrecompiledPageRuntimeHints.java
new file mode 100644
index 00000000000..111bdfc450d
--- /dev/null
+++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/aot/PrecompiledPageRuntimeHints.java
@@ -0,0 +1,52 @@
+/*
+ * 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.gsp.aot;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+
+import org.grails.gsp.GroovyPageMetaInfo;
+
+/**
+ * Registers what a page compiled at build time is read from.
+ *
+ *
Compiling a page splits it: the code becomes a class, and the static text between the code --
+ * most of the page -- is written beside it as a resource, along with the line numbers that map the
+ * generated code back to the page it came from. The class reads that resource as it renders.
+ *
+ *
An image carries a resource only when it has been asked to, and nothing asked for these: they
+ * are named by a convention rather than by any code. The page then rendered with nothing where its
+ * text should be, and reported a null the page itself could not explain.
+ *
+ * @since 8.0
+ */
+public class PrecompiledPageRuntimeHints implements RuntimeHintsRegistrar {
+
+ /** Where the pages compiled at build time are listed, read to find them at all. */
+ private static final String VIEWS = "gsp/views.properties";
+
+ @Override
+ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
+ hints.resources().registerPattern(VIEWS);
+ hints.resources().registerPattern("*" + GroovyPageMetaInfo.HTML_DATA_POSTFIX);
+ hints.resources().registerPattern("*" + GroovyPageMetaInfo.LINENUMBERS_DATA_POSTFIX);
+ }
+}
diff --git a/grails-gsp/core/src/main/groovy/org/grails/gsp/io/DefaultGroovyPageLocator.java b/grails-gsp/core/src/main/groovy/org/grails/gsp/io/DefaultGroovyPageLocator.java
index 04528d18247..c99b7d0431b 100644
--- a/grails-gsp/core/src/main/groovy/org/grails/gsp/io/DefaultGroovyPageLocator.java
+++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/io/DefaultGroovyPageLocator.java
@@ -34,6 +34,7 @@
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
+import org.springframework.aot.AotDetector;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
@@ -410,8 +411,30 @@ protected Resource findResource(List searchPaths) {
return foundResource;
}
+ /**
+ * Whether the pages compiled at build time should be used.
+ *
+ *
They are skipped in development so that editing a page takes effect without a restart, and
+ * that is decided by whether the application looks like a project on disk. An ahead-of-time
+ * image can look like one -- it is a single executable that may be run from anywhere, including
+ * the directory it was built in -- but it cannot compile a page at run time, because it cannot
+ * define a class at all. Reading the sources there renders nothing, so the compiled pages are
+ * used whatever the surroundings suggest.
+ */
private boolean isPrecompiledAvailable() {
- return precompiledGspMap != null && precompiledGspMap.size() > 0 && !Environment.isDevelopmentMode();
+ if (precompiledGspMap == null || precompiledGspMap.isEmpty()) {
+ return false;
+ }
+ return !isDevelopmentMode() || AotDetector.useGeneratedArtifacts();
+ }
+
+ /**
+ * Whether the application is being developed, which is decided by whether it looks like a project
+ * on disk. Overridable because that is derived from the working directory when the class is
+ * loaded, and so cannot be varied any other way.
+ */
+ protected boolean isDevelopmentMode() {
+ return Environment.isDevelopmentMode();
}
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
diff --git a/grails-gsp/core/src/main/resources/META-INF/spring/aot.factories b/grails-gsp/core/src/main/resources/META-INF/spring/aot.factories
new file mode 100644
index 00000000000..178ec340883
--- /dev/null
+++ b/grails-gsp/core/src/main/resources/META-INF/spring/aot.factories
@@ -0,0 +1,2 @@
+org.springframework.aot.hint.RuntimeHintsRegistrar=\
+org.grails.gsp.aot.PrecompiledPageRuntimeHints
diff --git a/grails-gsp/core/src/test/groovy/org/grails/gsp/aot/PrecompiledPageRuntimeHintsSpec.groovy b/grails-gsp/core/src/test/groovy/org/grails/gsp/aot/PrecompiledPageRuntimeHintsSpec.groovy
new file mode 100644
index 00000000000..9eea0dbe87b
--- /dev/null
+++ b/grails-gsp/core/src/test/groovy/org/grails/gsp/aot/PrecompiledPageRuntimeHintsSpec.groovy
@@ -0,0 +1,64 @@
+/*
+ * 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.gsp.aot
+
+import org.springframework.aot.hint.RuntimeHints
+import org.springframework.aot.hint.predicate.RuntimeHintsPredicates
+import spock.lang.Specification
+
+/**
+ * Covers what a page compiled at build time is read from being carried into an image.
+ *
+ *
Compiling a page splits it: the code becomes a class, and the static text between the code --
+ * most of the page -- is written beside it as a resource that the class reads as it renders. An
+ * image carries a resource only when asked, and these are named by convention rather than by any
+ * code, so nothing asked. Every page then rendered with nothing where its text should be.
+ */
+class PrecompiledPageRuntimeHintsSpec extends Specification {
+
+ RuntimeHints hints = new RuntimeHints()
+
+ void setup() {
+ new PrecompiledPageRuntimeHints().registerHints(hints, getClass().classLoader)
+ }
+
+ void 'the static text of a compiled page is carried'() {
+ expect: 'without it the page renders empty and reports a null it cannot explain'
+ RuntimeHintsPredicates.resource()
+ .forResource('gsp_demo_indexgsp_html.data')
+ .test(hints)
+ }
+
+ void 'the line numbers that map generated code back to the page are carried'() {
+ expect:
+ RuntimeHintsPredicates.resource()
+ .forResource('gsp_demo_indexgsp_linenumbers.data')
+ .test(hints)
+ }
+
+ void 'the list of the pages compiled at build time is carried'() {
+ expect: 'read to find the compiled pages at all'
+ RuntimeHintsPredicates.resource().forResource('gsp/views.properties').test(hints)
+ }
+
+ void 'an unrelated resource is not carried'() {
+ expect: 'the patterns name the compiled pages rather than everything'
+ !RuntimeHintsPredicates.resource().forResource('application.yml').test(hints)
+ }
+}
diff --git a/grails-gsp/core/src/test/groovy/org/grails/gsp/io/DefaultGroovyPageLocatorPrecompiledSpec.groovy b/grails-gsp/core/src/test/groovy/org/grails/gsp/io/DefaultGroovyPageLocatorPrecompiledSpec.groovy
new file mode 100644
index 00000000000..5c586afd413
--- /dev/null
+++ b/grails-gsp/core/src/test/groovy/org/grails/gsp/io/DefaultGroovyPageLocatorPrecompiledSpec.groovy
@@ -0,0 +1,125 @@
+/*
+ * 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.gsp.io
+
+import grails.util.Environment
+
+import org.springframework.aot.AotDetector
+import spock.lang.Specification
+
+/**
+ * Covers which pages are used when the application looks like a project on disk.
+ *
+ *
Development skips the pages compiled at build time so that editing one takes effect without a
+ * restart. An ahead-of-time image can look the same -- it is a single executable, and it may be run
+ * from the directory it was built in -- but it cannot compile a page at run time, so reading the
+ * sources renders nothing.
+ */
+class DefaultGroovyPageLocatorPrecompiledSpec extends Specification {
+
+ private static final String VIEW = '/views/index.gsp'
+
+ DefaultGroovyPageLocator locator = new DefaultGroovyPageLocator()
+
+ void setup() {
+ locator.setPrecompiledGspMap([(VIEW): CompiledPage.name])
+ }
+
+ private static final String AOT_KEY = 'spring.aot.enabled'
+
+ /** Restores the property, so the environment other specs observe is unchanged. */
+ private void withAot(boolean aot, Closure body) {
+ String previous = System.getProperty(AOT_KEY)
+ try {
+ aot ? System.setProperty(AOT_KEY, 'true') : System.clearProperty(AOT_KEY)
+ body.call()
+ }
+ finally {
+ previous == null ? System.clearProperty(AOT_KEY) : System.setProperty(AOT_KEY, previous)
+ }
+ }
+
+ /** A locator that believes it is looking at a project on disk, which the filesystem decides. */
+ private DefaultGroovyPageLocator developmentLocator() {
+ def developing = new DefaultGroovyPageLocator() {
+ @Override
+ protected boolean isDevelopmentMode() { true }
+ }
+ developing.setPrecompiledGspMap([(VIEW): CompiledPage.name])
+ developing
+ }
+
+ void 'a compiled page is used when the application is not a project on disk'() {
+ when:
+ def source = null
+ withAot(false) { source = locator.findPage(VIEW) }
+
+ then:
+ source instanceof GroovyPageCompiledScriptSource
+ }
+
+ void 'a compiled page is used in an ahead-of-time image even though it looks like development'() {
+ when: 'the executable is run from the directory it was built in'
+ def source = null
+ withAot(true) { source = developmentLocator().findPage(VIEW) }
+
+ then: 'reading the sources would render nothing, because no class can be defined'
+ source instanceof GroovyPageCompiledScriptSource
+ }
+
+ void 'development still prefers the sources so that an edit takes effect'() {
+ when:
+ def source = null
+ withAot(false) { source = developmentLocator().findPage(VIEW) }
+
+ then: 'no source file exists here, so nothing is found rather than the compiled page'
+ !(source instanceof GroovyPageCompiledScriptSource)
+ }
+
+ /**
+ * Stands in for a page the build compiled. The constants are the ones the compiler emits and the
+ * page's metadata is read from, so the locator can treat this like any other compiled page.
+ */
+ static class CompiledPage extends org.grails.gsp.GroovyPage {
+
+ public static final String CONTENT_TYPE = 'text/html;charset=UTF-8'
+
+ public static final Map JSP_TAGS = [:]
+
+ public static final Long LAST_MODIFIED = 0L
+
+ public static final String EXPRESSION_CODEC = 'html'
+
+ public static final String STATIC_CODEC = 'none'
+
+ public static final String OUT_CODEC = 'none'
+
+ public static final String TAGLIB_CODEC = 'none'
+
+ @Override
+ String getGroovyPageFileName() {
+ 'index.gsp'
+ }
+
+ @Override
+ Object run() {
+ null
+ }
+ }
+}
diff --git a/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolver.java b/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolver.java
index a537f3df971..7b6b38d50c2 100644
--- a/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolver.java
+++ b/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolver.java
@@ -37,7 +37,14 @@ public class GrailsSiteMeshViewResolver extends SiteMeshViewResolver {
private final ContentProcessor contentProcessor;
private final DecoratorSelector decoratorSelector;
- private final ServletContext servletContext;
+
+ public GrailsSiteMeshViewResolver(ViewResolver innerViewResolver,
+ ContentProcessor contentProcessor,
+ DecoratorSelector decoratorSelector) {
+ super(innerViewResolver, contentProcessor, decoratorSelector);
+ this.contentProcessor = contentProcessor;
+ this.decoratorSelector = decoratorSelector;
+ }
public GrailsSiteMeshViewResolver(ViewResolver innerViewResolver,
ContentProcessor contentProcessor,
@@ -46,7 +53,6 @@ public GrailsSiteMeshViewResolver(ViewResolver innerViewResolver,
super(innerViewResolver, contentProcessor, decoratorSelector, servletContext);
this.contentProcessor = contentProcessor;
this.decoratorSelector = decoratorSelector;
- this.servletContext = servletContext;
}
@Override
@@ -54,7 +60,7 @@ protected SiteMeshView createSiteMeshView(View innerView) {
// Forward-based JSP inner views are switched to include dispatch by
// SiteMeshViewResolver.prepareForBufferedRender (keyed on
// DispatchMode) before this hook runs.
- return new GrailsSiteMeshView(innerView, contentProcessor, decoratorSelector, servletContext,
+ return new GrailsSiteMeshView(innerView, contentProcessor, decoratorSelector, getServletContext(),
getInnerViewResolver());
}
}
diff --git a/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolverServletContextSpec.groovy b/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolverServletContextSpec.groovy
new file mode 100644
index 00000000000..3cc5b936503
--- /dev/null
+++ b/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolverServletContextSpec.groovy
@@ -0,0 +1,105 @@
+/*
+ * 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.sitemesh3
+
+import jakarta.servlet.ServletContext
+
+import org.sitemesh.DecoratorSelector
+import org.sitemesh.SiteMeshContext
+import org.sitemesh.content.ContentProcessor
+import org.sitemesh.content.tagrules.TagBasedContentProcessor
+import org.sitemesh.content.tagrules.html.CoreHtmlTagRuleBundle
+import org.springframework.beans.factory.support.GenericBeanDefinition
+import org.springframework.mock.web.MockServletContext
+import org.springframework.web.context.support.GenericWebApplicationContext
+import org.springframework.web.servlet.View
+import org.springframework.web.servlet.view.InternalResourceViewResolver
+import spock.lang.Specification
+
+/**
+ * Covers the resolver being built without a servlet context argument and receiving one from the
+ * container instead. A plain bean factory applies no {@code ServletContextAware} callback, so the
+ * path only exists in a real web application context.
+ */
+class GrailsSiteMeshViewResolverServletContextSpec extends Specification {
+
+ MockServletContext servletContext = new MockServletContext()
+ GenericWebApplicationContext context = new GenericWebApplicationContext(servletContext)
+
+ void setup() {
+ def target = new GenericBeanDefinition()
+ target.beanClass = InternalResourceViewResolver
+ context.registerBeanDefinition('jspViewResolver', target)
+
+ // definitions rather than singletons: the post-processor stands down unless the SiteMesh
+ // collaborators are present as bean definitions
+ context.registerBeanDefinition('contentProcessor',
+ new GenericBeanDefinition(beanClass: CaptureAwareContentProcessor))
+ def selector = new GenericBeanDefinition(beanClass: Sitemesh3LayoutFinder, lazyInit: true)
+ selector.constructorArgumentValues.addIndexedArgumentValue(0, null)
+ context.registerBeanDefinition('decoratorSelector', selector)
+ }
+
+ void cleanup() {
+ context.close()
+ }
+
+ void 'the rewritten resolver takes its servlet context from the container'() {
+ given:
+ new Sitemesh3ViewResolverDefinitionPostProcessor().postProcessBeanDefinitionRegistry(context)
+
+ when:
+ context.refresh()
+
+ then: 'nothing declares a servletContext bean definition for it to reference'
+ !context.containsBeanDefinition('servletContext')
+
+ and:
+ def resolver = context.getBean('jspViewResolver', GrailsSiteMeshViewResolver)
+ resolver.servletContext.is(servletContext)
+ }
+
+ void 'the injected servlet context reaches the view the resolver produces'() {
+ given:
+ new Sitemesh3ViewResolverDefinitionPostProcessor().postProcessBeanDefinitionRegistry(context)
+ context.refresh()
+
+ when: 'a view is resolved, which is where the servlet context is read'
+ def resolver = context.getBean('jspViewResolver', GrailsSiteMeshViewResolver)
+ View view = resolver.resolveViewName('someView', Locale.ENGLISH)
+
+ then:
+ view instanceof GrailsSiteMeshView
+ ((GrailsSiteMeshView) view).servletContext.is(servletContext)
+ }
+
+ void 'a resolver built with an explicit servlet context still carries it'() {
+ given: 'the constructor callers outside the container callback use'
+ ContentProcessor processor = new TagBasedContentProcessor(new CoreHtmlTagRuleBundle())
+ DecoratorSelector selector = { content, ctx -> new String[0] }
+ ServletContext explicit = new MockServletContext()
+
+ when:
+ def resolver = new GrailsSiteMeshViewResolver(
+ new InternalResourceViewResolver(), processor, selector, explicit)
+
+ then:
+ resolver.servletContext.is(explicit)
+ }
+}
diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/GroovyPagesGrailsPlugin.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/GroovyPagesGrailsPlugin.groovy
index 3d1b0ae61b6..ae982ed436c 100644
--- a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/GroovyPagesGrailsPlugin.groovy
+++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/GroovyPagesGrailsPlugin.groovy
@@ -21,9 +21,12 @@ package org.grails.plugins.web
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
+import org.springframework.beans.factory.BeanRegistry
+import org.springframework.beans.factory.BeanRegistrar
import org.springframework.beans.factory.config.PropertiesFactoryBean
import org.springframework.boot.web.servlet.ServletRegistrationBean
-import org.springframework.core.io.Resource
+import org.springframework.context.aot.AbstractAotProcessor
+import org.springframework.core.SpringProperties
import org.springframework.util.ClassUtils
import org.springframework.web.servlet.view.InternalResourceViewResolver
@@ -103,6 +106,24 @@ class GroovyPagesGrailsPlugin extends Plugin {
applicationContext.getBean('groovyPagesTemplateEngine', GroovyPagesTemplateEngine).clearPageCache()
}
+ /**
+ * Tag library beans autowire by name and are read from the artefacts the application knows
+ * about, which the {@link org.springframework.beans.factory.BeanRegistry} API cannot express, so
+ * their definitions are contributed by a dedicated post-processor. Contributing them here rather
+ * than from {@code doWithSpring()} leaves the definitions an ahead-of-time image generated, and
+ * the injection generated with them, in place.
+ */
+ @Override
+ BeanRegistrar beanRegistrar() {
+ { BeanRegistry registry, org.springframework.core.env.Environment environment ->
+ registry.registerBean('tagLibBeanDefinitionsPostProcessor', TagLibBeanDefinitionsPostProcessor) {
+ it.infrastructure().supplier {
+ new TagLibBeanDefinitionsPostProcessor(grailsApplication)
+ }
+ }
+ } as BeanRegistrar
+ }
+
/**
* Configures the various Spring beans required by GSP
*/
@@ -166,27 +187,22 @@ class GroovyPagesGrailsPlugin extends Plugin {
}
}
- def deployed = !Metadata.getCurrent().isDevelopmentEnvironmentAvailable()
groovyPageLocator(CachingGrailsConventionGroovyPageLocator) { bean ->
bean.lazyInit = true
if (customResourceLoader) {
resourceLoader = groovyPageResourceLoader
}
- if (deployed) {
- Resource defaultViews = applicationContext?.getResource('gsp/views.properties')
-
- if (defaultViews != null) {
- if (!defaultViews.exists()) {
- defaultViews = applicationContext?.getResource('classpath:gsp/views.properties')
- }
- }
-
- if (defaultViews?.exists()) {
- precompiledGspMap = { PropertiesFactoryBean pfb ->
- ignoreResourceNotFound = true
- locations = [defaultViews] as Resource[]
- }
- }
+ // Where the pages compiled at build time are listed. Attached whatever the
+ // surroundings, because whether to read from it is decided where a page is looked
+ // up, at run time, and only there is the answer knowable: deciding it here settles
+ // it while the definition is being generated, in the directory the application was
+ // built in, where a development environment is available -- so an image would be
+ // built believing it has to compile its pages, which is the one thing it cannot do.
+ // Named rather than resolved, so that what is written down is a location to look in
+ // and not a path on the machine that did the building.
+ precompiledGspMap = { PropertiesFactoryBean pfb ->
+ ignoreResourceNotFound = true
+ locations = ['gsp/views.properties', 'classpath:gsp/views.properties']
}
if (enableReload) {
cacheTimeout = gspCacheTimeout
@@ -250,24 +266,10 @@ class GroovyPagesGrailsPlugin extends Plugin {
// Configure a Spring MVC view resolver if none is defined
groovyPagesPostProcessor(GroovyPagesPostProcessor)
- // Now go through tag libraries and configure them in Spring too. With AOP proxies and so on
- for (taglib in application.tagLibClasses) {
-
- final tagLibClass = taglib.clazz
-
- "${taglib.fullName}"(tagLibClass) { bean ->
- bean.autowire = true
- bean.lazyInit = true
-
- // Taglib scoping support could be easily added here. Scope could be based on a static field in the taglib class.
- //bean.scope = 'request'
- }
- }
-
errorsViewStackTracePrinter(ErrorsViewStackTracePrinter, ref('grailsResourceLocator'))
- filteringCodecsByContentTypeSettings(FilteringCodecsByContentTypeSettings, application)
+ filteringCodecsByContentTypeSettings(FilteringCodecsByContentTypeSettings, ref('grailsApplication'))
- groovyPagesServlet(ServletRegistrationBean, new GroovyPagesServlet(), '*.gsp') {
+ groovyPagesServlet(ServletRegistrationBean, bean(GroovyPagesServlet), '*.gsp') {
if (Environment.isDevelopmentMode()) {
initParameters = [showSource: '1']
}
@@ -277,7 +279,22 @@ class GroovyPagesGrailsPlugin extends Plugin {
}
}
+ /**
+ * Whether the application is being developed, which decides where its pages are read from and
+ * whether they are watched for change.
+ *
+ *
While code is being generated the answer is no, whatever the machine doing the generating
+ * looks like. Generation runs in the project directory, so a development environment is
+ * available there and the question would otherwise be answered for the machine that built the
+ * application rather than the one that runs it: the pages would be read from a directory that
+ * exists only on the build machine, whose path would be written into the artifact, and an image
+ * cannot compile a page it finds there in any case.
+ */
+ @CompileStatic
protected boolean isDevelopmentMode() {
+ if (SpringProperties.getFlag(AbstractAotProcessor.AOT_PROCESSING)) {
+ return false
+ }
Metadata.getCurrent().isDevelopmentEnvironmentAvailable()
}
diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/TagLibBeanDefinitionsPostProcessor.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/TagLibBeanDefinitionsPostProcessor.groovy
new file mode 100644
index 00000000000..111f436141e
--- /dev/null
+++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/TagLibBeanDefinitionsPostProcessor.groovy
@@ -0,0 +1,95 @@
+/*
+ * 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
+
+import groovy.transform.CompileStatic
+import groovy.util.logging.Slf4j
+
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory
+import org.springframework.beans.factory.support.AbstractBeanDefinition
+import org.springframework.beans.factory.support.BeanDefinitionRegistry
+import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor
+import org.springframework.beans.factory.support.GenericBeanDefinition
+import org.springframework.core.PriorityOrdered
+
+import grails.core.GrailsApplication
+import grails.core.gsp.GrailsTagLibClass
+import org.grails.core.artefact.gsp.TagLibArtefactHandler
+
+/**
+ * Registers a bean definition for every tag library artefact, replacing the registration the GSP
+ * plugin previously performed through the {@code doWithSpring()} bean DSL.
+ *
+ *
Registering them there meant registering them again on every start, over whatever was already
+ * there. That is harmless on a running JVM, but an ahead-of-time image has already generated a
+ * definition for each tag library, carrying the field and method injection the generator worked out
+ * at build time. Replacing it discarded that injection, leaving a tag library holding null where it
+ * expected a collaborator -- and by-name autowiring did not stand in for it, because the
+ * collaborators are fields rather than properties. Contributing the definitions from here leaves the
+ * generated ones alone.
+ *
+ *
The artefacts are read from the application rather than named individually, so a tag library
+ * belongs to whoever declared it: the application, another plugin, or one supplied through
+ * {@code providedArtefacts}. An existing definition for the same name wins untouched, which
+ * preserves the ability to override a tag library -- including one deliberately declared without
+ * by-name autowiring, which cannot be told apart from a generated one. What a generated definition
+ * needs is carried into it while it is generated, by the processor that reads the autowire mode.
+ *
+ * @since 8.0
+ */
+@Slf4j
+@CompileStatic
+class TagLibBeanDefinitionsPostProcessor implements BeanDefinitionRegistryPostProcessor, PriorityOrdered {
+
+ private final GrailsApplication grailsApplication
+
+ TagLibBeanDefinitionsPostProcessor(GrailsApplication grailsApplication) {
+ this.grailsApplication = grailsApplication
+ }
+
+ @Override
+ void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) {
+ for (Object each : grailsApplication.getArtefacts(TagLibArtefactHandler.TYPE)) {
+ GrailsTagLibClass artefact = (GrailsTagLibClass) each
+ String beanName = artefact.fullName
+ if (!artefact.available) {
+ continue
+ }
+ if (registry.containsBeanDefinition(beanName)) {
+ continue
+ }
+ GenericBeanDefinition definition = new GenericBeanDefinition(
+ beanClass: artefact.clazz,
+ lazyInit: true,
+ autowireMode: AbstractBeanDefinition.AUTOWIRE_BY_NAME
+ )
+ registry.registerBeanDefinition(beanName, definition)
+ log.debug('Registered tag library {}', beanName)
+ }
+ }
+
+ @Override
+ void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {
+ }
+
+ @Override
+ int getOrder() {
+ HIGHEST_PRECEDENCE
+ }
+}
diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/aot/TagLibRuntimeHints.java b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/aot/TagLibRuntimeHints.java
new file mode 100644
index 00000000000..1429d9a1087
--- /dev/null
+++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/aot/TagLibRuntimeHints.java
@@ -0,0 +1,136 @@
+/*
+ * 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.taglib.aot;
+
+import java.io.IOException;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.aot.hint.MemberCategory;
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
+import org.springframework.core.io.support.ResourcePatternResolver;
+import org.springframework.core.type.classreading.CachingMetadataReaderFactory;
+import org.springframework.core.type.classreading.MetadataReaderFactory;
+import org.springframework.util.ClassUtils;
+
+import org.grails.aot.RegistrableTypes;
+
+/**
+ * Registers the tag libraries and page runtime a rendered page dispatches through.
+ *
+ *
A tag is resolved by name and invoked reflectively, so an image that keeps only the members
+ * something asked for renders a page until it reaches a tag it stripped. Which tags those are
+ * depends on what the page does: a field is only rendered by a form, and a flash message only
+ * exists after a redirect that set one, so a walk of an application's pages exercises neither and
+ * they fail for the first person who edits something.
+ *
+ *
Tag libraries are found rather than named. A plugin declares its own in whatever package it
+ * chooses, and its tags are as reachable from a page as the framework's own.
+ *
+ * @since 8.0
+ */
+public class TagLibRuntimeHints implements RuntimeHintsRegistrar {
+
+ private static final Log logger = LogFactory.getLog(TagLibRuntimeHints.class);
+
+ /**
+ * A tag library is identified by its name, wherever it is declared. The trailing wildcard also
+ * takes the classes it declares inside itself: the fields plugin keeps the stack a nested tag
+ * reads its bean from in one, and a tag that never nests does not reach it.
+ */
+ private static final String TAGLIB_PATTERN =
+ ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "**/*TagLib*.class";
+
+ /**
+ * The page rendering runtime. Registered by package rather than by name: a compiled page reaches
+ * all of it through Groovy, down to writing its output with an operator, and naming the types
+ * one at a time only ever describes the pages that have been rendered so far.
+ */
+ private static final String[] RUNTIME_PATTERNS = {
+ ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "org/grails/gsp/**/*.class",
+ ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "org/grails/taglib/**/*.class",
+ ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "org/grails/buffer/**/*.class"
+ };
+
+ private static String[] patterns() {
+ String[] all = new String[RUNTIME_PATTERNS.length + 1];
+ System.arraycopy(RUNTIME_PATTERNS, 0, all, 0, RUNTIME_PATTERNS.length);
+ all[RUNTIME_PATTERNS.length] = TAGLIB_PATTERN;
+ return all;
+ }
+
+ /**
+ * Types the plugin descriptors call. A descriptor is Groovy, so even a static call on a utility
+ * class is dispatched dynamically and needs the class to survive.
+ */
+ private static final String[] CALLED_FROM_DESCRIPTORS = {
+ "org.springframework.aot.AotDetector"
+ };
+
+ @Override
+ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
+ ClassLoader loader = (classLoader != null) ? classLoader : ClassUtils.getDefaultClassLoader();
+ for (String type : CALLED_FROM_DESCRIPTORS) {
+ hints.reflection().registerTypeIfPresent(loader, type,
+ MemberCategory.INVOKE_DECLARED_METHODS,
+ MemberCategory.INVOKE_PUBLIC_METHODS);
+ }
+ ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(loader);
+ MetadataReaderFactory metadataReaderFactory = new CachingMetadataReaderFactory(resolver);
+ int registered = 0;
+ for (String pattern : patterns()) {
+ Resource[] resources;
+ try {
+ resources = resolver.getResources(pattern);
+ }
+ catch (IOException ex) {
+ logger.warn("Unable to scan for " + pattern, ex);
+ continue;
+ }
+ for (Resource resource : resources) {
+ String className;
+ try {
+ className = metadataReaderFactory.getMetadataReader(resource)
+ .getClassMetadata().getClassName();
+ }
+ catch (IOException | RuntimeException ex) {
+ continue;
+ }
+ if (!RegistrableTypes.loads(className, loader)) {
+ continue;
+ }
+ // declared rather than public throughout: a tag's body may call a private helper on
+ // its own library, and a page reads the shared empty body as a field
+ hints.reflection().registerTypeIfPresent(loader, className,
+ MemberCategory.INVOKE_DECLARED_METHODS,
+ MemberCategory.INVOKE_PUBLIC_METHODS,
+ MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
+ MemberCategory.ACCESS_DECLARED_FIELDS,
+ MemberCategory.ACCESS_PUBLIC_FIELDS);
+ registered++;
+ }
+ }
+ logger.debug("Registered " + registered + " tag library and page runtime types");
+ }
+}
diff --git a/grails-gsp/plugin/src/main/resources/META-INF/spring/aot.factories b/grails-gsp/plugin/src/main/resources/META-INF/spring/aot.factories
new file mode 100644
index 00000000000..e56c5b482c9
--- /dev/null
+++ b/grails-gsp/plugin/src/main/resources/META-INF/spring/aot.factories
@@ -0,0 +1,2 @@
+org.springframework.aot.hint.RuntimeHintsRegistrar=\
+org.grails.plugins.web.taglib.aot.TagLibRuntimeHints
diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/plugins/web/TagLibBeanDefinitionsPostProcessorSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/plugins/web/TagLibBeanDefinitionsPostProcessorSpec.groovy
new file mode 100644
index 00000000000..591e36bc703
--- /dev/null
+++ b/grails-gsp/plugin/src/test/groovy/org/grails/plugins/web/TagLibBeanDefinitionsPostProcessorSpec.groovy
@@ -0,0 +1,117 @@
+/*
+ * 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
+
+import grails.core.DefaultGrailsApplication
+import grails.core.GrailsApplication
+
+import org.springframework.beans.factory.support.AbstractBeanDefinition
+import org.springframework.beans.factory.support.BeanDefinitionRegistry
+import org.springframework.beans.factory.support.DefaultListableBeanFactory
+import org.springframework.beans.factory.support.RootBeanDefinition
+import spock.lang.Specification
+
+/**
+ * Covers tag library definitions being contributed without replacing what is already registered.
+ *
+ *
The GSP plugin used to register them from {@code doWithSpring()}, over whatever was there. An
+ * ahead-of-time image has already generated a definition for each one, carrying the injection the
+ * generator worked out, and replacing it discarded that.
+ */
+class TagLibBeanDefinitionsPostProcessorSpec extends Specification {
+
+ BeanDefinitionRegistry registry = new DefaultListableBeanFactory()
+
+ GrailsApplication grailsApplication
+
+ void setup() {
+ grailsApplication = new DefaultGrailsApplication(DemoTagLib)
+ grailsApplication.initialise()
+ }
+
+ private void process() {
+ new TagLibBeanDefinitionsPostProcessor(grailsApplication).postProcessBeanDefinitionRegistry(registry)
+ }
+
+ void 'a tag library the application knows about is registered'() {
+ when:
+ process()
+
+ then:
+ registry.containsBeanDefinition(DemoTagLib.name)
+ }
+
+ void 'the definition is lazy and autowired by name'() {
+ when:
+ process()
+ AbstractBeanDefinition definition =
+ (AbstractBeanDefinition) registry.getBeanDefinition(DemoTagLib.name)
+
+ then: 'a tag library takes some collaborators by name rather than by annotation'
+ definition.lazyInit
+ definition.autowireMode == AbstractBeanDefinition.AUTOWIRE_BY_NAME
+ }
+
+ void 'a definition that is already registered is kept'() {
+ given: 'the definition an ahead-of-time image generated, carrying its own injection'
+ RootBeanDefinition generated = new RootBeanDefinition(DemoTagLib)
+ registry.registerBeanDefinition(DemoTagLib.name, generated)
+
+ when:
+ process()
+
+ then:
+ registry.getBeanDefinition(DemoTagLib.name).is(generated)
+ }
+
+ void 'a definition that declares no autowiring is left declaring none'() {
+ given: 'an application that deliberately took by-name autowiring off its own tag library'
+ RootBeanDefinition declared = new RootBeanDefinition(DemoTagLib)
+ declared.autowireMode = AbstractBeanDefinition.AUTOWIRE_NO
+ registry.registerBeanDefinition(DemoTagLib.name, declared)
+
+ when:
+ process()
+
+ then: 'which cannot be told apart from a generated one, so neither is touched -- what a ' +
+ 'generated definition needs is carried into it while it is generated'
+ declared.autowireMode == AbstractBeanDefinition.AUTOWIRE_NO
+ }
+
+ void 'a definition asking for something else keeps it'() {
+ given:
+ RootBeanDefinition declared = new RootBeanDefinition(DemoTagLib)
+ declared.autowireMode = AbstractBeanDefinition.AUTOWIRE_BY_TYPE
+ registry.registerBeanDefinition(DemoTagLib.name, declared)
+
+ when:
+ process()
+
+ then:
+ declared.autowireMode == AbstractBeanDefinition.AUTOWIRE_BY_TYPE
+ }
+
+ /** Recognised as a tag library by its name, which is what the artefact handler reads. */
+ static class DemoTagLib {
+
+ static namespace = 'demo'
+
+ Closure hello = { attrs -> }
+ }
+}
diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/plugins/web/taglib/aot/TagLibRuntimeHintsSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/plugins/web/taglib/aot/TagLibRuntimeHintsSpec.groovy
new file mode 100644
index 00000000000..2b63d2408a4
--- /dev/null
+++ b/grails-gsp/plugin/src/test/groovy/org/grails/plugins/web/taglib/aot/TagLibRuntimeHintsSpec.groovy
@@ -0,0 +1,84 @@
+/*
+ * 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.taglib.aot
+
+import org.springframework.aot.hint.MemberCategory
+import org.springframework.aot.hint.RuntimeHints
+import org.springframework.aot.hint.TypeReference
+import spock.lang.Specification
+
+/**
+ * Covers the tag libraries and page runtime surviving into an ahead-of-time image. What a page
+ * reaches depends on what it renders, so a missing registration shows up on the page that uses that
+ * tag rather than at start-up.
+ */
+class TagLibRuntimeHintsSpec extends Specification {
+
+ RuntimeHints hints = new RuntimeHints()
+
+ void setup() {
+ new TagLibRuntimeHints().registerHints(hints, getClass().classLoader)
+ }
+
+ private Set registeredTypes() {
+ hints.reflection().typeHints().collect { it.type.name } as Set
+ }
+
+ private boolean hasCategory(String type, MemberCategory category) {
+ def hint = hints.reflection().getTypeHint(TypeReference.of(type))
+ hint != null && hint.memberCategories.contains(category)
+ }
+
+ void 'the tag libraries on the classpath are registered'() {
+ expect:
+ registeredTypes().any { it.endsWith('TagLib') }
+ }
+
+ void 'the classes a tag library declares inside itself are registered too'() {
+ expect: 'the fields plugin keeps the bean stack a nested tag reads in one of these, so a ' +
+ 'pattern matching only the library itself renders a page until a tag nests'
+ registeredTypes().any { it.contains('TagLib$') }
+ }
+
+ void 'the page runtime a compiled page writes through is registered'() {
+ expect:
+ registeredTypes().any { it.startsWith('org.grails.gsp.') }
+ registeredTypes().any { it.startsWith('org.grails.buffer.') }
+ }
+
+ void 'fields are registered, not only methods'() {
+ given: 'a page reads the shared empty body from the tag output rather than calling for it'
+ String type = registeredTypes().find { it.startsWith('org.grails.taglib.') }
+
+ expect:
+ hasCategory(type, MemberCategory.ACCESS_DECLARED_FIELDS)
+ hasCategory(type, MemberCategory.INVOKE_DECLARED_METHODS)
+ }
+
+ void 'a class loader that resolves nothing yields no hints rather than failing'() {
+ given:
+ RuntimeHints empty = new RuntimeHints()
+
+ when:
+ new TagLibRuntimeHints().registerHints(empty, new URLClassLoader(new URL[0], null))
+
+ then:
+ noExceptionThrown()
+ }
+}
diff --git a/grails-mimetypes/src/main/groovy/org/grails/web/mime/aot/MimeTypeRuntimeHints.java b/grails-mimetypes/src/main/groovy/org/grails/web/mime/aot/MimeTypeRuntimeHints.java
new file mode 100644
index 00000000000..80b40ef22c2
--- /dev/null
+++ b/grails-mimetypes/src/main/groovy/org/grails/web/mime/aot/MimeTypeRuntimeHints.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.grails.web.mime.aot;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.aot.hint.MemberCategory;
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+
+/**
+ * Registers the content negotiation API.
+ *
+ *
Negotiation runs only for a request that states what it accepts. A browser always does;
+ * a bare command-line request does not, which is why the absence of these hints can pass an
+ * automated check and still fail for every real visitor.
+ *
+ * @since 8.0
+ */
+public class MimeTypeRuntimeHints implements RuntimeHintsRegistrar {
+
+ /**
+ * Types Groovy dispatches on. Named as strings, and registered only when present, so this stays
+ * correct for an application that does not use every plugin.
+ */
+ private static final String[] DISPATCHED_TYPES = {
+ "grails.web.mime.MimeType",
+ "org.grails.web.mime.DefaultAcceptHeaderParser",
+ "org.grails.web.mime.DefaultMimeUtility"
+ };
+
+ @Override
+ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
+ for (String type : DISPATCHED_TYPES) {
+ hints.reflection().registerTypeIfPresent(classLoader, type,
+ MemberCategory.INVOKE_DECLARED_METHODS,
+ MemberCategory.INVOKE_PUBLIC_METHODS,
+ MemberCategory.INVOKE_DECLARED_CONSTRUCTORS);
+ }
+ }
+}
diff --git a/grails-mimetypes/src/main/resources/META-INF/spring/aot.factories b/grails-mimetypes/src/main/resources/META-INF/spring/aot.factories
new file mode 100644
index 00000000000..e6d118d9714
--- /dev/null
+++ b/grails-mimetypes/src/main/resources/META-INF/spring/aot.factories
@@ -0,0 +1,2 @@
+org.springframework.aot.hint.RuntimeHintsRegistrar=\
+org.grails.web.mime.aot.MimeTypeRuntimeHints
diff --git a/grails-profiles/web/skeleton/grails-app/views/index.gsp b/grails-profiles/web/skeleton/grails-app/views/index.gsp
index 97362a9246a..add9c633cc9 100644
--- a/grails-profiles/web/skeleton/grails-app/views/index.gsp
+++ b/grails-profiles/web/skeleton/grails-app/views/index.gsp
@@ -2,6 +2,7 @@
<%@ page import="org.springframework.boot.SpringBootVersion"%>
<%@ page import="org.springframework.core.SpringVersion"%>
<%@ page import="org.springframework.util.ClassUtils"%>
+<%@ page import="org.springframework.util.ReflectionUtils"%>
${SpringVersion.getVersion()}
- <%-- Spring Security: only when the dependency is present --%>
+ <%-- Spring Security: only when the dependency is present. The call goes
+ through ReflectionUtils because invoking Method.invoke from Groovy
+ resolves to a caller-sensitive overload that a native image rejects. --%>
+
+ value="${springSecurityCoreVersionClass ? ReflectionUtils.invokeMethod(ReflectionUtils.findMethod(springSecurityCoreVersionClass, 'getVersion'), null) : null}"/>
diff --git a/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/aot/ScaffoldingRuntimeHints.java b/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/aot/ScaffoldingRuntimeHints.java
new file mode 100644
index 00000000000..a8b018804cb
--- /dev/null
+++ b/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/aot/ScaffoldingRuntimeHints.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package grails.plugin.scaffolding.aot;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.aot.hint.MemberCategory;
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+
+/**
+ * Registers the controller a scaffolded resource is served by.
+ *
+ *
Its actions are reached through Groovy, including the protected ones it defines for the write
+ * operations, so an image that keeps only the members something asked for serves the pages and then
+ * fails on the request that saves or removes a record.
+ *
+ * @since 8.0
+ */
+public class ScaffoldingRuntimeHints implements RuntimeHintsRegistrar {
+
+ private static final String[] DISPATCHED_TYPES = {
+ "grails.plugin.scaffolding.RestfulServiceController",
+ "grails.plugin.scaffolding.ScaffoldingViewResolver",
+ "grails.plugin.scaffolding.annotation.Scaffold"
+ };
+
+ @Override
+ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
+ for (String type : DISPATCHED_TYPES) {
+ hints.reflection().registerTypeIfPresent(classLoader, type,
+ MemberCategory.INVOKE_DECLARED_METHODS,
+ MemberCategory.INVOKE_PUBLIC_METHODS,
+ MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
+ MemberCategory.ACCESS_DECLARED_FIELDS);
+ }
+ }
+}
diff --git a/grails-scaffolding/src/main/resources/META-INF/spring/aot.factories b/grails-scaffolding/src/main/resources/META-INF/spring/aot.factories
new file mode 100644
index 00000000000..85ba8176d40
--- /dev/null
+++ b/grails-scaffolding/src/main/resources/META-INF/spring/aot.factories
@@ -0,0 +1,2 @@
+org.springframework.aot.hint.RuntimeHintsRegistrar=\
+grails.plugin.scaffolding.aot.ScaffoldingRuntimeHints
diff --git a/grails-spring/build.gradle b/grails-spring/build.gradle
index e0aaa04a5bc..0ee57f7d690 100644
--- a/grails-spring/build.gradle
+++ b/grails-spring/build.gradle
@@ -62,4 +62,5 @@ dependencies {
apply {
from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle')
+ from rootProject.layout.projectDirectory.file('gradle/test-config.gradle')
}
\ No newline at end of file
diff --git a/grails-spring/src/main/groovy/org/grails/spring/DefaultRuntimeSpringConfiguration.java b/grails-spring/src/main/groovy/org/grails/spring/DefaultRuntimeSpringConfiguration.java
index fa43f506078..0a97b8e452d 100644
--- a/grails-spring/src/main/groovy/org/grails/spring/DefaultRuntimeSpringConfiguration.java
+++ b/grails-spring/src/main/groovy/org/grails/spring/DefaultRuntimeSpringConfiguration.java
@@ -33,6 +33,7 @@
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
+import org.springframework.aot.AotDetector;
import org.springframework.beans.PropertyValue;
import org.springframework.beans.factory.ListableBeanFactory;
import org.springframework.beans.factory.config.BeanDefinition;
@@ -265,11 +266,36 @@ public void registerBeansWithRegistry(BeanDefinitionRegistry registry) {
private void registerUnrefreshedBeansWithRegistry(BeanDefinitionRegistry registry) {
if (context != null) {
for (String beanName : context.getBeanDefinitionNames()) {
- registry.registerBeanDefinition(beanName, context.getBeanDefinition(beanName));
+ register(registry, beanName, context.getBeanDefinition(beanName));
}
}
}
+ /**
+ * Registers a definition, leaving in place one that was generated ahead of time.
+ *
+ *
Running on generated artifacts, the plugins that produced these definitions already ran:
+ * they ran while the artifacts were being generated, and what they registered was written out as
+ * code and registered again here, ahead of this phase. Registering over that discards the
+ * instance supplier the generator wrote -- which resolves the constructor, the fields and the
+ * injection methods up front -- and replaces it with a definition that has to find all of that
+ * by reflection, which is what a generated image does not carry. So the generated one stands and
+ * this one is dropped.
+ *
+ *
Anything the generator did not produce a definition for is registered as usual, so a bean a
+ * plugin contributes conditionally is unaffected. On a normal start nothing is skipped, and a
+ * plugin overrides what came before it exactly as it did.
+ */
+ private void register(BeanDefinitionRegistry registry, String beanName, BeanDefinition definition) {
+ if (AotDetector.useGeneratedArtifacts() && registry.containsBeanDefinition(beanName)) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("[RuntimeConfiguration] Keeping the generated definition of bean [" + beanName + "]");
+ }
+ return;
+ }
+ registry.registerBeanDefinition(beanName, definition);
+ }
+
private void registerBeanConfigsWithRegistry(BeanDefinitionRegistry registry) {
for (BeanConfiguration bc : beanConfigs.values()) {
String beanName = bc.getName();
@@ -285,7 +311,7 @@ private void registerBeanConfigsWithRegistry(BeanDefinitionRegistry registry) {
}
}
- registry.registerBeanDefinition(beanName, bc.getBeanDefinition());
+ register(registry, beanName, bc.getBeanDefinition());
}
}
@@ -302,7 +328,7 @@ private void registerBeanDefinitionsWithRegistry(BeanDefinitionRegistry registry
}
}
final String beanName = key.toString();
- registry.registerBeanDefinition(beanName, bd);
+ register(registry, beanName, bd);
}
}
diff --git a/grails-spring/src/test/groovy/org/grails/spring/DefaultRuntimeSpringConfigurationAotSpec.groovy b/grails-spring/src/test/groovy/org/grails/spring/DefaultRuntimeSpringConfigurationAotSpec.groovy
new file mode 100644
index 00000000000..827388e09eb
--- /dev/null
+++ b/grails-spring/src/test/groovy/org/grails/spring/DefaultRuntimeSpringConfigurationAotSpec.groovy
@@ -0,0 +1,96 @@
+/*
+ * 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.spring
+
+import org.springframework.aot.AotDetector
+import org.springframework.beans.factory.support.DefaultListableBeanFactory
+import org.springframework.beans.factory.support.RootBeanDefinition
+import org.springframework.core.SpringProperties
+import spock.lang.Specification
+
+/**
+ * Covers plugin bean definitions being registered without replacing ones generated ahead of time.
+ *
+ *
Running on generated artifacts, the plugins that produced these definitions already ran: they
+ * ran while the artifacts were being generated, and what they registered was written out as code and
+ * registered again ahead of this phase. Registering over that discards the instance supplier the
+ * generator wrote and replaces it with a definition that finds everything by reflection.
+ */
+class DefaultRuntimeSpringConfigurationAotSpec extends Specification {
+
+ DefaultListableBeanFactory registry = new DefaultListableBeanFactory()
+
+ DefaultRuntimeSpringConfiguration springConfig = new DefaultRuntimeSpringConfiguration()
+
+ void cleanup() {
+ SpringProperties.setProperty(AotDetector.AOT_ENABLED, null)
+ }
+
+ private void runningOnGeneratedArtifacts(boolean enabled) {
+ SpringProperties.setProperty(AotDetector.AOT_ENABLED, String.valueOf(enabled))
+ }
+
+ /** A definition contributed the way a plugin's {@code doWithSpring()} contributes one. */
+ private void contribute(String beanName, Class> beanClass) {
+ springConfig.addSingletonBean(beanName, beanClass)
+ }
+
+ void 'a generated definition is kept'() {
+ given:
+ runningOnGeneratedArtifacts(true)
+ RootBeanDefinition generated = new RootBeanDefinition(Collaborator)
+ registry.registerBeanDefinition('subject', generated)
+ contribute('subject', Collaborator)
+
+ when:
+ springConfig.registerBeansWithRegistry(registry)
+
+ then:
+ registry.getBeanDefinition('subject').is(generated)
+ }
+
+ void 'a definition the generator did not produce is still registered'() {
+ given:
+ runningOnGeneratedArtifacts(true)
+ contribute('conditional', Collaborator)
+
+ when:
+ springConfig.registerBeansWithRegistry(registry)
+
+ then: 'a bean a plugin contributes conditionally has nothing generated for it'
+ registry.containsBeanDefinition('conditional')
+ }
+
+ void 'a plugin overrides what came before it on a normal start'() {
+ given:
+ runningOnGeneratedArtifacts(false)
+ RootBeanDefinition earlier = new RootBeanDefinition(Collaborator)
+ registry.registerBeanDefinition('subject', earlier)
+ contribute('subject', Collaborator)
+
+ when:
+ springConfig.registerBeansWithRegistry(registry)
+
+ then: 'the ordering plugins rely on to replace one another is unchanged'
+ !registry.getBeanDefinition('subject').is(earlier)
+ }
+
+ static class Collaborator {
+ }
+}
diff --git a/grails-test-examples/aot/build.gradle b/grails-test-examples/aot/build.gradle
new file mode 100644
index 00000000000..18fee968659
--- /dev/null
+++ b/grails-test-examples/aot/build.gradle
@@ -0,0 +1,71 @@
+/*
+ * 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.
+ */
+
+plugins {
+ id 'org.apache.grails.buildsrc.properties'
+ id 'org.apache.grails.buildsrc.dependency-validator'
+ id 'org.apache.grails.buildsrc.compile'
+ id 'org.apache.grails.buildsrc.vulnerability-scan'
+}
+
+version = '0.1'
+group = 'functionaltests'
+
+apply plugin: 'org.apache.grails.gradle.grails-web'
+apply plugin: 'org.apache.grails.gradle.grails-gsp'
+apply plugin: 'org.springframework.boot.aot'
+
+apply {
+ from rootProject.layout.projectDirectory.file('gradle/functional-test-config.gradle')
+}
+
+dependencies {
+ implementation platform(project(':grails-bom'))
+
+ implementation 'org.apache.grails:grails-dependencies-starter-web'
+ implementation 'org.apache.grails:grails-sitemesh3'
+
+ testImplementation 'org.apache.grails:grails-testing-support-web'
+}
+
+// Generation reflects the environment the packaged application runs in. Under development,
+// reloading is enabled and beans such as the url mappings holder take a proxied shape that has
+// no meaning in a packaged artifact and cannot be generated.
+tasks.named('processAot') {
+ systemProperty 'grails.env', 'production'
+}
+
+// The application must also start from what was generated, which is the half that catches a
+// runtime-only regression - a context that generates cleanly can still fail to boot.
+tasks.register('aotStartupCheck', JavaExec) {
+ dependsOn tasks.named('bootJar')
+ group = 'verification'
+ description = 'Starts the packaged application with AOT enabled and asserts the context comes up'
+ classpath = files(tasks.named('bootJar').flatMap { it.archiveFile })
+ mainClass = 'org.springframework.boot.loader.launch.JarLauncher'
+ systemProperties([
+ 'spring.aot.enabled': 'true',
+ 'grails.env' : 'production',
+ ])
+ args = ['--aot-startup-check', '--server.port=0', '--spring.main.banner-mode=off']
+}
+
+tasks.named('check') {
+ dependsOn tasks.named('aotStartupCheck')
+}
diff --git a/grails-test-examples/aot/grails-app/conf/application.yml b/grails-test-examples/aot/grails-app/conf/application.yml
new file mode 100644
index 00000000000..4314f81391d
--- /dev/null
+++ b/grails-test-examples/aot/grails-app/conf/application.yml
@@ -0,0 +1,20 @@
+# 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.
+
+grails:
+ profile: web
+info:
+ app:
+ name: aot
diff --git a/grails-test-examples/aot/grails-app/controllers/aot/GreetingController.groovy b/grails-test-examples/aot/grails-app/controllers/aot/GreetingController.groovy
new file mode 100644
index 00000000000..5125b0cca5a
--- /dev/null
+++ b/grails-test-examples/aot/grails-app/controllers/aot/GreetingController.groovy
@@ -0,0 +1,30 @@
+/*
+ * 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 aot
+
+/**
+ * Serves the one page this application has, so that the request path is exercised rather than only
+ * the start-up: a context that comes up cleanly can still fail on the first thing anyone asks it for.
+ */
+class GreetingController {
+
+ def index() {
+ [name: 'ahead of time']
+ }
+}
diff --git a/grails-test-examples/aot/grails-app/controllers/aot/UrlMappings.groovy b/grails-test-examples/aot/grails-app/controllers/aot/UrlMappings.groovy
new file mode 100644
index 00000000000..c9c95d37329
--- /dev/null
+++ b/grails-test-examples/aot/grails-app/controllers/aot/UrlMappings.groovy
@@ -0,0 +1,29 @@
+/*
+ * 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 aot
+
+class UrlMappings {
+
+ static mappings = {
+ "/$controller/$action?/$id?(.$format)?" { }
+ "/"(controller: 'greeting', action: 'index')
+ // no 500 or 404 view is mapped: this application exists to fail loudly, and a mapping to a
+ // page it does not have would answer a real failure with a second one about the error page
+ }
+}
diff --git a/grails-test-examples/aot/grails-app/init/aot/Application.groovy b/grails-test-examples/aot/grails-app/init/aot/Application.groovy
new file mode 100644
index 00000000000..21a35a5c958
--- /dev/null
+++ b/grails-test-examples/aot/grails-app/init/aot/Application.groovy
@@ -0,0 +1,112 @@
+/*
+ * 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 aot
+
+import grails.boot.GrailsApp
+import grails.boot.config.GrailsAutoConfiguration
+
+import org.springframework.context.ConfigurableApplicationContext
+
+class Application extends GrailsAutoConfiguration {
+
+ /**
+ * With {@code --aot-startup-check} the application starts, asks itself for the one page it has,
+ * and exits. The build runs it that way against the packaged jar with
+ * {@code spring.aot.enabled=true}.
+ *
+ *
The page is the point. Almost everything an application generated ahead of time gets wrong
+ * is something that starts perfectly well: a tag library holding null where a collaborator wired
+ * by name should be, a link generator whose own collaborator was injected into a field of an
+ * implementation the container only knows as an interface, a page looked up in a manifest an
+ * image did not carry. None of it is visible until something renders, so a check that only reads
+ * the bean names would pass while every one of those was broken.
+ */
+ static void main(String[] args) {
+ if (!args.contains('--aot-startup-check')) {
+ GrailsApp.run(Application, args)
+ return
+ }
+
+ ConfigurableApplicationContext context = (ConfigurableApplicationContext) GrailsApp.run(Application, args)
+ try {
+ assertBeanPresent(context, 'grailsApplication')
+ assertBeanPresent(context, 'filteringCodecsByContentTypeSettings')
+ assertBeanPresent(context, 'groovyPagesServlet')
+ assertBeanPresent(context, 'jspViewResolver')
+ assertBeanPresent(context, 'grailsUrlMappingsHolder')
+ // contributed by a plugin's @Configuration class, which is parsed by the processor the
+ // core plugin stands down in favour of once the definitions are generated
+ assertBeanPresent(context, 'propertySourcesPlaceholderConfigurer')
+ assertPageRenders(context)
+ }
+ finally {
+ context.close()
+ }
+ System.exit(0)
+ }
+
+ private static void assertBeanPresent(ConfigurableApplicationContext context, String name) {
+ if (!context.containsBean(name)) {
+ throw new IllegalStateException("AOT-processed context is missing the '${name}' bean")
+ }
+ }
+
+ /** Asks the running application for its page and checks what came back rendered. */
+ private static void assertPageRenders(ConfigurableApplicationContext context) {
+ Integer port = context.environment.getProperty('local.server.port', Integer)
+ if (port == null) {
+ throw new IllegalStateException('The application reported no port, so it is not serving')
+ }
+ String page = get(port, '/greeting/index')
+ assertRendered(page, 'hello from a service',
+ 'the tag library rendered without the collaborator it takes by name')
+ assertRendered(page, 'ahead of time', 'the action did not reach the page it returned')
+
+ // Followed rather than matched against a path written here. What the generator produces is
+ // whatever the mappings reverse to -- this application maps the action to the root -- so the
+ // question worth asking is whether the link it built leads back to the page it was built on.
+ String link = between(page, '
', '
')
+ if (!link) {
+ throw new IllegalStateException("AOT-processed application: the link generator built no " +
+ "link. Page was:\n${page}")
+ }
+ assertRendered(get(port, link), 'hello from a service',
+ "the link the generator built (${link}) does not lead back to the page")
+ }
+
+ private static String get(int port, String path) {
+ new URI("http://localhost:${port}${path}").toURL().getText('UTF-8')
+ }
+
+ private static String between(String page, String start, String end) {
+ int from = page.indexOf(start)
+ if (from < 0) {
+ return null
+ }
+ int to = page.indexOf(end, from + start.length())
+ to < 0 ? null : page.substring(from + start.length(), to).trim()
+ }
+
+ private static void assertRendered(String page, String expected, String whatItMeans) {
+ if (!page.contains(expected)) {
+ throw new IllegalStateException(
+ "AOT-processed application: ${whatItMeans}. Expected '${expected}' in:\n${page}")
+ }
+ }
+}
diff --git a/grails-test-examples/aot/grails-app/services/aot/GreetingService.groovy b/grails-test-examples/aot/grails-app/services/aot/GreetingService.groovy
new file mode 100644
index 00000000000..1c7a516e1bb
--- /dev/null
+++ b/grails-test-examples/aot/grails-app/services/aot/GreetingService.groovy
@@ -0,0 +1,30 @@
+/*
+ * 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 aot
+
+/**
+ * A collaborator the tag library takes by name, which is how Grails wires most of what it
+ * contributes: no annotation says to inject this, only that the bean and the property share a name.
+ */
+class GreetingService {
+
+ String greeting() {
+ 'hello from a service'
+ }
+}
diff --git a/grails-test-examples/aot/grails-app/taglib/aot/GreetingTagLib.groovy b/grails-test-examples/aot/grails-app/taglib/aot/GreetingTagLib.groovy
new file mode 100644
index 00000000000..994fe11723d
--- /dev/null
+++ b/grails-test-examples/aot/grails-app/taglib/aot/GreetingTagLib.groovy
@@ -0,0 +1,38 @@
+/*
+ * 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 aot
+
+/**
+ * Renders what the service says.
+ *
+ *
Its collaborator arrives by name rather than by annotation, which is the arrangement the
+ * generator writes out nothing for -- so a page reaching this tag is what tells the two apart: a
+ * tag library rebuilt from generated code without its autowire mode holds null here, and says so
+ * only when something renders it.
+ */
+class GreetingTagLib {
+
+ static namespace = 'aot'
+
+ def greetingService
+
+ def greeting = { attrs ->
+ out << greetingService.greeting()
+ }
+}
diff --git a/grails-test-examples/aot/grails-app/views/greeting/index.gsp b/grails-test-examples/aot/grails-app/views/greeting/index.gsp
new file mode 100644
index 00000000000..23d3b75444d
--- /dev/null
+++ b/grails-test-examples/aot/grails-app/views/greeting/index.gsp
@@ -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.
+ --%>
+<%--
+ Every line here is one of the things that only fails once a page is rendered.
+
+ The page itself is found through the manifest of pages compiled at build time, which an image
+ reads because it cannot compile one. The tag reaches a collaborator wired by name. The link is
+ built by the link generator, whose own collaborator is injected into a field of an implementation
+ the container only knows as an interface. The model value proves the action ran.
+--%>
+
+
+ahead of time
+
+
+
${name}
+
+
+
diff --git a/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPlugin.groovy b/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPlugin.groovy
index 1824e739b36..24b7680c775 100644
--- a/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPlugin.groovy
+++ b/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPlugin.groovy
@@ -30,6 +30,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.ApplicationContext
+import org.springframework.context.aot.AbstractAotProcessor
+import org.springframework.core.SpringProperties
import org.springframework.core.env.Environment
import org.springframework.web.filter.CorsFilter
@@ -117,8 +119,7 @@ class UrlMappingsGrailsPlugin extends Plugin {
grailsApplication.addArtefact(UrlMappingsArtefactHandler.TYPE, DefaultUrlMappings)
}
- boolean reloadEnabled = GrailsEnvironment.developmentMode ||
- GrailsEnvironment.current.reloadEnabled
+ boolean reloadEnabled = isReloadEnabled()
boolean corsFilterEnabled = environment.getProperty(Settings.SETTING_CORS_FILTER, Boolean, true)
// The url-mapping holder is a ProxyFactoryBean (reload mode) whose produced UrlMappings
@@ -155,6 +156,25 @@ class UrlMappingsGrailsPlugin extends Plugin {
}
}
+ /**
+ * Whether the mappings are to be reloadable, which decides how the holder is defined.
+ *
+ *
Not while the code is being generated, whatever the machine generating it looks like.
+ * Reloading swaps the mappings behind a proxy, and the proxy produces its {@code UrlMappings}
+ * through a target source rather than declaring the type -- so Spring can only learn what it
+ * produces by building it, which is exactly what reading a generated definition avoids.
+ * Generated that way nothing could be autowired by that type, and the application did not
+ * start. An image cannot reload anything in any case.
+ */
+ protected boolean isReloadEnabled() {
+ !SpringProperties.getFlag(AbstractAotProcessor.AOT_PROCESSING) && environmentReloadable
+ }
+
+ /** Whether the surroundings are ones that reload, which only a run can answer. */
+ protected boolean isEnvironmentReloadable() {
+ GrailsEnvironment.developmentMode || GrailsEnvironment.current.reloadEnabled
+ }
+
@CompileStatic
private static UrlMappingsHolder createUrlMappingsHolder(ApplicationContext applicationContext) {
def factory = new UrlMappingsHolderFactoryBean(applicationContext: applicationContext)
diff --git a/grails-url-mappings/src/test/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPluginSpec.groovy b/grails-url-mappings/src/test/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPluginSpec.groovy
index 289100a3f0b..f4a97541762 100644
--- a/grails-url-mappings/src/test/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPluginSpec.groovy
+++ b/grails-url-mappings/src/test/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPluginSpec.groovy
@@ -23,6 +23,7 @@ import org.springframework.beans.factory.config.RuntimeBeanReference
import org.springframework.beans.factory.support.AbstractBeanDefinition
import org.springframework.beans.factory.support.BeanRegistryAdapter
import org.springframework.beans.factory.support.DefaultListableBeanFactory
+import org.springframework.context.aot.AbstractAotProcessor
import org.springframework.core.env.StandardEnvironment
import grails.core.GrailsApplication
@@ -103,6 +104,50 @@ class UrlMappingsGrailsPluginSpec extends Specification {
!registry.containsBeanDefinition('urlMappingsTargetSource')
}
+ void "reloading is off while the code is being generated"() {
+ given: "surroundings that would otherwise reload"
+ def plugin = new Reloading()
+
+ expect: "which is how the holder comes to be a proxy"
+ plugin.isReloadEnabled()
+
+ when: "the same surroundings, while the code is being generated"
+ System.setProperty(AbstractAotProcessor.AOT_PROCESSING, 'true')
+
+ then: "the proxy produces its UrlMappings through a target source rather than declaring the " +
+ "type, so nothing could be autowired by that type from a generated definition"
+ !plugin.isReloadEnabled()
+
+ cleanup:
+ System.clearProperty(AbstractAotProcessor.AOT_PROCESSING)
+ }
+
+ void "the surroundings still decide on an ordinary start"() {
+ given:
+ System.clearProperty(AbstractAotProcessor.AOT_PROCESSING)
+
+ expect: "generation is the only thing that overrides them"
+ new Reloading().isReloadEnabled()
+ !new NotReloading().isReloadEnabled()
+ }
+
+ /** Stands in for surroundings that reload, which a test JVM is not. */
+ static class Reloading extends UrlMappingsGrailsPlugin {
+
+ @Override
+ protected boolean isEnvironmentReloadable() {
+ true
+ }
+ }
+
+ static class NotReloading extends UrlMappingsGrailsPlugin {
+
+ @Override
+ protected boolean isEnvironmentReloadable() {
+ false
+ }
+ }
+
private static void applyRegistrar(DefaultListableBeanFactory beanFactory, GrailsApplication application) {
def plugin = new UrlMappingsGrailsPlugin(grailsApplication: application)
def registrar = plugin.beanRegistrar()
diff --git a/grails-web-databinding/src/main/groovy/org/grails/web/databinding/aot/DataBindingRuntimeHints.java b/grails-web-databinding/src/main/groovy/org/grails/web/databinding/aot/DataBindingRuntimeHints.java
new file mode 100644
index 00000000000..49e004e19cf
--- /dev/null
+++ b/grails-web-databinding/src/main/groovy/org/grails/web/databinding/aot/DataBindingRuntimeHints.java
@@ -0,0 +1,62 @@
+/*
+ * 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.databinding.aot;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.aot.hint.MemberCategory;
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+
+/**
+ * Registers the data binding API a request is bound through.
+ *
+ *
Binding is reached only by a request that carries a body or parameters to bind, so a
+ * read-only walk of an application never records it and the absence shows up the first time
+ * a form is submitted.
+ *
+ * @since 8.0
+ */
+public class DataBindingRuntimeHints implements RuntimeHintsRegistrar {
+
+ /**
+ * Types Groovy dispatches on. Named as strings, and registered only when present, so this stays
+ * correct for an application that does not use every plugin.
+ */
+ private static final String[] DISPATCHED_TYPES = {
+ "grails.web.databinding.WebDataBinding",
+ "grails.web.databinding.DataBindingUtils",
+ "grails.databinding.DataBindingSource",
+ "grails.databinding.BindingHelper",
+ "grails.databinding.converters.ValueConverter",
+ // binding asks a target type whether it is an array, and Groovy makes that call
+ // reflectively on the Class object rather than directly
+ "java.lang.Class"
+ };
+
+ @Override
+ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
+ for (String type : DISPATCHED_TYPES) {
+ hints.reflection().registerTypeIfPresent(classLoader, type,
+ MemberCategory.INVOKE_DECLARED_METHODS,
+ MemberCategory.INVOKE_PUBLIC_METHODS,
+ MemberCategory.INVOKE_DECLARED_CONSTRUCTORS);
+ }
+ }
+}
diff --git a/grails-web-databinding/src/main/resources/META-INF/spring/aot.factories b/grails-web-databinding/src/main/resources/META-INF/spring/aot.factories
new file mode 100644
index 00000000000..09045e2d0db
--- /dev/null
+++ b/grails-web-databinding/src/main/resources/META-INF/spring/aot.factories
@@ -0,0 +1,2 @@
+org.springframework.aot.hint.RuntimeHintsRegistrar=\
+org.grails.web.databinding.aot.DataBindingRuntimeHints
diff --git a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/aot/UrlMappingRuntimeHints.java b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/aot/UrlMappingRuntimeHints.java
new file mode 100644
index 00000000000..14fd803f69a
--- /dev/null
+++ b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/aot/UrlMappingRuntimeHints.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.mapping.aot;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.aot.hint.MemberCategory;
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+
+/**
+ * Registers the URL mapping API a request is dispatched through.
+ *
+ *
Resolving a request reads the matched mapping reflectively through Groovy, so the
+ * declared methods of these types have to survive into the image. The parameter accessor in
+ * particular is reached only once a mapping carries parameters, which a walk of an
+ * application's pages need not do.
+ *
+ * @since 8.0
+ */
+public class UrlMappingRuntimeHints implements RuntimeHintsRegistrar {
+
+ /**
+ * Types Groovy dispatches on. Named as strings, and registered only when present, so this stays
+ * correct for an application that does not use every plugin.
+ */
+ private static final String[] DISPATCHED_TYPES = {
+ "grails.web.mapping.UrlMappingInfo",
+ "grails.web.mapping.UrlMapping",
+ "grails.web.mapping.UrlMappings",
+ "grails.web.mapping.UrlCreator",
+ "grails.web.mapping.LinkGenerator",
+ "grails.web.mapping.UrlMappingData"
+ };
+
+ @Override
+ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
+ for (String type : DISPATCHED_TYPES) {
+ hints.reflection().registerTypeIfPresent(classLoader, type,
+ MemberCategory.INVOKE_DECLARED_METHODS,
+ MemberCategory.INVOKE_PUBLIC_METHODS,
+ MemberCategory.INVOKE_DECLARED_CONSTRUCTORS);
+ }
+ }
+}
diff --git a/grails-web-url-mappings/src/main/resources/META-INF/spring/aot.factories b/grails-web-url-mappings/src/main/resources/META-INF/spring/aot.factories
new file mode 100644
index 00000000000..f1d12f9db9c
--- /dev/null
+++ b/grails-web-url-mappings/src/main/resources/META-INF/spring/aot.factories
@@ -0,0 +1,2 @@
+org.springframework.aot.hint.RuntimeHintsRegistrar=\
+org.grails.web.mapping.aot.UrlMappingRuntimeHints
diff --git a/settings.gradle b/settings.gradle
index ac25c7e025f..820ffbe9c3d 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -526,6 +526,7 @@ project(':grails-test-examples-redis').projectDir = new File(settingsDir, 'grail
// Functional Tests
include(
+ 'grails-test-examples-aot',
'grails-test-examples-app1',
'grails-test-examples-app2',
'grails-test-examples-app3',
@@ -573,6 +574,7 @@ include(
project(':grails-test-examples-async-events-pubsub-demo').projectDir = file('grails-test-examples/async-events-pubsub-demo')
project(':grails-test-examples-beans-dsl').projectDir = file('grails-test-examples/beans-dsl')
project(':grails-test-examples-beans-dsl-plugin').projectDir = file('grails-test-examples/beans-dsl-plugin')
+project(':grails-test-examples-aot').projectDir = file('grails-test-examples/aot')
project(':grails-test-examples-app1').projectDir = file('grails-test-examples/app1')
project(':grails-test-examples-app2').projectDir = file('grails-test-examples/app2')
project(':grails-test-examples-app3').projectDir = file('grails-test-examples/app3')