Skip to content

Adds support for server path variables. Closes #1292 #1296

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

Merged
merged 4 commits into from
Jul 2, 2025
Merged
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
9 changes: 9 additions & 0 deletions DevProxy.Abstractions/Utils/ProxyUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,15 @@ public static IEnumerable<string> GetWildcardPatterns(ReadOnlyCollection<string>
.OrderBy(x => x)];
}

#pragma warning disable CA1055
public static string UrlWithParametersToRegex(string urlWithParameters)
#pragma warning restore CA1055
{
ArgumentNullException.ThrowIfNull(urlWithParameters);

return $"^{Regex.Replace(Regex.Escape(urlWithParameters), "\\\\{[^}]+}", ".*")}";
}

internal static Assembly GetAssembly()
=> _assembly ??= (Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly());

Expand Down
47 changes: 32 additions & 15 deletions DevProxy.Plugins/Extensions/OpenApiDocumentExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information.

using DevProxy.Abstractions.Proxy;
using DevProxy.Abstractions.Utils;
using DevProxy.Plugins.Models;
using DevProxy.Plugins.Utils;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -116,27 +117,26 @@ [.. operationsFromRequests
{
logger.LogDebug("Checking server URL {ServerUrl}...", server.Url);

if (!requestUrl.StartsWith(server.Url, StringComparison.OrdinalIgnoreCase))
if (!UrlMatchesServerUrl(requestUrl, server.Url))
{
logger.LogDebug("Request URL {RequestUrl} does not match server URL {ServerUrl}", requestUrl, server.Url);
continue;
}

var serverUrl = new Uri(server.Url);
var serverPath = serverUrl.AbsolutePath.TrimEnd('/');
var requestUri = new Uri(requestUrl);
var urlPathFromRequest = requestUri.GetLeftPart(UriPartial.Path).Replace(server.Url.TrimEnd('/'), "", StringComparison.OrdinalIgnoreCase);
var absoluteUrlPathFromRequest = requestUri.GetLeftPart(UriPartial.Path);

foreach (var path in openApiDocument.Paths)
{
var urlPathFromSpec = path.Key;
logger.LogDebug("Checking path {UrlPath}...", urlPathFromSpec);
var absolutePathFromSpec = server.Url.TrimEnd('/') + urlPathFromSpec;
logger.LogDebug("Checking path {UrlPath}...", absolutePathFromSpec);

// check if path contains parameters. If it does,
// replace them with regex
if (urlPathFromSpec.Contains('{', StringComparison.OrdinalIgnoreCase))
if (absolutePathFromSpec.Contains('{', StringComparison.OrdinalIgnoreCase))
{
logger.LogDebug("Path {UrlPath} contains parameters and will be converted to Regex", urlPathFromSpec);
logger.LogDebug("Path {UrlPath} contains parameters and will be converted to Regex", absolutePathFromSpec);

// force replace all parameters with regex
// this is more robust than replacing parameters by name
Expand All @@ -147,24 +147,24 @@ [.. operationsFromRequests
// we also escape the path to make sure that regex special
// characters are not interpreted so that we won't fail
// on matching URLs that contain ()
urlPathFromSpec = Regex.Replace(Regex.Escape(urlPathFromSpec), @"\\\{[^}]+\}", $"([^/]+)");
absolutePathFromSpec = Regex.Replace(Regex.Escape(absolutePathFromSpec), @"\\\{[^}]+\}", $"([^/]+)");

logger.LogDebug("Converted path to Regex: {UrlPath}", urlPathFromSpec);
var regex = new Regex($"^{urlPathFromSpec}$");
if (regex.IsMatch(urlPathFromRequest))
logger.LogDebug("Converted path to Regex: {UrlPath}", absolutePathFromSpec);
var regex = new Regex($"^{absolutePathFromSpec}$");
if (regex.IsMatch(absoluteUrlPathFromRequest))
{
logger.LogDebug("Regex matches {RequestUrl}", urlPathFromRequest);
logger.LogDebug("Regex matches {RequestUrl}", absoluteUrlPathFromRequest);

return path;
}

logger.LogDebug("Regex does not match {RequestUrl}", urlPathFromRequest);
logger.LogDebug("Regex does not match {RequestUrl}", absoluteUrlPathFromRequest);
}
else
{
if (urlPathFromRequest.Equals(urlPathFromSpec, StringComparison.OrdinalIgnoreCase))
if (absoluteUrlPathFromRequest.Equals(absolutePathFromSpec, StringComparison.OrdinalIgnoreCase))
{
logger.LogDebug("{RequestUrl} matches {UrlPath}", requestUrl, urlPathFromSpec);
logger.LogDebug("{RequestUrl} matches {UrlPath}", requestUrl, absolutePathFromSpec);
return path;
}

Expand Down Expand Up @@ -216,4 +216,21 @@ public static OpenApiSecurityScheme[] GetOAuth2Schemes(this OpenApiDocument open
.Where(s => s.Value.Type == SecuritySchemeType.OAuth2)
.Select(s => s.Value)];
}

private static bool UrlMatchesServerUrl(string absoluteUrl, string serverUrl)
{
if (absoluteUrl.StartsWith(serverUrl, StringComparison.OrdinalIgnoreCase))
{
return true;
}

// If serverUrl contains parameters, use regex to compare it
if (!serverUrl.Contains('{', StringComparison.Ordinal))
{
return false;
}

var serverUrlPattern = ProxyUtils.UrlWithParametersToRegex(serverUrl);
return Regex.IsMatch(absoluteUrl, serverUrlPattern, RegexOptions.IgnoreCase);
}
}
17 changes: 15 additions & 2 deletions DevProxy.Plugins/Reporting/MinimalPermissionsPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.OpenApi.Models;
using Microsoft.OpenApi.Readers;
using System.Text.RegularExpressions;

namespace DevProxy.Plugins.Reporting;

Expand Down Expand Up @@ -185,7 +186,17 @@ private async Task<Dictionary<string, OpenApiDocument>> LoadApiSpecsAsync(string
Logger.LogDebug("No URL found for server '{Server}'", server.Description ?? "unnamed");
continue;
}
apiDefinitions[server.Url] = apiDefinition;

Logger.LogDebug("Found server '{Server}' with URL '{Url}'", server.Description ?? "unnamed", server.Url);

var serverUrl = server.Url;
if (server.Url.Contains('{', StringComparison.Ordinal))
{
serverUrl = ProxyUtils.UrlWithParametersToRegex(server.Url);
Logger.LogDebug("Transformed server URL '{OriginalUrl}' to '{TransformedUrl}'", server.Url, serverUrl);
}

apiDefinitions[serverUrl] = apiDefinition;
}
}
catch (Exception ex)
Expand All @@ -205,7 +216,9 @@ private async Task<Dictionary<string, OpenApiDocument>> LoadApiSpecsAsync(string
var url = request.Message.Split(' ')[1];
Logger.LogDebug("Matching request {RequestUrl} to API specs...", url);

var matchingKey = apiSpecsByUrl.Keys.FirstOrDefault(url.StartsWith);
var matchingKey = apiSpecsByUrl.Keys.FirstOrDefault(urlOrPattern =>
url.StartsWith(urlOrPattern, StringComparison.OrdinalIgnoreCase) ||
Regex.IsMatch(url, urlOrPattern));
if (matchingKey is null)
{
Logger.LogDebug("No matching API spec found for {RequestUrl}", url);
Expand Down
Loading