Add support for suppressing inbound and outbound routing

This commit is contained in:
Pranav K 2017-04-13 18:46:57 -07:00
parent c69e48f06b
commit 8fa95d66d4
14 changed files with 515 additions and 30 deletions

View File

@ -26,5 +26,15 @@ namespace Microsoft.AspNetCore.Mvc.Routing
/// route by provided route data.
/// </summary>
public string Name { get; set; }
/// <summary>
/// Gets or sets a value that determines if the route entry associated with this model participates in link generation.
/// </summary>
public bool SuppressLinkGeneration { get; set; }
/// <summary>
/// Gets or sets a value that determines if the route entry associated with this model participates in path matching (inbound routing).
/// </summary>
public bool SuppressPathMatching { get; set; }
}
}

View File

@ -66,6 +66,11 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
foreach (var action in context.Actions.OfType<ControllerActionDescriptor>())
{
if (action.AttributeRouteInfo != null && action.AttributeRouteInfo.SuppressPathMatching)
{
continue;
}
var extensionData = action.GetProperty<ApiDescriptionActionData>();
if (extensionData != null)
{

View File

@ -42,9 +42,11 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels
Name = other.Name;
Order = other.Order;
Template = other.Template;
SuppressLinkGeneration = other.SuppressLinkGeneration;
SuppressPathMatching = other.SuppressPathMatching;
}
public IRouteTemplateProvider Attribute { get; private set; }
public IRouteTemplateProvider Attribute { get;}
public string Template { get; set; }
@ -52,6 +54,16 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels
public string Name { get; set; }
/// <summary>
/// Gets or sets a value that determines if this model participates in link generation.
/// </summary>
public bool SuppressLinkGeneration { get; set; }
/// <summary>
/// Gets or sets a value that determines if this model participates in path matching (inbound routing).
/// </summary>
public bool SuppressPathMatching { get; set; }
public bool IsAbsoluteTemplate
{
get
@ -96,6 +108,8 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels
Template = combinedTemplate,
Order = right.Order ?? left.Order,
Name = ChooseName(left, right),
SuppressLinkGeneration = left.SuppressLinkGeneration || right.SuppressLinkGeneration,
SuppressPathMatching = left.SuppressPathMatching || right.SuppressPathMatching,
};
}

View File

