diff --git a/docs/scatter-gather.md b/docs/scatter-gather.md new file mode 100644 index 00000000..516d4d3e --- /dev/null +++ b/docs/scatter-gather.md @@ -0,0 +1,104 @@ +# Scatter/Gather + +ServiceCompose natively supports scatter/gather scenarios. Scatter/gather is supported through a fanout approach. Given an incoming HTTP request, ServiceComposer will issue as many downstream HTTP requests to fetch data from downstream endpoints. Once all data has been retrieved, they are composed and returned to the original upstream caller. + +The following configuration configures a scatter/gather endpoint: + + + +```cs +public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory) +{ + app.UseRouting(); + app.UseEndpoints(builder => builder.MapScatterGather(template: "api/scatter-gather", new ScatterGatherOptions() + { + 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") + } + })); +} +``` +snippet source | anchor + + +The above configuration snippet configures ServiceComposer to handle HTTP requests matching the template. Each time a matching request is dealt with, ServiceComposer invokes each configured gatherer and merges responses from each one into a response returned to the original issuer. + +The `Key` and `Destination` properties are mandatory. The key uniquely identifies each gatherer in the context of a specific request. The destination is the downstream URL of the endpoint to invoke to retrieve data. + +## Customizing downstream URLs + +If the incoming request contains a query string, the query string and its values are automatically appended to downstream URLs as is. It is possible to override that behavior by setting the `DownstreamUrlMapper` delegate as presented in the following snippet: + + + +```cs +public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory) +{ + app.UseEndpoints(builder => builder.MapScatterGather(template: "api/scatter-gather", new ScatterGatherOptions() + { + Gatherers = new List + { + new HttpGatherer("ASamplesSource", "https://a.web.server/api/samples/ASamplesSource") + { + DestinationUrlMapper = (request, destination) => destination.Replace( + "{this-is-contextual}", + request.HttpContext.Request.Query["this-is-contextual"]) + } + } + })); +} +``` +snippet source | anchor + + +The same approach can be used to customize the downstream URL before invocation. + +## Data format + +ServiceComposer scatter/gather support works only with JSON data. Gatherers must return an `IEnumerable`. By default, gatherers assume that the downstream endpoint result can be converted into a `JsonArray`. + +### Transforming returned data + +If there is a need to transform downstream data to respect the expected format, it's possible to create a custom gatherer and override the `TransformResponse` method: + + + +```cs +public class CustomHttpGatherer : HttpGatherer +{ + public CustomHttpGatherer(string key, string destination) : base(key, destination) { } + + protected override Task> TransformResponse(HttpResponseMessage responseMessage) + { + // retrieve the response as a string from the HttpResponseMessage + // and parse it as a JsonNode enumerable. + return base.TransformResponse(responseMessage); + } +} +``` +snippet source | anchor + + +### Taking control of the downstream invocation process + +If transforming returned data is not enough, it's possible to take full control over the downstream service invocation process by overriding the `Gather` method: + + + +```cs +public class CustomHttpGatherer : HttpGatherer +{ + public CustomHttpGatherer(string key, string destination) : base(key, destination) { } + + 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 + 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 526ad6ec..6488bb5d 100644 --- a/src/ServiceComposer.AspNetCore.Tests/API/APIApprovals.Approve_API.verified.txt +++ b/src/ServiceComposer.AspNetCore.Tests/API/APIApprovals.Approve_API.verified.txt @@ -60,6 +60,22 @@ 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 + { + protected Gatherer(string key) { } + public string Key { get; } + public abstract System.Threading.Tasks.Task> Gather(Microsoft.AspNetCore.Http.HttpContext context); + } + public class HttpGatherer : ServiceComposer.AspNetCore.Gatherer + { + public HttpGatherer(string key, string destinationUrl) { } + public System.Func DefaultDestinationUrlMapper { get; } + public string DestinationUrl { get; } + public System.Func DestinationUrlMapper { get; init; } + public override System.Threading.Tasks.Task> Gather(Microsoft.AspNetCore.Http.HttpContext context) { } + protected virtual string MapDestinationUrl(Microsoft.AspNetCore.Http.HttpRequest request, string destination) { } + protected virtual System.Threading.Tasks.Task> TransformResponse(System.Net.Http.HttpResponseMessage responseMessage) { } + } public static class HttpRequestExtensions { [return: System.Runtime.CompilerServices.Dynamic] @@ -74,6 +90,11 @@ namespace ServiceComposer.AspNetCore public static System.Threading.Tasks.Task Bind(this Microsoft.AspNetCore.Http.HttpRequest request) where T : new() { } } + public interface IAggregator + { + void Add(System.Collections.Generic.IEnumerable nodes); + System.Threading.Tasks.Task Aggregate(); + } public interface ICompositionContext { string RequestId { get; } @@ -159,6 +180,16 @@ namespace ServiceComposer.AspNetCore public bool UseOutputFormatters { get; set; } public void UseCustomJsonSerializerSettings(System.Func jsonSerializerSettingsConfig) { } } + public static class ScatterGatherEndpointBuilderExtensions + { + public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapScatterGather(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder builder, string template, ServiceComposer.AspNetCore.ScatterGatherOptions options) { } + } + public class ScatterGatherOptions + { + public ScatterGatherOptions() { } + public System.Type CustomAggregator { get; set; } + public System.Collections.Generic.IList Gatherers { get; set; } + } public static class ServiceCollectionExtensions { public static void AddViewModelComposition(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration = null) { } diff --git a/src/ServiceComposer.AspNetCore.Tests/ScatterGather/Get_with_2_gatherers.cs b/src/ServiceComposer.AspNetCore.Tests/ScatterGather/Get_with_2_gatherers.cs new file mode 100644 index 00000000..5a09bb9f --- /dev/null +++ b/src/ServiceComposer.AspNetCore.Tests/ScatterGather/Get_with_2_gatherers.cs @@ -0,0 +1,121 @@ +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.Extensions.DependencyInjection.Extensions; +using ServiceComposer.AspNetCore.Tests.Utils; + +namespace ServiceComposer.AspNetCore.Tests.ScatterGather; + +public class Get_with_2_gatherers +{ + [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 anotherSampleSourceClient = new SelfContainedWebApplicationFactoryWithWebHost + ( + configureServices: services => + { + services.AddRouting(); + }, + configure: app => + { + app.UseRouting(); + app.UseEndpoints(builder => + { + builder.MapGet("/samples/AnotherSamplesSource", () => + { + return new []{ new { Value = "AnotherSample" } }; + }); + }); + } + ).CreateClient(); + + var client = new SelfContainedWebApplicationFactoryWithWebHost + ( + configureServices: services => + { + HttpClient ClientProvider(string name) => + name switch + { + "ASamplesSource" => aSampleSourceClient, + "AnotherSamplesSource" => anotherSampleSourceClient, + _ => throw new NotSupportedException($"Missing HTTP client for {name}") + }; + + // TODO: does this need to register a default HTTP client? + // services.AddScatterGather(); + services.AddRouting(); + 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 HttpGatherer(key: "AnotherSamplesSource", destinationUrl: "/samples/AnotherSamplesSource") + } + }); + }); + } + ).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 = "AnotherSample"} + }, 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 new file mode 100644 index 00000000..080ade00 --- /dev/null +++ b/src/ServiceComposer.AspNetCore.Tests/ScatterGather/When_using_query_string.cs @@ -0,0 +1,122 @@ +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.Extensions.DependencyInjection.Extensions; +using ServiceComposer.AspNetCore.Tests.Utils; + +namespace ServiceComposer.AspNetCore.Tests.ScatterGather; + +public class When_using_query_string +{ + [Fact] + public async Task Values_are_propagated_to_downstream_destinations() + { + // Arrange + var aSampleSourceClient = new SelfContainedWebApplicationFactoryWithWebHost + ( + configureServices: services => + { + services.AddRouting(); + }, + configure: app => + { + app.UseRouting(); + app.UseEndpoints(builder => + { + builder.MapGet("/samples/ASamplesSource", (string culture) => + { + return new []{ new { Value = "ASample", Culture = culture } }; + }); + }); + } + ).CreateClient(); + + var anotherSampleSourceClient = new SelfContainedWebApplicationFactoryWithWebHost + ( + configureServices: services => + { + services.AddRouting(); + }, + configure: app => + { + app.UseRouting(); + app.UseEndpoints(builder => + { + builder.MapGet("/samples/AnotherSamplesSource", (string culture) => + { + return new []{ new { Value = "AnotherSample", Culture = culture } }; + }); + }); + } + ).CreateClient(); + + var client = new SelfContainedWebApplicationFactoryWithWebHost + ( + configureServices: services => + { + HttpClient ClientProvider(string name) => + name switch + { + "ASamplesSource" => aSampleSourceClient, + "AnotherSamplesSource" => anotherSampleSourceClient, + _ => throw new NotSupportedException($"Missing HTTP client for {name}") + }; + + // TODO: does this need to register a default HTTP client? + // services.AddScatterGather(); + services.AddRouting(); + 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 HttpGatherer(key: "AnotherSamplesSource", destinationUrl: "/samples/AnotherSamplesSource") + } + }); + }); + } + ).CreateClient(); + + // Act + var culture = "it-IT"; + var response = await client.GetAsync($"/samples?culture={culture}"); + + // 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", Culture = culture}, + new {Value = "AnotherSample", Culture = culture} + }, 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/Utils/DelegateHttpClientFactory.cs b/src/ServiceComposer.AspNetCore.Tests/Utils/DelegateHttpClientFactory.cs new file mode 100644 index 00000000..ad6ea942 --- /dev/null +++ b/src/ServiceComposer.AspNetCore.Tests/Utils/DelegateHttpClientFactory.cs @@ -0,0 +1,19 @@ +using System; +using System.Net.Http; + +namespace ServiceComposer.AspNetCore.Tests.Utils; + +public class DelegateHttpClientFactory : IHttpClientFactory +{ + private readonly Func _httpClientProvider; + + public DelegateHttpClientFactory(Func httpClientProvider) + { + _httpClientProvider = httpClientProvider; + } + + public HttpClient CreateClient(string name) + { + return _httpClientProvider(name); + } +} \ No newline at end of file diff --git a/src/ServiceComposer.AspNetCore/ScatterGather/DefaultAggregator.cs b/src/ServiceComposer.AspNetCore/ScatterGather/DefaultAggregator.cs new file mode 100644 index 00000000..e8e8e368 --- /dev/null +++ b/src/ServiceComposer.AspNetCore/ScatterGather/DefaultAggregator.cs @@ -0,0 +1,25 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Text.Json.Nodes; +using System.Threading.Tasks; + +namespace ServiceComposer.AspNetCore; + +class DefaultAggregator : IAggregator +{ + readonly ConcurrentBag allNodes = new(); + + public void Add(IEnumerable nodes) + { + foreach (var node in nodes) + { + allNodes.Add(node); + } + } + + public Task Aggregate() + { + var responsesArray = new JsonArray(allNodes.ToArray()); + return Task.FromResult(responsesArray); + } +} \ No newline at end of file diff --git a/src/ServiceComposer.AspNetCore/ScatterGather/Gatherer.cs b/src/ServiceComposer.AspNetCore/ScatterGather/Gatherer.cs new file mode 100644 index 00000000..40690125 --- /dev/null +++ b/src/ServiceComposer.AspNetCore/ScatterGather/Gatherer.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace ServiceComposer.AspNetCore; + +public abstract class Gatherer +{ + protected Gatherer(string key) + { + Key = key; + } + + public string Key { get; } + + // TODO: how to use generics to remove the dependency on JSON? + 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 new file mode 100644 index 00000000..ed660c13 --- /dev/null +++ b/src/ServiceComposer.AspNetCore/ScatterGather/HttpGatherer.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace ServiceComposer.AspNetCore; + +public class HttpGatherer : Gatherer +{ + public HttpGatherer(string key, string destinationUrl) + : base(key) + { + DestinationUrl = destinationUrl; + + DefaultDestinationUrlMapper = MapDestinationUrl; + DestinationUrlMapper = (request, destination) => DefaultDestinationUrlMapper(request, destination); + } + + public string DestinationUrl { get; } + + public Func DefaultDestinationUrlMapper { get; } + + public Func DestinationUrlMapper { get; init; } + + protected virtual string MapDestinationUrl(HttpRequest request, string destination) + { + return request.Query.Count == 0 + ? destination + : $"{destination}{request.QueryString}"; + } + + protected virtual async Task> TransformResponse(HttpResponseMessage responseMessage) + { + var nodes = new List(); + var gathererResponsesAsString = await responseMessage.Content.ReadAsStringAsync(); + // default behavior assumes downstream service returns a JSON array + var gathererResponses = JsonNode.Parse(gathererResponsesAsString)?.AsArray(); + if (gathererResponses is { Count: > 0 }) + { + // this has the side effect of reversing the order + // of the responses. This is why we reverse below. + for (var i = gathererResponses.Count - 1; i >= 0; i--) + { + var nodeAtIndex = gathererResponses[i]; + gathererResponses.Remove(nodeAtIndex); + nodes.Add(nodeAtIndex); + } + + nodes.Reverse(); + } + + return nodes; + } + + public override async Task> Gather(HttpContext context) + { + var factory = context.RequestServices.GetRequiredService(); + var client = factory.CreateClient(Key); + var destination = DestinationUrlMapper(context.Request, DestinationUrl); + var response = await client.GetAsync(destination); + return await TransformResponse(response); + } +} \ No newline at end of file diff --git a/src/ServiceComposer.AspNetCore/ScatterGather/IAggregator.cs b/src/ServiceComposer.AspNetCore/ScatterGather/IAggregator.cs new file mode 100644 index 00000000..706b21bd --- /dev/null +++ b/src/ServiceComposer.AspNetCore/ScatterGather/IAggregator.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Text.Json.Nodes; +using System.Threading.Tasks; + +namespace ServiceComposer.AspNetCore; + +public interface IAggregator +{ + void Add(IEnumerable nodes); + Task Aggregate(); +} \ No newline at end of file diff --git a/src/ServiceComposer.AspNetCore/ScatterGather/ScatterGatherEndpointBuilderExtensions.cs b/src/ServiceComposer.AspNetCore/ScatterGather/ScatterGatherEndpointBuilderExtensions.cs new file mode 100644 index 00000000..c1a38eb0 --- /dev/null +++ b/src/ServiceComposer.AspNetCore/ScatterGather/ScatterGatherEndpointBuilderExtensions.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace ServiceComposer.AspNetCore; + +public static class ScatterGatherEndpointBuilderExtensions +{ + public static IEndpointConventionBuilder MapScatterGather(this IEndpointRouteBuilder builder, string template, ScatterGatherOptions options) + { + return builder.MapGet(template, async context => + { + var aggregator = options.GetAggregator(context); + + var tasks = new List(); + foreach (var gatherer in options.Gatherers) + { + var task = gatherer.Gather(context) + .ContinueWith(t => + { + // TODO: how to handle errors? + // t.IsFaulted? + + aggregator.Add(t.Result); + }); + tasks.Add(task); + } + + 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()); + }); + } +} \ No newline at end of file diff --git a/src/ServiceComposer.AspNetCore/ScatterGather/ScatterGatherOptions.cs b/src/ServiceComposer.AspNetCore/ScatterGather/ScatterGatherOptions.cs new file mode 100644 index 00000000..74d5d4ef --- /dev/null +++ b/src/ServiceComposer.AspNetCore/ScatterGather/ScatterGatherOptions.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace ServiceComposer.AspNetCore; + +public class ScatterGatherOptions +{ + public Type CustomAggregator { get; set; } + internal IAggregator GetAggregator(HttpContext httpContext) + { + if(CustomAggregator != null) + { + return (IAggregator)httpContext.RequestServices.GetRequiredService(CustomAggregator); + } + return new DefaultAggregator(); + } + + public IList Gatherers { get; set; } +} \ No newline at end of file diff --git a/src/ServiceComposer.AspNetCore/ServiceComposer.AspNetCore.csproj.DotSettings b/src/ServiceComposer.AspNetCore/ServiceComposer.AspNetCore.csproj.DotSettings new file mode 100644 index 00000000..2c9e4ead --- /dev/null +++ b/src/ServiceComposer.AspNetCore/ServiceComposer.AspNetCore.csproj.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/src/Snippets/ActionResult/UseSetActionResultHandler.cs b/src/Snippets/ActionResult/UseSetActionResultHandler.cs index 1e2a6b30..f98b1bb4 100644 --- a/src/Snippets/ActionResult/UseSetActionResultHandler.cs +++ b/src/Snippets/ActionResult/UseSetActionResultHandler.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using ServiceComposer.AspNetCore; -namespace Snippets.NetCore3x.ActionResult +namespace Snippets.ActionResult { // begin-snippet: action-results public class UseSetActionResultHandler : ICompositionRequestsHandler diff --git a/src/Snippets/BasicUsage/MarketingProductInfo.cs b/src/Snippets/BasicUsage/MarketingProductInfo.cs index 274e7d04..bf856b0e 100644 --- a/src/Snippets/BasicUsage/MarketingProductInfo.cs +++ b/src/Snippets/BasicUsage/MarketingProductInfo.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc; using ServiceComposer.AspNetCore; -namespace Snippets.NetCore3x.BasicUsage +namespace Snippets.BasicUsage { // begin-snippet: basic-usage-marketing-handler public class MarketingProductInfo: ICompositionRequestsHandler diff --git a/src/Snippets/BasicUsage/SalesProductInfo.cs b/src/Snippets/BasicUsage/SalesProductInfo.cs index 3823ba2c..cd2ed270 100644 --- a/src/Snippets/BasicUsage/SalesProductInfo.cs +++ b/src/Snippets/BasicUsage/SalesProductInfo.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Routing; using ServiceComposer.AspNetCore; -namespace Snippets.NetCore3x.BasicUsage +namespace Snippets.BasicUsage { // begin-snippet: basic-usage-sales-handler public class SalesProductInfo : ICompositionRequestsHandler diff --git a/src/Snippets/BasicUsage/Startup.cs b/src/Snippets/BasicUsage/Startup.cs index c8a88014..448ba367 100644 --- a/src/Snippets/BasicUsage/Startup.cs +++ b/src/Snippets/BasicUsage/Startup.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.Logging; using ServiceComposer.AspNetCore; -namespace Snippets.NetCore3x.BasicUsage +namespace Snippets.BasicUsage { // begin-snippet: sample-startup public class Startup diff --git a/src/Snippets/CompositionOverController.cs b/src/Snippets/CompositionOverController.cs index 7605df52..417e80b3 100644 --- a/src/Snippets/CompositionOverController.cs +++ b/src/Snippets/CompositionOverController.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using ServiceComposer.AspNetCore; -namespace Snippets.NetCore3x +namespace Snippets { public class CompositionOverControllers { diff --git a/src/Snippets/DefaultCasing/Startup.cs b/src/Snippets/DefaultCasing/Startup.cs index e0888560..c3808671 100644 --- a/src/Snippets/DefaultCasing/Startup.cs +++ b/src/Snippets/DefaultCasing/Startup.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using ServiceComposer.AspNetCore; -namespace Snippets.NetCore3x.DefaultCasing +namespace Snippets.DefaultCasing { public class Startup { diff --git a/src/Snippets/ModelBinding/ConfigureAppForModelBinding.cs b/src/Snippets/ModelBinding/ConfigureAppForModelBinding.cs index f8b3d64d..c2b1ae91 100644 --- a/src/Snippets/ModelBinding/ConfigureAppForModelBinding.cs +++ b/src/Snippets/ModelBinding/ConfigureAppForModelBinding.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using ServiceComposer.AspNetCore; -namespace Snippets.NetCore3x.ModelBinding +namespace Snippets.ModelBinding { public class ConfigureAppForModelBinding { diff --git a/src/Snippets/ModelBinding/ModelBindingUsageHandler.cs b/src/Snippets/ModelBinding/ModelBindingUsageHandler.cs index 4d1a840d..b07f42da 100644 --- a/src/Snippets/ModelBinding/ModelBindingUsageHandler.cs +++ b/src/Snippets/ModelBinding/ModelBindingUsageHandler.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc; using ServiceComposer.AspNetCore; -namespace Snippets.NetCore3x.ModelBinding +namespace Snippets.ModelBinding { // begin-snippet: model-binding-model class BodyModel diff --git a/src/Snippets/ModelBinding/RawBodyUsageHandler.cs b/src/Snippets/ModelBinding/RawBodyUsageHandler.cs index 10c5a934..0a71561d 100644 --- a/src/Snippets/ModelBinding/RawBodyUsageHandler.cs +++ b/src/Snippets/ModelBinding/RawBodyUsageHandler.cs @@ -7,7 +7,7 @@ using Newtonsoft.Json.Linq; using ServiceComposer.AspNetCore; -namespace Snippets.NetCore3x.ModelBinding +namespace Snippets.ModelBinding { class RawBodyUsageHandler : ICompositionRequestsHandler { diff --git a/src/Snippets/SampleHandler/SampleHandler.cs b/src/Snippets/SampleHandler/SampleHandler.cs index ef6dd2d7..6065043d 100644 --- a/src/Snippets/SampleHandler/SampleHandler.cs +++ b/src/Snippets/SampleHandler/SampleHandler.cs @@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Mvc; using ServiceComposer.AspNetCore; -namespace Snippets.NetCore3x.SampleHandler +namespace Snippets.SampleHandler { // begin-snippet: sample-handler-with-authorization public class SampleHandlerWithAuthorization : ICompositionRequestsHandler diff --git a/src/Snippets/ScatterGather/CustomizingDownstreamURLs.cs b/src/Snippets/ScatterGather/CustomizingDownstreamURLs.cs new file mode 100644 index 00000000..e6303e6a --- /dev/null +++ b/src/Snippets/ScatterGather/CustomizingDownstreamURLs.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Logging; +using ServiceComposer.AspNetCore; + +namespace Snippets.ScatterGather; + +public class CustomizingDownstreamURLs +{ + // begin-snippet: scatter-gather-customizing-downstream-urls + public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory) + { + app.UseEndpoints(builder => builder.MapScatterGather(template: "api/scatter-gather", new ScatterGatherOptions() + { + Gatherers = new List + { + new HttpGatherer("ASamplesSource", "https://a.web.server/api/samples/ASamplesSource") + { + DestinationUrlMapper = (request, destination) => destination.Replace( + "{this-is-contextual}", + request.HttpContext.Request.Query["this-is-contextual"]) + } + } + })); + } + // end-snippet +} \ No newline at end of file diff --git a/src/Snippets/ScatterGather/GatherMethodOverride.cs b/src/Snippets/ScatterGather/GatherMethodOverride.cs new file mode 100644 index 00000000..251c33f0 --- /dev/null +++ b/src/Snippets/ScatterGather/GatherMethodOverride.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using ServiceComposer.AspNetCore; + +namespace Snippets.ScatterGather; + +public class GatherMethodOverride +{ + // begin-snippet: scatter-gather-gather-override + public class CustomHttpGatherer : HttpGatherer + { + public CustomHttpGatherer(string key, string destination) : base(key, destination) { } + + 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); + } + } + // end-snippet +} \ No newline at end of file diff --git a/src/Snippets/ScatterGather/Startup.cs b/src/Snippets/ScatterGather/Startup.cs new file mode 100644 index 00000000..0434c83f --- /dev/null +++ b/src/Snippets/ScatterGather/Startup.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Logging; +using ServiceComposer.AspNetCore; + +namespace Snippets.ScatterGather; + +public class Startup +{ + // begin-snippet: scatter-gather-basic-usage + public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory) + { + app.UseRouting(); + app.UseEndpoints(builder => builder.MapScatterGather(template: "api/scatter-gather", new ScatterGatherOptions() + { + 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") + } + })); + } + // end-snippet +} \ No newline at end of file diff --git a/src/Snippets/ScatterGather/TransformResponse.cs b/src/Snippets/ScatterGather/TransformResponse.cs new file mode 100644 index 00000000..49b7f3de --- /dev/null +++ b/src/Snippets/ScatterGather/TransformResponse.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using ServiceComposer.AspNetCore; + +namespace Snippets.ScatterGather; + +public class TransformResponseMethodOverride +{ + // begin-snippet: scatter-gather-transform-response + public class CustomHttpGatherer : HttpGatherer + { + public CustomHttpGatherer(string key, string destination) : base(key, destination) { } + + protected override Task> TransformResponse(HttpResponseMessage responseMessage) + { + // retrieve the response as a string from the HttpResponseMessage + // and parse it as a JsonNode enumerable. + return base.TransformResponse(responseMessage); + } + } + // end-snippet +} \ No newline at end of file diff --git a/src/Snippets/Serialization/ResponseSettingsBasedOnCasing.cs b/src/Snippets/Serialization/ResponseSettingsBasedOnCasing.cs index a0e1104f..edf0e5b5 100644 --- a/src/Snippets/Serialization/ResponseSettingsBasedOnCasing.cs +++ b/src/Snippets/Serialization/ResponseSettingsBasedOnCasing.cs @@ -1,7 +1,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Serialization; -namespace Snippets.NetCore3x.Serialization +namespace Snippets.Serialization { public class ResponseSettingsBasedOnCasing { diff --git a/src/Snippets/Serialization/Startup.cs b/src/Snippets/Serialization/Startup.cs index 0fe94e83..44bde9ae 100644 --- a/src/Snippets/Serialization/Startup.cs +++ b/src/Snippets/Serialization/Startup.cs @@ -2,7 +2,7 @@ using Newtonsoft.Json; using ServiceComposer.AspNetCore; -namespace Snippets.NetCore3x.Serialization +namespace Snippets.Serialization { public class Startup { diff --git a/src/Snippets/Serialization/UseOutputFormatters.cs b/src/Snippets/Serialization/UseOutputFormatters.cs index a8749975..649c397e 100644 --- a/src/Snippets/Serialization/UseOutputFormatters.cs +++ b/src/Snippets/Serialization/UseOutputFormatters.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using ServiceComposer.AspNetCore; -namespace Snippets.NetCore3x.Serialization +namespace Snippets.Serialization { public class UseOutputFormatters { diff --git a/src/Snippets/UpgradeGuides/1.x-to-2.0/UpgradeGuide.cs b/src/Snippets/UpgradeGuides/1.x-to-2.0/UpgradeGuide.cs index d990c435..444e9de2 100644 --- a/src/Snippets/UpgradeGuides/1.x-to-2.0/UpgradeGuide.cs +++ b/src/Snippets/UpgradeGuides/1.x-to-2.0/UpgradeGuide.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging; using ServiceComposer.AspNetCore; -namespace Snippets.NetCore3x.UpgradeGuides._1.x_to_2._0; +namespace Snippets.UpgradeGuides._1.x_to_2._0; public class UpgradeGuide { diff --git a/src/Snippets/ViewModelFactory/MarketingProductInfo.cs b/src/Snippets/ViewModelFactory/MarketingProductInfo.cs index 44eff876..e7076480 100644 --- a/src/Snippets/ViewModelFactory/MarketingProductInfo.cs +++ b/src/Snippets/ViewModelFactory/MarketingProductInfo.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc; using ServiceComposer.AspNetCore; -namespace Snipptes.ViewModelFactory +namespace Snippets.ViewModelFactory { // begin-snippet: view-model-factory-marketing-handler public class MarketingProductInfo: ICompositionRequestsHandler diff --git a/src/Snippets/ViewModelFactory/ProductViewModel.cs b/src/Snippets/ViewModelFactory/ProductViewModel.cs index e1e44bca..1aa3a24f 100644 --- a/src/Snippets/ViewModelFactory/ProductViewModel.cs +++ b/src/Snippets/ViewModelFactory/ProductViewModel.cs @@ -1,4 +1,4 @@ -namespace Snipptes.ViewModelFactory +namespace Snippets.ViewModelFactory { // begin-snippet: view-model-factory-product-view-model public class ProductViewModel diff --git a/src/Snippets/ViewModelFactory/ProductViewModelFactory.cs b/src/Snippets/ViewModelFactory/ProductViewModelFactory.cs index cd9d6a62..98f8fd07 100644 --- a/src/Snippets/ViewModelFactory/ProductViewModelFactory.cs +++ b/src/Snippets/ViewModelFactory/ProductViewModelFactory.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Routing; using ServiceComposer.AspNetCore; -namespace Snipptes.ViewModelFactory +namespace Snippets.ViewModelFactory { // begin-snippet: view-model-factory-product-view-model-factory class ProductViewModelFactory : IEndpointScopedViewModelFactory diff --git a/src/Snippets/ViewModelFactory/SalesProductInfo.cs b/src/Snippets/ViewModelFactory/SalesProductInfo.cs index 00a1a3ce..d342e4ca 100644 --- a/src/Snippets/ViewModelFactory/SalesProductInfo.cs +++ b/src/Snippets/ViewModelFactory/SalesProductInfo.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc; using ServiceComposer.AspNetCore; -namespace Snipptes.ViewModelFactory +namespace Snippets.ViewModelFactory { // begin-snippet: view-model-factory-sales-handler public class SalesProductInfo : ICompositionRequestsHandler diff --git a/src/Snippets/WriteSupport/EnableWriteSupport.cs b/src/Snippets/WriteSupport/EnableWriteSupport.cs index bd49db5c..c752ee96 100644 --- a/src/Snippets/WriteSupport/EnableWriteSupport.cs +++ b/src/Snippets/WriteSupport/EnableWriteSupport.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using ServiceComposer.AspNetCore; -namespace Snippets.NetCore3x.WriteSupport; +namespace Snippets.WriteSupport; public class EnableWriteSupport {