Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Validates that all URLs are covered by urlsToWatch. Closes #1002 #1004

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions dev-proxy-abstractions/PluginEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,8 @@ internal ProxyHttpEventArgsBase(SessionEventArgs session)

public SessionEventArgs Session { get; }

public bool HasRequestUrlMatch(ISet<UrlToWatch> watchedUrls)
{
var match = watchedUrls.FirstOrDefault(r => r.Url.IsMatch(Session.HttpClient.Request.RequestUri.AbsoluteUri));
return match is not null && !match.Exclude;
}
public bool HasRequestUrlMatch(ISet<UrlToWatch> watchedUrls) =>
ProxyUtils.MatchesUrlToWatch(watchedUrls, Session.HttpClient.Request.RequestUri.AbsoluteUri);
}

public class ProxyRequestArgs(SessionEventArgs session, ResponseState responseState) : ProxyHttpEventArgsBase(session)
Expand Down
103 changes: 103 additions & 0 deletions dev-proxy-abstractions/ProxyUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -352,4 +352,107 @@ public static void MergeHeaders(IList<MockResponseHeader> allHeaders, IList<Mock
}

public static JsonSerializerOptions JsonSerializerOptions => jsonSerializerOptions;

public static bool MatchesUrlToWatch(ISet<UrlToWatch> watchedUrls, string url)
{
if (url.Contains('*'))
{
// url contains a wildcard, so convert it to regex and compare
var match = watchedUrls.FirstOrDefault(r => {
var pattern = RegexToPattern(r.Url);
var result = UrlRegexComparer.CompareRegexPatterns(pattern, url);
return result != UrlRegexComparisonResult.PatternsMutuallyExclusive;
});
return match is not null && !match.Exclude;
}
else
{
var match = watchedUrls.FirstOrDefault(r => r.Url.IsMatch(url));
return match is not null && !match.Exclude;
}
}

public static string PatternToRegex(string pattern)
{
return $"^{Regex.Escape(pattern).Replace("\\*", ".*")}$";
}

public static string RegexToPattern(Regex regex)
{
return Regex.Unescape(regex.ToString())
.Trim('^', '$')
.Replace(".*", "*");
}

public static List<string> GetWildcardPatterns(List<string> urls)
{
return urls
.GroupBy(url =>
{
if (url.Contains('*'))
{
return url;
}

var uri = new Uri(url);
return $"{uri.Scheme}://{uri.Host}";
})
.Select(group =>
{
if (group.Count() == 1)
{
var url = group.First();
if (url.Contains('*'))
{
return url;
}

// For single URLs, use the URL up to the last segment
var uri = new Uri(url);
var path = uri.AbsolutePath;
var lastSlashIndex = path.LastIndexOf('/');
return $"{group.Key}{path[..lastSlashIndex]}/*";
}

// For multiple URLs, find the common prefix
var paths = group.Select(url => {
if (url.Contains('*'))
{
return url;
}

return new Uri(url).AbsolutePath;
}).ToList();
var commonPrefix = GetCommonPrefix(paths);
return $"{group.Key}{commonPrefix}*";
})
.OrderBy(x => x)
.ToList();
}

private static string GetCommonPrefix(List<string> paths)
{
if (paths.Count == 0) return string.Empty;

var firstPath = paths[0];
var commonPrefixLength = firstPath.Length;

for (var i = 1; i < paths.Count; i++)
{
commonPrefixLength = Math.Min(commonPrefixLength, paths[i].Length);
for (var j = 0; j < commonPrefixLength; j++)
{
if (firstPath[j] != paths[i][j])
{
commonPrefixLength = j;
break;
}
}
}

// Find the last complete path segment
var prefix = firstPath[..commonPrefixLength];
var lastSlashIndex = prefix.LastIndexOf('/');
return lastSlashIndex >= 0 ? prefix[..(lastSlashIndex + 1)] : prefix;
}
}
143 changes: 143 additions & 0 deletions dev-proxy-abstractions/UrlRegexComparer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System.Text.RegularExpressions;

