aspnetcore/src/Microsoft.AspNetCore.Routing/Template/TemplateBinder.cs

577 lines
22 KiB
C#

// 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<UriBuildingContext> _pool;
private readonly RouteValueDictionary _defaults;
private readonly RouteValueDictionary _filters;
private readonly RoutePattern _pattern;
/// <summary>
/// Creates a new instance of <see cref="TemplateBinder"/>.
/// </summary>
/// <param name="urlEncoder">The <see cref="UrlEncoder"/>.</param>
/// <param name="pool">The <see cref="ObjectPool{T}"/>.</param>
/// <param name="template">The <see cref="RouteTemplate"/> to bind values to.</param>
/// <param name="defaults">The default values for <paramref name="template"/>.</param>
public TemplateBinder(
UrlEncoder urlEncoder,
ObjectPool<UriBuildingContext> pool,
RouteTemplate template,
RouteValueDictionary defaults)
: this(urlEncoder, pool, template?.ToRoutePattern(), defaults)
{
}
/// <summary>
/// Creates a new instance of <see cref="TemplateBinder"/>.
/// </summary>
/// <param name="urlEncoder">The <see cref="UrlEncoder"/>.</param>
/// <param name="pool">The <see cref="ObjectPool{T}"/>.</param>
/// <param name="pattern">The <see cref="RoutePattern"/> to bind values to.</param>
/// <param name="defaults">The default values for <paramref name="pattern"/>.</param>
public TemplateBinder(
UrlEncoder urlEncoder,
ObjectPool<UriBuildingContext> 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<string> 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;
}
/// <summary>
/// Compares two objects for equality as parts of a case-insensitive path.
/// </summary>
/// <param name="a">An object to compare.</param>
/// <param name="b">An object to compare.</param>
/// <returns>True if the object are equal, otherwise false.</returns>
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<string> 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=<no value>.
//
// 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));
}
}
}
}