Skip to content

Commit 250c1e1

Browse files
committed
Support query string parameters in CrudApiPlugin actions
Split action URLs into path and query parts at the '?' delimiter. Query parameters are matched individually and order-independently via HttpUtility.ParseQueryString; values may be literal (exact match) or {param} placeholders. A parameter can be declared multiple times in the action URL (e.g. ?id={id1}&id={id2}) to capture repeated query parameters, bound positionally for use in the action's JSONPath query.
1 parent d12bf53 commit 250c1e1

1 file changed

Lines changed: 98 additions & 25 deletions

File tree

‎DevProxy.Plugins/Mocking/CrudApiPlugin.cs‎

Lines changed: 98 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
using System.Security.Claims;
2020
using System.Text.Json.Serialization;
2121
using System.Text.RegularExpressions;
22+
using System.Web;
2223
using Titanium.Web.Proxy.EventArguments;
2324
using Titanium.Web.Proxy.Http;
2425
using Titanium.Web.Proxy.Models;
@@ -246,6 +247,7 @@ private async Task SetupOpenIdConnectConfigurationAsync()
246247
});
247248

248249
var requestUrlWithoutQuery = request.RequestUri.GetLeftPart(UriPartial.Path);
250+
var requestQuery = request.RequestUri.Query;
249251
var parameters = new Dictionary<string, string>();
250252
var action = Configuration.Actions.FirstOrDefault(action =>
251253
{
@@ -256,33 +258,94 @@ private async Task SetupOpenIdConnectConfigurationAsync()
256258

257259
var absoluteActionUrl = (Configuration.BaseUrl + action.Url).Replace("//", "/", 8);
258260

259-
if (absoluteActionUrl == requestUrlWithoutQuery)
261+
// split action URL into path and query parts
262+
string actionPath;
263+
string? actionQuery = null;
264+
var queryIndex = absoluteActionUrl.IndexOf('?', StringComparison.OrdinalIgnoreCase);
265+
if (queryIndex >= 0)
260266
{
261-
return true;
267+
actionPath = absoluteActionUrl[..queryIndex];
268+
actionQuery = absoluteActionUrl[queryIndex..];
269+
}
270+
else
271+
{
272+
actionPath = absoluteActionUrl;
262273
}
263274

264-
// check if the action contains parameters
265-
// if it doesn't, it's not a match for the current request for sure
266-
if (!absoluteActionUrl.Contains('{', StringComparison.OrdinalIgnoreCase))
275+
// match the path part
276+
var pathMatched = false;
277+
if (actionPath == requestUrlWithoutQuery)
267278
{
268-
return false;
279+
pathMatched = true;
280+
}
281+
else if (actionPath.Contains('{', StringComparison.OrdinalIgnoreCase))
282+
{
283+
var pathRegex = Regex.Replace(Regex.Escape(actionPath).Replace("\\{", "{", StringComparison.OrdinalIgnoreCase), "({[^}]+})", parameterMatchEvaluator);
284+
var pathMatch = Regex.Match(requestUrlWithoutQuery, pathRegex);
285+
if (pathMatch.Success)
286+
{
287+
pathMatched = true;
288+
foreach (var groupName in pathMatch.Groups.Keys)
289+
{
290+
if (groupName == "0")
291+
{
292+
continue;
293+
}
294+
parameters[groupName] = Uri.UnescapeDataString(pathMatch.Groups[groupName].Value);
295+
}
296+
}
269297
}
270298

271-
// convert parameters into named regex groups
272-
var urlRegex = Regex.Replace(Regex.Escape(absoluteActionUrl).Replace("\\{", "{", StringComparison.OrdinalIgnoreCase), "({[^}]+})", parameterMatchEvaluator);
273-
var match = Regex.Match(requestUrlWithoutQuery, urlRegex);
274-
if (!match.Success)
299+
if (!pathMatched)
275300
{
276301
return false;
277302
}
278303

279-
foreach (var groupName in match.Groups.Keys)
304+
// if the action has no query string definition, it's a match
305+
if (string.IsNullOrEmpty(actionQuery))
306+
{
307+
return true;
308+
}
309+
310+
// match query string parameters individually (order-independent)
311+
var actionQueryParams = HttpUtility.ParseQueryString(actionQuery.TrimStart('?'));
312+
var requestQueryParams = HttpUtility.ParseQueryString(requestQuery.TrimStart('?'));
313+
314+
foreach (string? key in actionQueryParams.AllKeys)
280315
{
281-
if (groupName == "0")
316+
if (key is null)
282317
{
283318
continue;
284319
}
285-
parameters.Add(groupName, Uri.UnescapeDataString(match.Groups[groupName].Value));
320+
321+
// a key can be declared multiple times in the action (e.g. ?id={id1}&id={id2})
322+
// and supplied multiple times in the request (e.g. ?id=1&id=2); pair them by position
323+
var actionValues = actionQueryParams.GetValues(key) ?? [];
324+
var requestValues = requestQueryParams.GetValues(key);
325+
326+
if (requestValues is null)
327+
{
328+
return false;
329+
}
330+
331+
for (var i = 0; i < actionValues.Length; i++)
332+
{
333+
// check if the declared value is a parameter pattern like {param}
334+
var paramMatch = Regex.Match(actionValues[i], "^{([^}]+)}$");
335+
if (paramMatch.Success)
336+
{
337+
// bind the i-th declared placeholder to the i-th supplied value
338+
if (i < requestValues.Length)
339+
{
340+
var paramName = paramMatch.Groups[1].Value.Replace('-', '_');
341+
parameters[paramName] = requestValues[i] ?? string.Empty;
342+
}
343+
}
344+
else if (!requestValues.Contains(actionValues[i], StringComparer.Ordinal))
345+
{
346+
return false;
347+
}
348+
}
286349
}
287350
return true;
288351
});
@@ -541,8 +604,8 @@ private void GetOne(SessionEventArgs e, CrudApiAction action, IDictionary<string
541604
{
542605
try
543606
{
544-
var item = _data?.SelectToken(ReplaceParams(action.Query, parameters));
545-
if (item is null)
607+
if (!TryReplaceParams(action.Query, parameters, out var query) ||
608+
_data?.SelectToken(query) is not JToken item)
546609
{
547610
SendNotFoundResponse(e);
548611
Logger.LogRequest($"404 {action.Url}", MessageType.Mocked, new LoggingContext(e));
@@ -563,7 +626,15 @@ private void GetMany(SessionEventArgs e, CrudApiAction action, IDictionary<strin
563626
{
564627
try
565628
{
566-
var items = (_data?.SelectTokens(ReplaceParams(action.Query, parameters))) ?? [];
629+
// an action may reference more indexed values than were supplied
630+
// (e.g. {id2} with a single id); treat that as no matches
631+
if (!TryReplaceParams(action.Query, parameters, out var query))
632+
{
633+
SendJsonResponse("[]", HttpStatusCode.OK, e);
634+
Logger.LogRequest($"200 {action.Url}", MessageType.Mocked, new LoggingContext(e));
635+
return;
636+
}
637+
var items = _data?.SelectTokens(query) ?? [];
567638
SendJsonResponse(JsonConvert.SerializeObject(items, Formatting.Indented), HttpStatusCode.OK, e);
568639
Logger.LogRequest($"200 {action.Url}", MessageType.Mocked, new LoggingContext(e));
569640
}
@@ -594,8 +665,8 @@ private void Merge(SessionEventArgs e, CrudApiAction action, IDictionary<string,
594665
{
595666
try
596667
{
597-
var item = _data?.SelectToken(ReplaceParams(action.Query, parameters));
598-
if (item is null)
668+
if (!TryReplaceParams(action.Query, parameters, out var query) ||
669+
_data?.SelectToken(query) is not JToken item)
599670
{
600671
SendNotFoundResponse(e);
601672
Logger.LogRequest($"404 {action.Url}", MessageType.Mocked, new LoggingContext(e));
@@ -617,8 +688,8 @@ private void Update(SessionEventArgs e, CrudApiAction action, IDictionary<string
617688
{
618689
try
619690
{
620-
var item = _data?.SelectToken(ReplaceParams(action.Query, parameters));
621-
if (item is null)
691+
if (!TryReplaceParams(action.Query, parameters, out var query) ||
692+
_data?.SelectToken(query) is not JToken item)
622693
{
623694
SendNotFoundResponse(e);
624695
Logger.LogRequest($"404 {action.Url}", MessageType.Mocked, new LoggingContext(e));
@@ -640,8 +711,8 @@ private void Delete(SessionEventArgs e, CrudApiAction action, IDictionary<string
640711
{
641712
try
642713
{
643-
var item = _data?.SelectToken(ReplaceParams(action.Query, parameters));
644-
if (item is null)
714+
if (!TryReplaceParams(action.Query, parameters, out var query) ||
715+
_data?.SelectToken(query) is not JToken item)
645716
{
646717
SendNotFoundResponse(e);
647718
Logger.LogRequest($"404 {action.Url}", MessageType.Mocked, new LoggingContext(e));
@@ -676,17 +747,19 @@ private static bool HasPermission(string permission, string permissionString)
676747
return permissions.Contains(permission, StringComparer.OrdinalIgnoreCase);
677748
}
678749

679-
private static string ReplaceParams(string query, IDictionary<string, string> parameters)
750+
private static bool TryReplaceParams(string query, IDictionary<string, string> parameters, out string result)
680751
{
681-
var result = Regex.Replace(query, "{([^}]+)}", new MatchEvaluator(m =>
752+
result = Regex.Replace(query, "{([^}]+)}", new MatchEvaluator(m =>
682753
{
683754
return $"{{{m.Groups[1].Value.Replace('-', '_')}}}";
684755
}));
685756
foreach (var param in parameters)
686757
{
687758
result = result.Replace($"{{{param.Key}}}", param.Value, StringComparison.OrdinalIgnoreCase);
688759
}
689-
return result;
760+
// report unresolved placeholders (e.g. an action referencing {id2} when only one
761+
// value was supplied) so callers can treat it as no match instead of failing
762+
return !Regex.IsMatch(result, "{[^}]+}");
690763
}
691764

692765
protected override void Dispose(bool disposing)

0 commit comments

Comments
 (0)