namespace DevProxy.Abstractions;

enum UrlRegexComparisonResult
{
/// <summary>
/// The first pattern is broader than the second pattern.
/// </summary>
FirstPatternBroader,

/// <summary>
/// The second pattern is broader than the first pattern.
/// </summary>
SecondPatternBroader,

/// <summary>
/// The patterns are equivalent.
/// </summary>
PatternsEquivalent,

/// <summary>
/// The patterns are mutually exclusive.
/// </summary>
PatternsMutuallyExclusive
}

class UrlRegexComparer
{
/// <summary>
/// Compares two URL patterns and returns a value indicating their
// relationship.
/// </summary>
/// <param name="pattern1">First URL pattern</param>
/// <param name="pattern2">Second URL pattern</param>
/// <returns>1 when the first pattern is broader; -1 when the second pattern
/// is broader or patterns are mutually exclusive; 0 when the patterns are
/// equal</returns>
public static UrlRegexComparisonResult CompareRegexPatterns(string pattern1, string pattern2)
{
var regex1 = new Regex(ProxyUtils.PatternToRegex(pattern1));
var regex2 = new Regex(ProxyUtils.PatternToRegex(pattern2));

// Generate test URLs based on patterns
var testUrls = GenerateTestUrls(pattern1, pattern2);

var matches1 = testUrls.Where(url => regex1.IsMatch(url)).ToList();
var matches2 = testUrls.Where(url => regex2.IsMatch(url)).ToList();

bool pattern1MatchesAll = matches2.All(regex1.IsMatch);
bool pattern2MatchesAll = matches1.All(regex2.IsMatch);

if (pattern1MatchesAll && !pattern2MatchesAll)
// Pattern 1 is broader
return UrlRegexComparisonResult.FirstPatternBroader;
else if (pattern2MatchesAll && !pattern1MatchesAll)
// Pattern 2 is broader
return UrlRegexComparisonResult.SecondPatternBroader;
else if (pattern1MatchesAll && pattern2MatchesAll)
// Patterns are equivalent
return UrlRegexComparisonResult.PatternsEquivalent;
else
// Patterns have different matching sets
return UrlRegexComparisonResult.PatternsMutuallyExclusive;
}

private static List<string> GenerateTestUrls(string pattern1, string pattern2)
{
var urls = new HashSet<string>();

// Extract domains and paths from patterns
var domains = ExtractDomains(pattern1)
.Concat(ExtractDomains(pattern2))
.Distinct()
.ToList();

var paths = ExtractPaths(pattern1)
.Concat(ExtractPaths(pattern2))
.Distinct()
.ToList();

// Generate combinations
foreach (var domain in domains)
{
foreach (var path in paths)
{
urls.Add($"https://{domain}/{path}");
}

// Add variants
urls.Add($"https://{domain}/");
urls.Add($"https://sub.{domain}/path");
urls.Add($"https://other-{domain}/different");
}

return urls.ToList();
}

private static HashSet<string> ExtractDomains(string pattern)
{
var domains = new HashSet<string>();

// Extract literal domains
var domainMatch = Regex.Match(Regex.Unescape(pattern), @"https://([^/\s]+)");
if (domainMatch.Success)
{
var domain = domainMatch.Groups[1].Value;
if (!domain.Contains(".*"))
domains.Add(domain);
}

// Add test domains
domains.Add("example.com");
domains.Add("test.com");

return domains;
}

private static HashSet<string> ExtractPaths(string pattern)
{
var paths = new HashSet<string>();

// Extract literal paths
var pathMatch = Regex.Match(pattern, @"https://[^/]+(/[^/\s]+)");
if (pathMatch.Success)
{
var path = pathMatch.Groups[1].Value;
if (!path.Contains(".*"))
paths.Add(path.TrimStart('/'));
}

// Add test paths
paths.Add("api");
paths.Add("users");
paths.Add("path1/path2");

return paths;
}
}
15 changes: 13 additions & 2 deletions dev-proxy-plugins/Mocks/CrudApiPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,6 @@ public override async Task RegisterAsync()

