diff --git a/.netconfig b/.netconfig index 8934c7b..4806f92 100644 --- a/.netconfig +++ b/.netconfig @@ -168,3 +168,8 @@ sha = cf76df0d6a218c26ebe117339fe3445050b0532a etag = aed711a45e051edfddfcb76d9f8021d30f9817c342cfe8d1cc38f2af37b47aa8 weak +[file "src/WhatsApp/Extensions/FunctionContextAccessor.cs"] + url = https://github.com/devlooped/catbag/blob/main/Microsoft/Azure/Functions/Worker/FunctionContextAccessor.cs + sha = 91ea14062eb6ac6b9dc85d41b90fdbb56c46efbf + etag = 3d9f39632c0a896dea7aa0cebcef77a7b668fdedfd8d2c8bf79826fa3a85121f + weak diff --git a/readme.md b/readme.md index 8606b33..4a3d869 100644 --- a/readme.md +++ b/readme.md @@ -30,6 +30,9 @@ To pay the Maintenance Fee, [become a Sponsor](https://github.com/sponsors/devlo var builder = FunctionsApplication.CreateBuilder(args); builder.ConfigureFunctionsWebApplication(); +builder.UseWhatsApp(); // 👈 setup middleware + +// add your messages handler here 👇 builder.Services.AddWhatsApp(); builder.Build().Run(); diff --git a/src/SampleApp/Sample/Program.cs b/src/SampleApp/Sample/Program.cs index 8f42bbd..16cd0ff 100644 --- a/src/SampleApp/Sample/Program.cs +++ b/src/SampleApp/Sample/Program.cs @@ -48,6 +48,8 @@ storage : CloudStorageAccount.Parse(builder.Configuration["AzureWebJobsStorage"])); +builder.UseWhatsApp(); + var whatsapp = builder.Services .AddWhatsApp(configure: options => { diff --git a/src/Tests/IntegrationTests.cs b/src/Tests/IntegrationTests.cs index cd1cedf..536e7e1 100644 --- a/src/Tests/IntegrationTests.cs +++ b/src/Tests/IntegrationTests.cs @@ -1,5 +1,7 @@ -using Microsoft.Extensions.Configuration; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Moq; namespace Devlooped.WhatsApp; @@ -35,6 +37,7 @@ public async Task RunConversationAsync() .Build(); var services = new ServiceCollection() + .AddSingleton(Mock.Of()) .AddSingleton(configuration) .AddSingleton(new TestConversationStorage(CloudStorageAccount.DevelopmentStorageAccount)); diff --git a/src/Tests/PipelineTests.cs b/src/Tests/PipelineTests.cs index 64684ba..c12c069 100644 --- a/src/Tests/PipelineTests.cs +++ b/src/Tests/PipelineTests.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Moq; @@ -122,6 +123,7 @@ public async Task ConversationCalledAfterCustom() .Callback(() => order.Add("storage:save")); var services = new ServiceCollection() + .AddSingleton(Mock.Of()) .AddSingleton(configuration) .AddSingleton(conversation.Object); @@ -183,6 +185,7 @@ public async Task ConversationRestored() }); var services = new ServiceCollection() + .AddSingleton(Mock.Of()) .AddSingleton(configuration) .AddSingleton(storage); @@ -237,6 +240,7 @@ public async Task CanSendMessagesThroughPipeline() }); var services = new ServiceCollection() + .AddSingleton(Mock.Of()) .AddSingleton(configuration); var sent = 0; diff --git a/src/WhatsApp/AzureFunctionsConsole.cs b/src/WhatsApp/AzureFunctionsConsole.cs index 73f76b2..2748f28 100644 --- a/src/WhatsApp/AzureFunctionsConsole.cs +++ b/src/WhatsApp/AzureFunctionsConsole.cs @@ -14,7 +14,7 @@ namespace Devlooped.WhatsApp; /// class AzureFunctionsConsole( IWhatsAppClient client, - IWhatsAppHandler handler, + Func handler, ILogger logger, IHostEnvironment environment) { @@ -70,7 +70,7 @@ public async Task MessageConsole([HttpTrigger(AuthorizationLevel. // Await all responses // No action needed, just make sure all items are processed - _ = Task.Run(() => handler.HandleAsync([message]).ToArrayAsync().AsTask()).Ignore(); + _ = Task.Run(() => handler().HandleAsync([message]).ToArrayAsync().AsTask()).Ignore(); } else { diff --git a/src/WhatsApp/AzureFunctionsProcessors.cs b/src/WhatsApp/AzureFunctionsProcessors.cs index 9876b60..3c1bc36 100644 --- a/src/WhatsApp/AzureFunctionsProcessors.cs +++ b/src/WhatsApp/AzureFunctionsProcessors.cs @@ -8,13 +8,13 @@ namespace Devlooped.WhatsApp; -class AzureFunctionsProcessors(PipelineRunner runner, IOptions options) +class AzureFunctionsProcessors(Func runner, IOptions options) { readonly WhatsAppOptions options = options.Value; [Function("whatsapp_dequeue")] public Task DequeueAsync([QueueTrigger("whatsappwebhook", Connection = "AzureWebJobsStorage")] string json) - => runner.ProcessAsync(json); + => runner().ProcessAsync(json); [Function("whatsapp_eventgrid")] public async Task HandleEventGrid( @@ -25,7 +25,7 @@ public async Task HandleEventGrid( [Microsoft.Azure.Functions.Worker.Http.FromBody] EventGridEvent e) #endif { - await runner.ProcessAsync(Regex.Unescape(e.Data.ToString()).Trim('"')); + await runner().ProcessAsync(Regex.Unescape(e.Data.ToString()).Trim('"')); return new OkResult(); } @@ -41,7 +41,7 @@ public async Task ProcessAsync( using var reader = new StreamReader(req.Body, Encoding.UTF8); var json = await reader.ReadToEndAsync(); - await runner.ProcessAsync(json); + await runner().ProcessAsync(json); return new OkResult(); } } diff --git a/src/WhatsApp/AzureFunctionsWebhook.cs b/src/WhatsApp/AzureFunctionsWebhook.cs index 3943427..a3b44cd 100644 --- a/src/WhatsApp/AzureFunctionsWebhook.cs +++ b/src/WhatsApp/AzureFunctionsWebhook.cs @@ -24,7 +24,7 @@ namespace Devlooped.WhatsApp; class AzureFunctionsWebhook( IMessageProcessor messageProcessor, IWhatsAppClient whatsapp, - IWhatsAppHandler handler, + Func handler, IOptions metaOptions, IOptions functionOptions, IHostEnvironment hosting, @@ -116,7 +116,7 @@ async Task ProcessFlowDataAsync(string json, EncryptedFlowData en FlowDataResponse? flowResponse = default; - await foreach (var response in handler.HandleAsync([flow])) + await foreach (var response in handler().HandleAsync([flow])) { if (response is FlowDataResponse fdr) { diff --git a/src/WhatsApp/Extensions/FunctionContextAccessor.cs b/src/WhatsApp/Extensions/FunctionContextAccessor.cs new file mode 100644 index 0000000..9fbab0d --- /dev/null +++ b/src/WhatsApp/Extensions/FunctionContextAccessor.cs @@ -0,0 +1,111 @@ +// +#region License +// MIT License +// +// Copyright (c) Daniel Cazzulino +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +#endregion + +#nullable enable + +using System.Diagnostics; +using System.Threading.Tasks; +using System.Threading; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Middleware; +using Microsoft.Extensions.DependencyInjection; + +// Follows implementation of HttpContextAccessor at https://github.com/dotnet/aspnetcore/blob/main/src/Http/Http/src/HttpContextAccessor.cs + +namespace Microsoft.Azure.Functions.Worker +{ + /// + /// Provides access to the current , if one is available. + /// + public interface IFunctionContextAccessor + { + /// + /// Gets or sets the current . + /// Returns if there is no active . + /// + FunctionContext? FunctionContext { get; set; } + } +} + +namespace Microsoft.Extensions.Hosting +{ + /// + /// Extension method to allow access to the current + /// from dependency injection. + /// + public static class FunctionContextAccessorExtensions + { + /// + /// Adds a default implementation for the service. + /// + public static IFunctionsWorkerApplicationBuilder UseFunctionContextAccessor(this IFunctionsWorkerApplicationBuilder builder) + { + builder.UseMiddleware(); + builder.Services.AddSingleton(); + return builder; + } + } + + [DebuggerDisplay("FunctionContext = {FunctionContext}")] + class FunctionContextAccessor : IFunctionContextAccessor + { + static readonly AsyncLocal current = new AsyncLocal(); + + public virtual FunctionContext? FunctionContext + { + get => current.Value?.Context; + set + { + var holder = current.Value; + if (holder != null) + { + // Clear current context trapped in the AsyncLocals, as its done. + holder.Context = default; + } + + if (value != null) + { + // Use an object indirection to hold the context in the AsyncLocal, + // so it can be cleared in all ExecutionContexts when its cleared. + current.Value = new FunctionContextHolder { Context = value }; + } + } + } + + class FunctionContextHolder + { + public FunctionContext? Context; + } + } + + class FunctionContextAccessorMiddleware(IFunctionContextAccessor accessor) : IFunctionsWorkerMiddleware + { + public Task Invoke(FunctionContext context, FunctionExecutionDelegate next) + { + accessor.FunctionContext = context; + return next(context); + } + } +} \ No newline at end of file diff --git a/src/WhatsApp/PipelineRunner.cs b/src/WhatsApp/PipelineRunner.cs index 4fdb1d2..6bc5bdc 100644 --- a/src/WhatsApp/PipelineRunner.cs +++ b/src/WhatsApp/PipelineRunner.cs @@ -7,7 +7,7 @@ namespace Devlooped.WhatsApp; class PipelineRunner( Idempotency idempotency, IWhatsAppClient whatsapp, - IWhatsAppHandler handler, + Func handler, IOptions functionOptions, ILogger logger) { @@ -45,7 +45,7 @@ public async Task ProcessAsync(string json) { // Await all responses // No action needed, just make sure all items are processed - await handler.HandleAsync([message]).ToArrayAsync(); + await handler().HandleAsync([message]).ToArrayAsync(); logger.LogInformation($"Completed work item: {message.Id}"); } catch (Exception e) diff --git a/src/WhatsApp/TaskSchedulerProcessor.cs b/src/WhatsApp/TaskSchedulerProcessor.cs index 136753b..475dc1d 100644 --- a/src/WhatsApp/TaskSchedulerProcessor.cs +++ b/src/WhatsApp/TaskSchedulerProcessor.cs @@ -27,7 +27,7 @@ public static WhatsAppHandlerBuilder UseTaskSchedulerProcessor(this WhatsAppHand return builder; } - class TaskSchedulerMessageProcessor(PipelineRunner runner, TaskScheduler scheduler) : IMessageProcessor + class TaskSchedulerMessageProcessor(Func runner, TaskScheduler scheduler) : IMessageProcessor { public Task EnqueueAsync(string json, CancellationToken cancellation = default) { @@ -40,6 +40,6 @@ public Task EnqueueAsync(string json, CancellationToken cancellation = default) return Task.CompletedTask; } - async Task ProcessAsync(string json) => await runner.ProcessAsync(json); + async Task ProcessAsync(string json) => await runner().ProcessAsync(json); } } \ No newline at end of file diff --git a/src/WhatsApp/WhatsAppApplicationBuilderExtensions.cs b/src/WhatsApp/WhatsAppApplicationBuilderExtensions.cs new file mode 100644 index 0000000..d1c4033 --- /dev/null +++ b/src/WhatsApp/WhatsAppApplicationBuilderExtensions.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Hosting; + +namespace Devlooped.WhatsApp; + +[EditorBrowsable(EditorBrowsableState.Never)] +public static class WhatsAppApplicationBuilderExtensions +{ + /// + /// Adds required WhatsApp middleware to the functions worker application builder. + /// + public static IFunctionsWorkerApplicationBuilder UseWhatsApp(this IFunctionsWorkerApplicationBuilder builder) + => builder.UseFunctionContextAccessor(); +} diff --git a/src/WhatsApp/WhatsAppClientExtensions.cs b/src/WhatsApp/WhatsAppClientExtensions.cs index 39cb2b5..cf90412 100644 --- a/src/WhatsApp/WhatsAppClientExtensions.cs +++ b/src/WhatsApp/WhatsAppClientExtensions.cs @@ -1,8 +1,11 @@ -namespace Devlooped.WhatsApp; +using System.ComponentModel; + +namespace Devlooped.WhatsApp; /// /// Usability extensions for common messaging scenarios for WhatsApp. /// +[EditorBrowsable(EditorBrowsableState.Never)] public static partial class WhatsAppClientExtensions { /// diff --git a/src/WhatsApp/WhatsAppHandlerExtensions.cs b/src/WhatsApp/WhatsAppHandlerExtensions.cs index 8d1fba3..eec7233 100644 --- a/src/WhatsApp/WhatsAppHandlerExtensions.cs +++ b/src/WhatsApp/WhatsAppHandlerExtensions.cs @@ -1,4 +1,5 @@ -using System.Runtime.CompilerServices; +using System.ComponentModel; +using System.Runtime.CompilerServices; namespace Devlooped.WhatsApp; @@ -6,6 +7,7 @@ namespace Devlooped.WhatsApp; /// Provides the extension method to build a pipeline /// around a given handler. /// +[EditorBrowsable(EditorBrowsableState.Never)] public static class WhatsAppHandlerExtensions { /// diff --git a/src/WhatsApp/WhatsAppServiceCollectionExtensions.cs b/src/WhatsApp/WhatsAppServiceCollectionExtensions.cs index 73a80a8..27c8caf 100644 --- a/src/WhatsApp/WhatsAppServiceCollectionExtensions.cs +++ b/src/WhatsApp/WhatsAppServiceCollectionExtensions.cs @@ -1,4 +1,6 @@ -using Azure.Data.Tables; +using System.ComponentModel; +using Azure.Data.Tables; +using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -9,6 +11,7 @@ namespace Devlooped.WhatsApp; /// Provides extension methods for registering and /// with a . /// +[EditorBrowsable(EditorBrowsableState.Never)] public static class WhatsAppServiceCollectionExtensions { /// Registers a singleton and in the . @@ -232,6 +235,9 @@ public static WhatsAppHandlerBuilder AddWhatsApp? configure) { + if (services.AsEnumerable().FirstOrDefault(x => x.ServiceType == typeof(IFunctionContextAccessor)) == null) + throw new InvalidOperationException("Function context accessor is missing. Please ensure you call UseWhatsApp() on the functions application builder to register IFunctionContextAccessor."); + services.AddHttpClient("whatsapp").AddStandardResilienceHandler(); services.AddHybridCache(); services.AddSingleton(); @@ -274,6 +280,20 @@ static WhatsAppHandlerBuilder ConfigureServices(IServiceCollection services, Wha services.Add(new ServiceDescriptor(typeof(IWhatsAppHandler), builder.Build, lifetime)); services.Add(new ServiceDescriptor(typeof(PipelineRunner), typeof(PipelineRunner), lifetime)); + services.Add(new ServiceDescriptor(typeof(Func), services => () => + { + var accessor = services.GetRequiredService(); + var ctx = accessor.FunctionContext ?? throw new InvalidOperationException("FunctionContext is not available. Ensure UseWhatsApp() has been called on the application builder."); + return ctx.InstanceServices.GetRequiredService(); + }, lifetime)); + + services.Add(new ServiceDescriptor(typeof(Func), services => () => + { + var accessor = services.GetRequiredService(); + var ctx = accessor.FunctionContext ?? throw new InvalidOperationException("FunctionContext is not available. Ensure UseWhatsApp() has been called on the application builder."); + return ctx.InstanceServices.GetRequiredService(); + }, lifetime)); + // By default we use the queue processor, but it's idempotent if // called subsequently builder.UseQueueProcessor(true);