@ -88,6 +88,11 @@ namespace Microsoft.AspNetCore.Mvc.Internal
// action by expected route values, and then use the TemplateBinder to generate the link.
foreach (var routeInfo in routeInfos)
{
if (routeInfo.SuppressLinkGeneration)
{
continue;
}
var defaults = new RouteValueDictionary();
foreach (var kvp in routeInfo.ActionDescriptor.RouteValues)
{
@ -117,7 +122,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal
// We're creating one AttributeRouteMatchingEntry per group, so we need to identify the distinct set of
// groups. It's guaranteed that all members of the group have the same template and precedence,
// so we only need to hang on to a single instance of the RouteInfo for each group.
var groups = GroupRouteInfos(routeInfos);
var groups = GetInboundRouteGroups(routeInfos);
foreach (var group in groups)
{
var handler = _handlerFactory(group.ToArray());
@ -135,9 +140,11 @@ namespace Microsoft.AspNetCore.Mvc.Internal
}
}
private static IEnumerable<IGrouping<RouteInfo, ActionDescriptor>> GroupRouteInfos(List<RouteInfo> routeInfos)
private static IEnumerable<IGrouping<RouteInfo, ActionDescriptor>> GetInboundRouteGroups(List<RouteInfo> routeInfos)
{
return routeInfos.GroupBy(r => r, r => r.ActionDescriptor, RouteInfoEqualityComparer.Instance);
return routeInfos
.Where(routeInfo => !routeInfo.SuppressPathMatching)
.GroupBy(r => r, r => r.ActionDescriptor, RouteInfoEqualityComparer.Instance);
}
private static List<RouteInfo> GetRouteInfos(IReadOnlyList<ActionDescriptor> actions)
@ -194,8 +201,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal
try
{
RouteTemplate parsedTemplate;
if (!templateCache.TryGetValue(action.AttributeRouteInfo.Template, out parsedTemplate))
if (!templateCache.TryGetValue(action.AttributeRouteInfo.Template, out var parsedTemplate))
{
// Parsing with throw if the template is invalid.
parsedTemplate = TemplateParser.Parse(action.AttributeRouteInfo.Template);
@ -203,6 +209,8 @@ namespace Microsoft.AspNetCore.Mvc.Internal
}
routeInfo.RouteTemplate = parsedTemplate;
routeInfo.SuppressPathMatching = action.AttributeRouteInfo.SuppressPathMatching;
routeInfo.SuppressLinkGeneration = action.AttributeRouteInfo.SuppressLinkGeneration;
}
catch (Exception ex)
{
@ -243,6 +251,10 @@ namespace Microsoft.AspNetCore.Mvc.Internal
public string RouteName { get; set; }
public RouteTemplate RouteTemplate { get; set; }
public bool SuppressPathMatching { get; set; }
public bool SuppressLinkGeneration { get; set; }
}
private class RouteInfoEqualityComparer : IEqualityComparer<RouteInfo>

View File

@ -1,15 +0,0 @@
// Copyright (c) .NET Foundation. 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.AspNetCore.Routing.Tree;
namespace Microsoft.AspNetCore.Mvc.Internal
{
public class AttributeRouteEntries
{
public List<InboundRouteEntry> InboundEntries { get; } = new List<InboundRouteEntry>();
public List<OutboundRouteEntry> OutboundEntries { get; } = new List<OutboundRouteEntry>();
}
}

View File

@ -365,9 +365,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal
AttributeRouteModel action,
AttributeRouteModel controller)
{
var combinedRoute = AttributeRouteModel.CombineAttributeRouteModel(
controller,
action);
var combinedRoute = AttributeRouteModel.CombineAttributeRouteModel(controller, action);
if (combinedRoute == null)
{
@ -375,11 +373,13 @@ namespace Microsoft.AspNetCore.Mvc.Internal
}
else
{
return new AttributeRouteInfo()
return new AttributeRouteInfo
{
Template = combinedRoute.Template,
Order = combinedRoute.Order ?? DefaultAttributeRouteOrder,
Name = combinedRoute.Name,
SuppressLinkGeneration = combinedRoute.SuppressLinkGeneration,
SuppressPathMatching = combinedRoute.SuppressPathMatching,
};
}
}

View File