ConfigSection?.Bind(_configuration);

PluginEvents.BeforeRequest += OnRequestAsync;

_proxyConfiguration = Context.Configuration;

_configuration.ApiFile = Path.GetFullPath(ProxyUtils.ReplacePathTokens(_configuration.ApiFile), Path.GetDirectoryName(_proxyConfiguration?.ConfigFile ?? string.Empty) ?? string.Empty);
Expand All @@ -103,8 +101,21 @@ public override async Task RegisterAsync()
_configuration.Auth = CrudApiAuthType.None;
}

if (!ProxyUtils.MatchesUrlToWatch(UrlsToWatch, _configuration.BaseUrl))
{
Logger.LogWarning(
"The base URL of the API {baseUrl} does not match any URL to watch. The {plugin} plugin will be disabled. To enable it, add {url}* to the list of URLs to watch and restart Dev Proxy.",
_configuration.BaseUrl,
Name,
_configuration.BaseUrl
);
return;
}

LoadData();
await SetupOpenIdConnectConfigurationAsync();

PluginEvents.BeforeRequest += OnRequestAsync;
}

private async Task SetupOpenIdConnectConfigurationAsync()
Expand Down
60 changes: 57 additions & 3 deletions dev-proxy-plugins/Mocks/MockResponsePlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,61 @@ private void OnOptionsLoaded(object? sender, OptionsLoadedArgs e)

// load the responses from the configured mocks file
_loader?.InitResponsesWatcher();

ValidateMocks();
}

private void ValidateMocks()
{
Logger.LogDebug("Validating mock responses");

if (_configuration.NoMocks)
{
Logger.LogDebug("Mocks are disabled");
return;
}

if (_configuration.Mocks is null ||
!_configuration.Mocks.Any())
{
Logger.LogDebug("No mock responses defined");
return;
}

var unmatchedMockUrls = new List<string>();

foreach (var mock in _configuration.Mocks)
{
if (mock.Request is null)
{
Logger.LogDebug("Mock response is missing a request");
continue;
}

if (string.IsNullOrEmpty(mock.Request.Url))
{
Logger.LogDebug("Mock response is missing a URL");
continue;
}

if (!ProxyUtils.MatchesUrlToWatch(UrlsToWatch, mock.Request.Url))
{
unmatchedMockUrls.Add(mock.Request.Url);
}
}

if (unmatchedMockUrls.Count == 0)
{
return;
}

var suggestedWildcards = ProxyUtils.GetWildcardPatterns(unmatchedMockUrls);
Logger.LogWarning(
"The following URLs in {mocksFile} don't match any URL to watch: {unmatchedMocks}. Add the following URLs to URLs to watch: {urlsToWatch}",
_configuration.MocksFile,
string.Join(", ", unmatchedMockUrls),
string.Join(", ", suggestedWildcards)
);
}

protected virtual Task OnRequestAsync(object? sender, ProxyRequestArgs e)
Expand Down Expand Up @@ -180,9 +235,8 @@ _configuration.Mocks is null ||
return false;
}

//turn mock URL with wildcard into a regex and match against the request URL
var mockResponseUrlRegex = Regex.Escape(mockResponse.Request.Url).Replace("\\*", ".*");
return Regex.IsMatch(request.Url, $"^{mockResponseUrlRegex}$") &&
// turn mock URL with wildcard into a regex and match against the request URL
return Regex.IsMatch(request.Url, ProxyUtils.PatternToRegex(mockResponse.Request.Url)) &&
HasMatchingBody(mockResponse, request) &&
IsNthRequest(mockResponse);
});
Expand Down
Loading