Skip to content

Commit e8d5a8b

Browse files
committed
Merge branch '7.1.x' into 7.0.x-gormservice-dev-racecondition
2 parents 9540fbf + b4dcd8a commit e8d5a8b

File tree

6 files changed

+378
-1
lines changed

6 files changed

+378
-1
lines changed

grails-doc/src/en/guide/upgrading/upgrading60x.adoc

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,3 +754,109 @@ grails:
754754
* **8.0**: `SimpleEnumMarshaller` will become the default
755755

756756
The legacy `org.grails.web.converters.marshaller.json.EnumMarshaller` and `org.grails.web.converters.marshaller.xml.EnumMarshaller` classes are marked as `@Deprecated(forRemoval = true, since = "7.0.2")` and will be removed in Grails 8.0.
757+
758+
===== 12.27 Greedy Extension Parameter Matching in URL Mappings
759+
760+
Grails 7.1 introduces a new greedy extension parameter marker (`+`) for URL mappings that provides more intuitive handling of file extensions in URLs with multiple dots.
761+
762+
====== The Problem with Default Behavior
763+
764+
By default, Grails URL mappings with optional extensions split at the **first** dot in the path:
765+
766+
[source,groovy]
767+
----
768+
"/$id(.$format)?"(controller: 'user', action: 'profile')
769+
----
770+
771+
When matching the URL `/test.test.json`:
772+
- `id` = `test` (stops at first dot)
773+
- `format` = `test.json` (everything after first dot)
774+
775+
This can be problematic when IDs legitimately contain dots, such as file names, qualified class names, or version numbers.
776+
777+
====== New Greedy Matching Behavior
778+
779+
The new `+` marker enables **greedy** matching, which splits at the **last** dot instead:
780+
781+
[source,groovy]
782+
----
783+
"/$id+(.$format)?"(controller: 'user', action: 'profile')
784+
----
785+
786+
Now the same URL `/test.test.json` matches as:
787+
- `id` = `test.test` (everything up to last dot)
788+
- `format` = `json` (extension after last dot)
789+
790+
====== Syntax
791+
792+
The `+` marker is added after the variable name and before the optional marker:
793+
794+
**Required parameter with greedy extension:**
795+
[source,groovy]
796+
----
797+
"/$id+(.$format)?"(controller: 'file', action: 'download')
798+
----
799+
800+
**Optional parameter with greedy extension:**
801+
[source,groovy]
802+
----
803+
"/$id+?(.$format)?"(controller: 'resource', action: 'show')
804+
----
805+
806+
====== Use Cases
807+
808+
Greedy extension matching is particularly useful for:
809+
810+
1. **File downloads with complex names:**
811+
+
812+
[source,groovy]
813+
----
814+
"/files/$filename+(.$format)?"(controller: 'file', action: 'download')
815+
----
816+
+
817+
Matches `/files/document.final.v2.pdf` → `filename=document.final.v2`, `format=pdf`
818+
819+
2. **Versioned resources:**
820+
+
821+
[source,groovy]
822+
----
823+
"/api/$resource+(.$format)?"(controller: 'api', action: 'show')
824+
----
825+
+
826+
Matches `/api/user.service.v1.json` → `resource=user.service.v1`, `format=json`
827+
828+
3. **Qualified class names:**
829+
+
830+
[source,groovy]
831+
----
832+
"/docs/$className+(.$format)?"(controller: 'documentation', action: 'show')
833+
----
834+
+
835+
Matches `/docs/com.example.MyClass.html` → `className=com.example.MyClass`, `format=html`
836+
837+
====== Behavior Details
838+
839+
- **With dots:** The greedy marker splits at the **last** dot, treating everything before as the parameter value and everything after as the extension
840+
- **Without dots:** URLs without any dots match entirely as the parameter with no format
841+
- **Extension optional:** When the extension is marked optional `(.$format)?`, URLs work both with and without extensions
842+
843+
**Examples:**
844+
845+
[source,groovy]
846+
----
847+
"/$id+(.$format)?"(controller: 'resource', action: 'show')
848+
849+
// URL Matches:
850+
/test.test.json → id='test.test', format='json'
851+
/test.json → id='test', format='json'
852+
/simpletest → id='simpletest', format=null
853+
/foo.bar.baz.xml → id='foo.bar.baz', format='xml'
854+
----
855+
856+
====== Backward Compatibility
857+
858+
The `+` marker is opt-in and fully backward compatible:
859+
860+
- Existing URL mappings without the `+` marker continue to work as before (splitting at the first dot)
861+
- No changes are required to existing applications
862+
- The feature can be adopted incrementally on a per-mapping basis

