diff --git a/docs/scatter-gather.md b/docs/scatter-gather.md index 516d4d3e..bdd70841 100644 --- a/docs/scatter-gather.md +++ b/docs/scatter-gather.md @@ -12,7 +12,7 @@ public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory) app.UseRouting(); app.UseEndpoints(builder => builder.MapScatterGather(template: "api/scatter-gather", new ScatterGatherOptions() { - Gatherers = new List + Gatherers = new List { new HttpGatherer(key: "ASamplesSource", destinationUrl: "https://a.web.server/api/samples/ASamplesSource"), new HttpGatherer(key: "AnotherSamplesSource", destinationUrl: "https://another.web.server/api/samples/AnotherSamplesSource") @@ -38,7 +38,7 @@ public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory) { app.UseEndpoints(builder => builder.MapScatterGather(template: "api/scatter-gather", new ScatterGatherOptions() { - Gatherers = new List + Gatherers = new List { new HttpGatherer("ASamplesSource", "https://a.web.server/api/samples/ASamplesSource") { @@ -78,7 +78,7 @@ public class CustomHttpGatherer : HttpGatherer } } ``` -snippet source | anchor +snippet source | anchor ### Taking control of the downstream invocation process @@ -94,11 +94,10 @@ public class CustomHttpGatherer : HttpGatherer public override Task> Gather(HttpContext context) { - // by overriding this method we can implement custom logic - // to gather the responses from the downstream service. + return base.Gather(context); } } ``` -snippet source | anchor +snippet source | anchor diff --git a/src/ServiceComposer.AspNetCore.Tests/API/APIApprovals.Approve_API.verified.txt b/src/ServiceComposer.AspNetCore.Tests/API/APIApprovals.Approve_API.verified.txt index 6488bb5d..6dd3cfab 100644 --- a/src/ServiceComposer.AspNetCore.Tests/API/APIApprovals.Approve_API.verified.txt +++ b/src/ServiceComposer.AspNetCore.Tests/API/APIApprovals.Approve_API.verified.txt @@ -60,13 +60,14 @@ namespace ServiceComposer.AspNetCore " removed in v3. Use attribute routing based composition, and CompositionEventHan" + "dler.", true)] public delegate System.Threading.Tasks.Task EventHandler(string requestId, [System.Runtime.CompilerServices.Dynamic] object viewModel, TEvent @event, Microsoft.AspNetCore.Routing.RouteData routeData, Microsoft.AspNetCore.Http.HttpRequest httpRequest); - public abstract class Gatherer + public abstract class Gatherer : ServiceComposer.AspNetCore.IGatherer + where T : class { protected Gatherer(string key) { } public string Key { get; } - public abstract System.Threading.Tasks.Task> Gather(Microsoft.AspNetCore.Http.HttpContext context); + public abstract System.Threading.Tasks.Task> Gather(Microsoft.AspNetCore.Http.HttpContext context); } - public class HttpGatherer : ServiceComposer.AspNetCore.Gatherer + public class HttpGatherer : ServiceComposer.AspNetCore.Gatherer { public HttpGatherer(string key, string destinationUrl) { } public System.Func DefaultDestinationUrlMapper { get; } @@ -92,8 +93,8 @@ namespace ServiceComposer.AspNetCore } public interface IAggregator { - void Add(System.Collections.Generic.IEnumerable nodes); - System.Threading.Tasks.Task Aggregate(); + void Add(System.Collections.Generic.IEnumerable nodes); + System.Threading.Tasks.Task Aggregate(); } public interface ICompositionContext { @@ -117,6 +118,11 @@ namespace ServiceComposer.AspNetCore System.Threading.Tasks.Task Handle(Microsoft.AspNetCore.Http.HttpRequest request); } public interface IEndpointScopedViewModelFactory : ServiceComposer.AspNetCore.IViewModelFactory { } + public interface IGatherer + { + string Key { get; } + System.Threading.Tasks.Task> Gather(Microsoft.AspNetCore.Http.HttpContext context); + } [System.Obsolete("IHandleRequests is obsoleted and will be treated as an error starting v2 and remo" + "ved in v3. Use attribute routing based composition and ICompositionRequestsHandl" + "er.", true)] @@ -188,7 +194,7 @@ namespace ServiceComposer.AspNetCore { public ScatterGatherOptions() { } public System.Type CustomAggregator { get; set; } - public System.Collections.Generic.IList Gatherers { get; set; } + public System.Collections.Generic.IList Gatherers { get; set; } } public static class ServiceCollectionExtensions { diff --git a/src/ServiceComposer.AspNetCore.Tests/ScatterGather/Get_with_2_gatherers.cs b/src/ServiceComposer.AspNetCore.Tests/ScatterGather/Get_with_2_gatherers.cs index 5a09bb9f..371bb4fd 100644 --- a/src/ServiceComposer.AspNetCore.Tests/ScatterGather/Get_with_2_gatherers.cs +++ b/src/ServiceComposer.AspNetCore.Tests/ScatterGather/Get_with_2_gatherers.cs @@ -73,6 +73,7 @@ HttpClient ClientProvider(string name) => // TODO: does this need to register a default HTTP client? // services.AddScatterGather(); services.AddRouting(); + services.AddControllers(); services.Replace( new ServiceDescriptor(typeof(IHttpClientFactory), new DelegateHttpClientFactory(ClientProvider))); @@ -84,7 +85,7 @@ HttpClient ClientProvider(string name) => { builder.MapScatterGather(template: "/samples", new ScatterGatherOptions { - Gatherers = new List + Gatherers = new List { new HttpGatherer(key: "ASamplesSource", destinationUrl: "/samples/ASamplesSource"), new HttpGatherer(key: "AnotherSamplesSource", destinationUrl: "/samples/AnotherSamplesSource") diff --git a/src/ServiceComposer.AspNetCore.Tests/ScatterGather/Get_with_HttpGatherer_and_CustomGatherer.cs b/src/ServiceComposer.AspNetCore.Tests/ScatterGather/Get_with_HttpGatherer_and_CustomGatherer.cs new file mode 100644 index 00000000..b81814d8 --- /dev/null +++ b/src/ServiceComposer.AspNetCore.Tests/ScatterGather/Get_with_HttpGatherer_and_CustomGatherer.cs @@ -0,0 +1,113 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ServiceComposer.AspNetCore.Testing; +using Xunit; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection.Extensions; +using ServiceComposer.AspNetCore.Tests.Utils; + +namespace ServiceComposer.AspNetCore.Tests.ScatterGather; + +public class Get_with_HttpGatherer_and_CustomGatherer +{ + class CustomGatherer : IGatherer + { + public string Key { get; } = "CustomGatherer"; + public Task> Gather(HttpContext context) + { + var data = (IEnumerable)(new []{ new { Value = "ACustomSample" } }); + return Task.FromResult(data); + } + } + + [Fact] + public async Task Returns_expected_response() + { + // Arrange + var aSampleSourceClient = new SelfContainedWebApplicationFactoryWithWebHost + ( + configureServices: services => + { + services.AddRouting(); + }, + configure: app => + { + app.UseRouting(); + app.UseEndpoints(builder => + { + builder.MapGet("/samples/ASamplesSource", () => + { + return new []{ new { Value = "ASample" } }; + }); + }); + } + ).CreateClient(); + + var client = new SelfContainedWebApplicationFactoryWithWebHost + ( + configureServices: services => + { + HttpClient ClientProvider(string name) => + name switch + { + "ASamplesSource" => aSampleSourceClient, + _ => throw new NotSupportedException($"Missing HTTP client for {name}") + }; + + // TODO: does this need to register a default HTTP client? + // services.AddScatterGather(); + services.AddRouting(); + services.AddControllers(); + services.Replace( + new ServiceDescriptor(typeof(IHttpClientFactory), + new DelegateHttpClientFactory(ClientProvider))); + }, + configure: app => + { + app.UseRouting(); + app.UseEndpoints(builder => + { + builder.MapScatterGather(template: "/samples", new ScatterGatherOptions + { + Gatherers = new List + { + new HttpGatherer(key: "ASamplesSource", destinationUrl: "/samples/ASamplesSource"), + new CustomGatherer() + } + }); + }); + } + ).CreateClient(); + + // Act + var response = await client.GetAsync("/samples"); + + // Assert + Assert.True(response.IsSuccessStatusCode); + + var responseString = await response.Content.ReadAsStringAsync(); + var responseArray = JsonNode.Parse(responseString)!.AsArray(); + var responseArrayAsJsonStrings = new HashSet(responseArray.Select(n=>n.ToJsonString())); + + var expectedArray = JsonNode.Parse(JsonSerializer.Serialize( new[] + { + new {Value = "ASample"}, + new {Value = "ACustomSample"} + }, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }))!.AsArray(); + var expectedArrayAsJsonStrings = new HashSet(expectedArray.Select(n=>n.ToJsonString())); + + Assert.Equal(2, responseArray.Count); + Assert.Equivalent(expectedArrayAsJsonStrings, responseArrayAsJsonStrings); + } +} + diff --git a/src/ServiceComposer.AspNetCore.Tests/ScatterGather/When_using_query_string.cs b/src/ServiceComposer.AspNetCore.Tests/ScatterGather/When_using_query_string.cs index 080ade00..3448d1c7 100644 --- a/src/ServiceComposer.AspNetCore.Tests/ScatterGather/When_using_query_string.cs +++ b/src/ServiceComposer.AspNetCore.Tests/ScatterGather/When_using_query_string.cs @@ -84,7 +84,7 @@ HttpClient ClientProvider(string name) => { builder.MapScatterGather(template: "/samples", new ScatterGatherOptions { - Gatherers = new List + Gatherers = new List { new HttpGatherer(key: "ASamplesSource", destinationUrl: "/samples/ASamplesSource"), new HttpGatherer(key: "AnotherSamplesSource", destinationUrl: "/samples/AnotherSamplesSource") diff --git a/src/ServiceComposer.AspNetCore/ScatterGather/DefaultAggregator.cs b/src/ServiceComposer.AspNetCore/ScatterGather/DefaultAggregator.cs index e8e8e368..57f31994 100644 --- a/src/ServiceComposer.AspNetCore/ScatterGather/DefaultAggregator.cs +++ b/src/ServiceComposer.AspNetCore/ScatterGather/DefaultAggregator.cs @@ -9,17 +9,17 @@ class DefaultAggregator : IAggregator { readonly ConcurrentBag allNodes = new(); - public void Add(IEnumerable nodes) + public void Add(IEnumerable nodes) { foreach (var node in nodes) { - allNodes.Add(node); + allNodes.Add((JsonNode)node); } } - public Task Aggregate() + public Task Aggregate() { var responsesArray = new JsonArray(allNodes.ToArray()); - return Task.FromResult(responsesArray); + return Task.FromResult((object)responsesArray); } } \ No newline at end of file diff --git a/src/ServiceComposer.AspNetCore/ScatterGather/Gatherer.cs b/src/ServiceComposer.AspNetCore/ScatterGather/Gatherer.cs index 40690125..cc5190ef 100644 --- a/src/ServiceComposer.AspNetCore/ScatterGather/Gatherer.cs +++ b/src/ServiceComposer.AspNetCore/ScatterGather/Gatherer.cs @@ -1,11 +1,10 @@ using System.Collections.Generic; -using System.Text.Json.Nodes; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; namespace ServiceComposer.AspNetCore; -public abstract class Gatherer +public abstract class Gatherer : IGatherer where T : class { protected Gatherer(string key) { @@ -14,6 +13,10 @@ protected Gatherer(string key) public string Key { get; } - // TODO: how to use generics to remove the dependency on JSON? - public abstract Task> Gather(HttpContext context); + Task> IGatherer.Gather(HttpContext context) + { + return Gather(context).ContinueWith(t => (IEnumerable)t.Result); + } + + public abstract Task> Gather(HttpContext context); } \ No newline at end of file diff --git a/src/ServiceComposer.AspNetCore/ScatterGather/HttpGatherer.cs b/src/ServiceComposer.AspNetCore/ScatterGather/HttpGatherer.cs index ed660c13..0cfcb8ec 100644 --- a/src/ServiceComposer.AspNetCore/ScatterGather/HttpGatherer.cs +++ b/src/ServiceComposer.AspNetCore/ScatterGather/HttpGatherer.cs @@ -8,7 +8,7 @@ namespace ServiceComposer.AspNetCore; -public class HttpGatherer : Gatherer +public class HttpGatherer : Gatherer { public HttpGatherer(string key, string destinationUrl) : base(key) diff --git a/src/ServiceComposer.AspNetCore/ScatterGather/IAggregator.cs b/src/ServiceComposer.AspNetCore/ScatterGather/IAggregator.cs index 706b21bd..9ca2db66 100644 --- a/src/ServiceComposer.AspNetCore/ScatterGather/IAggregator.cs +++ b/src/ServiceComposer.AspNetCore/ScatterGather/IAggregator.cs @@ -6,6 +6,6 @@ namespace ServiceComposer.AspNetCore; public interface IAggregator { - void Add(IEnumerable nodes); - Task Aggregate(); + void Add(IEnumerable nodes); + Task Aggregate(); } \ No newline at end of file diff --git a/src/ServiceComposer.AspNetCore/ScatterGather/IGatherer.cs b/src/ServiceComposer.AspNetCore/ScatterGather/IGatherer.cs new file mode 100644 index 00000000..f5fdbc5c --- /dev/null +++ b/src/ServiceComposer.AspNetCore/ScatterGather/IGatherer.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace ServiceComposer.AspNetCore; + +public interface IGatherer +{ + string Key { get; } + Task> Gather(HttpContext context); +} \ No newline at end of file diff --git a/src/ServiceComposer.AspNetCore/ScatterGather/ScatterGatherEndpointBuilderExtensions.cs b/src/ServiceComposer.AspNetCore/ScatterGather/ScatterGatherEndpointBuilderExtensions.cs index c1a38eb0..4f42afa6 100644 --- a/src/ServiceComposer.AspNetCore/ScatterGather/ScatterGatherEndpointBuilderExtensions.cs +++ b/src/ServiceComposer.AspNetCore/ScatterGather/ScatterGatherEndpointBuilderExtensions.cs @@ -31,9 +31,8 @@ public static IEndpointConventionBuilder MapScatterGather(this IEndpointRouteBui await Task.WhenAll(tasks); var responses = await aggregator.Aggregate(); - // TODO: support output formatters by using the WriteModelAsync extension method. - // It must be under a setting flag, because it requires a dependency on MVC. - await context.Response.WriteAsync(responses.ToJsonString()); + // TODO: support output formatters by using the WriteModelAsync extension method. It must be under a setting flag, because it requires a dependency on MVC. + await context.Response.WriteAsync( System.Text.Json.JsonSerializer.Serialize(responses)); }); } } \ No newline at end of file diff --git a/src/ServiceComposer.AspNetCore/ScatterGather/ScatterGatherOptions.cs b/src/ServiceComposer.AspNetCore/ScatterGather/ScatterGatherOptions.cs index 74d5d4ef..0140ef07 100644 --- a/src/ServiceComposer.AspNetCore/ScatterGather/ScatterGatherOptions.cs +++ b/src/ServiceComposer.AspNetCore/ScatterGather/ScatterGatherOptions.cs @@ -14,8 +14,9 @@ internal IAggregator GetAggregator(HttpContext httpContext) { return (IAggregator)httpContext.RequestServices.GetRequiredService(CustomAggregator); } + return new DefaultAggregator(); } - public IList Gatherers { get; set; } + public IList Gatherers { get; set; } } \ No newline at end of file diff --git a/src/Snippets/ScatterGather/CustomizingDownstreamURLs.cs b/src/Snippets/ScatterGather/CustomizingDownstreamURLs.cs index e6303e6a..c1b98dee 100644 --- a/src/Snippets/ScatterGather/CustomizingDownstreamURLs.cs +++ b/src/Snippets/ScatterGather/CustomizingDownstreamURLs.cs @@ -12,7 +12,7 @@ public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory) { app.UseEndpoints(builder => builder.MapScatterGather(template: "api/scatter-gather", new ScatterGatherOptions() { - Gatherers = new List + Gatherers = new List { new HttpGatherer("ASamplesSource", "https://a.web.server/api/samples/ASamplesSource") { diff --git a/src/Snippets/ScatterGather/GatherMethodOverride.cs b/src/Snippets/ScatterGather/GatherMethodOverride.cs index 251c33f0..96519e86 100644 --- a/src/Snippets/ScatterGather/GatherMethodOverride.cs +++ b/src/Snippets/ScatterGather/GatherMethodOverride.cs @@ -16,8 +16,7 @@ public CustomHttpGatherer(string key, string destination) : base(key, destinatio public override Task> Gather(HttpContext context) { - // by overriding this method we can implement custom logic - // to gather the responses from the downstream service. + return base.Gather(context); } } diff --git a/src/Snippets/ScatterGather/Startup.cs b/src/Snippets/ScatterGather/Startup.cs index 0434c83f..380973f1 100644 --- a/src/Snippets/ScatterGather/Startup.cs +++ b/src/Snippets/ScatterGather/Startup.cs @@ -14,7 +14,7 @@ public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory) app.UseRouting(); app.UseEndpoints(builder => builder.MapScatterGather(template: "api/scatter-gather", new ScatterGatherOptions() { - Gatherers = new List + Gatherers = new List { new HttpGatherer(key: "ASamplesSource", destinationUrl: "https://a.web.server/api/samples/ASamplesSource"), new HttpGatherer(key: "AnotherSamplesSource", destinationUrl: "https://another.web.server/api/samples/AnotherSamplesSource") diff --git a/src/Snippets/ScatterGather/TransformResponse.cs b/src/Snippets/ScatterGather/TransformResponse.cs index 49b7f3de..d5abac67 100644 --- a/src/Snippets/ScatterGather/TransformResponse.cs +++ b/src/Snippets/ScatterGather/TransformResponse.cs @@ -3,6 +3,7 @@ using System.Net.Http; using System.Text.Json.Nodes; using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; using ServiceComposer.AspNetCore; namespace Snippets.ScatterGather;