diff --git a/samples/RoutingSample/PrefixRoute.cs b/samples/RoutingSample/PrefixRoute.cs index fe1f4f092a..735cf4f48d 100644 --- a/samples/RoutingSample/PrefixRoute.cs +++ b/samples/RoutingSample/PrefixRoute.cs @@ -50,5 +50,10 @@ namespace RoutingSample return null; } + + public RouteBindResult Bind(RouteBindContext context) + { + return null; + } } } diff --git a/samples/RoutingSample/Startup.cs b/samples/RoutingSample/Startup.cs index f780ac69b7..d77cc7530b 100644 --- a/samples/RoutingSample/Startup.cs +++ b/samples/RoutingSample/Startup.cs @@ -26,11 +26,11 @@ namespace RoutingSample var endpoint1 = new HttpContextRouteEndpoint(async (context) => await context.Response.WriteAsync("match1")); var endpoint2 = new HttpContextRouteEndpoint(async (context) => await context.Response.WriteAsync("Hello, World!")); - var rb1 = new RouteBuilder(endpoint1, routes); + var rb1 = new RouteBuilder(endpoint1, routes.Routes); rb1.AddPrefixRoute("api/store"); rb1.AddTemplateRoute("api/{controller}/{*extra}", new { controller = "Store" }); - var rb2 = new RouteBuilder(endpoint2, routes); + var rb2 = new RouteBuilder(endpoint2, routes.Routes); rb2.AddPrefixRoute("hello/world"); rb2.AddPrefixRoute(""); } diff --git a/src/Microsoft.AspNet.Routing/DefaultRouteEngine.cs b/src/Microsoft.AspNet.Routing/DefaultRouteEngine.cs index 2f76d5822c..623a7a4776 100644 --- a/src/Microsoft.AspNet.Routing/DefaultRouteEngine.cs +++ b/src/Microsoft.AspNet.Routing/DefaultRouteEngine.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.txt in the project root for license information. +using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNet.Abstractions; @@ -20,13 +21,13 @@ namespace Microsoft.AspNet.Routing public async Task Invoke(HttpContext context) { - RouteContext routeContext = new RouteContext(context); + var routeContext = new RouteContext(context); - for (int i = 0; i < Routes.Count; i++) + for (var i = 0; i < Routes.Count; i++) { - IRoute route = Routes[i]; + var route = Routes[i]; - RouteMatch match = route.Match(routeContext); + var match = route.Match(routeContext); if (match != null) { context.SetFeature(new RouteValues(match.Values)); @@ -41,5 +42,23 @@ namespace Microsoft.AspNet.Routing return false; } + + public string GetUrl(HttpContext context, IDictionary values) + { + var routeBindContext = new RouteBindContext(context, values); + + for (var i = 0; i < Routes.Count; i++) + { + var route = Routes[i]; + + var result = route.Bind(routeBindContext); + if (result != null) + { + return result.Url; + } + } + + return null; + } } } diff --git a/src/Microsoft.AspNet.Routing/IRoute.cs b/src/Microsoft.AspNet.Routing/IRoute.cs index 561299ca4e..d503837afc 100644 --- a/src/Microsoft.AspNet.Routing/IRoute.cs +++ b/src/Microsoft.AspNet.Routing/IRoute.cs @@ -5,5 +5,7 @@ namespace Microsoft.AspNet.Routing public interface IRoute { RouteMatch Match(RouteContext context); + + RouteBindResult Bind(RouteBindContext context); } } diff --git a/src/Microsoft.AspNet.Routing/IRouteEngine.cs b/src/Microsoft.AspNet.Routing/IRouteEngine.cs index d54c7dc581..b2aa0b42af 100644 --- a/src/Microsoft.AspNet.Routing/IRouteEngine.cs +++ b/src/Microsoft.AspNet.Routing/IRouteEngine.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.txt in the project root for license information. +using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNet.Abstractions; @@ -7,6 +8,10 @@ namespace Microsoft.AspNet.Routing { public interface IRouteEngine { + IRouteCollection Routes { get; } + Task Invoke(HttpContext context); + + string GetUrl(HttpContext context, IDictionary values); } } diff --git a/src/Microsoft.AspNet.Routing/Owin/BuilderExtensions.cs b/src/Microsoft.AspNet.Routing/Owin/BuilderExtensions.cs index 999517009b..032f990abf 100644 --- a/src/Microsoft.AspNet.Routing/Owin/BuilderExtensions.cs +++ b/src/Microsoft.AspNet.Routing/Owin/BuilderExtensions.cs @@ -6,14 +6,14 @@ namespace Microsoft.AspNet.Routing.Owin { public static class BuilderExtensions { - public static IRouteCollection UseRouter(this IBuilder builder) + public static IRouteEngine UseRouter(this IBuilder builder) { var routes = new DefaultRouteCollection(); var engine = new DefaultRouteEngine(routes); builder.Use((next) => new RouterMiddleware(next, engine).Invoke); - return routes; + return engine; } } } diff --git a/src/Microsoft.AspNet.Routing/RouteBindContext.cs b/src/Microsoft.AspNet.Routing/RouteBindContext.cs new file mode 100644 index 0000000000..6f9677f524 --- /dev/null +++ b/src/Microsoft.AspNet.Routing/RouteBindContext.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.AspNet.Abstractions; + +namespace Microsoft.AspNet.Routing +{ + public class RouteBindContext + { + public RouteBindContext(HttpContext context, IDictionary values) + { + Context = context; + Values = values; + + if (Context != null) + { + var ambientValues = context.GetFeature(); + AmbientValues = ambientValues == null ? null : ambientValues.Values; + } + } + + public IDictionary AmbientValues { get; private set; } + + public HttpContext Context { get; private set; } + + public IDictionary Values { get; private set; } + } +} diff --git a/src/Microsoft.AspNet.Routing/RouteBindResult.cs b/src/Microsoft.AspNet.Routing/RouteBindResult.cs new file mode 100644 index 0000000000..ae81820bde --- /dev/null +++ b/src/Microsoft.AspNet.Routing/RouteBindResult.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.txt in the project root for license information. + +namespace Microsoft.AspNet.Routing +{ + public class RouteBindResult + { + public RouteBindResult(string url) + { + Url = url; + } + + public string Url { get; private set; } + } +} diff --git a/src/Microsoft.AspNet.Routing/Template/Template.cs b/src/Microsoft.AspNet.Routing/Template/Template.cs index 13fb861ded..b1cde36d71 100644 --- a/src/Microsoft.AspNet.Routing/Template/Template.cs +++ b/src/Microsoft.AspNet.Routing/Template/Template.cs @@ -12,9 +12,6 @@ namespace Microsoft.AspNet.Routing.Template { private const string SeparatorString = "/"; - private readonly TemplateMatcher _matcher; - private readonly TemplateBinder _binder; - public Template(List segments) { if (segments == null) @@ -37,20 +34,12 @@ namespace Microsoft.AspNet.Routing.Template } } } - - _matcher = new TemplateMatcher(this); - _binder = new TemplateBinder(this); } public List Parameters { get; private set; } public List Segments { get; private set; } - public IDictionary Match(string requestPath, IDictionary defaults) - { - return _matcher.Match(requestPath, defaults); - } - private string DebuggerToString() { return string.Join(SeparatorString, Segments.Select(s => s.DebuggerToString())); diff --git a/src/Microsoft.AspNet.Routing/Template/TemplateBinder.cs b/src/Microsoft.AspNet.Routing/Template/TemplateBinder.cs index 638af9dd8e..b712933cf5 100644 --- a/src/Microsoft.AspNet.Routing/Template/TemplateBinder.cs +++ b/src/Microsoft.AspNet.Routing/Template/TemplateBinder.cs @@ -24,7 +24,7 @@ namespace Microsoft.AspNet.Routing.Template public Template Template { get; private set; } - public BoundRouteTemplate Bind(IDictionary defaults, IDictionary ambientValues, IDictionary values) + public string Bind(IDictionary defaults, IDictionary ambientValues, IDictionary values) { if (values == null) { @@ -184,7 +184,7 @@ namespace Microsoft.AspNet.Routing.Template } // Step 2: If the route is a match generate the appropriate URI - private BoundRouteTemplate BindValues(TemplateBindingContext bindingContext) + private string BindValues(TemplateBindingContext bindingContext) { var context = new UriBuildingContext(); @@ -261,10 +261,7 @@ namespace Microsoft.AspNet.Routing.Template encoded.Append(Uri.EscapeDataString(converted)); } - return new BoundRouteTemplate() - { - Path = encoded.ToString(), - }; + return encoded.ToString(); } private static string UriEncode(string str) diff --git a/src/Microsoft.AspNet.Routing/Template/TemplateMatcher.cs b/src/Microsoft.AspNet.Routing/Template/TemplateMatcher.cs index 05039dd6bb..5be2d50601 100644 --- a/src/Microsoft.AspNet.Routing/Template/TemplateMatcher.cs +++ b/src/Microsoft.AspNet.Routing/Template/TemplateMatcher.cs @@ -97,6 +97,10 @@ namespace Microsoft.AspNet.Routing.Template { values.Add(part.Name, defaultValue); } + else if (part.IsOptional) + { + // This is optional (with no default value) - there's nothing to capture here, so just move on. + } else { // There's no default for this parameter diff --git a/src/Microsoft.AspNet.Routing/Template/TemplateRoute.cs b/src/Microsoft.AspNet.Routing/Template/TemplateRoute.cs index e64338d49d..a3a47d98e2 100644 --- a/src/Microsoft.AspNet.Routing/Template/TemplateRoute.cs +++ b/src/Microsoft.AspNet.Routing/Template/TemplateRoute.cs @@ -11,6 +11,8 @@ namespace Microsoft.AspNet.Routing.Template private readonly IRouteEndpoint _endpoint; private readonly Template _parsedTemplate; private readonly string _routeTemplate; + private readonly TemplateMatcher _matcher; + private readonly TemplateBinder _binder; public TemplateRoute(IRouteEndpoint endpoint, string routeTemplate) : this(endpoint, routeTemplate, null) @@ -25,11 +27,14 @@ namespace Microsoft.AspNet.Routing.Template } _endpoint = endpoint; - _routeTemplate = routeTemplate ?? String.Empty; + _routeTemplate = routeTemplate ?? string.Empty; _defaults = defaults ?? new Dictionary(StringComparer.OrdinalIgnoreCase); // The parser will throw for invalid routes. _parsedTemplate = TemplateParser.Parse(RouteTemplate); + + _matcher = new TemplateMatcher(_parsedTemplate); + _binder = new TemplateBinder(_parsedTemplate); } public IDictionary Defaults @@ -55,12 +60,12 @@ namespace Microsoft.AspNet.Routing.Template } var requestPath = context.RequestPath; - if (!String.IsNullOrEmpty(requestPath) && requestPath[0] == '/') + if (!string.IsNullOrEmpty(requestPath) && requestPath[0] == '/') { requestPath = requestPath.Substring(1); } - IDictionary values = _parsedTemplate.Match(requestPath, _defaults); + var values = _matcher.Match(requestPath, _defaults); if (values == null) { // If we got back a null value set, that means the URI did not match @@ -71,5 +76,11 @@ namespace Microsoft.AspNet.Routing.Template return new RouteMatch(_endpoint, values); } } + + public RouteBindResult Bind(RouteBindContext context) + { + var path = _binder.Bind(_defaults, context.AmbientValues, context.Values); + return path == null ? null : new RouteBindResult(path); + } } } diff --git a/test/Microsoft.AspNet.Routing.Tests/Template/RouteValueDictionary.cs b/test/Microsoft.AspNet.Routing.Tests/Template/RouteValueDictionary.cs deleted file mode 100644 index 6a9a9a1f6b..0000000000 --- a/test/Microsoft.AspNet.Routing.Tests/Template/RouteValueDictionary.cs +++ /dev/null @@ -1,28 +0,0 @@ - -using System; -using System.Collections.Generic; -using System.Reflection; - -namespace Microsoft.AspNet.Routing.Template.Tests -{ - // This is just a placeholder - public class RouteValueDictionary : Dictionary - { - public RouteValueDictionary() - : base(StringComparer.OrdinalIgnoreCase) - { - } - - public RouteValueDictionary(object obj) - : base(StringComparer.OrdinalIgnoreCase) - { - if (obj != null) - { - foreach (var property in obj.GetType().GetTypeInfo().GetProperties()) - { - Add(property.Name, property.GetValue(obj)); - } - } - } - } -} diff --git a/test/Microsoft.AspNet.Routing.Tests/Template/TemplateBinderTests.cs b/test/Microsoft.AspNet.Routing.Tests/Template/TemplateBinderTests.cs index 218478d15e..68711bb630 100644 --- a/test/Microsoft.AspNet.Routing.Tests/Template/TemplateBinderTests.cs +++ b/test/Microsoft.AspNet.Routing.Tests/Template/TemplateBinderTests.cs @@ -139,7 +139,7 @@ namespace Microsoft.AspNet.Routing.Template.Tests else { Assert.NotNull(boundTemplate); - Assert.Equal(expected, boundTemplate.Path); + Assert.Equal(expected, boundTemplate); } } @@ -989,7 +989,7 @@ namespace Microsoft.AspNet.Routing.Template.Tests // We want to chop off the query string and compare that using an unordered comparison var expectedParts = new PathAndQuery(expected); - var actualParts = new PathAndQuery(boundTemplate.Path); + var actualParts = new PathAndQuery(boundTemplate); Assert.Equal(expectedParts.Path, actualParts.Path); diff --git a/test/Microsoft.AspNet.Routing.Tests/Template/TemplateMatcherTests.cs b/test/Microsoft.AspNet.Routing.Tests/Template/TemplateMatcherTests.cs index b445e31e8c..44b7f9941b 100644 --- a/test/Microsoft.AspNet.Routing.Tests/Template/TemplateMatcherTests.cs +++ b/test/Microsoft.AspNet.Routing.Tests/Template/TemplateMatcherTests.cs @@ -733,6 +733,37 @@ namespace Microsoft.AspNet.Routing.Template.Tests Assert.False(match.ContainsKey("action")); } + [Fact] + public void MatchDoesNotSetOptionalParameter_EmptyString() + { + // Arrange + var route = CreateMatcher("{controller?}"); + var url = ""; + + // Act + var match = route.Match(url, null); + + // Assert + Assert.NotNull(match); + Assert.Equal(0, match.Values.Count); + Assert.False(match.ContainsKey("controller")); + } + + [Fact] + public void Match_EmptyRouteWith_EmptyString() + { + // Arrange + var route = CreateMatcher(""); + var url = ""; + + // Act + var match = route.Match(url, null); + + // Assert + Assert.NotNull(match); + Assert.Equal(0, match.Values.Count); + } + [Fact] public void MatchMultipleOptionalParameters() { diff --git a/test/Microsoft.AspNet.Routing.Tests/Template/TemplateRouteTests.cs b/test/Microsoft.AspNet.Routing.Tests/Template/TemplateRouteTests.cs new file mode 100644 index 0000000000..a2da4d343d --- /dev/null +++ b/test/Microsoft.AspNet.Routing.Tests/Template/TemplateRouteTests.cs @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.AspNet.Abstractions; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Routing.Template.Tests +{ + public class TemplateRouteTests + { + #region Route Matching + + // PathString in HttpAbstractions guarantees a leading slash - so no value in testing other cases. + [Fact] + public void Match_Success_LeadingSlash() + { + // Arrange + var route = CreateRoute("{controller}/{action}"); + var context = CreateRouteContext("/Home/Index"); + + // Act + var match = route.Match(context); + + // Assert + Assert.NotNull(match); + Assert.Equal(2, match.Values.Count); + Assert.Equal("Home", match.Values["controller"]); + Assert.Equal("Index", match.Values["action"]); + } + + [Fact] + public void Match_Success_RootUrl() + { + // Arrange + var route = CreateRoute(""); + var context = CreateRouteContext("/"); + + // Act + var match = route.Match(context); + + // Assert + Assert.NotNull(match); + Assert.Equal(0, match.Values.Count); + } + + [Fact] + public void Match_Success_Defaults() + { + // Arrange + var route = CreateRoute("{controller}/{action}", new { action = "Index" }); + var context = CreateRouteContext("/Home"); + + // Act + var match = route.Match(context); + + // Assert + Assert.NotNull(match); + Assert.Equal(2, match.Values.Count); + Assert.Equal("Home", match.Values["controller"]); + Assert.Equal("Index", match.Values["action"]); + } + + [Fact] + public void Match_Fails() + { + // Arrange + var route = CreateRoute("{controller}/{action}"); + var context = CreateRouteContext("/Home"); + + // Act + var match = route.Match(context); + + // Assert + Assert.Null(match); + } + + private static RouteContext CreateRouteContext(string requestPath) + { + var request = new Mock(MockBehavior.Strict); + request.SetupGet(r => r.Path).Returns(new PathString(requestPath)); + + var context = new Mock(MockBehavior.Strict); + context.SetupGet(c => c.Request).Returns(request.Object); + + return new RouteContext(context.Object); + } + + #endregion + + #region Route Binding + + [Fact] + public void Bind_Success() + { + // Arrange + var route = CreateRoute("{controller}"); + var context = CreateRouteBindContext(new {controller = "Home"}); + + // Act + var bind = route.Bind(context); + + // Assert + Assert.NotNull(bind); + Assert.Equal("Home", bind.Url); + } + + [Fact] + public void Bind_Fail() + { + // Arrange + var route = CreateRoute("{controller}/{action}"); + var context = CreateRouteBindContext(new { controller = "Home" }); + + // Act + var bind = route.Bind(context); + + // Assert + Assert.Null(bind); + } + + [Fact] + public void Bind_Success_AmbientValues() + { + // Arrange + var route = CreateRoute("{controller}/{action}"); + var context = CreateRouteBindContext(new { action = "Index"}, new { controller = "Home" }); + + // Act + var bind = route.Bind(context); + + // Assert + Assert.NotNull(bind); + Assert.Equal("Home/Index", bind.Url); + } + + private static RouteBindContext CreateRouteBindContext(object values) + { + return CreateRouteBindContext(new RouteValueDictionary(values), null); + } + + private static RouteBindContext CreateRouteBindContext(object values, object ambientValues) + { + return CreateRouteBindContext(new RouteValueDictionary(values), new RouteValueDictionary(ambientValues)); + } + + private static RouteBindContext CreateRouteBindContext(IDictionary values, IDictionary ambientValues) + { + var context = new Mock(MockBehavior.Strict); + context.Setup(c => c.GetFeature()).Returns(new RouteValues(ambientValues)); + + return new RouteBindContext(context.Object, values); + } + + #endregion + + private static TemplateRoute CreateRoute(string template) + { + return new TemplateRoute(CreateEndpoint(), template); + } + + private static TemplateRoute CreateRoute(string template, object defaults) + { + return new TemplateRoute(CreateEndpoint(), template, new RouteValueDictionary(defaults)); + } + + private static IRouteEndpoint CreateEndpoint() + { + return new Mock(MockBehavior.Strict).Object; + } + } +} diff --git a/test/Microsoft.AspNet.Routing.Tests/project.json b/test/Microsoft.AspNet.Routing.Tests/project.json index 6261e25ace..44d7a183fe 100644 --- a/test/Microsoft.AspNet.Routing.Tests/project.json +++ b/test/Microsoft.AspNet.Routing.Tests/project.json @@ -7,6 +7,7 @@ "configurations": { "net45": { "dependencies": { + "Moq": "4.2.1402.2112", "Owin": "1.0", "xunit": "1.9.2", "xunit.extensions": "1.9.2"