grails-web-url-mappings/src/main/groovy/grails/web/mapping/UrlMappingData.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,9 @@ public interface UrlMappingData {
7070
* @return Whether the pattern has an optional extension
7171
*/
7272
boolean hasOptionalExtension();
73+
74+
/**
75+
* @return Whether the parameter before the optional extension should use greedy matching (last-dot split)
76+
*/
77+
boolean hasGreedyExtensionParam();
7378
}

grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/DefaultUrlMappingData.java

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ public class DefaultUrlMappingData implements UrlMappingData {
4646

4747
private List<Boolean> optionalTokens = new ArrayList<>();
4848
private boolean hasOptionalExtension;
49+
private boolean hasGreedyExtensionParam;
4950

5051
public DefaultUrlMappingData(String urlPattern) {
5152
Assert.hasLength(urlPattern, "Argument [urlPattern] cannot be null or blank");
@@ -65,6 +66,11 @@ public boolean hasOptionalExtension() {
6566
return hasOptionalExtension;
6667
}
6768

69+
@Override
70+
public boolean hasGreedyExtensionParam() {
71+
return hasGreedyExtensionParam;
72+
}
73+
6874
private String[] tokenizeUrlPattern(String urlPattern) {
6975
// remove starting / and split
7076
return urlPattern.substring(1).split(SLASH);
@@ -93,7 +99,23 @@ private void parseUrls(List<String> urls, String[] tokens, List<Boolean> optiona
9399
if (hasOptionalExtension) {
94100
int i = lastToken.indexOf(optionalExtensionPattern);
95101
optionalExtension = lastToken.substring(i, lastToken.length());
96-
tokens[tokens.length - 1] = lastToken.substring(0, i);
102+
String beforeExtension = lastToken.substring(0, i);
103+
104+
// Check if the parameter before the extension ends with + (greedy marker)
105+
// Can be either (*)+ for required greedy or (*)+? for optional greedy
106+
if (beforeExtension.endsWith("+") || beforeExtension.endsWith("+?")) {
107+
hasGreedyExtensionParam = true;
108+
// Remove the + (keep ? if it follows)
109+
if (beforeExtension.endsWith("+?")) {
110+
// (*)+? -> (*)? (optional greedy)
111+
beforeExtension = beforeExtension.substring(0, beforeExtension.length() - 2) + "?";
112+
} else {
113+
// (*)+ -> (*) (required greedy)
114+
beforeExtension = beforeExtension.substring(0, beforeExtension.length() - 1);
115+
}
116+
}
117+
118+
tokens[tokens.length - 1] = beforeExtension;
97119
}
98120

99121
}

grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/RegexUrlMapping.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,15 @@ protected Pattern convertToRegex(String url) {
245245
// happen any time a URL mapping ends with a pattern like
246246
// /$someVariable(.$someExtension)
247247
pattern += "/([^/]+)\\.([^/.]+)?";
248+
} else if (urlData.hasGreedyExtensionParam() && urlData.hasOptionalExtension()) {
249+
// Handle greedy extension param (+ marker): match everything up to the last dot
250+
// The key is to make the entire dot+extension group optional using (?: )?
251+
// For /(*)+(\.(*))? we want regex: /(.+?)(?:\.([^/.]+))? (required, greedy)
252+
// For /(*)?+(\.(*))? we want regex: /(.+?)?(?:\.([^/.]+))? (optional, greedy)
253+
String processed = urlEnd
254+
.replace("/(*)?(\\.(*))?", "/(.+?)?(?:\\.([^/.]+))?") // Optional greedy: (*)?+
255+
.replace("/(*)(\\.(*))?", "/(.+?)(?:\\.([^/.]+))?"); // Required greedy: (*)+
256+
pattern += processed;
248257
} else {
249258
pattern += urlEnd
250259
.replace("(\\.(*))", "(\\.[^/]+)?")

grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/ResponseCodeMappingData.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ public boolean hasOptionalExtension() {
6161
return false;
6262
}
6363

64+
@Override
65+
public boolean hasGreedyExtensionParam() {
66+
return false;
67+
}
68+
6469
public int getResponseCode() {
6570
return responseCode;
6671
}

0 commit comments

Comments
 (0)