Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
19 changes: 10 additions & 9 deletions src/Ocelot/Configuration/Creator/AggregatesCreator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,16 @@ private Route SetUpAggregateRoute(IEnumerable<Route> routes, FileAggregateRoute
{
var applicableRoutes = new List<DownstreamRoute>();
var allRoutes = routes.SelectMany(x => x.DownstreamRoute);
var downstreamRoutes = aggregateRoute.RouteKeys.Select(routeKey => allRoutes.FirstOrDefault(q => q.Key == routeKey));
foreach (var downstreamRoute in downstreamRoutes)
{
if (downstreamRoute == null)
{
return null;
}

applicableRoutes.Add(downstreamRoute);
var orderedKeys = aggregateRoute.RouteKeys.ToList();
foreach (var key in orderedKeys)
{
var match = allRoutes.FirstOrDefault(r => r.Key == key);
if (match is null)
{
return null;
}

applicableRoutes.Add(match);
}

var upstreamTemplatePattern = _creator.Create(aggregateRoute);
Expand Down
18 changes: 15 additions & 3 deletions src/Ocelot/Multiplexer/MultiplexingMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,13 @@ private IEnumerable<Task<HttpContext>> ProcessRouteWithComplexAggregation(Aggreg
JToken jObject, HttpContext httpContext, DownstreamRoute downstreamRoute)
{
var processing = new List<Task<HttpContext>>();
var values = jObject.SelectTokens(matchAdvancedAgg.JsonPath).Select(s => s.ToString()).Distinct();
var values = jObject.SelectTokens(matchAdvancedAgg.JsonPath).Select(s => s.ToString()).Distinct().ToList();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
var values = jObject.SelectTokens(matchAdvancedAgg.JsonPath).Select(s => s.ToString()).Distinct().ToList();
var values = jObject.SelectTokens(matchAdvancedAgg.JsonPath).Select(s => s.ToString()).Distinct().ToArray();

Copy link
Member

@raman-m raman-m Oct 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be fair, creating a list or another collection isn't necessary since the foreach operator already processes IEnumerable object 🤣 LoL →

var values = jObject.SelectTokens(matchAdvancedAgg.JsonPath).Select(s => s.ToString()).Distinct();
foreach (var value in values)

@NandanDevHub Please avoid using ToList() to create the list, as it consumes a small amount of heap and adds unnecessary pressure on the garbage collector!
FYI, Distinct() is essentially a wrapper algorithm applied to a collection generated by multiple Select() operations.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My suggestion:

-        var values = jObject.SelectTokens(matchAdvancedAgg.JsonPath).Select(s => s.ToString()).Distinct();
+        var values = jObject.SelectTokens(matchAdvancedAgg.JsonPath).Select(ToString).Distinct();

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@raman-m I've kept the explicit lambda here since SelectTokens() returns JToken items, i felt this way each tokens value is safely converted to string.

Also in below cmmit i confirmed the EOL formatting is fine now.

Copy link
Member

@raman-m raman-m Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, fair enough!
Could the code line be like this?
My suggestion:

var values = jObject.SelectTokens(matchAdvancedAgg.JsonPath).Distinct().Select(s => s.ToString());

The idea is to skip creating the collection and applying the Distinct algorithm afterward. Instead, it's more efficient to search for distinct objects directly and then perform the casting to strings.
I'm not sure about the correctness because Distinct might return all J-tokens.

foreach (var value in values)
{
var tPnv = httpContext.Items.TemplatePlaceholderNameAndValues();
tPnv.Add(new PlaceholderNameAndValue('{' + matchAdvancedAgg.Parameter + '}', value));
var tPnv = new List<PlaceholderNameAndValue>(httpContext.Items.TemplatePlaceholderNameAndValues())
{
new('{' + matchAdvancedAgg.Parameter + '}', value),
};
processing.Add(ProcessRouteAsync(httpContext, downstreamRoute, tPnv));
}

Expand Down Expand Up @@ -255,6 +257,16 @@ protected virtual Task MapAsync(HttpContext httpContext, Route route, List<HttpC
return Task.CompletedTask;
}

// ensure each context retains its correct aggregate key for proper response mapping
if (route.DownstreamRouteConfig != null && route.DownstreamRouteConfig.Count > 0)
{
for (int i = 0; i < contexts.Count && i < route.DownstreamRouteConfig.Count; i++)
{
var key = route.DownstreamRouteConfig[i].RouteKey;
contexts[i].Items["CurrentAggregateRouteKey"] = key;
}
}

var aggregator = _factory.Get(route);
return aggregator.Aggregate(route, httpContext, contexts);
}
Expand Down
128 changes: 128 additions & 0 deletions test/Ocelot.AcceptanceTests/AggregateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,134 @@ public void Should_return_response_200_with_copied_form_sent_on_multiple_service
.BDDfy();
}

[Fact]
[Trait("Bug", "2248")]
[Trait("PR", "2328")] // https://github.com/ThreeMammals/Ocelot/pull/2328
public void Should_match_downstream_routes_using_route_keys_array()
{
var portUser = PortFinder.GetRandomPort();
var userRoute = GivenRoute(portUser, "/user", "/user");
userRoute.Key = "User";

var portProduct = PortFinder.GetRandomPort();
var productRoute = GivenRoute(portProduct, "/product", "/product");
productRoute.Key = "Product";

var aggregate = new FileAggregateRoute
{
RouteKeys = new() { "User", "Product" },
UpstreamPathTemplate = "/composite",
UpstreamHttpMethod = ["Get"],
};

var configuration = GivenConfiguration(userRoute, productRoute);
configuration.Aggregates = new() { aggregate };
this.Given(_ => GivenThereIsAConfiguration(configuration))
.And(_ => GivenOcelotIsRunning())
.And(_ => GivenThereIsAServiceRunningOn(portUser, "/user", MapGetUser))
.And(_ => GivenThereIsAServiceRunningOn(portProduct, "/product", MapGetProduct))
.When(_ => WhenIGetUrlOnTheApiGateway("/composite"))
.Then(_ => ThenTheStatusCodeShouldBeOK())
.BDDfy();
}

[Fact]
[Trait("Bug", "2248")]
[Trait("PR", "2328")] // https://github.com/ThreeMammals/Ocelot/pull/2328
public void Should_expand_jsonpath_array_into_multiple_parameterized_calls()
{
var commentsPort = PortFinder.GetRandomPort();
var usersPort = PortFinder.GetRandomPort();

var comments = new FileRoute
{
Key = "comments",
DownstreamScheme = "http",
DownstreamHostAndPorts = new() { new("localhost", commentsPort) },
DownstreamPathTemplate = "/comments",
UpstreamPathTemplate = "/comments",
UpstreamHttpMethod = [HttpMethods.Get],
};

var user = new FileRoute
{
Key = "user",
DownstreamScheme = "http",
DownstreamHostAndPorts = new() { new("localhost", usersPort) },
DownstreamPathTemplate = "/users/{userId}",
UpstreamPathTemplate = "/users/{userId}",
UpstreamHttpMethod = [HttpMethods.Get],
};

var aggregate = new FileAggregateRoute
{
UpstreamPathTemplate = "/aggregatecommentuser",
UpstreamHttpMethod = [HttpMethods.Get],
RouteKeys = ["comments", "user"],
RouteKeysConfig = new()
{
new AggregateRouteConfig
{
RouteKey = "user",
JsonPath = "$[*].userId",
Parameter = "userId",
},
},
};

var config = new FileConfiguration
{
Routes = new() { comments, user },
Aggregates = new() { aggregate },
};

handler.GivenThereIsAServiceRunningOn(commentsPort, async ctx =>
{
if (ctx.Request.Path.Value == "/comments")
{
ctx.Response.StatusCode = 200;
ctx.Response.ContentType = "application/json";
await ctx.Response.WriteAsync("[{\"id\":1,\"userId\":1},{\"id\":2,\"userId\":2}]");
}
else
{
ctx.Response.StatusCode = 404;
}
});

handler.GivenThereIsAServiceRunningOn(usersPort, async ctx =>
{
var parts = ctx.Request.Path.Value?.Trim('/').Split('/');
var ok = parts?.Length == 2 && parts[0] == "users" && int.TryParse(parts[1], out var id);
ctx.Response.StatusCode = ok ? 200 : 400;
ctx.Response.ContentType = "application/json";
await ctx.Response.WriteAsync(ok
? $"{{\"id\":{parts![1]},\"name\":\"User-{parts[1]}\"}}"
: "{\"error\":\"bad id\"}");
});

var expected =
"{\"comments\":[{\"id\":1,\"userId\":1},{\"id\":2,\"userId\":2}],\"user\":[{\"id\":1,\"name\":\"User-1\"},{\"id\":2,\"name\":\"User-2\"}]}";

this.Given(_ => GivenThereIsAConfiguration(config))
.And(_ => GivenOcelotIsRunning())
.When(_ => WhenIGetUrlOnTheApiGateway("/aggregatecommentuser"))
.Then(_ => ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
.And(_ => ThenTheResponseBodyShouldBe(expected))
.BDDfy();
}

Task MapGetUser(HttpContext ctx)
{
ctx.Response.StatusCode = 200;
return ctx.Response.WriteAsync("OK-user");
}
Task MapGetProduct(HttpContext ctx)
{
ctx.Response.StatusCode = 200;
return ctx.Response.WriteAsync("OK-product");
}

private static string FormatFormCollection(IFormCollection reqForm)
{
var sb = new StringBuilder()
Expand Down