From 0ecfc3605c2b30dd1cbccd9f6f270d6041acf12e Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Thu, 4 Jun 2026 18:40:52 -0700 Subject: [PATCH 1/4] Add typed attribute converters to TypeConvertingMap, session, request, flash and servletContext Extract the value-level conversion logic into an internal helper class `org.grails.util.TypeConverters` (toByte/toInteger/toLong/toBoolean/ toStringValue/toDate/toList, etc., each with a default-value overload), have the existing `TypeConvertingMap` instance `getX` methods delegate to it, and add a `string`/`getString` converter. Expose the full set of converters (`string`, `byte`, `char`, `short`, `int`, `long`, `double`, `float`, `boolean`, `list`, `date`) directly on `HttpSession`, `HttpServletRequest`, `ServletContext` and the `flash` scope (`FlashScope`) by calling the internal converters on the attribute value, so attribute access is statically compilable and type-safe under `@CompileStatic`/`@GrailsCompileStatic`, e.g.: session.int('age', 21) session.string('tz', 'America/Los_Angeles') flash.string('notice') Both the conversion logic and the default-value handling live in the single internal `TypeConverters` class, shared by the map and the extensions, with no new conversion methods added to the public `TypeConvertingMap` API. Calling the internal converters directly avoids any per-access allocation. The only exception is the boolean default, whose "is the value present" rule differs by holder (map containsKey vs attribute set) and so stays per-site by design. Includes tests covering conversion, null-safety, defaults and static resolution, plus documentation. --- .../util/AbstractTypeConvertingMap.java | 221 +++----------- .../grails/util/TypeConvertingMap.groovy | 8 + .../org/grails/util/TypeConverters.java | 274 ++++++++++++++++++ .../grails/util/TypeConvertingMapTests.groovy | 68 +++++ .../controllers/typeConverters.adoc | 27 ++ .../web/servlet/FlashScopeExtension.groovy | 127 ++++++++ .../HttpServletRequestExtension.groovy | 100 ++++++- .../web/servlet/HttpSessionExtension.groovy | 106 ++++++- .../servlet/ServletContextExtension.groovy | 101 ++++++- ...rg.codehaus.groovy.runtime.ExtensionModule | 2 +- .../servlet/FlashScopeExtensionSpec.groovy | 82 ++++++ .../HttpServletRequestExtensionSpec.groovy | 76 +++++ .../servlet/HttpSessionExtensionSpec.groovy | 104 +++++++ .../ServletContextExtensionSpec.groovy | 72 +++++ 14 files changed, 1173 insertions(+), 195 deletions(-) create mode 100644 grails-core/src/main/groovy/org/grails/util/TypeConverters.java create mode 100644 grails-web-core/src/main/groovy/org/grails/web/servlet/FlashScopeExtension.groovy create mode 100644 grails-web-core/src/test/groovy/org/grails/web/servlet/FlashScopeExtensionSpec.groovy create mode 100644 grails-web-core/src/test/groovy/org/grails/web/servlet/HttpServletRequestExtensionSpec.groovy create mode 100644 grails-web-core/src/test/groovy/org/grails/web/servlet/HttpSessionExtensionSpec.groovy create mode 100644 grails-web-core/src/test/groovy/org/grails/web/servlet/ServletContextExtensionSpec.groovy diff --git a/grails-core/src/main/groovy/grails/util/AbstractTypeConvertingMap.java b/grails-core/src/main/groovy/grails/util/AbstractTypeConvertingMap.java index 37e486b51f2..09d45ca9124 100644 --- a/grails-core/src/main/groovy/grails/util/AbstractTypeConvertingMap.java +++ b/grails-core/src/main/groovy/grails/util/AbstractTypeConvertingMap.java @@ -18,12 +18,7 @@ */ package grails.util; -import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Arrays; import java.util.Collection; -import java.util.Collections; import java.util.Date; import java.util.Iterator; import java.util.LinkedHashMap; @@ -35,6 +30,8 @@ import org.codehaus.groovy.runtime.DefaultGroovyMethods; import org.codehaus.groovy.util.HashCodeHelper; +import org.grails.util.TypeConverters; + /** * AbstractTypeConvertingMap is a Map with type conversion capabilities. * @@ -47,7 +44,6 @@ */ @SuppressWarnings({ "rawtypes", "unchecked" }) public abstract class AbstractTypeConvertingMap extends GroovyObjectSupport implements Map, Cloneable { - private static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd HH:mm:ss.S"; protected Map wrappedMap; public AbstractTypeConvertingMap() { @@ -120,29 +116,11 @@ public int hashCode() { * @return The integer value or null if there isn't one */ public Byte getByte(String name) { - Object o = get(name); - if (o instanceof Number) { - return ((Number) o).byteValue(); - } - - if (o != null) { - try { - String string = o.toString(); - if (string != null && string.length() > 0) { - return Byte.parseByte(string); - } - } - catch (NumberFormatException e) {} - } - return null; + return TypeConverters.toByte(get(name)); } public Byte getByte(String name, Integer defaultValue) { - Byte value = getByte(name); - if (value == null && defaultValue != null) { - value = (byte) defaultValue.intValue(); - } - return value; + return TypeConverters.toByte(get(name), defaultValue); } /** @@ -151,26 +129,11 @@ public Byte getByte(String name, Integer defaultValue) { * @return The Character value or null if there isn't one */ public Character getChar(String name) { - Object o = get(name); - if (o instanceof Character) { - return (Character) o; - } - - if (o != null) { - String string = o.toString(); - if (string != null && string.length() == 1) { - return string.charAt(0); - } - } - return null; + return TypeConverters.toCharacter(get(name)); } public Character getChar(String name, Integer defaultValue) { - Character value = getChar(name); - if (value == null && defaultValue != null) { - value = (char) defaultValue.intValue(); - } - return value; + return TypeConverters.toCharacter(get(name), defaultValue); } /** @@ -179,29 +142,11 @@ public Character getChar(String name, Integer defaultValue) { * @return The integer value or null if there isn't one */ public Integer getInt(String name) { - Object o = get(name); - if (o instanceof Number) { - return ((Number) o).intValue(); - } - - if (o != null) { - try { - String string = o.toString(); - if (string != null) { - return Integer.parseInt(string); - } - } - catch (NumberFormatException e) {} - } - return null; + return TypeConverters.toInteger(get(name)); } public Integer getInt(String name, Integer defaultValue) { - Integer value = getInt(name); - if (value == null) { - value = defaultValue; - } - return value; + return TypeConverters.toInteger(get(name), defaultValue); } /** @@ -210,26 +155,11 @@ public Integer getInt(String name, Integer defaultValue) { * @return The long value or null if there isn't one */ public Long getLong(String name) { - Object o = get(name); - if (o instanceof Number) { - return ((Number) o).longValue(); - } - - if (o != null) { - try { - return Long.parseLong(o.toString()); - } - catch (NumberFormatException e) {} - } - return null; + return TypeConverters.toLong(get(name)); } public Long getLong(String name, Long defaultValue) { - Long value = getLong(name); - if (value == null) { - value = defaultValue; - } - return value; + return TypeConverters.toLong(get(name), defaultValue); } /** @@ -238,29 +168,11 @@ public Long getLong(String name, Long defaultValue) { * @return The short value or null if there isn't one */ public Short getShort(String name) { - Object o = get(name); - if (o instanceof Number) { - return ((Number) o).shortValue(); - } - - if (o != null) { - try { - String string = o.toString(); - if (string != null) { - return Short.parseShort(string); - } - } - catch (NumberFormatException e) {} - } - return null; + return TypeConverters.toShort(get(name)); } public Short getShort(String name, Integer defaultValue) { - Short value = getShort(name); - if (value == null && defaultValue != null) { - value = defaultValue.shortValue(); - } - return value; + return TypeConverters.toShort(get(name), defaultValue); } /** @@ -269,29 +181,11 @@ public Short getShort(String name, Integer defaultValue) { * @return The double value or null if there isn't one */ public Double getDouble(String name) { - Object o = get(name); - if (o instanceof Number) { - return ((Number) o).doubleValue(); - } - - if (o != null) { - try { - String string = o.toString(); - if (string != null) { - return Double.parseDouble(string); - } - } - catch (NumberFormatException e) {} - } - return null; + return TypeConverters.toDouble(get(name)); } public Double getDouble(String name, Double defaultValue) { - Double value = getDouble(name); - if (value == null) { - value = defaultValue; - } - return value; + return TypeConverters.toDouble(get(name), defaultValue); } /** @@ -300,29 +194,11 @@ public Double getDouble(String name, Double defaultValue) { * @return The double value or null if there isn't one */ public Float getFloat(String name) { - Object o = get(name); - if (o instanceof Number) { - return ((Number) o).floatValue(); - } - - if (o != null) { - try { - String string = o.toString(); - if (string != null) { - return Float.parseFloat(string); - } - } - catch (NumberFormatException e) {} - } - return null; + return TypeConverters.toFloat(get(name)); } public Float getFloat(String name, Float defaultValue) { - Float value = getFloat(name); - if (value == null) { - value = defaultValue; - } - return value; + return TypeConverters.toFloat(get(name), defaultValue); } /** @@ -331,21 +207,7 @@ public Float getFloat(String name, Float defaultValue) { * @return The boolean value or null if there isn't one */ public Boolean getBoolean(String name) { - Object o = get(name); - if (o instanceof Boolean) { - return (Boolean) o; - } - - if (o != null) { - try { - String string = o.toString(); - if (string != null) { - return GrailsStringUtils.toBoolean(string); - } - } - catch (Exception e) {} - } - return null; + return TypeConverters.toBoolean(get(name)); } public Boolean getBoolean(String name, Boolean defaultValue) { @@ -358,13 +220,26 @@ public Boolean getBoolean(String name, Boolean defaultValue) { return value; } + /** + * Helper method for obtaining a String value from a parameter + * @param name The name of the parameter + * @return The String value or null if there isn't one + */ + public String getString(String name) { + return TypeConverters.toStringValue(get(name)); + } + + public String getString(String name, String defaultValue) { + return TypeConverters.toStringValue(get(name), defaultValue); + } + /** * Obtains a date for the parameter name using the default format * @param name - * @return The date (in the {@link DEFAULT_DATE_FORMAT}) or null + * @return The date or null */ public Date getDate(String name) { - return getDate(name, DEFAULT_DATE_FORMAT); + return TypeConverters.toDate(get(name)); } /** @@ -374,19 +249,7 @@ public Date getDate(String name) { * @return The date or null */ public Date getDate(String name, String format) { - Object value = get(name); - if (value instanceof Date) { - return (Date) value; - } - - if (value != null) { - try { - return new SimpleDateFormat(format).parse(value.toString()); - } catch (ParseException e) { - // ignore - } - } - return null; + return TypeConverters.toDate(get(name), format); } /** @@ -422,11 +285,7 @@ public Date date(String name, Collection formats) { } private Date getDate(String name, Collection formats) { - for (String format : formats) { - Date date = getDate(name, format); - if (date != null) return date; - } - return null; + return TypeConverters.toDate(get(name), formats); } /** @@ -435,17 +294,7 @@ private Date getDate(String name, Collection formats) { * @return A list of values */ public List getList(String name) { - Object paramValues = get(name); - if (paramValues == null) { - return Collections.emptyList(); - } - if (paramValues.getClass().isArray()) { - return Arrays.asList((Object[]) paramValues); - } - if (paramValues instanceof Collection) { - return new ArrayList((Collection) paramValues); - } - return Collections.singletonList(paramValues); + return TypeConverters.toList(get(name)); } public List list(String name) { diff --git a/grails-core/src/main/groovy/grails/util/TypeConvertingMap.groovy b/grails-core/src/main/groovy/grails/util/TypeConvertingMap.groovy index 7a503e48a9b..2156b6fac49 100644 --- a/grails-core/src/main/groovy/grails/util/TypeConvertingMap.groovy +++ b/grails-core/src/main/groovy/grails/util/TypeConvertingMap.groovy @@ -111,4 +111,12 @@ class TypeConvertingMap extends AbstractTypeConvertingMap { Boolean 'boolean'(String name, Boolean defaultValue) { return getBoolean(name, defaultValue) } + + String string(String name) { + return getString(name) + } + + String string(String name, String defaultValue) { + return getString(name, defaultValue) + } } diff --git a/grails-core/src/main/groovy/org/grails/util/TypeConverters.java b/grails-core/src/main/groovy/org/grails/util/TypeConverters.java new file mode 100644 index 00000000000..a7d70ab9c27 --- /dev/null +++ b/grails-core/src/main/groovy/org/grails/util/TypeConverters.java @@ -0,0 +1,274 @@ +/* + * 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.util; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +import grails.util.GrailsStringUtils; + +/** + * Internal helpers that convert a raw {@code Object} value to a target type without allocating + * an intermediate map. These power the type conversion methods on {@code TypeConvertingMap} and + * the servlet attribute extensions, keeping the conversion logic in a single place. + * + *

This is an internal class and is not part of the public Grails API. + * + * @since 8.0 + */ +@SuppressWarnings({ "rawtypes", "unchecked" }) +public final class TypeConverters { + + public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd HH:mm:ss.S"; + + private TypeConverters() { + } + + public static Byte toByte(Object o) { + if (o instanceof Number) { + return ((Number) o).byteValue(); + } + if (o != null) { + try { + String string = o.toString(); + if (string != null && string.length() > 0) { + return Byte.parseByte(string); + } + } + catch (NumberFormatException e) {} + } + return null; + } + + public static Character toCharacter(Object o) { + if (o instanceof Character) { + return (Character) o; + } + if (o != null) { + String string = o.toString(); + if (string != null && string.length() == 1) { + return string.charAt(0); + } + } + return null; + } + + public static Integer toInteger(Object o) { + if (o instanceof Number) { + return ((Number) o).intValue(); + } + if (o != null) { + try { + String string = o.toString(); + if (string != null) { + return Integer.parseInt(string); + } + } + catch (NumberFormatException e) {} + } + return null; + } + + public static Long toLong(Object o) { + if (o instanceof Number) { + return ((Number) o).longValue(); + } + if (o != null) { + try { + return Long.parseLong(o.toString()); + } + catch (NumberFormatException e) {} + } + return null; + } + + public static Short toShort(Object o) { + if (o instanceof Number) { + return ((Number) o).shortValue(); + } + if (o != null) { + try { + String string = o.toString(); + if (string != null) { + return Short.parseShort(string); + } + } + catch (NumberFormatException e) {} + } + return null; + } + + public static Double toDouble(Object o) { + if (o instanceof Number) { + return ((Number) o).doubleValue(); + } + if (o != null) { + try { + String string = o.toString(); + if (string != null) { + return Double.parseDouble(string); + } + } + catch (NumberFormatException e) {} + } + return null; + } + + public static Float toFloat(Object o) { + if (o instanceof Number) { + return ((Number) o).floatValue(); + } + if (o != null) { + try { + String string = o.toString(); + if (string != null) { + return Float.parseFloat(string); + } + } + catch (NumberFormatException e) {} + } + return null; + } + + public static Boolean toBoolean(Object o) { + if (o instanceof Boolean) { + return (Boolean) o; + } + if (o != null) { + try { + String string = o.toString(); + if (string != null) { + return GrailsStringUtils.toBoolean(string); + } + } + catch (Exception e) {} + } + return null; + } + + public static String toStringValue(Object o) { + if (o == null) { + return null; + } + if (o instanceof Object[]) { + Object[] array = (Object[]) o; + return array.length > 0 && array[0] != null ? array[0].toString() : null; + } + return o.toString(); + } + + public static Date toDate(Object value) { + return toDate(value, DEFAULT_DATE_FORMAT); + } + + public static Date toDate(Object value, String format) { + if (value instanceof Date) { + return (Date) value; + } + if (value != null) { + try { + return new SimpleDateFormat(format).parse(value.toString()); + } catch (ParseException e) { + // ignore + } + } + return null; + } + + public static Date toDate(Object value, Collection formats) { + for (String format : formats) { + Date date = toDate(value, format); + if (date != null) return date; + } + return null; + } + + public static List toList(Object paramValues) { + if (paramValues == null) { + return Collections.emptyList(); + } + if (paramValues.getClass().isArray()) { + return Arrays.asList((Object[]) paramValues); + } + if (paramValues instanceof Collection) { + return new ArrayList((Collection) paramValues); + } + return Collections.singletonList(paramValues); + } + + public static Byte toByte(Object o, Integer defaultValue) { + Byte value = toByte(o); + if (value == null && defaultValue != null) { + return (byte) defaultValue.intValue(); + } + return value; + } + + public static Character toCharacter(Object o, Integer defaultValue) { + Character value = toCharacter(o); + if (value == null && defaultValue != null) { + return (char) defaultValue.intValue(); + } + return value; + } + + public static Character toCharacter(Object o, Character defaultValue) { + Character value = toCharacter(o); + return value != null ? value : defaultValue; + } + + public static Short toShort(Object o, Integer defaultValue) { + Short value = toShort(o); + if (value == null && defaultValue != null) { + return defaultValue.shortValue(); + } + return value; + } + + public static Integer toInteger(Object o, Integer defaultValue) { + Integer value = toInteger(o); + return value != null ? value : defaultValue; + } + + public static Long toLong(Object o, Long defaultValue) { + Long value = toLong(o); + return value != null ? value : defaultValue; + } + + public static Double toDouble(Object o, Double defaultValue) { + Double value = toDouble(o); + return value != null ? value : defaultValue; + } + + public static Float toFloat(Object o, Float defaultValue) { + Float value = toFloat(o); + return value != null ? value : defaultValue; + } + + public static String toStringValue(Object o, String defaultValue) { + String value = toStringValue(o); + return value != null ? value : defaultValue; + } +} diff --git a/grails-core/src/test/groovy/org/grails/util/TypeConvertingMapTests.groovy b/grails-core/src/test/groovy/org/grails/util/TypeConvertingMapTests.groovy index 956bea51d41..9b8cb395570 100644 --- a/grails-core/src/test/groovy/org/grails/util/TypeConvertingMapTests.groovy +++ b/grails-core/src/test/groovy/org/grails/util/TypeConvertingMapTests.groovy @@ -54,6 +54,74 @@ class TypeConvertingMapTests { assert !toTypeConverting(a: 1, b: 2).equals(toTypeConverting(b: 2, a: null)) } + @Test + void testGetString() { + def map = toTypeConverting(name: 'Bob', count: 5, missing: null) + + assert map.getString('name') == 'Bob' + assert map.getString('count') == '5' + assert map.getString('missing') == null + assert map.getString('absent') == null + } + + @Test + void testGetStringWithDefault() { + def map = toTypeConverting(name: 'Bob') + + assert map.getString('name', 'fallback') == 'Bob' + assert map.getString('absent', 'fallback') == 'fallback' + } + + @Test + void testGetStringWithArrayValueReturnsFirstElement() { + def map = toTypeConverting(names: ['Bob', 'Judy'] as String[], empty: new String[0]) + + assert map.getString('names') == 'Bob' + assert map.getString('empty') == null + } + + @Test + void testStringFacade() { + def map = toTypeConverting(name: 'Bob') + + assert map.string('name') == 'Bob' + assert map.string('absent', 'fallback') == 'fallback' + } + + @Test + @CompileStatic + void testStringIsStaticallyTyped() { + TypeConvertingMap map = new TypeConvertingMap(name: 'Bob') + String name = map.string('name') + + assert name == 'Bob' + } + + @Test + void testTypeConverters() { + assert TypeConverters.toInteger('42') == 42 + assert TypeConverters.toInteger(42L) == 42 + assert TypeConverters.toInteger('not a number') == null + assert TypeConverters.toInteger(null) == null + assert TypeConverters.toLong('42') == 42L + assert TypeConverters.toBoolean('true') == true + assert TypeConverters.toStringValue(['a', 'b'] as String[]) == 'a' + assert TypeConverters.toList('one') == ['one'] + assert TypeConverters.toList(['a', 'b'] as String[]) == ['a', 'b'] + } + + @Test + void testTypeConvertersWithDefaults() { + assert TypeConverters.toInteger('42', 7) == 42 + assert TypeConverters.toInteger(null, 7) == 7 + assert TypeConverters.toInteger('not a number', 7) == 7 + assert TypeConverters.toStringValue('present', 'fallback') == 'present' + assert TypeConverters.toStringValue(null, 'fallback') == 'fallback' + assert TypeConverters.toByte(null, 5) == (byte) 5 + assert TypeConverters.toShort(null, 5) == (short) 5 + assert TypeConverters.toCharacter(null, (int) 'A') == 'A' as char + } + @Test void testHashCode() { assert toTypeConverting(a: 1, b: 2).hashCode() == toTypeConverting(a: 1, b: 2).hashCode() diff --git a/grails-doc/src/en/guide/theWebLayer/controllers/typeConverters.adoc b/grails-doc/src/en/guide/theWebLayer/controllers/typeConverters.adoc index d461705e090..ba5ef58327e 100644 --- a/grails-doc/src/en/guide/theWebLayer/controllers/typeConverters.adoc +++ b/grails-doc/src/en/guide/theWebLayer/controllers/typeConverters.adoc @@ -53,3 +53,30 @@ for (name in params.list('name')) { println name } ---- + + +=== Type Conversion of Attributes + + +The same convenience methods are also available on the `session`, `request`, `flash` and `servletContext` objects for converting attribute values. This is especially useful under static compilation (for example in a class annotated with `@CompileStatic` or `@GrailsCompileStatic`), where dynamic attribute access such as `session.userTimeZoneId` does not compile and the explicit alternative requires a cast: + +[source,groovy] +---- +// returns an Integer, or null if the attribute is absent or not convertible +def page = request.int('page') + +// returns the attribute as an Integer, or the supplied default if absent +def page = request.int('page', 1) + +// String, Boolean, Long, Date and the other converters are available too +String timeZoneId = session.string('userTimeZoneId', 'America/Los_Angeles') +Boolean active = session.boolean('active', false) + +// flash and servletContext attributes work the same way +String notice = flash.string('notice') +int maxUploads = servletContext.int('maxUploads', 10) +---- + +As with `params`, each method is null-safe and safe from parsing errors, and each accepts an optional default value as a second argument that is returned when the attribute is absent or cannot be converted. + +NOTE: The `string` method returns the first element when the underlying attribute is an array, mirroring the single-value semantics of the other converters. Use the `list` method to obtain every value. diff --git a/grails-web-core/src/main/groovy/org/grails/web/servlet/FlashScopeExtension.groovy b/grails-web-core/src/main/groovy/org/grails/web/servlet/FlashScopeExtension.groovy new file mode 100644 index 00000000000..559c32a49f9 --- /dev/null +++ b/grails-web-core/src/main/groovy/org/grails/web/servlet/FlashScopeExtension.groovy @@ -0,0 +1,127 @@ +/* + * 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.servlet + +import groovy.transform.CompileStatic + +import org.grails.util.TypeConverters + +import grails.web.mvc.FlashScope + +/** + * An extension that adds type conversion methods to the {@link FlashScope} interface + * + * @since 8.0 + */ +@CompileStatic +class FlashScopeExtension { + + static Byte 'byte'(FlashScope flash, String name) { + TypeConverters.toByte(flash.get(name)) + } + + static Byte 'byte'(FlashScope flash, String name, Integer defaultValue) { + TypeConverters.toByte(flash.get(name), defaultValue) + } + + static Character 'char'(FlashScope flash, String name) { + TypeConverters.toCharacter(flash.get(name)) + } + + static Character 'char'(FlashScope flash, String name, Character defaultValue) { + TypeConverters.toCharacter(flash.get(name), defaultValue) + } + + static Character 'char'(FlashScope flash, String name, Integer defaultValue) { + TypeConverters.toCharacter(flash.get(name), defaultValue) + } + + static Short 'short'(FlashScope flash, String name) { + TypeConverters.toShort(flash.get(name)) + } + + static Short 'short'(FlashScope flash, String name, Integer defaultValue) { + TypeConverters.toShort(flash.get(name), defaultValue) + } + + static Integer 'int'(FlashScope flash, String name) { + TypeConverters.toInteger(flash.get(name)) + } + + static Integer 'int'(FlashScope flash, String name, Integer defaultValue) { + TypeConverters.toInteger(flash.get(name), defaultValue) + } + + static Long 'long'(FlashScope flash, String name) { + TypeConverters.toLong(flash.get(name)) + } + + static Long 'long'(FlashScope flash, String name, Long defaultValue) { + TypeConverters.toLong(flash.get(name), defaultValue) + } + + static Double 'double'(FlashScope flash, String name) { + TypeConverters.toDouble(flash.get(name)) + } + + static Double 'double'(FlashScope flash, String name, Double defaultValue) { + TypeConverters.toDouble(flash.get(name), defaultValue) + } + + static Float 'float'(FlashScope flash, String name) { + TypeConverters.toFloat(flash.get(name)) + } + + static Float 'float'(FlashScope flash, String name, Float defaultValue) { + TypeConverters.toFloat(flash.get(name), defaultValue) + } + + static Boolean 'boolean'(FlashScope flash, String name) { + TypeConverters.toBoolean(flash.get(name)) + } + + // boolean default is presence-based (key present), which cannot be expressed from the value alone + static Boolean 'boolean'(FlashScope flash, String name, Boolean defaultValue) { + flash.containsKey(name) ? TypeConverters.toBoolean(flash.get(name)) : defaultValue + } + + static String string(FlashScope flash, String name) { + TypeConverters.toStringValue(flash.get(name)) + } + + static String string(FlashScope flash, String name, String defaultValue) { + TypeConverters.toStringValue(flash.get(name), defaultValue) + } + + static List list(FlashScope flash, String name) { + TypeConverters.toList(flash.get(name)) + } + + static Date date(FlashScope flash, String name) { + TypeConverters.toDate(flash.get(name)) + } + + static Date date(FlashScope flash, String name, String format) { + TypeConverters.toDate(flash.get(name), format) + } + + static Date date(FlashScope flash, String name, Collection formats) { + TypeConverters.toDate(flash.get(name), formats) + } +} diff --git a/grails-web-core/src/main/groovy/org/grails/web/servlet/HttpServletRequestExtension.groovy b/grails-web-core/src/main/groovy/org/grails/web/servlet/HttpServletRequestExtension.groovy index 7e1beb85717..86bbdf5242a 100644 --- a/grails-web-core/src/main/groovy/org/grails/web/servlet/HttpServletRequestExtension.groovy +++ b/grails-web-core/src/main/groovy/org/grails/web/servlet/HttpServletRequestExtension.groovy @@ -20,6 +20,8 @@ package org.grails.web.servlet import groovy.transform.CompileStatic +import org.grails.util.TypeConverters + import jakarta.servlet.http.HttpServletRequest import org.grails.web.util.WebUtils @@ -27,11 +29,11 @@ import org.grails.web.util.WebUtils /** * An extension that adds methods to the {@link HttpServletRequest} object * - * + * * @author Jeff Brown * @author Graeme Rocher * @since 3.0 - * + * */ @CompileStatic class HttpServletRequestExtension { @@ -155,4 +157,98 @@ class HttpServletRequestExtension { static boolean isPost(HttpServletRequest request) { request.method == 'POST' } + + static Byte 'byte'(HttpServletRequest request, String name) { + TypeConverters.toByte(request.getAttribute(name)) + } + + static Byte 'byte'(HttpServletRequest request, String name, Integer defaultValue) { + TypeConverters.toByte(request.getAttribute(name), defaultValue) + } + + static Character 'char'(HttpServletRequest request, String name) { + TypeConverters.toCharacter(request.getAttribute(name)) + } + + static Character 'char'(HttpServletRequest request, String name, Character defaultValue) { + TypeConverters.toCharacter(request.getAttribute(name), defaultValue) + } + + static Character 'char'(HttpServletRequest request, String name, Integer defaultValue) { + TypeConverters.toCharacter(request.getAttribute(name), defaultValue) + } + + static Short 'short'(HttpServletRequest request, String name) { + TypeConverters.toShort(request.getAttribute(name)) + } + + static Short 'short'(HttpServletRequest request, String name, Integer defaultValue) { + TypeConverters.toShort(request.getAttribute(name), defaultValue) + } + + static Integer 'int'(HttpServletRequest request, String name) { + TypeConverters.toInteger(request.getAttribute(name)) + } + + static Integer 'int'(HttpServletRequest request, String name, Integer defaultValue) { + TypeConverters.toInteger(request.getAttribute(name), defaultValue) + } + + static Long 'long'(HttpServletRequest request, String name) { + TypeConverters.toLong(request.getAttribute(name)) + } + + static Long 'long'(HttpServletRequest request, String name, Long defaultValue) { + TypeConverters.toLong(request.getAttribute(name), defaultValue) + } + + static Double 'double'(HttpServletRequest request, String name) { + TypeConverters.toDouble(request.getAttribute(name)) + } + + static Double 'double'(HttpServletRequest request, String name, Double defaultValue) { + TypeConverters.toDouble(request.getAttribute(name), defaultValue) + } + + static Float 'float'(HttpServletRequest request, String name) { + TypeConverters.toFloat(request.getAttribute(name)) + } + + static Float 'float'(HttpServletRequest request, String name, Float defaultValue) { + TypeConverters.toFloat(request.getAttribute(name), defaultValue) + } + + static Boolean 'boolean'(HttpServletRequest request, String name) { + TypeConverters.toBoolean(request.getAttribute(name)) + } + + // boolean default is presence-based (attribute set), which cannot be expressed from the value alone + static Boolean 'boolean'(HttpServletRequest request, String name, Boolean defaultValue) { + Object value = request.getAttribute(name) + value != null ? TypeConverters.toBoolean(value) : defaultValue + } + + static String string(HttpServletRequest request, String name) { + TypeConverters.toStringValue(request.getAttribute(name)) + } + + static String string(HttpServletRequest request, String name, String defaultValue) { + TypeConverters.toStringValue(request.getAttribute(name), defaultValue) + } + + static List list(HttpServletRequest request, String name) { + TypeConverters.toList(request.getAttribute(name)) + } + + static Date date(HttpServletRequest request, String name) { + TypeConverters.toDate(request.getAttribute(name)) + } + + static Date date(HttpServletRequest request, String name, String format) { + TypeConverters.toDate(request.getAttribute(name), format) + } + + static Date date(HttpServletRequest request, String name, Collection formats) { + TypeConverters.toDate(request.getAttribute(name), formats) + } } diff --git a/grails-web-core/src/main/groovy/org/grails/web/servlet/HttpSessionExtension.groovy b/grails-web-core/src/main/groovy/org/grails/web/servlet/HttpSessionExtension.groovy index c2d5dc6e361..04d1c5126e5 100644 --- a/grails-web-core/src/main/groovy/org/grails/web/servlet/HttpSessionExtension.groovy +++ b/grails-web-core/src/main/groovy/org/grails/web/servlet/HttpSessionExtension.groovy @@ -20,6 +20,8 @@ package org.grails.web.servlet import groovy.transform.CompileStatic +import org.grails.util.TypeConverters + import jakarta.servlet.http.HttpSession /** @@ -30,25 +32,119 @@ import jakarta.servlet.http.HttpSession * @author Graeme Rocher * * @since 3.0 - * + * */ @CompileStatic class HttpSessionExtension { - + static getProperty(HttpSession session, String name) { def mp = session.class.metaClass.getMetaProperty(name) return mp ? mp.getProperty(session) : session.getAttribute(name) } - + static propertyMissing(HttpSession session, String name, value) { session.setAttribute(name, value) } - + static getAt(HttpSession session, String name) { getProperty(session, name) } - + static propertyMissing(HttpSession session, String name) { getProperty(session, name) } + + static Byte 'byte'(HttpSession session, String name) { + TypeConverters.toByte(session.getAttribute(name)) + } + + static Byte 'byte'(HttpSession session, String name, Integer defaultValue) { + TypeConverters.toByte(session.getAttribute(name), defaultValue) + } + + static Character 'char'(HttpSession session, String name) { + TypeConverters.toCharacter(session.getAttribute(name)) + } + + static Character 'char'(HttpSession session, String name, Character defaultValue) { + TypeConverters.toCharacter(session.getAttribute(name), defaultValue) + } + + static Character 'char'(HttpSession session, String name, Integer defaultValue) { + TypeConverters.toCharacter(session.getAttribute(name), defaultValue) + } + + static Short 'short'(HttpSession session, String name) { + TypeConverters.toShort(session.getAttribute(name)) + } + + static Short 'short'(HttpSession session, String name, Integer defaultValue) { + TypeConverters.toShort(session.getAttribute(name), defaultValue) + } + + static Integer 'int'(HttpSession session, String name) { + TypeConverters.toInteger(session.getAttribute(name)) + } + + static Integer 'int'(HttpSession session, String name, Integer defaultValue) { + TypeConverters.toInteger(session.getAttribute(name), defaultValue) + } + + static Long 'long'(HttpSession session, String name) { + TypeConverters.toLong(session.getAttribute(name)) + } + + static Long 'long'(HttpSession session, String name, Long defaultValue) { + TypeConverters.toLong(session.getAttribute(name), defaultValue) + } + + static Double 'double'(HttpSession session, String name) { + TypeConverters.toDouble(session.getAttribute(name)) + } + + static Double 'double'(HttpSession session, String name, Double defaultValue) { + TypeConverters.toDouble(session.getAttribute(name), defaultValue) + } + + static Float 'float'(HttpSession session, String name) { + TypeConverters.toFloat(session.getAttribute(name)) + } + + static Float 'float'(HttpSession session, String name, Float defaultValue) { + TypeConverters.toFloat(session.getAttribute(name), defaultValue) + } + + static Boolean 'boolean'(HttpSession session, String name) { + TypeConverters.toBoolean(session.getAttribute(name)) + } + + // boolean default is presence-based (attribute set), which cannot be expressed from the value alone + static Boolean 'boolean'(HttpSession session, String name, Boolean defaultValue) { + Object value = session.getAttribute(name) + value != null ? TypeConverters.toBoolean(value) : defaultValue + } + + static String string(HttpSession session, String name) { + TypeConverters.toStringValue(session.getAttribute(name)) + } + + static String string(HttpSession session, String name, String defaultValue) { + TypeConverters.toStringValue(session.getAttribute(name), defaultValue) + } + + static List list(HttpSession session, String name) { + TypeConverters.toList(session.getAttribute(name)) + } + + static Date date(HttpSession session, String name) { + TypeConverters.toDate(session.getAttribute(name)) + } + + static Date date(HttpSession session, String name, String format) { + TypeConverters.toDate(session.getAttribute(name), format) + } + + static Date date(HttpSession session, String name, Collection formats) { + TypeConverters.toDate(session.getAttribute(name), formats) + } } diff --git a/grails-web-core/src/main/groovy/org/grails/web/servlet/ServletContextExtension.groovy b/grails-web-core/src/main/groovy/org/grails/web/servlet/ServletContextExtension.groovy index 3ea9540cf52..71fdc9634b7 100644 --- a/grails-web-core/src/main/groovy/org/grails/web/servlet/ServletContextExtension.groovy +++ b/grails-web-core/src/main/groovy/org/grails/web/servlet/ServletContextExtension.groovy @@ -18,6 +18,10 @@ */ package org.grails.web.servlet +import groovy.transform.CompileStatic + +import org.grails.util.TypeConverters + import jakarta.servlet.ServletContext /** @@ -26,13 +30,108 @@ import jakarta.servlet.ServletContext * @author Jeff Brown * @since 3.0 */ +@CompileStatic class ServletContextExtension { static propertyMissing(ServletContext context, String name, value) { context.setAttribute(name, value) } - + static propertyMissing(ServletContext context, String name) { context.getAttribute(name) } + + static Byte 'byte'(ServletContext context, String name) { + TypeConverters.toByte(context.getAttribute(name)) + } + + static Byte 'byte'(ServletContext context, String name, Integer defaultValue) { + TypeConverters.toByte(context.getAttribute(name), defaultValue) + } + + static Character 'char'(ServletContext context, String name) { + TypeConverters.toCharacter(context.getAttribute(name)) + } + + static Character 'char'(ServletContext context, String name, Character defaultValue) { + TypeConverters.toCharacter(context.getAttribute(name), defaultValue) + } + + static Character 'char'(ServletContext context, String name, Integer defaultValue) { + TypeConverters.toCharacter(context.getAttribute(name), defaultValue) + } + + static Short 'short'(ServletContext context, String name) { + TypeConverters.toShort(context.getAttribute(name)) + } + + static Short 'short'(ServletContext context, String name, Integer defaultValue) { + TypeConverters.toShort(context.getAttribute(name), defaultValue) + } + + static Integer 'int'(ServletContext context, String name) { + TypeConverters.toInteger(context.getAttribute(name)) + } + + static Integer 'int'(ServletContext context, String name, Integer defaultValue) { + TypeConverters.toInteger(context.getAttribute(name), defaultValue) + } + + static Long 'long'(ServletContext context, String name) { + TypeConverters.toLong(context.getAttribute(name)) + } + + static Long 'long'(ServletContext context, String name, Long defaultValue) { + TypeConverters.toLong(context.getAttribute(name), defaultValue) + } + + static Double 'double'(ServletContext context, String name) { + TypeConverters.toDouble(context.getAttribute(name)) + } + + static Double 'double'(ServletContext context, String name, Double defaultValue) { + TypeConverters.toDouble(context.getAttribute(name), defaultValue) + } + + static Float 'float'(ServletContext context, String name) { + TypeConverters.toFloat(context.getAttribute(name)) + } + + static Float 'float'(ServletContext context, String name, Float defaultValue) { + TypeConverters.toFloat(context.getAttribute(name), defaultValue) + } + + static Boolean 'boolean'(ServletContext context, String name) { + TypeConverters.toBoolean(context.getAttribute(name)) + } + + // boolean default is presence-based (attribute set), which cannot be expressed from the value alone + static Boolean 'boolean'(ServletContext context, String name, Boolean defaultValue) { + Object value = context.getAttribute(name) + value != null ? TypeConverters.toBoolean(value) : defaultValue + } + + static String string(ServletContext context, String name) { + TypeConverters.toStringValue(context.getAttribute(name)) + } + + static String string(ServletContext context, String name, String defaultValue) { + TypeConverters.toStringValue(context.getAttribute(name), defaultValue) + } + + static List list(ServletContext context, String name) { + TypeConverters.toList(context.getAttribute(name)) + } + + static Date date(ServletContext context, String name) { + TypeConverters.toDate(context.getAttribute(name)) + } + + static Date date(ServletContext context, String name, String format) { + TypeConverters.toDate(context.getAttribute(name), format) + } + + static Date date(ServletContext context, String name, Collection formats) { + TypeConverters.toDate(context.getAttribute(name), formats) + } } diff --git a/grails-web-core/src/main/resources/META-INF/services/org.codehaus.groovy.runtime.ExtensionModule b/grails-web-core/src/main/resources/META-INF/services/org.codehaus.groovy.runtime.ExtensionModule index 556957478b9..04d863f5fe5 100644 --- a/grails-web-core/src/main/resources/META-INF/services/org.codehaus.groovy.runtime.ExtensionModule +++ b/grails-web-core/src/main/resources/META-INF/services/org.codehaus.groovy.runtime.ExtensionModule @@ -1,3 +1,3 @@ moduleName=grails-web-servlets-module moduleVersion=1.0 -extensionClasses=org.grails.web.servlet.HttpServletRequestExtension,org.grails.web.servlet.HttpServletResponseExtension,org.grails.web.servlet.HttpSessionExtension,org.grails.web.servlet.ServletContextExtension +extensionClasses=org.grails.web.servlet.HttpServletRequestExtension,org.grails.web.servlet.HttpServletResponseExtension,org.grails.web.servlet.HttpSessionExtension,org.grails.web.servlet.ServletContextExtension,org.grails.web.servlet.FlashScopeExtension diff --git a/grails-web-core/src/test/groovy/org/grails/web/servlet/FlashScopeExtensionSpec.groovy b/grails-web-core/src/test/groovy/org/grails/web/servlet/FlashScopeExtensionSpec.groovy new file mode 100644 index 00000000000..c694267097c --- /dev/null +++ b/grails-web-core/src/test/groovy/org/grails/web/servlet/FlashScopeExtensionSpec.groovy @@ -0,0 +1,82 @@ +/* + * 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.servlet + +import groovy.transform.CompileStatic + +import grails.web.mvc.FlashScope + +import spock.lang.Specification + +class FlashScopeExtensionSpec extends Specification { + + private FlashScope newFlash() { + // false => do not register with the session, so put/get work without a bound request + new GrailsFlashScope(false) + } + + void "Test typed converters read and convert flash attributes"() { + given: + FlashScope flash = newFlash() + flash.put('age', '42') + flash.put('active', 'true') + flash.put('tz', 'America/Los_Angeles') + + expect: + flash.int('age') == 42 + flash.long('age') == 42L + flash.boolean('active') == true + flash.string('tz') == 'America/Los_Angeles' + } + + void "Test typed converters are null-safe and honor defaults"() { + given: + FlashScope flash = newFlash() + + expect: + flash.int('missing') == null + flash.int('missing', 7) == 7 + flash.boolean('missing') == null + flash.boolean('missing', true) == true + flash.string('missing') == null + flash.string('missing', 'fallback') == 'fallback' + flash.list('missing') == [] + } + + void "Test converters resolve under static compilation"() { + given: + FlashScope flash = newFlash() + flash.put('age', '21') + + expect: + StaticCaller.readAge(flash) == 21 + StaticCaller.readTimeZone(flash) == 'America/Los_Angeles' + } + + @CompileStatic + static class StaticCaller { + static Integer readAge(FlashScope flash) { + flash.int('age') + } + + static String readTimeZone(FlashScope flash) { + flash.string('userTimeZoneId', 'America/Los_Angeles') + } + } +} diff --git a/grails-web-core/src/test/groovy/org/grails/web/servlet/HttpServletRequestExtensionSpec.groovy b/grails-web-core/src/test/groovy/org/grails/web/servlet/HttpServletRequestExtensionSpec.groovy new file mode 100644 index 00000000000..ac8d922997e --- /dev/null +++ b/grails-web-core/src/test/groovy/org/grails/web/servlet/HttpServletRequestExtensionSpec.groovy @@ -0,0 +1,76 @@ +/* + * 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.servlet + +import groovy.transform.CompileStatic + +import jakarta.servlet.http.HttpServletRequest + +import org.springframework.mock.web.MockHttpServletRequest + +import spock.lang.Specification + +class HttpServletRequestExtensionSpec extends Specification { + + void "Test typed converters read and convert request attributes"() { + given: + HttpServletRequest request = new MockHttpServletRequest() + request.setAttribute('page', '3') + request.setAttribute('total', 99L) + request.setAttribute('name', 'Bob') + + expect: + request.int('page') == 3 + request.long('total') == 99L + request.string('name') == 'Bob' + } + + void "Test typed converters are null-safe and honor defaults"() { + given: + HttpServletRequest request = new MockHttpServletRequest() + + expect: + request.int('missing') == null + request.int('missing', 1) == 1 + request.boolean('missing', true) == true + request.string('missing', 'fallback') == 'fallback' + request.list('missing') == [] + } + + void "Test converters resolve under static compilation"() { + given: + HttpServletRequest request = new MockHttpServletRequest() + request.setAttribute('page', '5') + + expect: + StaticCaller.readPage(request) == 5 + StaticCaller.readName(request) == 'anonymous' + } + + @CompileStatic + static class StaticCaller { + static Integer readPage(HttpServletRequest request) { + request.int('page', 1) + } + + static String readName(HttpServletRequest request) { + request.string('user', 'anonymous') + } + } +} diff --git a/grails-web-core/src/test/groovy/org/grails/web/servlet/HttpSessionExtensionSpec.groovy b/grails-web-core/src/test/groovy/org/grails/web/servlet/HttpSessionExtensionSpec.groovy new file mode 100644 index 00000000000..06de529e0c0 --- /dev/null +++ b/grails-web-core/src/test/groovy/org/grails/web/servlet/HttpSessionExtensionSpec.groovy @@ -0,0 +1,104 @@ +/* + * 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.servlet + +import groovy.transform.CompileStatic + +import jakarta.servlet.http.HttpSession + +import org.springframework.mock.web.MockHttpSession + +import spock.lang.Specification + +class HttpSessionExtensionSpec extends Specification { + + void "Test typed converters read and convert session attributes"() { + given: + HttpSession session = new MockHttpSession() + session.setAttribute('age', '42') + session.setAttribute('rate', 3.5d) + session.setAttribute('active', 'true') + session.setAttribute('tz', 'America/Los_Angeles') + + expect: + session.int('age') == 42 + session.long('age') == 42L + session.double('rate') == 3.5d + session.boolean('active') == true + session.string('tz') == 'America/Los_Angeles' + } + + void "Test typed converters are null-safe and honor defaults"() { + given: + HttpSession session = new MockHttpSession() + + expect: + session.int('missing') == null + session.int('missing', 7) == 7 + session.boolean('missing') == null + session.boolean('missing', true) == true + session.string('missing') == null + session.string('missing', 'fallback') == 'fallback' + session.list('missing') == [] + } + + void "Test the date converter parses session attributes"() { + given: + HttpSession session = new MockHttpSession() + session.setAttribute('createdOn', '2026-06-04 09:30:00.0') + + expect: + session.date('createdOn') != null + + and: "an unparseable value yields null" + session.date('missing') == null + } + + void "Test the list converter always returns a list"() { + given: + HttpSession session = new MockHttpSession() + session.setAttribute('single', 'one') + session.setAttribute('many', ['a', 'b'] as String[]) + + expect: + session.list('single') == ['one'] + session.list('many') == ['a', 'b'] + } + + void "Test converters resolve under static compilation"() { + given: + HttpSession session = new MockHttpSession() + session.setAttribute('age', '21') + + expect: "the statically compiled helper resolves session.int / session.string" + StaticCaller.readAge(session) == 21 + StaticCaller.readTimeZone(session) == 'America/Los_Angeles' + } + + @CompileStatic + static class StaticCaller { + static Integer readAge(HttpSession session) { + session.int('age') + } + + static String readTimeZone(HttpSession session) { + session.string('userTimeZoneId', 'America/Los_Angeles') + } + } +} diff --git a/grails-web-core/src/test/groovy/org/grails/web/servlet/ServletContextExtensionSpec.groovy b/grails-web-core/src/test/groovy/org/grails/web/servlet/ServletContextExtensionSpec.groovy new file mode 100644 index 00000000000..496b955d559 --- /dev/null +++ b/grails-web-core/src/test/groovy/org/grails/web/servlet/ServletContextExtensionSpec.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.web.servlet + +import groovy.transform.CompileStatic + +import jakarta.servlet.ServletContext + +import org.springframework.mock.web.MockServletContext + +import spock.lang.Specification + +class ServletContextExtensionSpec extends Specification { + + void "Test typed converters read and convert servletContext attributes"() { + given: + ServletContext context = new MockServletContext() + context.setAttribute('maxUploads', '10') + context.setAttribute('appName', 'demo') + + expect: + context.int('maxUploads') == 10 + context.string('appName') == 'demo' + } + + void "Test typed converters are null-safe and honor defaults"() { + given: + ServletContext context = new MockServletContext() + + expect: + context.int('missing') == null + context.int('missing', 4) == 4 + context.string('missing', 'fallback') == 'fallback' + } + + void "Test converters resolve under static compilation"() { + given: + ServletContext context = new MockServletContext() + context.setAttribute('maxUploads', '8') + + expect: + StaticCaller.readMaxUploads(context) == 8 + StaticCaller.readAppName(context) == 'grails' + } + + @CompileStatic + static class StaticCaller { + static Integer readMaxUploads(ServletContext context) { + context.int('maxUploads', 1) + } + + static String readAppName(ServletContext context) { + context.string('appName', 'grails') + } + } +} From fe12b234e707458cf01ff36fb845d387a2eae12c Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Fri, 5 Jun 2026 10:20:37 -0700 Subject: [PATCH 2/4] Harden TypeConverters against values whose toString() returns null Guard SimpleDateFormat parsing in toDate against a null toString() (which throws NullPointerException, not the caught ParseException) and name all empty catch variables 'ignored'. Adds test coverage for the null toString() edge case across every converter. --- .../org/grails/util/TypeConverters.java | 23 ++++++++++--------- .../grails/util/TypeConvertingMapTests.groovy | 23 +++++++++++++++++++ 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/grails-core/src/main/groovy/org/grails/util/TypeConverters.java b/grails-core/src/main/groovy/org/grails/util/TypeConverters.java index a7d70ab9c27..f154146c43d 100644 --- a/grails-core/src/main/groovy/org/grails/util/TypeConverters.java +++ b/grails-core/src/main/groovy/org/grails/util/TypeConverters.java @@ -57,7 +57,7 @@ public static Byte toByte(Object o) { return Byte.parseByte(string); } } - catch (NumberFormatException e) {} + catch (NumberFormatException ignored) {} } return null; } @@ -86,7 +86,7 @@ public static Integer toInteger(Object o) { return Integer.parseInt(string); } } - catch (NumberFormatException e) {} + catch (NumberFormatException ignored) {} } return null; } @@ -99,7 +99,7 @@ public static Long toLong(Object o) { try { return Long.parseLong(o.toString()); } - catch (NumberFormatException e) {} + catch (NumberFormatException ignored) {} } return null; } @@ -115,7 +115,7 @@ public static Short toShort(Object o) { return Short.parseShort(string); } } - catch (NumberFormatException e) {} + catch (NumberFormatException ignored) {} } return null; } @@ -131,7 +131,7 @@ public static Double toDouble(Object o) { return Double.parseDouble(string); } } - catch (NumberFormatException e) {} + catch (NumberFormatException ignored) {} } return null; } @@ -147,7 +147,7 @@ public static Float toFloat(Object o) { return Float.parseFloat(string); } } - catch (NumberFormatException e) {} + catch (NumberFormatException ignored) {} } return null; } @@ -163,7 +163,7 @@ public static Boolean toBoolean(Object o) { return GrailsStringUtils.toBoolean(string); } } - catch (Exception e) {} + catch (Exception ignored) {} } return null; } @@ -188,10 +188,11 @@ public static Date toDate(Object value, String format) { return (Date) value; } if (value != null) { - try { - return new SimpleDateFormat(format).parse(value.toString()); - } catch (ParseException e) { - // ignore + String string = value.toString(); + if (string != null) { + try { + return new SimpleDateFormat(format).parse(string); + } catch (ParseException ignored) {} } } return null; diff --git a/grails-core/src/test/groovy/org/grails/util/TypeConvertingMapTests.groovy b/grails-core/src/test/groovy/org/grails/util/TypeConvertingMapTests.groovy index 9b8cb395570..c7e5e73d44e 100644 --- a/grails-core/src/test/groovy/org/grails/util/TypeConvertingMapTests.groovy +++ b/grails-core/src/test/groovy/org/grails/util/TypeConvertingMapTests.groovy @@ -122,6 +122,29 @@ class TypeConvertingMapTests { assert TypeConverters.toCharacter(null, (int) 'A') == 'A' as char } + @Test + void testConvertersWithValueWhoseToStringReturnsNull() { + def value = new NullToString() + + assert TypeConverters.toByte(value) == null + assert TypeConverters.toCharacter(value) == null + assert TypeConverters.toInteger(value) == null + assert TypeConverters.toLong(value) == null + assert TypeConverters.toShort(value) == null + assert TypeConverters.toDouble(value) == null + assert TypeConverters.toFloat(value) == null + assert TypeConverters.toBoolean(value) == null + assert TypeConverters.toStringValue(value) == null + assert TypeConverters.toDate(value) == null + assert TypeConverters.toInteger(value, 7) == 7 + assert TypeConverters.toStringValue(value, 'fallback') == 'fallback' + } + + static class NullToString { + @Override + String toString() { null } + } + @Test void testHashCode() { assert toTypeConverting(a: 1, b: 2).hashCode() == toTypeConverting(a: 1, b: 2).hashCode() From a0a3bdb8eb6e7e9035e4cc3f5a3b42f866a3b890 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Fri, 5 Jun 2026 10:23:34 -0700 Subject: [PATCH 3/4] Genericize TypeConverters.toList and drop blanket @SuppressWarnings The class-level @SuppressWarnings({"rawtypes","unchecked"}) was copied from AbstractTypeConvertingMap, where it covers raw Map usage. In TypeConverters the only raw-type usage was toList; genericize it to return List using a typed ArrayList copy so the suppression is no longer needed, and remove it. --- .../src/main/groovy/org/grails/util/TypeConverters.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/grails-core/src/main/groovy/org/grails/util/TypeConverters.java b/grails-core/src/main/groovy/org/grails/util/TypeConverters.java index f154146c43d..ae1dd01341a 100644 --- a/grails-core/src/main/groovy/org/grails/util/TypeConverters.java +++ b/grails-core/src/main/groovy/org/grails/util/TypeConverters.java @@ -38,7 +38,6 @@ * * @since 8.0 */ -@SuppressWarnings({ "rawtypes", "unchecked" }) public final class TypeConverters { public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd HH:mm:ss.S"; @@ -206,7 +205,7 @@ public static Date toDate(Object value, Collection formats) { return null; } - public static List toList(Object paramValues) { + public static List toList(Object paramValues) { if (paramValues == null) { return Collections.emptyList(); } @@ -214,7 +213,7 @@ public static List toList(Object paramValues) { return Arrays.asList((Object[]) paramValues); } if (paramValues instanceof Collection) { - return new ArrayList((Collection) paramValues); + return new ArrayList<>((Collection) paramValues); } return Collections.singletonList(paramValues); } From 60980ef30146760caf5e6060be5bdfd676fb88a4 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Fri, 5 Jun 2026 10:27:59 -0700 Subject: [PATCH 4/4] Move internal TypeConverters to org.apache.grails.core.internal.util Relocate the internal helper out of the legacy org.grails.util package into the project's org.apache.grails namespace: .core for gradle-project uniqueness, .internal to mark it as non-public (mirroring the old grails.* reservation), and .util since it was extracted from util-package code. Update all imports in AbstractTypeConvertingMap, the servlet extensions and the test. --- .../src/main/groovy/grails/util/AbstractTypeConvertingMap.java | 2 +- .../grails/core/internal}/util/TypeConverters.java | 2 +- .../test/groovy/org/grails/util/TypeConvertingMapTests.groovy | 1 + .../groovy/org/grails/web/servlet/FlashScopeExtension.groovy | 2 +- .../org/grails/web/servlet/HttpServletRequestExtension.groovy | 2 +- .../groovy/org/grails/web/servlet/HttpSessionExtension.groovy | 2 +- .../org/grails/web/servlet/ServletContextExtension.groovy | 2 +- 7 files changed, 7 insertions(+), 6 deletions(-) rename grails-core/src/main/groovy/org/{grails => apache/grails/core/internal}/util/TypeConverters.java (99%) diff --git a/grails-core/src/main/groovy/grails/util/AbstractTypeConvertingMap.java b/grails-core/src/main/groovy/grails/util/AbstractTypeConvertingMap.java index 09d45ca9124..b7daf52ba52 100644 --- a/grails-core/src/main/groovy/grails/util/AbstractTypeConvertingMap.java +++ b/grails-core/src/main/groovy/grails/util/AbstractTypeConvertingMap.java @@ -30,7 +30,7 @@ import org.codehaus.groovy.runtime.DefaultGroovyMethods; import org.codehaus.groovy.util.HashCodeHelper; -import org.grails.util.TypeConverters; +import org.apache.grails.core.internal.util.TypeConverters; /** * AbstractTypeConvertingMap is a Map with type conversion capabilities. diff --git a/grails-core/src/main/groovy/org/grails/util/TypeConverters.java b/grails-core/src/main/groovy/org/apache/grails/core/internal/util/TypeConverters.java similarity index 99% rename from grails-core/src/main/groovy/org/grails/util/TypeConverters.java rename to grails-core/src/main/groovy/org/apache/grails/core/internal/util/TypeConverters.java index ae1dd01341a..693cea1153b 100644 --- a/grails-core/src/main/groovy/org/grails/util/TypeConverters.java +++ b/grails-core/src/main/groovy/org/apache/grails/core/internal/util/TypeConverters.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.grails.util; +package org.apache.grails.core.internal.util; import java.text.ParseException; import java.text.SimpleDateFormat; diff --git a/grails-core/src/test/groovy/org/grails/util/TypeConvertingMapTests.groovy b/grails-core/src/test/groovy/org/grails/util/TypeConvertingMapTests.groovy index c7e5e73d44e..cff8d062446 100644 --- a/grails-core/src/test/groovy/org/grails/util/TypeConvertingMapTests.groovy +++ b/grails-core/src/test/groovy/org/grails/util/TypeConvertingMapTests.groovy @@ -20,6 +20,7 @@ package org.grails.util import grails.util.TypeConvertingMap import groovy.transform.CompileStatic +import org.apache.grails.core.internal.util.TypeConverters import org.junit.jupiter.api.Test /** diff --git a/grails-web-core/src/main/groovy/org/grails/web/servlet/FlashScopeExtension.groovy b/grails-web-core/src/main/groovy/org/grails/web/servlet/FlashScopeExtension.groovy index 559c32a49f9..22ab7dc331d 100644 --- a/grails-web-core/src/main/groovy/org/grails/web/servlet/FlashScopeExtension.groovy +++ b/grails-web-core/src/main/groovy/org/grails/web/servlet/FlashScopeExtension.groovy @@ -20,7 +20,7 @@ package org.grails.web.servlet import groovy.transform.CompileStatic -import org.grails.util.TypeConverters +import org.apache.grails.core.internal.util.TypeConverters import grails.web.mvc.FlashScope diff --git a/grails-web-core/src/main/groovy/org/grails/web/servlet/HttpServletRequestExtension.groovy b/grails-web-core/src/main/groovy/org/grails/web/servlet/HttpServletRequestExtension.groovy index 86bbdf5242a..c8d2ac532bf 100644 --- a/grails-web-core/src/main/groovy/org/grails/web/servlet/HttpServletRequestExtension.groovy +++ b/grails-web-core/src/main/groovy/org/grails/web/servlet/HttpServletRequestExtension.groovy @@ -20,7 +20,7 @@ package org.grails.web.servlet import groovy.transform.CompileStatic -import org.grails.util.TypeConverters +import org.apache.grails.core.internal.util.TypeConverters import jakarta.servlet.http.HttpServletRequest diff --git a/grails-web-core/src/main/groovy/org/grails/web/servlet/HttpSessionExtension.groovy b/grails-web-core/src/main/groovy/org/grails/web/servlet/HttpSessionExtension.groovy index 04d1c5126e5..663e514ce45 100644 --- a/grails-web-core/src/main/groovy/org/grails/web/servlet/HttpSessionExtension.groovy +++ b/grails-web-core/src/main/groovy/org/grails/web/servlet/HttpSessionExtension.groovy @@ -20,7 +20,7 @@ package org.grails.web.servlet import groovy.transform.CompileStatic -import org.grails.util.TypeConverters +import org.apache.grails.core.internal.util.TypeConverters import jakarta.servlet.http.HttpSession diff --git a/grails-web-core/src/main/groovy/org/grails/web/servlet/ServletContextExtension.groovy b/grails-web-core/src/main/groovy/org/grails/web/servlet/ServletContextExtension.groovy index 71fdc9634b7..67a2dd5d635 100644 --- a/grails-web-core/src/main/groovy/org/grails/web/servlet/ServletContextExtension.groovy +++ b/grails-web-core/src/main/groovy/org/grails/web/servlet/ServletContextExtension.groovy @@ -20,7 +20,7 @@ package org.grails.web.servlet import groovy.transform.CompileStatic -import org.grails.util.TypeConverters +import org.apache.grails.core.internal.util.TypeConverters import jakarta.servlet.ServletContext