@ -4,7 +4,6 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using Microsoft.AspNetCore.Routing;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.ApplicationModels
@ -15,10 +14,13 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels
public void CopyConstructor_CopiesAllProperties()
{
// Arrange
var route = new AttributeRouteModel(new HttpGetAttribute("/api/Products"));
route.Name = "products";
route.Order = 5;
var route = new AttributeRouteModel(new HttpGetAttribute("/api/Products"))
{
Name = "products",
Order = 5,
SuppressLinkGeneration = true,
SuppressPathMatching = true,
};
// Act
var route2 = new AttributeRouteModel(route);
@ -277,6 +279,80 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels
Assert.Equal(expectedName, combined.Name);
}
[Fact]
public void Combine_SetsSuppressLinkGenerationToFalse_IfNeitherIsTrue()
{
// Arrange
var left = new AttributeRouteModel
{
Template = "Template"
};
var right = new AttributeRouteModel();
var combined = AttributeRouteModel.CombineAttributeRouteModel(left, right);
// Assert
Assert.False(combined.SuppressLinkGeneration);
}
[Theory]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public void Combine_SetsSuppressLinkGenerationToTrue_IfEitherIsTrue(bool leftSuppress, bool rightSuppress)
{
// Arrange
var left = new AttributeRouteModel
{
Template = "Template",
SuppressLinkGeneration = leftSuppress,
};
var right = new AttributeRouteModel
{
SuppressLinkGeneration = rightSuppress,
};
var combined = AttributeRouteModel.CombineAttributeRouteModel(left, right);
// Assert
Assert.True(combined.SuppressLinkGeneration);
}
[Fact]
public void Combine_SetsSuppressPathGenerationToFalse_IfNeitherIsTrue()
{
// Arrange
var left = new AttributeRouteModel
{
Template = "Template",
};
var right = new AttributeRouteModel();
var combined = AttributeRouteModel.CombineAttributeRouteModel(left, right);
// Assert
Assert.False(combined.SuppressPathMatching);
}
[Theory]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public void Combine_SetsSuppressPathGenerationToTrue_IfEitherIsTrue(bool leftSuppress, bool rightSuppress)
{
// Arrange
var left = new AttributeRouteModel
{
Template = "Template",
SuppressPathMatching = leftSuppress,
};
var right = new AttributeRouteModel
{
SuppressPathMatching = rightSuppress,
};
var combined = AttributeRouteModel.CombineAttributeRouteModel(left, right);
// Assert
Assert.True(combined.SuppressPathMatching);
}
public static IEnumerable<object[]> CombineNamesTestData
{
get

View File

@ -545,6 +545,231 @@ namespace Microsoft.AspNetCore.Mvc.Internal
Assert.IsType<RouteCreationException>(exception.InnerException);
}
[Fact]
public void GetEntries_DoesNotCreateOutboundEntriesForAttributesWithSuppressForLinkGenerationSetToTrue()
{
// Arrange
var actions = new List<ActionDescriptor>()
{
new ActionDescriptor()
{
AttributeRouteInfo = new AttributeRouteInfo
{
Template = "blog/get/{id}",
Name = "BLOG_LINK1",
SuppressLinkGeneration = true,
},
},
new ActionDescriptor()
{
AttributeRouteInfo = new AttributeRouteInfo
{
Template = "blog/{snake-cased-name}",
Name = "BLOG_INDEX2",
},
},
new ActionDescriptor()
{
AttributeRouteInfo = new AttributeRouteInfo
{
Template = "blog/",
Name = "BLOG_HOME",
SuppressPathMatching = true,
},
},
};
var builder = CreateBuilder();
var actionDescriptorProvider = CreateActionDescriptorProvider(actions);
var route = CreateRoute(CreateHandler().Object, actionDescriptorProvider.Object);
// Act
route.AddEntries(builder, actionDescriptorProvider.Object.ActionDescriptors);
// Assert
Assert.Collection(
builder.OutboundEntries,
e =>
{
Assert.Equal("BLOG_INDEX2", e.RouteName);
Assert.Equal("blog/{snake-cased-name}", e.RouteTemplate.TemplateText);
},
e =>
{
Assert.Equal("BLOG_HOME", e.RouteName);
Assert.Equal("blog/", e.RouteTemplate.TemplateText);
});
}
[Fact]
public void GetEntries_DoesNotCreateOutboundEntriesForAttributesWithSuppressForLinkGenerationSetToTrue_WhenMultipleAttributesHaveTheSameTemplate()
{
// Arrange
var actions = new List<ActionDescriptor>()
{
new ActionDescriptor()
{
AttributeRouteInfo = new AttributeRouteInfo
{
Template = "blog/get/{id}",
Name = "BLOG_LINK1",
SuppressLinkGeneration = true,
},
},
new ActionDescriptor()
{
AttributeRouteInfo = new AttributeRouteInfo
{
Template = "blog/get/{id}",
Name = "BLOG_LINK2",
},
},
new ActionDescriptor()
{
AttributeRouteInfo = new AttributeRouteInfo
{
Template = "blog/",
Name = "BLOG_HOME",
SuppressPathMatching = true,
},
},
};
var builder = CreateBuilder();
var actionDescriptorProvider = CreateActionDescriptorProvider(actions);
var route = CreateRoute(CreateHandler().Object, actionDescriptorProvider.Object);
// Act
route.AddEntries(builder, actionDescriptorProvider.Object.ActionDescriptors);
// Assert
Assert.Collection(
builder.OutboundEntries,
e =>
{
Assert.Equal("BLOG_LINK2", e.RouteName);
Assert.Equal("blog/get/{id}", e.RouteTemplate.TemplateText);
},
e =>
{
Assert.Equal("BLOG_HOME", e.RouteName);
Assert.Equal("blog/", e.RouteTemplate.TemplateText);
});
}
[Fact]
public void GetEntries_DoesNotCreateInboundEntriesForAttributesWithSuppressForPathMatchingSetToTrue()
{
// Arrange
var actions = new List<ActionDescriptor>()
{
new ActionDescriptor()
{
AttributeRouteInfo = new AttributeRouteInfo
{
Template = "blog/get/{id}",
Name = "BLOG_LINK1",
SuppressLinkGeneration = true,
},
},
new ActionDescriptor()
{
AttributeRouteInfo = new AttributeRouteInfo
{
Template = "blog/{snake-cased-name}",
Name = "BLOG_LINK2",
},
},
new ActionDescriptor()
{
AttributeRouteInfo = new AttributeRouteInfo
{
Template = "blog/",
Name = "BLOG_HOME",
SuppressPathMatching = true,
},
},
};
var builder = CreateBuilder();
var actionDescriptorProvider = CreateActionDescriptorProvider(actions);
var route = CreateRoute(CreateHandler().Object, actionDescriptorProvider.Object);
// Act
route.AddEntries(builder, actionDescriptorProvider.Object.ActionDescriptors);
// Assert
Assert.Collection(
builder.InboundEntries,
e =>
{
Assert.Equal("BLOG_LINK1", e.RouteName);
Assert.Equal("blog/get/{id}", e.RouteTemplate.TemplateText);
},
e =>
{
Assert.Equal("BLOG_LINK2", e.RouteName);
Assert.Equal("blog/{snake-cased-name}", e.RouteTemplate.TemplateText);
});
}
[Fact]
public void GetEntries_DoesNotCreateInboundEntriesForAttributesWithSuppressForPathMatchingSetToTrue_WhenMultipleAttributesHaveTheSameTemplate()
{
// Arrange
var actions = new List<ActionDescriptor>()
{
new ActionDescriptor()
{
AttributeRouteInfo = new AttributeRouteInfo
{
Template = "blog/get/{id}",
Name = "BLOG_LINK1",
SuppressPathMatching = true,
},
},
new ActionDescriptor()
{
AttributeRouteInfo = new AttributeRouteInfo
{
Template = "blog/get/{id}",
Name = "BLOG_LINK2",
},
},
new ActionDescriptor()
{
AttributeRouteInfo = new AttributeRouteInfo
{
Template = "blog/",
Name = "BLOG_HOME",
SuppressLinkGeneration = true,
},
},
};
var builder = CreateBuilder();
var actionDescriptorProvider = CreateActionDescriptorProvider(actions);
var route = CreateRoute(CreateHandler().Object, actionDescriptorProvider.Object);
// Act
route.AddEntries(builder, actionDescriptorProvider.Object.ActionDescriptors);
// Assert
Assert.Collection(
builder.InboundEntries,
e =>
{
Assert.Equal("BLOG_LINK2", e.RouteName);
Assert.Equal("blog/get/{id}", e.RouteTemplate.TemplateText);
},
e =>
{
Assert.Equal("BLOG_HOME", e.RouteName);
Assert.Equal("blog/", e.RouteTemplate.TemplateText);
});
}
private static TreeRouteBuilder CreateBuilder()
{
var services = new ServiceCollection()

View File

@ -1052,6 +1052,19 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
Assert.Equal("ApiExplorerReload/NewIndex", description.RelativePath);
}
[Fact]
public async Task ApiExplorer_DoesNotListActionsSuppressedForPathMatching()
{
// Act
var body = await Client.GetStringAsync("ApiExplorerInboundOutbound/SuppressedForLinkGeneration");
var result = JsonConvert.DeserializeObject<List<ApiExplorerData>>(body);
// Assert
var description = Assert.Single(result);
Assert.Empty(description.ParameterDescriptions);
Assert.Equal("ApiExplorerInboundOutbound/SuppressedForLinkGeneration", description.RelativePath);
}
private IEnumerable<string> GetSortedMediaTypes(ApiExplorerResponseType apiResponseType)
{
return apiResponseType.ResponseFormats

View File

@ -1,6 +1,7 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
@ -126,5 +127,44 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
var body = await response.Content.ReadAsStringAsync();
Assert.Equal("From Header - HelloWorld", body);
}
[Fact]
public async Task ActionModelSuppressedForPathMatching_CannotBeRouted()
{
// Arrange & Act
var response = await Client.GetAsync("Home/CannotBeRouted");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task ActionModelNotSuppressedForPathMatching_CanBeRouted()
{
// Arrange & Act
var response = await Client.GetStringAsync("Home/CanBeRouted");
// Assert
Assert.Equal("Hello world", response);
}
[Fact]
public async Task ActionModelSuppressedForLinkGeneration_CannotBeLinked()
{
// Act & Assert
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => Client.GetStringAsync("Home/RouteToSuppressLinkGeneration"));
Assert.Equal("No route matches the supplied values.", ex.Message);
}
[Fact]
public async Task ActionModelSuppressedForPathMatching_CanBeLinked()
{
// Arrange & Act
var response = await Client.GetAsync("Home/RouteToSuppressPathMatching");
// Assert
Assert.Equal("/Home/CannotBeRouted", response.Headers.Location.ToString());
}
}
}

