Skip to content

Instantly share code, notes, and snippets.

@DamianEdwards
Created January 4, 2023 01:22
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save DamianEdwards/9a61e8892a200c39e9b436c84a802cf9 to your computer and use it in GitHub Desktop.
Save DamianEdwards/9a61e8892a200c39e9b436c84a802cf9 to your computer and use it in GitHub Desktop.
PathGenerator utility for ASP.NET Core that generates URL paths (e.g. for links) using patterns that follow usual route syntax and provided values.
** Value Objects **
/user/123/posts?page=2&f=&q=test%20with%20spaces
/user/123/posts/?page=2&f=&q=test%20with%20spaces
user/123/posts?page=2
user/123/entity%20with%20spaces?page=2
/user/123?page=2
/user/123/posts?page=2
** Ordinal **
/user/123/posts?page=2
/user/123/posts?page=2&q=test%20with%20spaces
** Interpolated **
/user/123/posts?page=2
/user/123/posts?page=47&f=&q=test%20with%20spaces
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Routing.Patterns;
using Microsoft.AspNetCore.Routing.Template;
namespace Microsoft.AspNetCore.Routing;
/// <summary>
/// Provides methods for generating URL paths from route patterns and values.
/// </summary>
public static class PathGenerator
{
private static readonly ConcurrentDictionary<string, (RouteTemplate, string[])> _routeTemplateCache = new();
public static string GetPath(UrlEncoder urlEncoder, [InterpolatedStringHandlerArgument(nameof(urlEncoder))] UrlEncoderInterpolatedStringHandler builder)
{
return GetPath(builder);
}
public static string GetPath(UrlEncoderInterpolatedStringHandler builder)
{
var pathString = builder.GetFormattedText();
// Validate the generated path string
if (!Uri.IsWellFormedUriString(pathString, UriKind.Relative))
{
throw new InvalidOperationException($"The path '{pathString}' is invalid.");
}
return pathString;
}
public static string GetPath([StringSyntax("Route")] string pattern, params object?[] values)
{
var urlEncoder = UrlEncoder.Default;
var encodedValues = new string[values.Length];
for (int i = 0; i < values.Length; i++)
{
var rawValue = values[i];
if (rawValue?.ToString() is { } value && !string.IsNullOrEmpty(value))
{
var encodedValue = urlEncoder.Encode(value);
encodedValues[i] = encodedValue;
}
else
{
encodedValues[i] = "";
}
}
Debug.Assert(values.Length == encodedValues.Length);
var pathString = string.Format(CultureInfo.InvariantCulture, pattern, encodedValues);
// Validate the generated path string
if (!Uri.IsWellFormedUriString(pathString, UriKind.Relative))
{
throw new InvalidOperationException($"The path '{pathString}' is invalid.");
}
return pathString;
}
public static string GetPath([StringSyntax("Route")] string pattern, object values)
{
var routeValues = new RouteValueDictionary(values);
return GetPath(pattern, routeValues);
}
public static string GetPath([StringSyntax("Route")] string pattern, IReadOnlyDictionary<string, object?> routeValues)
{
var urlEncoder = UrlEncoder.Default;
var (routeTemplate, requiredParameterNames) = _routeTemplateCache.GetOrAdd(pattern, static key =>
{
var template = new RouteTemplate(RoutePatternFactory.Parse(key));
var requiredParams = template.Parameters
.Where(p => !p.IsOptional && p.DefaultValue is null && !string.IsNullOrEmpty(p.Name))
.Select(p => p.Name ?? "")
.ToArray();
return (template, requiredParams);
});
foreach (var name in requiredParameterNames)
{
if (!routeValues.ContainsKey(name))
{
throw new InvalidOperationException($"Missing a required value for route parameter '{name}'");
}
}
var remainingValueNames = new HashSet<string>(routeValues.Keys);
var sb = new StringBuilder(pattern.Length);
var prependSlash = pattern.StartsWith('/');
foreach (var segment in routeTemplate.Segments)
{
if (segment.IsSimple && segment.Parts[0] is { IsLiteral: true } path)
{
if (prependSlash)
{
sb.Append('/');
}
sb.Append(path.Text);
prependSlash = true;
}
else
{
foreach (var part in segment.Parts)
{
if (part.IsLiteral || part.IsOptionalSeperator)
{
if (prependSlash)
{
sb.Append('/');
}
sb.Append(part.Text);
prependSlash = true;
}
else if (part.IsParameter && part.Name is not null)
{
var rawValue = routeValues[part.Name];
remainingValueNames.Remove(part.Name);
if (rawValue?.ToString() is { } value)
{
var encodedValue = urlEncoder.Encode(value);
if (prependSlash)
{
sb.Append('/');
}
sb.Append(encodedValue);
prependSlash = true;
}
else if (!part.IsOptional)
{
throw new InvalidOperationException($"Value supplied for required parameter '{part.Name}' cannot be null.");
}
}
else
{
throw new InvalidOperationException($"The route template segment '{segment}' is not supported for path generation.");
}
}
}
}
if (pattern.EndsWith('/'))
{
sb.Append('/');
}
// Add remaining values to the querystring
var isFirst = true;
foreach (var key in remainingValueNames)
{
if (isFirst)
{
sb.Append('?');
isFirst = false;
}
else
{
sb.Append('&');
}
sb.Append(urlEncoder.Encode(key));
sb.Append('=');
var rawValue = routeValues[key];
if (rawValue?.ToString() is { } value && !string.IsNullOrEmpty(value))
{
sb.Append(urlEncoder.Encode(value));
}
}
return sb.ToString();
}
}
[InterpolatedStringHandler]
public readonly ref struct UrlEncoderInterpolatedStringHandler
{
private readonly StringBuilder _sb;
private readonly UrlEncoder _urlEncoder;
public UrlEncoderInterpolatedStringHandler(int literalLength, int formattedCount)
: this (UrlEncoder.Default, literalLength, formattedCount)
{
}
public UrlEncoderInterpolatedStringHandler(UrlEncoder urlEncoder, int literalLength, int formattedCount)
{
_sb = new StringBuilder(literalLength);
_urlEncoder = urlEncoder;
}
public void AppendLiteral(string s)
{
_sb.Append(s);
}
public void AppendFormatted<T>(T t)
{
if (t?.ToString() is { } value && !string.IsNullOrEmpty(value))
{
var encodedValue = _urlEncoder.Encode(value);
_sb.Append(encodedValue);
}
}
public void AppendFormatted<T>(T t, string format) where T : IFormattable
{
if (t?.ToString(format, CultureInfo.InvariantCulture) is { } value && !string.IsNullOrEmpty(value))
{
var encodedValue = _urlEncoder.Encode(value);
_sb.Append(encodedValue);
}
}
internal string GetFormattedText() => _sb.ToString();
}
using System.Text;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () =>
{
var id = 123;
var entity = "posts";
var page = 2;
var f = "";
var sb = new StringBuilder();
sb.AppendLine("** Value Objects **");
sb.AppendLine(PathGenerator.GetPath("/user/{id}/{entity}", new { id, entity, page, f, q = "test with spaces" }));
sb.AppendLine(PathGenerator.GetPath("/user/{id}/{entity}/", new { id, entity, page, f, q = "test with spaces" }));
sb.AppendLine(PathGenerator.GetPath("user/{id}/{entity}", new { id, entity, page }));
sb.AppendLine(PathGenerator.GetPath("user/{id}/{entity}", new { id, entity = "entity with spaces", page }));
sb.AppendLine(PathGenerator.GetPath("/user/{id}/{entity?}", new { id, page }));
sb.AppendLine(PathGenerator.GetPath("/user/{id}/{entity?}", new { id, entity, page }));
sb.AppendLine();
sb.AppendLine("** Ordinal **");
sb.AppendLine(PathGenerator.GetPath("/user/{0}/{1}?page={2}", id, entity, page));
sb.AppendLine(PathGenerator.GetPath("/user/{0}/{1}?page={2}&q={3}", id, entity, page, "test with spaces"));
sb.AppendLine();
sb.AppendLine("** Interpolated **");
sb.AppendLine(PathGenerator.GetPath($"/user/{id}/{entity}?page={page}"));
sb.AppendLine(PathGenerator.GetPath($"/user/{id}/{entity}?page={47}&f={f}&q={"test with spaces"}"));
return Results.Text(sb.ToString(), "text/plain", Encoding.UTF8);
});
app.Run();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment