diff --git a/src/Microsoft.AspNet.Routing/RouteCollection.cs b/src/Microsoft.AspNet.Routing/RouteCollection.cs
index b7a6e94897..0f0974f527 100644
--- a/src/Microsoft.AspNet.Routing/RouteCollection.cs
+++ b/src/Microsoft.AspNet.Routing/RouteCollection.cs
@@ -203,24 +203,31 @@ namespace Microsoft.AspNet.Routing
{
var url = path.Value;
- if (!string.IsNullOrEmpty(url) && _options.LowercaseUrls)
+ if (!string.IsNullOrEmpty(url) && (_options.LowercaseUrls || _options.AppendTrailingSlash))
{
var indexOfSeparator = url.IndexOfAny(new char[] { '?', '#' });
+ var urlWithoutQueryString = url;
+ var queryString = string.Empty;
- // No query string, lowercase the url
- if (indexOfSeparator == -1)
+ if (indexOfSeparator != -1)
{
- url = url.ToLowerInvariant();
+ urlWithoutQueryString = url.Substring(0, indexOfSeparator);
+ queryString = url.Substring(indexOfSeparator);
}
- else
- {
- var lowercaseUrl = url.Substring(0, indexOfSeparator).ToLowerInvariant();
- var queryString = url.Substring(indexOfSeparator);
- // queryString will contain the delimiter ? or # as the first character, so it's safe to append.
- url = lowercaseUrl + queryString;
+ if (_options.LowercaseUrls)
+ {
+ urlWithoutQueryString = urlWithoutQueryString.ToLowerInvariant();
}
+ if (_options.AppendTrailingSlash && !urlWithoutQueryString.EndsWith("/"))
+ {
+ urlWithoutQueryString += "/";
+ }
+
+ // queryString will contain the delimiter ? or # as the first character, so it's safe to append.
+ url = urlWithoutQueryString + queryString;
+
return new PathString(url);
}
diff --git a/src/Microsoft.AspNet.Routing/RouteOptions.cs b/src/Microsoft.AspNet.Routing/RouteOptions.cs
index 4bcb1d556b..034ca0fad4 100644
--- a/src/Microsoft.AspNet.Routing/RouteOptions.cs
+++ b/src/Microsoft.AspNet.Routing/RouteOptions.cs
@@ -15,6 +15,11 @@ namespace Microsoft.AspNet.Routing
///
public bool LowercaseUrls { get; set; }
+ ///
+ /// Gets or sets a value indicating whether a trailing slash should be appended to the generated URLs.
+ ///
+ public bool AppendTrailingSlash { get; set; }
+
private IDictionary _constraintTypeMap = GetDefaultConstraintMap();
public IDictionary ConstraintMap
diff --git a/test/Microsoft.AspNet.Routing.Tests/RouteCollectionTest.cs b/test/Microsoft.AspNet.Routing.Tests/RouteCollectionTest.cs
index fc65ffed02..df6cb94b67 100644
--- a/test/Microsoft.AspNet.Routing.Tests/RouteCollectionTest.cs
+++ b/test/Microsoft.AspNet.Routing.Tests/RouteCollectionTest.cs
@@ -20,16 +20,22 @@ namespace Microsoft.AspNet.Routing
public class RouteCollectionTest
{
[Theory]
- [InlineData(@"Home/Index/23", "/home/index/23", true)]
- [InlineData(@"Home/Index/23", "/Home/Index/23", false)]
- [InlineData(@"Home/Index/23?Param1=ABC&Param2=Xyz", "/Home/Index/23?Param1=ABC&Param2=Xyz", false)]
- [InlineData(@"Home/Index/23?Param1=ABC&Param2=Xyz", "/home/index/23?Param1=ABC&Param2=Xyz", true)]
- [InlineData(@"Home/Index/23#Param1=ABC&Param2=Xyz", "/Home/Index/23#Param1=ABC&Param2=Xyz", false)]
- [InlineData(@"Home/Index/23#Param1=ABC&Param2=Xyz", "/home/index/23#Param1=ABC&Param2=Xyz", true)]
- public void GetVirtualPath_CanLowerCaseUrls_BasedOnOptions(
+ [InlineData(@"Home/Index/23", "/home/index/23", true, false)]
+ [InlineData(@"Home/Index/23", "/Home/Index/23", false, false)]
+ [InlineData(@"Home/Index/23", "/home/index/23/", true, true)]
+ [InlineData(@"Home/Index/23", "/Home/Index/23/", false, true)]
+ [InlineData(@"Home/Index/23?Param1=ABC&Param2=Xyz", "/Home/Index/23/?Param1=ABC&Param2=Xyz", false, true)]
+ [InlineData(@"Home/Index/23?Param1=ABC&Param2=Xyz", "/Home/Index/23?Param1=ABC&Param2=Xyz", false, false)]
+ [InlineData(@"Home/Index/23?Param1=ABC&Param2=Xyz", "/home/index/23/?Param1=ABC&Param2=Xyz", true, true)]
+ [InlineData(@"Home/Index/23#Param1=ABC&Param2=Xyz", "/Home/Index/23/#Param1=ABC&Param2=Xyz", false, true)]
+ [InlineData(@"Home/Index/23#Param1=ABC&Param2=Xyz", "/home/index/23#Param1=ABC&Param2=Xyz", true, false)]
+ [InlineData(@"Home/Index/23/?Param1=ABC&Param2=Xyz", "/home/index/23/?Param1=ABC&Param2=Xyz", true, true)]
+ [InlineData(@"Home/Index/23/#Param1=ABC&Param2=Xyz", "/home/index/23/#Param1=ABC&Param2=Xyz", true, false)]
+ public void GetVirtualPath_CanLowerCaseUrls_And_AppendTrailingSlash_BasedOnOptions(
string returnUrl,
- string lowercaseUrl,
- bool lowercaseUrls)
+ string expectedUrl,
+ bool lowercaseUrls,
+ bool appendTrailingSlash)
{
// Arrange
var target = new Mock(MockBehavior.Strict);
@@ -39,17 +45,21 @@ namespace Microsoft.AspNet.Routing
var routeCollection = new RouteCollection();
routeCollection.Add(target.Object);
- var virtualPathContext = CreateVirtualPathContext(options: GetRouteOptions(lowercaseUrls));
+ var virtualPathContext = CreateVirtualPathContext(
+ options: GetRouteOptions(
+ lowerCaseUrls: lowercaseUrls,
+ useBestEffortLinkGeneration: true,
+ appendTrailingSlash: appendTrailingSlash));
// Act
var pathData = routeCollection.GetVirtualPath(virtualPathContext);
// Assert
- Assert.Equal(new PathString(lowercaseUrl), pathData.VirtualPath);
+ Assert.Equal(new PathString(expectedUrl), pathData.VirtualPath);
Assert.Same(target.Object, pathData.Router);
Assert.Empty(pathData.DataTokens);
}
-
+
[Theory]
[InlineData(@"\u0130", @"/\u0130", true)]
[InlineData(@"\u0049", @"/\u0049", true)]
@@ -114,7 +124,7 @@ namespace Microsoft.AspNet.Routing
Assert.Equal(dataToken.Value, pathData.DataTokens[dataToken.Key]);
}
}
-
+
[Fact]
public async Task RouteAsync_FirstMatches()
{
@@ -225,7 +235,7 @@ namespace Microsoft.AspNet.Routing
// Assert
Assert.Null(stringVirtualPath);
}
-
+
[Fact]
public void NamedRouteTests_GetNamedRoute_AmbiguousRoutesInCollection_DoesNotThrowForUnambiguousRoute()
{
@@ -245,7 +255,7 @@ namespace Microsoft.AspNet.Routing
Assert.Equal("Route1", namedRouter.Name);
Assert.Empty(pathData.DataTokens);
}
-
+
[Fact]
public void NamedRouteTests_GetNamedRoute_AmbiguousRoutesInCollection_ThrowsForAmbiguousRoute()
{
@@ -880,11 +890,16 @@ namespace Microsoft.AspNet.Routing
return target;
}
- private static RouteOptions GetRouteOptions(bool lowerCaseUrls = false, bool useBestEffortLinkGeneration = true)
+ private static RouteOptions GetRouteOptions(
+ bool lowerCaseUrls = false,
+ bool useBestEffortLinkGeneration = true,
+ bool appendTrailingSlash = false)
{
var routeOptions = new RouteOptions();
routeOptions.LowercaseUrls = lowerCaseUrls;
routeOptions.UseBestEffortLinkGeneration = useBestEffortLinkGeneration;
+ routeOptions.AppendTrailingSlash = appendTrailingSlash;
+
return routeOptions;
}
}