View File

@ -0,0 +1,44 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Reflection;
using ApiExplorerWebSite.Controllers;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
namespace ApiExplorerWebSite
{
// Disables ApiExplorer for a specific controller type.
// This is part of the test that validates that ApiExplorer can be configured via
// convention
public class ApiExplorerInboundOutboundConvention : IApplicationModelConvention
{
private readonly TypeInfo _type;
public ApiExplorerInboundOutboundConvention(Type type)
{
_type = type.GetTypeInfo();
}
public void Apply(ApplicationModel application)
{
foreach (var controller in application.Controllers)
{
if (controller.ControllerType == _type)
{
foreach (var action in controller.Actions)
{
if (action.ActionName == nameof(ApiExplorerInboundOutBoundController.SuppressedForPathMatching))
{
action.Selectors[0].AttributeRouteModel.SuppressPathMatching = true;
}
else if (action.ActionName == nameof(ApiExplorerInboundOutBoundController.SuppressedForLinkGeneration))
{
action.Selectors[0].AttributeRouteModel.SuppressLinkGeneration = true;
}
}
}
}
}
}
}

View File

@ -0,0 +1,20 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Microsoft.AspNetCore.Mvc;
namespace ApiExplorerWebSite.Controllers
{
public class ApiExplorerInboundOutBoundController : Controller
{
[HttpGet("ApiExplorerInboundOutbound/SuppressedForLinkGeneration")]
public void SuppressedForLinkGeneration()
{
}
[HttpGet("ApiExplorerInboundOutbound/SuppressedForPathMatching")]
public void SuppressedForPathMatching()
{
}
}
}

