diff --git a/src/Microsoft.AspNet.Routing/Constraints/OptionalRouteConstraint.cs b/src/Microsoft.AspNet.Routing/Constraints/OptionalRouteConstraint.cs new file mode 100644 index 0000000000..2890505e81 --- /dev/null +++ b/src/Microsoft.AspNet.Routing/Constraints/OptionalRouteConstraint.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.AspNet.Http; + +namespace Microsoft.AspNet.Routing.Constraints +{ + /// + /// Defines a constraint on an optional parameter. If the parameter is present, then it is constrained by InnerConstraint. + /// + public class OptionalRouteConstraint : IRouteConstraint + { + public OptionalRouteConstraint([NotNull] IRouteConstraint innerConstraint) + { + InnerConstraint = innerConstraint; + } + + public IRouteConstraint InnerConstraint { get; } + + public bool Match([NotNull] HttpContext httpContext, + [NotNull] IRouter route, + [NotNull] string routeKey, + [NotNull] IDictionary values, + RouteDirection routeDirection) + { + object value; + if (values.TryGetValue(routeKey, out value)) + { + return InnerConstraint.Match(httpContext, + route, + routeKey, + values, + routeDirection); + } + + return true; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Routing/RouteConstraintBuilder.cs b/src/Microsoft.AspNet.Routing/RouteConstraintBuilder.cs index 59f8efbe2d..bb03d0a62e 100644 --- a/src/Microsoft.AspNet.Routing/RouteConstraintBuilder.cs +++ b/src/Microsoft.AspNet.Routing/RouteConstraintBuilder.cs @@ -20,7 +20,7 @@ namespace Microsoft.AspNet.Routing private readonly string _displayName; private readonly Dictionary> _constraints; - + private readonly HashSet _optionalParameters; /// /// Creates a new instance. /// @@ -34,6 +34,7 @@ namespace Microsoft.AspNet.Routing _displayName = displayName; _constraints = new Dictionary>(StringComparer.OrdinalIgnoreCase); + _optionalParameters = new HashSet(StringComparer.OrdinalIgnoreCase); } /// @@ -55,7 +56,15 @@ namespace Microsoft.AspNet.Routing constraint = new CompositeRouteConstraint(kvp.Value.ToArray()); } - constraints.Add(kvp.Key, constraint); + if (_optionalParameters.Contains(kvp.Key)) + { + var optionalConstraint = new OptionalRouteConstraint(constraint); + constraints.Add(kvp.Key, optionalConstraint); + } + else + { + constraints.Add(kvp.Key, constraint); + } } return constraints; @@ -123,6 +132,15 @@ namespace Microsoft.AspNet.Routing Add(key, constraint); } + /// + /// Sets the given key as optional. + /// + /// The key. + public void SetOptional([NotNull] string key) + { + _optionalParameters.Add(key); + } + private void Add(string key, IRouteConstraint constraint) { List list; diff --git a/src/Microsoft.AspNet.Routing/Template/TemplateRoute.cs b/src/Microsoft.AspNet.Routing/Template/TemplateRoute.cs index 9af02c11a4..05d041e401 100644 --- a/src/Microsoft.AspNet.Routing/Template/TemplateRoute.cs +++ b/src/Microsoft.AspNet.Routing/Template/TemplateRoute.cs @@ -274,10 +274,15 @@ namespace Microsoft.AspNet.Routing.Template foreach (var parameter in parsedTemplate.Parameters) { + if (parameter.IsOptional) + { + constraintBuilder.SetOptional(parameter.Name); + } + foreach (var inlineConstraint in parameter.InlineConstraints) { constraintBuilder.AddResolvedConstraint(parameter.Name, inlineConstraint.Constraint); - } + } } return constraintBuilder.Build(); diff --git a/test/Microsoft.AspNet.Routing.Tests/RouteConstraintBuilderTest.cs b/test/Microsoft.AspNet.Routing.Tests/RouteConstraintBuilderTest.cs index 472fb238a9..e01ad7cefc 100644 --- a/test/Microsoft.AspNet.Routing.Tests/RouteConstraintBuilderTest.cs +++ b/test/Microsoft.AspNet.Routing.Tests/RouteConstraintBuilderTest.cs @@ -102,6 +102,53 @@ namespace Microsoft.AspNet.Routing "of type 'DefaultInlineConstraintResolver'."); } + [Fact] + public void AddResolvedConstraint_ForOptionalParameter() + { + var builder = CreateBuilder("{controller}/{action}/{id}"); + builder.SetOptional("id"); + builder.AddResolvedConstraint("id", "int"); + + var result = builder.Build(); + Assert.Equal(1, result.Count); + Assert.Equal("id", result.First().Key); + Assert.IsType(Assert.Single(result).Value); + } + + [Fact] + public void AddResolvedConstraint_SetOptionalParameter_AfterAddingTheParameter() + { + var builder = CreateBuilder("{controller}/{action}/{id}"); + builder.AddResolvedConstraint("id", "int"); + builder.SetOptional("id"); + + var result = builder.Build(); + Assert.Equal(1, result.Count); + Assert.Equal("id", result.First().Key); + Assert.IsType(Assert.Single(result).Value); + } + + [Fact] + public void AddResolvedConstraint_And_AddConstraint_ForOptionalParameter() + { + var builder = CreateBuilder("{controller}/{action}/{name}"); + builder.SetOptional("name"); + builder.AddResolvedConstraint("name", "alpha"); + var minLenConstraint = new MinLengthRouteConstraint(10); + builder.AddConstraint("name", minLenConstraint); + + var result = builder.Build(); + Assert.Equal(1, result.Count); + Assert.Equal("name", result.First().Key); + Assert.IsType(Assert.Single(result).Value); + var optionalConstraint = (OptionalRouteConstraint)result.First().Value; + var compositeConstraint = Assert.IsType(optionalConstraint.InnerConstraint); ; + Assert.Equal(compositeConstraint.Constraints.Count(), 2); + + Assert.Single(compositeConstraint.Constraints, c => c is MinLengthRouteConstraint); + Assert.Single(compositeConstraint.Constraints, c => c is AlphaRouteConstraint); + } + [Theory] [InlineData("abc", "abc", true)] // simple case [InlineData("abc", "bbb|abc", true)] // Regex or diff --git a/test/Microsoft.AspNet.Routing.Tests/Template/TemplateRouteTest.cs b/test/Microsoft.AspNet.Routing.Tests/Template/TemplateRouteTest.cs index 13b2a860d1..5baaf3b9c8 100644 --- a/test/Microsoft.AspNet.Routing.Tests/Template/TemplateRouteTest.cs +++ b/test/Microsoft.AspNet.Routing.Tests/Template/TemplateRouteTest.cs @@ -224,6 +224,82 @@ namespace Microsoft.AspNet.Routing.Template Assert.NotSame(route.DataTokens, context.RouteData.DataTokens); } + [Fact] + public async Task RouteAsync_InlineConstrait_OptionalParameter() + { + // Arrange + var template = "{controller}/{action}/{id:int?}"; + + var context = CreateRouteContext("/Home/Index/5"); + + IDictionary routeValues = null; + var mockTarget = new Mock(MockBehavior.Strict); + mockTarget + .Setup(s => s.RouteAsync(It.IsAny())) + .Callback(ctx => + { + routeValues = ctx.RouteData.Values; + ctx.IsHandled = true; + }) + .Returns(Task.FromResult(true)); + + var route = new TemplateRoute( + mockTarget.Object, + template, + defaults: null, + constraints: null, + dataTokens: null, + inlineConstraintResolver: _inlineConstraintResolver); + + // Act + await route.RouteAsync(context); + + // Assert + Assert.NotNull(routeValues); + Assert.True(routeValues.ContainsKey("id")); + Assert.Equal("5", routeValues["id"]); + + Assert.True(context.RouteData.Values.ContainsKey("id")); + Assert.Equal("5", context.RouteData.Values["id"]); + } + + [Fact] + public async Task RouteAsync_InlineConstrait_OptionalParameter_NotPresent() + { + // Arrange + var template = "{controller}/{action}/{id:int?}"; + + var context = CreateRouteContext("/Home/Index"); + + IDictionary routeValues = null; + var mockTarget = new Mock(MockBehavior.Strict); + mockTarget + .Setup(s => s.RouteAsync(It.IsAny())) + .Callback(ctx => + { + routeValues = ctx.RouteData.Values; + ctx.IsHandled = true; + }) + .Returns(Task.FromResult(true)); + + var route = new TemplateRoute( + mockTarget.Object, + template, + defaults: null, + constraints: null, + dataTokens: null, + inlineConstraintResolver: _inlineConstraintResolver); + + // Act + await route.RouteAsync(context); + + // Assert + Assert.NotNull(routeValues); + Assert.False(routeValues.ContainsKey("id")); + + Assert.False(context.RouteData.Values.ContainsKey("id")); + } + [Fact] public async Task RouteAsync_MergesExistingRouteData_PassedToConstraint() { @@ -463,6 +539,177 @@ namespace Microsoft.AspNet.Routing.Template Assert.Equal(0, sink.Writes.Count); } + [Fact] + public async Task RouteAsync_InlineConstraint_OptionalParameter() + { + // Arrange + var template = "{controller}/{action}/{id:int?}"; + + var context = CreateRouteContext("/Home/Index/5"); + + IDictionary routeValues = null; + var mockTarget = new Mock(MockBehavior.Strict); + mockTarget + .Setup(s => s.RouteAsync(It.IsAny())) + .Callback(ctx => + { + routeValues = ctx.RouteData.Values; + ctx.IsHandled = true; + }) + .Returns(Task.FromResult(true)); + + var route = new TemplateRoute( + mockTarget.Object, + template, + defaults: null, + constraints: null, + dataTokens: null, + inlineConstraintResolver: _inlineConstraintResolver); + + Assert.NotEmpty(route.Constraints); + Assert.IsType(route.Constraints["id"]); + + // Act + await route.RouteAsync(context); + + // Assert + Assert.True(context.IsHandled); + Assert.True(routeValues.ContainsKey("id")); + Assert.Equal("5", routeValues["id"]); + + Assert.True(context.RouteData.Values.ContainsKey("id")); + Assert.Equal("5", context.RouteData.Values["id"]); + } + + [Fact] + public async Task RouteAsync_InlineConstraint_OptionalParameter_NotPresent() + { + // Arrange + var template = "{controller}/{action}/{id:int?}"; + + var context = CreateRouteContext("/Home/Index"); + + IDictionary routeValues = null; + var mockTarget = new Mock(MockBehavior.Strict); + mockTarget + .Setup(s => s.RouteAsync(It.IsAny())) + .Callback(ctx => + { + routeValues = ctx.RouteData.Values; + ctx.IsHandled = true; + }) + .Returns(Task.FromResult(true)); + + var route = new TemplateRoute( + mockTarget.Object, + template, + defaults: null, + constraints: null, + dataTokens: null, + inlineConstraintResolver: _inlineConstraintResolver); + + Assert.NotEmpty(route.Constraints); + Assert.IsType(route.Constraints["id"]); + + // Act + await route.RouteAsync(context); + + // Assert + Assert.True(context.IsHandled); + Assert.NotNull(routeValues); + Assert.False(routeValues.ContainsKey("id")); + Assert.False(context.RouteData.Values.ContainsKey("id")); + } + + [Fact] + public async Task RouteAsync_InlineConstraint_OptionalParameter_WithInConstructorConstraint() + { + // Arrange + var template = "{controller}/{action}/{id:int?}"; + + var context = CreateRouteContext("/Home/Index/5"); + + IDictionary routeValues = null; + var mockTarget = new Mock(MockBehavior.Strict); + mockTarget + .Setup(s => s.RouteAsync(It.IsAny())) + .Callback(ctx => + { + routeValues = ctx.RouteData.Values; + ctx.IsHandled = true; + }) + .Returns(Task.FromResult(true)); + + var constraints = new Dictionary(); + constraints.Add("id", new RangeRouteConstraint(1, 20)); + + var route = new TemplateRoute( + mockTarget.Object, + template, + defaults: null, + constraints: constraints, + dataTokens: null, + inlineConstraintResolver: _inlineConstraintResolver); + + Assert.NotEmpty(route.Constraints); + Assert.IsType(route.Constraints["id"]); + var innerConstraint = ((OptionalRouteConstraint)route.Constraints["id"]).InnerConstraint; + Assert.IsType(innerConstraint); + var compositeConstraint = (CompositeRouteConstraint)innerConstraint; + Assert.Equal(compositeConstraint.Constraints.Count(), 2); + + Assert.Single(compositeConstraint.Constraints, c => c is IntRouteConstraint); + Assert.Single(compositeConstraint.Constraints, c => c is RangeRouteConstraint); + + // Act + await route.RouteAsync(context); + + // Assert + Assert.True(context.IsHandled); + Assert.True(routeValues.ContainsKey("id")); + Assert.Equal("5", routeValues["id"]); + + Assert.True(context.RouteData.Values.ContainsKey("id")); + Assert.Equal("5", context.RouteData.Values["id"]); + } + + [Fact] + public async Task RouteAsync_InlineConstraint_OptionalParameter_ConstraintFails() + { + // Arrange + var template = "{controller}/{action}/{id:range(1,20)?}"; + + var context = CreateRouteContext("/Home/Index/100"); + + IDictionary routeValues = null; + var mockTarget = new Mock(MockBehavior.Strict); + mockTarget + .Setup(s => s.RouteAsync(It.IsAny())) + .Callback(ctx => + { + routeValues = ctx.RouteData.Values; + ctx.IsHandled = true; + }) + .Returns(Task.FromResult(true)); + + var route = new TemplateRoute( + mockTarget.Object, + template, + defaults: null, + constraints: null, + dataTokens: null, + inlineConstraintResolver: _inlineConstraintResolver); + + Assert.NotEmpty(route.Constraints); + Assert.IsType(route.Constraints["id"]); + + // Act + await route.RouteAsync(context); + + // Assert + Assert.False(context.IsHandled); + } + #region Route Matching // PathString in HttpAbstractions guarantees a leading slash - so no value in testing other cases. @@ -994,6 +1241,118 @@ namespace Microsoft.AspNet.Routing.Template Assert.Equal(expectedValues.OrderBy(kvp => kvp.Key), constraint.Values.OrderBy(kvp => kvp.Key)); } + [Fact] + public void GetVirtualPath_InlineConstraints_Success() + { + // Arrange + var route = CreateRoute("{controller}/{action}/{id:int}"); + var context = CreateVirtualPathContext( + values: new { action = "Index", controller = "Home", id = 4 }); + + // Act + var path = route.GetVirtualPath(context); + + // Assert + Assert.Equal("Home/Index/4", path); + } + + [Fact] + public void GetVirtualPath_InlineConstraints_NonMatchingvalue() + { + // Arrange + var route = CreateRoute("{controller}/{action}/{id:int}"); + var context = CreateVirtualPathContext( + values: new { action = "Index", controller = "Home", id = "asf" }); + + // Act + var path = route.GetVirtualPath(context); + + // Assert + Assert.Null(path); + } + + [Fact] + public void GetVirtualPath_InlineConstraints_OptionalParameter_ValuePresent() + { + // Arrange + var route = CreateRoute("{controller}/{action}/{id:int?}"); + var context = CreateVirtualPathContext( + values: new { action = "Index", controller = "Home", id = 98 }); + + // Act + var path = route.GetVirtualPath(context); + + // Assert + Assert.Equal("Home/Index/98", path); + } + + [Fact] + public void GetVirtualPath_InlineConstraints_OptionalParameter_ValueNotPresent() + { + // Arrange + var route = CreateRoute("{controller}/{action}/{id:int?}"); + var context = CreateVirtualPathContext( + values: new { action = "Index", controller = "Home" }); + + // Act + var path = route.GetVirtualPath(context); + + // Assert + Assert.Equal("Home/Index", path); + } + + [Fact] + public void GetVirtualPath_InlineConstraints_OptionalParameter_ValuePresent_ConstraintFails() + { + // Arrange + var route = CreateRoute("{controller}/{action}/{id:int?}"); + var context = CreateVirtualPathContext( + values: new { action = "Index", controller = "Home", id = "sdfd" }); + + // Act + var path = route.GetVirtualPath(context); + + // Assert + Assert.Null(path); + } + + [Fact] + public void GetVirtualPath_InlineConstraints_CompositeInlineConstraint() + { + // Arrange + var route = CreateRoute("{controller}/{action}/{id:int:range(1,20)}"); + var context = CreateVirtualPathContext( + values: new { action = "Index", controller = "Home", id = 14 }); + + // Act + var path = route.GetVirtualPath(context); + + // Assert + Assert.Equal("Home/Index/14", path); + } + + [Fact] + public void GetVirtualPath_InlineConstraints_CompositeConstraint_FromConstructor() + { + // Arrange + var constraint = new MaxLengthRouteConstraint(20); + var route = CreateRoute( + template: "{controller}/{action}/{name:alpha}", + defaults: null, + accept: true, + constraints: new { name = constraint }); + + var context = CreateVirtualPathContext( + values: new { action = "Index", controller = "Home", name = "products" }); + + // Act + var path = route.GetVirtualPath(context); + + // Assert + Assert.Equal("Home/Index/products", path); + } + + private static VirtualPathContext CreateVirtualPathContext(object values) { return CreateVirtualPathContext(new RouteValueDictionary(values), null); @@ -1263,6 +1622,9 @@ namespace Microsoft.AspNet.Routing.Template { var resolverMock = new Mock(); resolverMock.Setup(o => o.ResolveConstraint("int")).Returns(new IntRouteConstraint()); + resolverMock.Setup(o => o.ResolveConstraint("range(1,20)")).Returns(new RangeRouteConstraint(1, 20)); + resolverMock.Setup(o => o.ResolveConstraint("alpha")).Returns(new AlphaRouteConstraint()); + return resolverMock.Object; }