From 3fadca6a1b96c69ecfc217f20c69146d584cb3fe Mon Sep 17 00:00:00 2001 From: Jass Bagga Date: Thu, 2 Nov 2017 10:57:37 -0700 Subject: [PATCH] Add IConstraintFactory (#487) Addresses part of #472 --- .../CompositeDispatcherValueConstraint.cs | 52 +++++ .../Constraints/DefaultConstraintFactory.cs | 152 ++++++++++++++ .../DispatcherValueConstraintBuilder.cs | 189 ++++++++++++++++++ .../DispatcherValueConstraintContext.cs | 0 .../Constraints/IConstraintFactory.cs | 18 ++ .../IDispatcherValueConstraint.cs | 0 .../OptionalDispatcherValueConstraint.cs | 42 ++++ .../RegexDispatcherValueConstraint.cs | 58 ++++++ .../DispatcherOptions.cs | 3 + .../LoggerExtensions.cs | 15 ++ .../Properties/Resources.Designer.cs | 70 +++++++ .../Resources.resx | 15 ++ .../CompositeDispatcherValueConstraintTest.cs | 60 ++++++ .../RegexDispatcherValueConstraintTest.cs | 120 +++++++++++ 14 files changed, 794 insertions(+) create mode 100644 src/Microsoft.AspNetCore.Dispatcher/Constraints/CompositeDispatcherValueConstraint.cs create mode 100644 src/Microsoft.AspNetCore.Dispatcher/Constraints/DefaultConstraintFactory.cs create mode 100644 src/Microsoft.AspNetCore.Dispatcher/Constraints/DispatcherValueConstraintBuilder.cs rename src/Microsoft.AspNetCore.Dispatcher/{ => Constraints}/DispatcherValueConstraintContext.cs (100%) create mode 100644 src/Microsoft.AspNetCore.Dispatcher/Constraints/IConstraintFactory.cs rename src/Microsoft.AspNetCore.Dispatcher/{ => Constraints}/IDispatcherValueConstraint.cs (100%) create mode 100644 src/Microsoft.AspNetCore.Dispatcher/Constraints/OptionalDispatcherValueConstraint.cs create mode 100644 src/Microsoft.AspNetCore.Dispatcher/Constraints/RegexDispatcherValueConstraint.cs create mode 100644 test/Microsoft.AspNetCore.Dispatcher.Test/Constraints/CompositeDispatcherValueConstraintTest.cs create mode 100644 test/Microsoft.AspNetCore.Dispatcher.Test/Constraints/RegexDispatcherValueConstraintTest.cs diff --git a/src/Microsoft.AspNetCore.Dispatcher/Constraints/CompositeDispatcherValueConstraint.cs b/src/Microsoft.AspNetCore.Dispatcher/Constraints/CompositeDispatcherValueConstraint.cs new file mode 100644 index 0000000000..8e7ea40209 --- /dev/null +++ b/src/Microsoft.AspNetCore.Dispatcher/Constraints/CompositeDispatcherValueConstraint.cs @@ -0,0 +1,52 @@ +// 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.Collections.Generic; + +namespace Microsoft.AspNetCore.Dispatcher +{ + /// + /// Constrains a dispatcher value by several child constraints. + /// + public class CompositeDispatcherValueConstraint : IDispatcherValueConstraint + { + /// + /// Initializes a new instance of the class. + /// + /// The child constraints that must match for this constraint to match. + public CompositeDispatcherValueConstraint(IEnumerable constraints) + { + if (constraints == null) + { + throw new ArgumentNullException(nameof(constraints)); + } + + Constraints = constraints; + } + + /// + /// Gets the child constraints that must match for this constraint to match. + /// + public IEnumerable Constraints { get; private set; } + + /// + public bool Match(DispatcherValueConstraintContext constraintContext) + { + if (constraintContext == null) + { + throw new ArgumentNullException(nameof(constraintContext)); + } + + foreach (var constraint in Constraints) + { + if (!constraint.Match(constraintContext)) + { + return false; + } + } + + return true; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Dispatcher/Constraints/DefaultConstraintFactory.cs b/src/Microsoft.AspNetCore.Dispatcher/Constraints/DefaultConstraintFactory.cs new file mode 100644 index 0000000000..687dc84afd --- /dev/null +++ b/src/Microsoft.AspNetCore.Dispatcher/Constraints/DefaultConstraintFactory.cs @@ -0,0 +1,152 @@ +// 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.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Dispatcher +{ + /// + /// The default implementation of . Resolves constraints by parsing + /// a constraint key and constraint arguments, using a map to resolve the constraint type, and calling an + /// appropriate constructor for the constraint type. + /// + public class DefaultConstraintFactory : IConstraintFactory + { + private readonly IDictionary _constraintMap; + + /// + /// Initializes a new instance of the class. + /// + /// + /// Accessor for containing the constraints of interest. + /// + public DefaultConstraintFactory(IOptions dispatcherOptions) + { + _constraintMap = dispatcherOptions.Value.ConstraintMap; + } + + /// + /// + /// A typical constraint looks like the following + /// "exampleConstraint(arg1, arg2, 12)". + /// Here if the type registered for exampleConstraint has a single constructor with one argument, + /// The entire string "arg1, arg2, 12" will be treated as a single argument. + /// In all other cases arguments are split at comma. + /// + public virtual IDispatcherValueConstraint ResolveConstraint(string constraint) + { + if (constraint == null) + { + throw new ArgumentNullException(nameof(constraint)); + } + + string constraintKey; + string argumentString; + var indexOfFirstOpenParens = constraint.IndexOf('('); + if (indexOfFirstOpenParens >= 0 && constraint.EndsWith(")", StringComparison.Ordinal)) + { + constraintKey = constraint.Substring(0, indexOfFirstOpenParens); + argumentString = constraint.Substring(indexOfFirstOpenParens + 1, + constraint.Length - indexOfFirstOpenParens - 2); + } + else + { + constraintKey = constraint; + argumentString = null; + } + + if (!_constraintMap.TryGetValue(constraintKey, out var constraintType)) + { + // Cannot resolve the constraint key + return null; + } + + if (!typeof(IDispatcherValueConstraint).GetTypeInfo().IsAssignableFrom(constraintType.GetTypeInfo())) + { + throw new InvalidOperationException( + Resources.FormatDefaultConstraintResolver_TypeNotConstraint( + constraintType, constraintKey, typeof(IDispatcherValueConstraint).Name)); + } + + try + { + return CreateConstraint(constraintType, argumentString); + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"An error occurred while trying to create an instance of route constraint '{constraintType.FullName}'.", + exception); + } + } + + private static IDispatcherValueConstraint CreateConstraint(Type constraintType, string argumentString) + { + // No arguments - call the default constructor + if (argumentString == null) + { + return (IDispatcherValueConstraint)Activator.CreateInstance(constraintType); + } + + var constraintTypeInfo = constraintType.GetTypeInfo(); + ConstructorInfo activationConstructor = null; + object[] parameters = null; + var constructors = constraintTypeInfo.DeclaredConstructors.ToArray(); + + // If there is only one constructor and it has a single parameter, pass the argument string directly + // This is necessary for the RegexDispatcherValueConstraint to ensure that patterns are not split on commas. + if (constructors.Length == 1 && constructors[0].GetParameters().Length == 1) + { + activationConstructor = constructors[0]; + parameters = ConvertArguments(activationConstructor.GetParameters(), new string[] { argumentString }); + } + else + { + var arguments = argumentString.Split(',').Select(argument => argument.Trim()).ToArray(); + + var matchingConstructors = constructors.Where(ci => ci.GetParameters().Length == arguments.Length) + .ToArray(); + var constructorMatches = matchingConstructors.Length; + + if (constructorMatches == 0) + { + throw new InvalidOperationException( + Resources.FormatDefaultConstraintResolver_CouldNotFindCtor( + constraintTypeInfo.Name, arguments.Length)); + } + else if (constructorMatches == 1) + { + activationConstructor = matchingConstructors[0]; + parameters = ConvertArguments(activationConstructor.GetParameters(), arguments); + } + else + { + throw new InvalidOperationException( + Resources.FormatDefaultConstraintResolver_AmbiguousCtors( + constraintTypeInfo.Name, arguments.Length)); + } + } + + return (IDispatcherValueConstraint)activationConstructor.Invoke(parameters); + } + + private static object[] ConvertArguments(ParameterInfo[] parameterInfos, string[] arguments) + { + var parameters = new object[parameterInfos.Length]; + for (var i = 0; i < parameterInfos.Length; i++) + { + var parameter = parameterInfos[i]; + var parameterType = parameter.ParameterType; + parameters[i] = Convert.ChangeType(arguments[i], parameterType, CultureInfo.InvariantCulture); + } + + return parameters; + } + } +} + diff --git a/src/Microsoft.AspNetCore.Dispatcher/Constraints/DispatcherValueConstraintBuilder.cs b/src/Microsoft.AspNetCore.Dispatcher/Constraints/DispatcherValueConstraintBuilder.cs new file mode 100644 index 0000000000..ed93324b98 --- /dev/null +++ b/src/Microsoft.AspNetCore.Dispatcher/Constraints/DispatcherValueConstraintBuilder.cs @@ -0,0 +1,189 @@ +// 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.Collections.Generic; + +namespace Microsoft.AspNetCore.Dispatcher +{ + /// + /// A builder for producing a mapping of keys to . + /// + /// + /// allows iterative building a set of route constraints, and will + /// merge multiple entries for the same key. + /// + public class DispatcherValueConstraintBuilder + { + private readonly IConstraintFactory _constraintFactory; + private readonly string _rawText; + private readonly Dictionary> _constraints; + private readonly HashSet _optionalParameters; + + /// + /// Creates a new instance. + /// + /// The . + /// The display name (for use in error messages). + public DispatcherValueConstraintBuilder( + IConstraintFactory constraintFactory, + string rawText) + { + if (constraintFactory == null) + { + throw new ArgumentNullException(nameof(constraintFactory)); + } + + if (rawText == null) + { + throw new ArgumentNullException(nameof(rawText)); + } + + _constraintFactory = constraintFactory; + _rawText = rawText; + + _constraints = new Dictionary>(StringComparer.OrdinalIgnoreCase); + _optionalParameters = new HashSet(StringComparer.OrdinalIgnoreCase); + } + + /// + /// Builds a mapping of constraints. + /// + /// An of the constraints. + public IDictionary Build() + { + var constraints = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in _constraints) + { + IDispatcherValueConstraint constraint; + if (kvp.Value.Count == 1) + { + constraint = kvp.Value[0]; + } + else + { + constraint = new CompositeDispatcherValueConstraint(kvp.Value.ToArray()); + } + + if (_optionalParameters.Contains(kvp.Key)) + { + var optionalConstraint = new OptionalDispatcherValueConstraint(constraint); + constraints.Add(kvp.Key, optionalConstraint); + } + else + { + constraints.Add(kvp.Key, constraint); + } + } + + return constraints; + } + + /// + /// Adds a constraint instance for the given key. + /// + /// The key. + /// + /// The constraint instance. Must either be a string or an instance of . + /// + /// + /// If the is a string, it will be converted to a . + /// + /// For example, the string Product[0-9]+ will be converted to the regular expression + /// ^(Product[0-9]+). See for more details. + /// + public void AddConstraint(string key, object value) + { + if (key == null) + { + throw new ArgumentNullException(nameof(key)); + } + + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + var constraint = value as IDispatcherValueConstraint; + if (constraint == null) + { + var regexPattern = value as string; + if (regexPattern == null) + { + throw new InvalidOperationException( + Resources.FormatDispatcherValueConstraintBuilder_ValidationMustBeStringOrCustomConstraint( + key, + value, + _rawText, + typeof(IDispatcherValueConstraint))); + } + + var constraintsRegEx = "^(" + regexPattern + ")$"; + constraint = new RegexDispatcherValueConstraint(constraintsRegEx); + } + + Add(key, constraint); + } + + /// + /// Adds a constraint for the given key, resolved by the . + /// + /// The key. + /// The text to be resolved by . + /// + /// The can create instances + /// based on . See to register + /// custom constraint types. + /// + public void AddResolvedConstraint(string key, string constraintText) + { + if (key == null) + { + throw new ArgumentNullException(nameof(key)); + } + + if (constraintText == null) + { + throw new ArgumentNullException(nameof(constraintText)); + } + + var constraint = _constraintFactory.ResolveConstraint(constraintText); + if (constraint == null) + { + throw new InvalidOperationException( + Resources.FormatDispatcherValueConstraintBuilder_CouldNotResolveConstraint( + key, + constraintText, + _rawText, + _constraintFactory.GetType().Name)); + } + + Add(key, constraint); + } + + /// + /// Sets the given key as optional. + /// + /// The key. + public void SetOptional(string key) + { + if (key == null) + { + throw new ArgumentNullException(nameof(key)); + } + + _optionalParameters.Add(key); + } + + private void Add(string key, IDispatcherValueConstraint constraint) + { + if (!_constraints.TryGetValue(key, out var list)) + { + list = new List(); + _constraints.Add(key, list); + } + + list.Add(constraint); + } + } +} diff --git a/src/Microsoft.AspNetCore.Dispatcher/DispatcherValueConstraintContext.cs b/src/Microsoft.AspNetCore.Dispatcher/Constraints/DispatcherValueConstraintContext.cs similarity index 100% rename from src/Microsoft.AspNetCore.Dispatcher/DispatcherValueConstraintContext.cs rename to src/Microsoft.AspNetCore.Dispatcher/Constraints/DispatcherValueConstraintContext.cs diff --git a/src/Microsoft.AspNetCore.Dispatcher/Constraints/IConstraintFactory.cs b/src/Microsoft.AspNetCore.Dispatcher/Constraints/IConstraintFactory.cs new file mode 100644 index 0000000000..ca5acbbc74 --- /dev/null +++ b/src/Microsoft.AspNetCore.Dispatcher/Constraints/IConstraintFactory.cs @@ -0,0 +1,18 @@ +// 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. + +namespace Microsoft.AspNetCore.Dispatcher +{ + /// + /// Defines an abstraction for resolving constraints as instances of . + /// + public interface IConstraintFactory + { + /// + /// Resolves the constraint. + /// + /// The constraint to resolve. + /// The the constraint was resolved to. + IDispatcherValueConstraint ResolveConstraint(string constraint); + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Dispatcher/IDispatcherValueConstraint.cs b/src/Microsoft.AspNetCore.Dispatcher/Constraints/IDispatcherValueConstraint.cs similarity index 100% rename from src/Microsoft.AspNetCore.Dispatcher/IDispatcherValueConstraint.cs rename to src/Microsoft.AspNetCore.Dispatcher/Constraints/IDispatcherValueConstraint.cs diff --git a/src/Microsoft.AspNetCore.Dispatcher/Constraints/OptionalDispatcherValueConstraint.cs b/src/Microsoft.AspNetCore.Dispatcher/Constraints/OptionalDispatcherValueConstraint.cs new file mode 100644 index 0000000000..f7fccc2a65 --- /dev/null +++ b/src/Microsoft.AspNetCore.Dispatcher/Constraints/OptionalDispatcherValueConstraint.cs @@ -0,0 +1,42 @@ +// 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; + +namespace Microsoft.AspNetCore.Dispatcher +{ + /// + /// Defines a constraint on an optional parameter. If the parameter is present, then it is constrained by InnerConstraint. + /// + public class OptionalDispatcherValueConstraint : IDispatcherValueConstraint + { + public OptionalDispatcherValueConstraint(IDispatcherValueConstraint innerConstraint) + { + if (innerConstraint == null) + { + throw new ArgumentNullException(nameof(innerConstraint)); + } + + InnerConstraint = innerConstraint; + } + + public IDispatcherValueConstraint InnerConstraint { get; } + + /// + public bool Match(DispatcherValueConstraintContext constraintContext) + { + if (constraintContext == null) + { + throw new ArgumentNullException(nameof(constraintContext)); + } + + if (constraintContext.Values.TryGetValue(constraintContext.Key, out var routeValue) + && routeValue != null) + { + return InnerConstraint.Match(constraintContext); + } + + return true; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Dispatcher/Constraints/RegexDispatcherValueConstraint.cs b/src/Microsoft.AspNetCore.Dispatcher/Constraints/RegexDispatcherValueConstraint.cs new file mode 100644 index 0000000000..99d0a10cfe --- /dev/null +++ b/src/Microsoft.AspNetCore.Dispatcher/Constraints/RegexDispatcherValueConstraint.cs @@ -0,0 +1,58 @@ +// 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.Globalization; +using System.Text.RegularExpressions; + +namespace Microsoft.AspNetCore.Dispatcher +{ + public class RegexDispatcherValueConstraint : IDispatcherValueConstraint + { + private static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(10); + + public RegexDispatcherValueConstraint(Regex regex) + { + if (regex == null) + { + throw new ArgumentNullException(nameof(regex)); + } + + Constraint = regex; + } + + public RegexDispatcherValueConstraint(string regexPattern) + { + if (regexPattern == null) + { + throw new ArgumentNullException(nameof(regexPattern)); + } + + Constraint = new Regex( + regexPattern, + RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, + RegexMatchTimeout); + } + + public Regex Constraint { get; private set; } + + /// + public bool Match(DispatcherValueConstraintContext constraintContext) + { + if (constraintContext == null) + { + throw new ArgumentNullException(nameof(constraintContext)); + } + + if (constraintContext.Values.TryGetValue(constraintContext.Key, out var routeValue) + && routeValue != null) + { + var parameterValueString = Convert.ToString(routeValue, CultureInfo.InvariantCulture); + + return Constraint.IsMatch(parameterValueString); + } + + return false; + } + } +} diff --git a/src/Microsoft.AspNetCore.Dispatcher/DispatcherOptions.cs b/src/Microsoft.AspNetCore.Dispatcher/DispatcherOptions.cs index 1df9f50cc9..a4a26f662f 100644 --- a/src/Microsoft.AspNetCore.Dispatcher/DispatcherOptions.cs +++ b/src/Microsoft.AspNetCore.Dispatcher/DispatcherOptions.cs @@ -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.Collections.Generic; namespace Microsoft.AspNetCore.Dispatcher @@ -8,5 +9,7 @@ namespace Microsoft.AspNetCore.Dispatcher public class DispatcherOptions { public MatcherCollection Matchers { get; } = new MatcherCollection(); + + public IDictionary ConstraintMap = new Dictionary(); } } diff --git a/src/Microsoft.AspNetCore.Dispatcher/LoggerExtensions.cs b/src/Microsoft.AspNetCore.Dispatcher/LoggerExtensions.cs index b9d91cba8e..89ff7048b6 100644 --- a/src/Microsoft.AspNetCore.Dispatcher/LoggerExtensions.cs +++ b/src/Microsoft.AspNetCore.Dispatcher/LoggerExtensions.cs @@ -83,6 +83,21 @@ namespace Microsoft.AspNetCore.Dispatcher new EventId(3, "NoEndpointMatchedRequestMethod"), "No endpoint matched request method '{Method}'."); + // DispatcherValueConstraintMatcher + private static readonly Action _routeValueDoesNotMatchConstraint = LoggerMessage.Define( + LogLevel.Debug, + 1, + "Route value '{RouteValue}' with key '{RouteKey}' did not match the constraint '{RouteConstraint}'."); + + public static void RouteValueDoesNotMatchConstraint( + this ILogger logger, + object routeValue, + string routeKey, + IDispatcherValueConstraint routeConstraint) + { + _routeValueDoesNotMatchConstraint(logger, routeValue, routeKey, routeConstraint, null); + } + public static void AmbiguousEndpoints(this ILogger logger, string ambiguousEndpoints) { _ambiguousEndpoints(logger, ambiguousEndpoints, null); diff --git a/src/Microsoft.AspNetCore.Dispatcher/Properties/Resources.Designer.cs b/src/Microsoft.AspNetCore.Dispatcher/Properties/Resources.Designer.cs index 7cfa53c69e..ff22f3d26c 100644 --- a/src/Microsoft.AspNetCore.Dispatcher/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNetCore.Dispatcher/Properties/Resources.Designer.cs @@ -38,6 +38,76 @@ namespace Microsoft.AspNetCore.Dispatcher internal static string FormatArgument_NullOrEmpty() => GetString("Argument_NullOrEmpty"); + /// + /// The constructor to use for activating the constraint type '{0}' is ambiguous. Multiple constructors were found with the following number of parameters: {1}. + /// + internal static string DefaultConstraintResolver_AmbiguousCtors + { + get => GetString("DefaultConstraintResolver_AmbiguousCtors"); + } + + /// + /// The constructor to use for activating the constraint type '{0}' is ambiguous. Multiple constructors were found with the following number of parameters: {1}. + /// + internal static string FormatDefaultConstraintResolver_AmbiguousCtors(object p0, object p1) + => string.Format(CultureInfo.CurrentCulture, GetString("DefaultConstraintResolver_AmbiguousCtors"), p0, p1); + + /// + /// Could not find a constructor for constraint type '{0}' with the following number of parameters: {1}. + /// + internal static string DefaultConstraintResolver_CouldNotFindCtor + { + get => GetString("DefaultConstraintResolver_CouldNotFindCtor"); + } + + /// + /// Could not find a constructor for constraint type '{0}' with the following number of parameters: {1}. + /// + internal static string FormatDefaultConstraintResolver_CouldNotFindCtor(object p0, object p1) + => string.Format(CultureInfo.CurrentCulture, GetString("DefaultConstraintResolver_CouldNotFindCtor"), p0, p1); + + /// + /// The constraint type '{0}' which is mapped to constraint key '{1}' must implement the '{2}' interface. + /// + internal static string DefaultConstraintResolver_TypeNotConstraint + { + get => GetString("DefaultConstraintResolver_TypeNotConstraint"); + } + + /// + /// The constraint type '{0}' which is mapped to constraint key '{1}' must implement the '{2}' interface. + /// + internal static string FormatDefaultConstraintResolver_TypeNotConstraint(object p0, object p1, object p2) + => string.Format(CultureInfo.CurrentCulture, GetString("DefaultConstraintResolver_TypeNotConstraint"), p0, p1, p2); + + /// + /// The constraint entry '{0}' - '{1}' on the route '{2}' could not be resolved by the constraint resolver of type '{3}'. + /// + internal static string DispatcherValueConstraintBuilder_CouldNotResolveConstraint + { + get => GetString("DispatcherValueConstraintBuilder_CouldNotResolveConstraint"); + } + + /// + /// The constraint entry '{0}' - '{1}' on the route '{2}' could not be resolved by the constraint resolver of type '{3}'. + /// + internal static string FormatDispatcherValueConstraintBuilder_CouldNotResolveConstraint(object p0, object p1, object p2, object p3) + => string.Format(CultureInfo.CurrentCulture, GetString("DispatcherValueConstraintBuilder_CouldNotResolveConstraint"), p0, p1, p2, p3); + + /// + /// The constraint entry '{0}' - '{1}' on the route '{2}' must have a string value or be of a type which implements '{3}'. + /// + internal static string DispatcherValueConstraintBuilder_ValidationMustBeStringOrCustomConstraint + { + get => GetString("DispatcherValueConstraintBuilder_ValidationMustBeStringOrCustomConstraint"); + } + + /// + /// The constraint entry '{0}' - '{1}' on the route '{2}' must have a string value or be of a type which implements '{3}'. + /// + internal static string FormatDispatcherValueConstraintBuilder_ValidationMustBeStringOrCustomConstraint(object p0, object p1, object p2, object p3) + => string.Format(CultureInfo.CurrentCulture, GetString("DispatcherValueConstraintBuilder_ValidationMustBeStringOrCustomConstraint"), p0, p1, p2, p3); + /// /// The collection cannot be empty. /// diff --git a/src/Microsoft.AspNetCore.Dispatcher/Resources.resx b/src/Microsoft.AspNetCore.Dispatcher/Resources.resx index 23f44055ad..cb7814278c 100644 --- a/src/Microsoft.AspNetCore.Dispatcher/Resources.resx +++ b/src/Microsoft.AspNetCore.Dispatcher/Resources.resx @@ -124,6 +124,21 @@ Value cannot be null or empty. + + The constructor to use for activating the constraint type '{0}' is ambiguous. Multiple constructors were found with the following number of parameters: {1}. + + + Could not find a constructor for constraint type '{0}' with the following number of parameters: {1}. + + + The constraint type '{0}' which is mapped to constraint key '{1}' must implement the '{2}' interface. + + + The constraint entry '{0}' - '{1}' on the route '{2}' could not be resolved by the constraint resolver of type '{3}'. + + + The constraint entry '{0}' - '{1}' on the route '{2}' must have a string value or be of a type which implements '{3}'. + The collection cannot be empty. diff --git a/test/Microsoft.AspNetCore.Dispatcher.Test/Constraints/CompositeDispatcherValueConstraintTest.cs b/test/Microsoft.AspNetCore.Dispatcher.Test/Constraints/CompositeDispatcherValueConstraintTest.cs new file mode 100644 index 0000000000..2c24ab60f0 --- /dev/null +++ b/test/Microsoft.AspNetCore.Dispatcher.Test/Constraints/CompositeDispatcherValueConstraintTest.cs @@ -0,0 +1,60 @@ +// 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.Expressions; +using Microsoft.AspNetCore.Http; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Dispatcher +{ + public class CompositeDispatcherValueConstraintTest + { + [Theory] + [InlineData(true, true, true)] + [InlineData(true, false, false)] + [InlineData(false, true, false)] + [InlineData(false, false, false)] + public void CompositeRouteConstraint_Match_CallsMatchOnInnerConstraints( + bool inner1Result, + bool inner2Result, + bool expected) + { + // Arrange + var inner1 = MockConstraintWithResult(inner1Result); + var inner2 = MockConstraintWithResult(inner2Result); + + // Act + var constraint = new CompositeDispatcherValueConstraint(new[] { inner1.Object, inner2.Object }); + var actual = TestConstraint(constraint, null); + + // Assert + Assert.Equal(expected, actual); + } + + static Expression> ConstraintMatchMethodExpression = + c => c.Match( + It.IsAny()); + + private static Mock MockConstraintWithResult(bool result) + { + var mock = new Mock(); + mock.Setup(ConstraintMatchMethodExpression) + .Returns(result) + .Verifiable(); + return mock; + } + + private static bool TestConstraint(IDispatcherValueConstraint constraint, object value, Action routeConfig = null) + { + var httpContext = new DefaultHttpContext(); + var values = new DispatcherValueCollection() { { "fake", value } }; + var constraintPurpose = ConstraintPurpose.IncomingRequest; + + var dispatcherValueConstraintContext = new DispatcherValueConstraintContext(httpContext, values, constraintPurpose); + + return constraint.Match(dispatcherValueConstraintContext); + } + } +} diff --git a/test/Microsoft.AspNetCore.Dispatcher.Test/Constraints/RegexDispatcherValueConstraintTest.cs b/test/Microsoft.AspNetCore.Dispatcher.Test/Constraints/RegexDispatcherValueConstraintTest.cs new file mode 100644 index 0000000000..6b6f23bc62 --- /dev/null +++ b/test/Microsoft.AspNetCore.Dispatcher.Test/Constraints/RegexDispatcherValueConstraintTest.cs @@ -0,0 +1,120 @@ +// 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.Text.RegularExpressions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Testing; +using Xunit; + +namespace Microsoft.AspNetCore.Dispatcher +{ + public class RegexDispatcherValueConstraintTest + { + [Theory] + [InlineData("abc", "abc", true)] // simple match + [InlineData("Abc", "abc", true)] // case insensitive match + [InlineData("Abc ", "abc", true)] // Extra space on input match (because we don't add ^({0})$ + [InlineData("Abcd", "abc", true)] // Extra char + [InlineData("^Abcd", "abc", true)] // Extra special char + [InlineData("Abc", " abc", false)] // Missing char + [InlineData("123-456-2334", @"^\d{3}-\d{3}-\d{4}$", true)] // ssn + [InlineData(@"12/4/2013", @"^\d{1,2}\/\d{1,2}\/\d{4}$", true)] // date + [InlineData(@"abc@def.com", @"^\w+[\w\.]*\@\w+((-\w+)|(\w*))\.[a-z]{2,3}$", true)] // email + public void RegexConstraintBuildRegexVerbatimFromInput( + string routeValue, + string constraintValue, + bool shouldMatch) + { + // Arrange + var constraint = new RegexDispatcherValueConstraint(constraintValue); + var values = new DispatcherValueCollection(new { controller = routeValue }); + + // Act + var match = TestConstraint(constraint, values, "controller"); + + // Assert + Assert.Equal(shouldMatch, match); + } + + [Fact] + public void RegexConstraint_TakesRegexAsInput_SimpleMatch() + { + // Arrange + var constraint = new RegexDispatcherValueConstraint(new Regex("^abc$")); + var values = new DispatcherValueCollection(new { controller = "abc" }); + + // Act + var match = TestConstraint(constraint, values, "controller"); + + // Assert + Assert.True(match); + } + + [Fact] + public void RegexConstraintConstructedWithRegex_SimpleFailedMatch() + { + // Arrange + var constraint = new RegexDispatcherValueConstraint(new Regex("^abc$")); + var values = new DispatcherValueCollection(new { controller = "Abc" }); + + // Act + var match = TestConstraint(constraint, values, "controller"); + + // Assert + Assert.False(match); + } + + [Fact] + public void RegexConstraintFailsIfKeyIsNotFoundInRouteValues() + { + // Arrange + var constraint = new RegexDispatcherValueConstraint(new Regex("^abc$")); + var values = new DispatcherValueCollection(new { action = "abc" }); + + // Act + var match = TestConstraint(constraint, values, "controller"); + + // Assert + Assert.False(match); + } + + [Theory] + [InlineData("tr-TR")] + [InlineData("en-US")] + public void RegexConstraintIsCultureInsensitiveWhenConstructedWithString(string culture) + { + if (TestPlatformHelper.IsMono) + { + // The Regex in Mono returns true when matching the Turkish I for the a-z range which causes the test + // to fail. Tracked via #100. + return; + } + + // Arrange + var constraint = new RegexDispatcherValueConstraint("^([a-z]+)$"); + var values = new DispatcherValueCollection(new { controller = "\u0130" }); // Turkish upper-case dotted I + + using (new CultureReplacer(culture)) + { + // Act + var match = TestConstraint(constraint, values, "controller"); + + // Assert + Assert.False(match); + } + } + + private static bool TestConstraint(IDispatcherValueConstraint constraint, DispatcherValueCollection values, string routeKey) + { + var httpContext = new DefaultHttpContext(); + var constraintPurpose = ConstraintPurpose.IncomingRequest; + + var dispatcherValueConstraintContext = new DispatcherValueConstraintContext(httpContext, values, constraintPurpose) + { + Key = routeKey + }; + + return constraint.Match(dispatcherValueConstraintContext); + } + } +}