View File

@ -3,6 +3,7 @@
using System.IO;
using System.Linq;
using ApiExplorerWebSite.Controllers;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Formatters;
@ -25,6 +26,8 @@ namespace ApiExplorerWebSite
options.Conventions.Add(new ApiExplorerVisibilityEnabledConvention());
options.Conventions.Add(new ApiExplorerVisibilityDisabledConvention(
typeof(ApiExplorerVisbilityDisabledByConventionController)));
options.Conventions.Add(new ApiExplorerInboundOutboundConvention(
typeof(ApiExplorerInboundOutBoundController)));
var jsonOutputFormatter = options.OutputFormatters.OfType<JsonOutputFormatter>().First();

View File

@ -1,7 +1,10 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
namespace ApplicationModelWebSite
{
@ -17,5 +20,40 @@ namespace ApplicationModelWebSite
{
return ControllerContext.ActionDescriptor.Properties["source"].ToString() + " - " + helloWorld;
}
[HttpGet("Home/CannotBeRouted", Name = nameof(SuppressPathMatching))]
[HttpGet("Home/CanBeRouted")]
[SuppressPatchMatchingConvention]
public object SuppressPathMatching()
{
return "Hello world";
}
[HttpGet("Home/SuppressLinkGeneration", Name = nameof(SuppressLinkGeneration))]
[SuppressLinkGenerationConvention]
public object SuppressLinkGeneration() => "Hello world";
[HttpGet("Home/RouteToSuppressLinkGeneration")]
public IActionResult RouteToSuppressLinkGeneration() => RedirectToRoute(nameof(SuppressLinkGeneration));
[HttpGet("Home/RouteToSuppressPathMatching")]
public IActionResult RouteToSuppressPathMatching() => RedirectToRoute(nameof(SuppressPathMatching));
private class SuppressPatchMatchingConvention : Attribute, IActionModelConvention
{
public void Apply(ActionModel model)
{
var selector = model.Selectors.First(f => f.AttributeRouteModel.Template == "Home/CannotBeRouted");
selector.AttributeRouteModel.SuppressPathMatching = true;
}
}
private class SuppressLinkGenerationConvention : Attribute, IActionModelConvention
{
public void Apply(ActionModel model)
{
model.Selectors[0].AttributeRouteModel.SuppressLinkGeneration = true;
}
}
}
}