diff --git a/grails-bootstrap/src/main/resources/grails-banner.txt b/grails-bootstrap/src/main/resources/grails-banner.txt index fdc35394390..c6792b01bcf 100644 --- a/grails-bootstrap/src/main/resources/grails-banner.txt +++ b/grails-bootstrap/src/main/resources/grails-banner.txt @@ -1,4 +1,3 @@ - > < > ____ _ _ < > / ___|_ __ __ _(_) |___ < @@ -6,4 +5,4 @@ > | |_| | | | (_| | | \__ \ < > \____|_| \__,_|_|_|___/ < > https://grails.apache.org < -> < +> < \ No newline at end of file diff --git a/grails-core/src/main/groovy/grails/boot/GrailsApp.groovy b/grails-core/src/main/groovy/grails/boot/GrailsApp.groovy index 16bc28b2a88..3aac0c6d0f0 100644 --- a/grails-core/src/main/groovy/grails/boot/GrailsApp.groovy +++ b/grails-core/src/main/groovy/grails/boot/GrailsApp.groovy @@ -27,12 +27,10 @@ import org.codehaus.groovy.control.CompilationFailedException import org.codehaus.groovy.control.CompilationUnit import org.codehaus.groovy.control.CompilerConfiguration -import org.springframework.boot.ResourceBanner import org.springframework.boot.SpringApplication import org.springframework.boot.web.context.WebServerApplicationContext import org.springframework.context.ConfigurableApplicationContext import org.springframework.core.env.ConfigurableEnvironment -import org.springframework.core.io.ClassPathResource import org.springframework.core.io.ResourceLoader import grails.compiler.ast.ClassInjector @@ -61,7 +59,6 @@ import org.grails.plugins.support.WatchPattern @CompileStatic class GrailsApp extends SpringApplication { - private static final String GRAILS_BANNER = 'grails-banner.txt' private static final String SPRING_PROFILES = 'spring.profiles.active' private static boolean developmentModeActive = false @@ -95,7 +92,7 @@ class GrailsApp extends SpringApplication { */ GrailsApp(ResourceLoader resourceLoader, Class... sources) { super(resourceLoader, sources) - banner = new ResourceBanner(new ClassPathResource(GRAILS_BANNER)) + banner = new GrailsBanner() } @Override diff --git a/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy b/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy new file mode 100644 index 00000000000..f4163944747 --- /dev/null +++ b/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy @@ -0,0 +1,380 @@ +/* + * 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 groovy.transform.CompileStatic +import groovy.transform.MapConstructor +import groovy.transform.stc.ClosureParams +import groovy.transform.stc.SimpleType + +import org.springframework.boot.Banner +import org.springframework.boot.SpringBootVersion +import org.springframework.core.SpringVersion +import org.springframework.core.env.Environment +import org.springframework.core.io.ClassPathResource + +import grails.util.BuildSettings + +/** + * The default Grails application banner. + * + * @since 7.1 + */ +@CompileStatic +@MapConstructor(noArg = true) +class GrailsBanner implements Banner { + + private static final int FALLBACK_BANNER_WIDTH = 0 + private static final String DEFAULT_BANNER_FILE = 'grails-banner.txt' + + String bannerFile = DEFAULT_BANNER_FILE + int bannerPaddingTop = 1 + int bannerPaddingBottom = 1 + int artPaddingBottom = 0 + + /** + * Prints the banner to the specified PrintStream. + * + * @param environment the current environment + * @param sourceClass the source class + * @param out the PrintStream to print to + */ + @Override + void printBanner(Environment environment, Class sourceClass, PrintStream out) { + + def bannerWidth = FALLBACK_BANNER_WIDTH + + bannerPaddingTop.times { out.println() } + if (shouldDisplayArt(environment)) { + def art = createBannerArt(environment) + bannerWidth = longestLineLength(art) ?: FALLBACK_BANNER_WIDTH + out.println(art) + } + artPaddingBottom.times { out.println() } + if (shouldDisplayVersions(environment)) { + createVersionsFormatter().format(createBannerVersions(environment), bannerWidth) + .forEach { out.println(it) } + } + bannerPaddingBottom.times { out.println() } + } + + /** + * Creates the banner art to be displayed. + * + * @param environment the current environment + * @return the banner art + */ + protected String createBannerArt(Environment environment) { + if (bannerFile != DEFAULT_BANNER_FILE) { + // Banner file was programmatically set, use it directly + def customBannerResource = new ClassPathResource(bannerFile) + if (customBannerResource.exists()) { + return customBannerResource.inputStream.text + } + } else { + // Use configured banner file or default + def configBannerFile = environment.getProperty('grails.banner.art.file', String, DEFAULT_BANNER_FILE) + def bannerResource = new ClassPathResource(configBannerFile) + if (bannerResource.exists()) { + return bannerResource.inputStream.text + } + } + return '' + } + + /** + * Creates a map of versions to be displayed in the 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 includedVersions = defaultIncluded + .tap { removeAll(configExcluded) } + .tap { addAll(configIncluded) } + .unique() + if (sortOrder) { + includedVersions.sort { a, b -> + def indexA = sortOrder.indexOf(a) + def indexB = sortOrder.indexOf(b) + if (indexA == -1 && indexB == -1) { + return 0 + } else if (indexA == -1) { + return 1 + } else if (indexB == -1) { + return -1 + } else { + return indexA <=> indexB + } + } + } + includedVersions.collectEntries { key -> + switch (VersionOption.fromString(key)) { + case VersionOption.APP: + [(env.getProperty('info.app.name') ?: 'app'): env.getProperty('info.app.version') ?: 'unknown'] + break + case VersionOption.JVM: + ['JVM': System.getProperty('java.vendor') + ' ' + System.getProperty('java.version')] + break + case VersionOption.GRAILS: + ['Grails': BuildSettings.grailsVersion] + break + case VersionOption.GROOVY: + ['Groovy': GroovySystem.version] + break + case VersionOption.SPRING_BOOT: + ['Spring Boot': SpringBootVersion.version] + break + case VersionOption.SPRING: + ['Spring': SpringVersion.version] + break + case VersionOption.SPRING_SECURITY: + ['Spring Security': findVersion('org.springframework.security.core.SpringSecurityCoreVersion')] + break + case VersionOption.TOMCAT: + ['Tomcat': findVersion('org.apache.catalina.util.ServerInfo')] + break + case VersionOption.JETTY: + ['Jetty': findVersion('org.eclipse.jetty.util.Jetty')] + break + case VersionOption.UNDERTOW: + ['Undertow': findVersion('io.undertow.Undertow')] + break + default: + null + } + } as Map + } + + /** + * Finds the implementation version of the specified class. + * + * @param className the fully qualified class name + * @return the implementation version, or 'unknown' if not found + */ + private static String findVersion(String className) { + try { + def pkg = Class.forName(className).package + return pkg?.implementationVersion ?: 'unknown' + } catch (ClassNotFoundException ignore) { + return 'unknown' + } + } + + /** + * Finds the configured versions from the environment. + * + * @param env the current environment + * @param propertyName the property name to look for + * @param filter the filter closure + * @return a list of configured versions + */ + 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) + } + + /** + * Determines whether to display the banner art. + * + * @param env the current environment + * @return true if the banner art should be displayed, false otherwise + */ + @SuppressWarnings('GrMethodMayBeStatic') + protected boolean shouldDisplayArt(Environment env) { + env.getProperty('grails.banner.art.display', Boolean, true) + } + + /** + * Determines whether to display the version information. + * + * @param env the current environment + * @return true if the version information should be displayed, false otherwise + */ + @SuppressWarnings('GrMethodMayBeStatic') + protected boolean shouldDisplayVersions(Environment env) { + env.getProperty('grails.banner.versions.display', Boolean, true) + } + + /** + * Creates the versions formatter to format the version information. + * + * @return the versions formatter + */ + protected VersionsFormatter createVersionsFormatter() { + new DefaultVersionFormatter() + } + + /** + * Calculates the length of the longest line in the given text. + * + * @param text the text to analyze + * @return the length of the longest line + */ + private static int longestLineLength(String text) { + text.readLines()*.size()?.max() ?: 0 + } + + /** + * Strategy interface for formatting version information + * into printable lines. + */ + @FunctionalInterface + interface VersionsFormatter { + + /** + * Formats the version information into a list of banner lines. + * + * @param versions An insertion-ordered map (e.g., LinkedHashMap) + * mapping human-readable labels to version values. + * The iteration order defines the order of the + * formatted output. + * @param bannerWidth Total banner width in characters + * @return a list of lines to print, without line-termination characters + */ + List format(Map versions, int bannerWidth) + } + + /** + * The default implementation of the VersionsFormatter. + */ + @CompileStatic + @MapConstructor(noArg = true) + static class DefaultVersionFormatter implements VersionsFormatter { + + int margin = 4 + int maxItemsPerRow = 0 // 0 or negative = unlimited + String itemSeparator = ' | ' + String pairSeparator = ': ' + + /** + * Formats the version information into centered lines that fit + * within the banner width. + */ + @Override + List format(Map versions, int bannerWidth) { + def columnWidth = bannerWidth - margin * 2 + List rows = [] + def currentRow = new StringBuilder() + def countInRow = 0 + + versions.each { k, v -> + String item = "$k${pairSeparator}$v" + def proposedLength = currentRow.length() + (countInRow > 0 ? itemSeparator.size() : 0) + item.size() + def wouldOverflow = (countInRow > 0 && proposedLength > columnWidth) + def hitCountLimit = (maxItemsPerRow > 0 && countInRow >= maxItemsPerRow) + + if (wouldOverflow || hitCountLimit) { + rows << currentRow.center(bannerWidth) + currentRow.length = 0 + countInRow = 0 + } + + if (currentRow.size() > 0) { + currentRow << itemSeparator + } + currentRow << item + countInRow++ + } + + if (countInRow > 0) { + rows << currentRow.center(bannerWidth) + } + + return rows + } + } + + /** + * Enumeration of supported version options. + */ + @CompileStatic + enum VersionOption { + APP, + JVM, + GRAILS, + GROOVY, + SPRING_BOOT, + SPRING, + SPRING_SECURITY, + TOMCAT, + JETTY, + UNDERTOW + + final String key + + VersionOption() { + this.key = name().toLowerCase().replace('_', '-') + } + + static VersionOption fromString(String value) { + try { + return valueOf(value.toUpperCase().replace('-', '_')) + } catch (IllegalArgumentException ignore) { + return null + } + } + } + + /** + * Enumeration of default version options. + */ + @CompileStatic + enum DefaultVersionOption { + APP, + JVM, + GRAILS, + GROOVY, + SPRING_BOOT, + SPRING + + final String key + + DefaultVersionOption() { + this.key = name().toLowerCase().replace('_', '-') + } + } + + /** + * Enumeration of optional version options. + */ + @CompileStatic + enum OptionalVersionOption { + SPRING_SECURITY, + TOMCAT, + JETTY, + UNDERTOW + + final String key + + OptionalVersionOption() { + this.key = name().toLowerCase().replace('_', '-') + } + } +} 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 new file mode 100644 index 00000000000..e4ca458afdd --- /dev/null +++ b/grails-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,37 @@ +{ + "properties": [ + { + "name": "grails.banner.art.display", + "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.versions.display", + "description": "Whether to display version information in the Grails banner.", + "type": "java.lang.Boolean", + "defaultValue": true + }, + { + "name": "grails.banner.versions.include", + "description": "A list of optional versions to include in the banner.", + "type": "java.util.List" + }, + { + "name": "grails.banner.versions.exclude", + "description": "A list of versions to exclude from the banner.", + "type": "java.util.List" + }, + { + "name": "grails.banner.versions.order", + "description": "A sorted list of versions to put first in the banner versions.", + "type": "java.util.List" + } + ] +} \ No newline at end of file diff --git a/grails-doc/src/en/guide/conf/applicationClass/customizing.adoc b/grails-doc/src/en/guide/conf/applicationClass/customizing.adoc index 3363bf86c40..abab83a105a 100644 --- a/grails-doc/src/en/guide/conf/applicationClass/customizing.adoc +++ b/grails-doc/src/en/guide/conf/applicationClass/customizing.adoc @@ -54,3 +54,128 @@ class Application extends GrailsAutoConfiguration { ... } ---- + +[id=customizing-the-banner] +=== Customizing the Banner + +The banner that is printed on application startup can be customized in several ways. You can use the Spring Boot way and provide your own banner text file by placing a file named `banner.txt` in the `src/main/resources` directory of your application. This will replace the default Grails banner described below. + +To turn off the banner completely, set the `spring.main.banner-mode` property to `off` in the `application.yml` file: +[source,yaml] +---- +spring: + main: + banner-mode: "off" +---- + +The default Grails banner is configurable via the following configuration properties: +[source,yaml] +---- +grails: + banner: + art: + display: true # Whether to display the banner art (default: true). + file: 'banner.txt' # Path to a custom banner file on the classpath (default: null). + versions: + display: true # Whether to display version information (default: true). + include: + # Include optional versions (default: []). + # These will trigger class loading to determine the version + # and show 'unknown' if the class is not found. + # Valid options are: + - spring-security + - tomcat + - jetty + - undertow + exclude: + # Exclude any of the versions included by default (default: []). + # Valid options are: + - app + - grails + - groovy + - jvm + - spring-boot + - spring + order: + # Optional ordering in which to display the versions in the banner. + # You can specify any number of the options listed below + # to move them to the top of the list in the specified order. + # If no config is specified, the default order is: + - app + - jvm + - grails + - groovy + - spring-boot + - spring + - spring-security + - tomcat + - jetty + - undertow +---- + +It is also possible to customize the Grails banner by overriding any or all of the following methods of the `GrailsBanner` class and assigning it to the `banner` property of the `GrailsApp` class: +[source,groovy] +---- +import grails.boot.GrailsApp +import grails.boot.banner.GrailsBanner +import grails.boot.config.GrailsAutoConfiguration +import org.springframework.core.env.Environment + +class Application extends GrailsAutoConfiguration { + + static void main(String[] args) { + def app = new GrailsApp(Application) + app.banner = new GrailsBanner( + // You can specify any path on the classpath here, + // typically stored in src/main/resources. + // This will take precedence over the 'grails.banner.art.file' config value + bannerFile: 'my-banner.txt', // default: 'grails-banner.txt' (provided by Grails) + + // Number of blank lines to print for padding + paddingTop: 10, // default: 1 + artPaddingBottom: 1, // default: 0 + paddingBottom: 10 // default: 1 + ) { + + @Override + String createBannerArt(Environment env) { + // This will take precedence over the bannerFile property + 'MY CUSTOM BANNER!' + } + + @Override + Map createBannerVersions(Environment env) { + // Return a map of version names to version values + super.createBannerVersions(env) + ['Some version': '1.0.0'] + } + + @Override + boolean shouldDisplayArt(Environment env) { true } + + @Override + boolean shouldDisplayVersions(Environment env) { true } + + @Override + GrailsBanner.VersionsFormatter createVersionsFormatter() { + // You can provide your own implementation of the VersionsFormatter interface: + def functionalInterfaceExample = { Map versions, int bannerWidth -> + // Return a list of strings representing the lines printed, + // in this case, one single line with all versions, comma-separated + [versions.collect { k, v -> "$k: $v" }.join(', ')] + } as GrailsBanner.VersionsFormatter + + // or customize the default implementation: + def customizingDefaultExample = new GrailsBanner.DefaultVersionsFormatter( + margin: 10, // min spaces to the left and right before breaking, default: 4 + maxItemsPerRow: 3, // 0 or negative = unlimited, default: 0 + itemSeparator: ' <> ', // default: ' | ' + pairSeparator: '-> ' // default: ': ' + ) + + return functionalInterfaceExample + } + } + app.run(args) + } +} +---- diff --git a/grails-doc/src/en/guide/introduction/whatsNew.adoc b/grails-doc/src/en/guide/introduction/whatsNew.adoc index bd984f31105..ad1b55d2f9e 100644 --- a/grails-doc/src/en/guide/introduction/whatsNew.adoc +++ b/grails-doc/src/en/guide/introduction/whatsNew.adoc @@ -74,7 +74,7 @@ The `@Scaffold` annotation was added to customize scaffolding generation for con ==== Bootstrap 5.3.3 support Bootstrap 5.3.3 support. -Saffolding and Fields tags now optionally support boostrap classes. +Scaffolding and Fields tags now optionally support boostrap classes. ==== Prioritization of AutoConfiguration over bean overriding. @@ -89,5 +89,8 @@ With the removal of Micronaut, and the fixes to the asset pipeline plugin, Grail The `g:form` tag now automatically provides csrf protection when Spring Security CSRF is enabled. +==== Grails Banner versions and customization (Grails 7.1+) - +A Grails banner was introduced in Grails 7 that is displayed on application startup. +From Grails 7.1 onwards, this banner now shows versions of foundational dependencies +and can be xref:conf.adoc#customizing-the-banner[customized].