// 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; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Routing.Internal; using Microsoft.AspNetCore.Routing.Patterns; using Microsoft.Extensions.ObjectPool; namespace Microsoft.AspNetCore.Routing.Template { public class TemplateBinder { private readonly UrlEncoder _urlEncoder; private readonly ObjectPool _pool; private readonly RouteValueDictionary _defaults; private readonly RouteValueDictionary _filters; private readonly RoutePattern _pattern; /// /// Creates a new instance of . /// /// The . /// The . /// The to bind values to. /// The default values for . public TemplateBinder( UrlEncoder urlEncoder, ObjectPool pool, RouteTemplate template, RouteValueDictionary defaults) : this(urlEncoder, pool, template?.ToRoutePattern(), defaults) { } /// /// Creates a new instance of . /// /// The . /// The . /// The to bind values to. /// The default values for . public TemplateBinder( UrlEncoder urlEncoder, ObjectPool pool, RoutePattern pattern, RouteValueDictionary defaults) { if (urlEncoder == null) { throw new ArgumentNullException(nameof(urlEncoder)); } if (pool == null) { throw new ArgumentNullException(nameof(pool)); } if (pattern == null) { throw new ArgumentNullException(nameof(pattern)); } _urlEncoder = urlEncoder; _pool = pool; _pattern = pattern; _defaults = defaults; // Any default that doesn't have a corresponding parameter is a 'filter' and if a value // is provided for that 'filter' it must match the value in defaults. _filters = new RouteValueDictionary(_defaults); foreach (var parameter in _pattern.Parameters) { _filters.Remove(parameter.Name); } } // Step 1: Get the list of values we're going to try to use to match and generate this URI public TemplateValuesResult GetValues(RouteValueDictionary ambientValues, RouteValueDictionary values) { return GetValues(ambientValues: ambientValues, explicitValues: values, requiredKeys: null); } internal TemplateValuesResult GetValues( RouteValueDictionary ambientValues, RouteValueDictionary explicitValues, IEnumerable requiredKeys) { var context = new TemplateBindingContext(_defaults); var canCopyParameterAmbientValues = true; // In case a route template is not parameterized, we do not know what the required parameters are, so look // at required values of an endpoint to get the key names and then use them to decide if ambient values // should be used. // For example, in case of MVC it flattens out a route template like below // {controller}/{action}/{id?} // to // Products/Index/{id?}, // defaults: new { controller = "Products", action = "Index" }, // requiredValues: new { controller = "Products", action = "Index" } // In the above example, "controller" and "action" are no longer parameters. if (requiredKeys != null) { canCopyParameterAmbientValues = CanCopyParameterAmbientValues( ambientValues: ambientValues, explicitValues: explicitValues, requiredKeys); } if (canCopyParameterAmbientValues) { // Copy ambient values when no explicit values are provided CopyParameterAmbientValues( ambientValues: ambientValues, explicitValues: explicitValues, context); } // Add all remaining new values to the list of values we will use for URI generation CopyNonParamaterExplicitValues(explicitValues, context); // Accept all remaining default values if they match a required parameter CopyParameterDefaultValues(context); if (!AllRequiredParametersHaveValue(context)) { return null; } // Any default values that don't appear as parameters are treated like filters. Any new values // provided must match these defaults. if (!FiltersMatch(explicitValues)) { return null; } // Add any ambient values that don't match parameters - they need to be visible to constraints // but they will ignored by link generation. var combinedValues = new RouteValueDictionary(context.AcceptedValues); CopyNonParameterAmbientValues( ambientValues: ambientValues, combinedValues: combinedValues, context); return new TemplateValuesResult() { AcceptedValues = context.AcceptedValues, CombinedValues = combinedValues, }; } // Step 2: If the route is a match generate the appropriate URI public string BindValues(RouteValueDictionary acceptedValues) { var context = _pool.Get(); var result = BindValues(context, acceptedValues); _pool.Return(context); return result; } private string BindValues(UriBuildingContext context, RouteValueDictionary acceptedValues) { for (var i = 0; i < _pattern.PathSegments.Count; i++) { Debug.Assert(context.BufferState == SegmentState.Beginning); Debug.Assert(context.UriState == SegmentState.Beginning); var segment = _pattern.PathSegments[i]; for (var j = 0; j < segment.Parts.Count; j++) { var part = segment.Parts[j]; if (part is RoutePatternLiteralPart literalPart) { if (!context.Accept(literalPart.Content)) { return null; } } else if (part is RoutePatternSeparatorPart separatorPart) { if (!context.Accept(separatorPart.Content)) { return null; } } else if (part is RoutePatternParameterPart parameterPart) { // If it's a parameter, get its value var hasValue = acceptedValues.TryGetValue(parameterPart.Name, out var value); if (hasValue) { acceptedValues.Remove(parameterPart.Name); } var isSameAsDefault = false; if (_defaults != null && _defaults.TryGetValue(parameterPart.Name, out var defaultValue) && RoutePartsEqual(value, defaultValue)) { isSameAsDefault = true; } var converted = Convert.ToString(value, CultureInfo.InvariantCulture); if (isSameAsDefault) { // If the accepted value is the same as the default value buffer it since // we won't necessarily add it to the URI we generate. if (!context.Buffer(converted)) { return null; } } else { // If the value is not accepted, it is null or empty value in the // middle of the segment. We accept this if the parameter is an // optional parameter and it is preceded by an optional seperator. // I this case, we need to remove the optional seperator that we // have added to the URI // Example: template = {id}.{format?}. parameters: id=5 // In this case after we have generated "5.", we wont find any value // for format, so we remove '.' and generate 5. if (!context.Accept(converted)) { if (j != 0 && parameterPart.IsOptional && (separatorPart = segment.Parts[j - 1] as RoutePatternSeparatorPart) != null) { context.Remove(separatorPart.Content); } else { return null; } } } } } context.EndSegment(); } // Generate the query string from the remaining values var wroteFirst = false; foreach (var kvp in acceptedValues) { if (_defaults != null && _defaults.ContainsKey(kvp.Key)) { // This value is a 'filter' we don't need to put it in the query string. continue; } var values = kvp.Value as IEnumerable; if (values != null && !(values is string)) { foreach (var value in values) { wroteFirst |= AddParameterToContext(context, kvp.Key, value, wroteFirst); } } else { wroteFirst |= AddParameterToContext(context, kvp.Key, kvp.Value, wroteFirst); } } return context.ToString(); } private bool AddParameterToContext(UriBuildingContext context, string key, object value, bool wroteFirst) { var converted = Convert.ToString(value, CultureInfo.InvariantCulture); if (!string.IsNullOrEmpty(converted)) { context.Writer.Write(wroteFirst ? '&' : '?'); _urlEncoder.Encode(context.Writer, key); context.Writer.Write('='); _urlEncoder.Encode(context.Writer, converted); return true; } return false; } /// /// Compares two objects for equality as parts of a case-insensitive path. /// /// An object to compare. /// An object to compare. /// True if the object are equal, otherwise false. public static bool RoutePartsEqual(object a, object b) { var sa = a as string; var sb = b as string; // In case of strings, consider empty and null the same. // Since null cannot tell us the type, consider it to be a string if the other value is a string. if ((sa == string.Empty && sb == null) || (sb == string.Empty && sa == null)) { return true; } else if (sa != null && sb != null) { // For strings do a case-insensitive comparison return string.Equals(sa, sb, StringComparison.OrdinalIgnoreCase); } else { if (a != null && b != null) { // Explicitly call .Equals() in case it is overridden in the type return a.Equals(b); } else { // At least one of them is null. Return true if they both are return a == b; } } } private static bool IsRoutePartNonEmpty(object routePart) { var routePartString = routePart as string; if (routePartString == null) { return routePart != null; } else { return routePartString.Length > 0; } } private bool CanCopyParameterAmbientValues( RouteValueDictionary ambientValues, RouteValueDictionary explicitValues, IEnumerable requiredKeys) { foreach (var keyName in requiredKeys) { if (!explicitValues.TryGetValue(keyName, out var explicitValue)) { continue; } object ambientValue = null; var hasAmbientValue = ambientValues != null && ambientValues.TryGetValue(keyName, out ambientValue); // This indicates an explicit value was provided whose key does not exist in ambient values // Example: Link from controller-action to a page or vice versa. if (!hasAmbientValue) { return false; } if (!RoutePartsEqual(ambientValue, explicitValue)) { return false; } } return true; } private void CopyParameterAmbientValues( RouteValueDictionary ambientValues, RouteValueDictionary explicitValues, TemplateBindingContext context) { // Find out which entries in the URI are valid for the URI we want to generate. // If the URI had ordered parameters a="1", b="2", c="3" and the new values // specified that b="9", then we need to invalidate everything after it. The new // values should then be a="1", b="9", c=. // // We also handle the case where a parameter is optional but has no value - we shouldn't // accept additional parameters that appear *after* that parameter. for (var i = 0; i < _pattern.Parameters.Count; i++) { var parameter = _pattern.Parameters[i]; // If it's a parameter subsegment, examine the current value to see if it matches the new value var parameterName = parameter.Name; var hasExplicitValue = explicitValues.TryGetValue(parameterName, out var explicitValue); object ambientValue = null; var hasAmbientValue = ambientValues != null && ambientValues.TryGetValue(parameterName, out ambientValue); if (hasExplicitValue && hasAmbientValue && !RoutePartsEqual(ambientValue, explicitValue)) { // Stop copying current values when we find one that doesn't match break; } if (!hasExplicitValue && !hasAmbientValue && _defaults?.ContainsKey(parameter.Name) != true) { // This is an unsatisfied parameter value and there are no defaults. We might still // be able to generate a URL but we should stop 'accepting' ambient values. // // This might be a case like: // template: a/{b?}/{c?} // ambient: { c = 17 } // values: { } // // We can still generate a URL from this ("/a") but we shouldn't accept 'c' because // we can't use it. // // In the example above we should fall into this block for 'b'. break; } // If the parameter is a match, add it to the list of values we will use for URI generation if (hasExplicitValue) { if (IsRoutePartNonEmpty(explicitValue)) { context.Accept(parameterName, explicitValue); } } else if (hasAmbientValue) { context.Accept(parameterName, ambientValue); } } } private void CopyNonParamaterExplicitValues(RouteValueDictionary explicitValues, TemplateBindingContext context) { foreach (var kvp in explicitValues) { if (IsRoutePartNonEmpty(kvp.Value)) { context.Accept(kvp.Key, kvp.Value); } } } private void CopyParameterDefaultValues(TemplateBindingContext context) { for (var i = 0; i < _pattern.Parameters.Count; i++) { var parameter = _pattern.Parameters[i]; if (parameter.IsOptional || parameter.IsCatchAll) { continue; } if (context.NeedsValue(parameter.Name)) { // Add the default value only if there isn't already a new value for it and // only if it actually has a default value, which we determine based on whether // the parameter value is required. context.AcceptDefault(parameter.Name); } } } private bool AllRequiredParametersHaveValue(TemplateBindingContext context) { for (var i = 0; i < _pattern.Parameters.Count; i++) { var parameter = _pattern.Parameters[i]; if (parameter.IsOptional || parameter.IsCatchAll) { continue; } if (!context.AcceptedValues.ContainsKey(parameter.Name)) { // We don't have a value for this parameter, so we can't generate a url. return false; } } return true; } private bool FiltersMatch(RouteValueDictionary explicitValues) { foreach (var filter in _filters) { var parameter = _pattern.GetParameter(filter.Key); if (parameter != null) { continue; } if (explicitValues.TryGetValue(filter.Key, out var value) && !RoutePartsEqual(value, filter.Value)) { // If there is a non-parameterized value in the route and there is a // new value for it and it doesn't match, this route won't match. return false; } } return true; } private void CopyNonParameterAmbientValues( RouteValueDictionary ambientValues, RouteValueDictionary combinedValues, TemplateBindingContext context) { if (ambientValues == null) { return; } foreach (var kvp in ambientValues) { if (IsRoutePartNonEmpty(kvp.Value)) { var parameter = _pattern.GetParameter(kvp.Key); if (parameter == null && !context.AcceptedValues.ContainsKey(kvp.Key)) { combinedValues.Add(kvp.Key, kvp.Value); } } } } [DebuggerDisplay("{DebuggerToString(),nq}")] private struct TemplateBindingContext { private readonly RouteValueDictionary _defaults; private readonly RouteValueDictionary _acceptedValues; public TemplateBindingContext(RouteValueDictionary defaults) { _defaults = defaults; _acceptedValues = new RouteValueDictionary(); } public RouteValueDictionary AcceptedValues { get { return _acceptedValues; } } public void Accept(string key, object value) { if (!_acceptedValues.ContainsKey(key)) { _acceptedValues.Add(key, value); } } public void AcceptDefault(string key) { Debug.Assert(!_acceptedValues.ContainsKey(key)); if (_defaults != null && _defaults.TryGetValue(key, out var value)) { _acceptedValues.Add(key, value); } } public bool NeedsValue(string key) { return !_acceptedValues.ContainsKey(key); } private string DebuggerToString() { return string.Format("{{Accepted: '{0}'}}", string.Join(", ", _acceptedValues.Keys)); } } } }