From 81b0795e1916df00e4f89c2f346320e8b571b839 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Thu, 15 May 2025 18:08:04 -0300 Subject: [PATCH] Introduce pipeline of handlers In order to provide more complex behavior for handling incoming messages over the azure function, we introduce the handler builder, which follows the design of the IChatClient functionality pipelines (see https://learn.microsoft.com/en-us/dotnet/ai/microsoft-extensions-ai#functionality-pipelines). This is work in progress, pending the integration into the azure functions builder extension methods. --- .netconfig | 18 +- src/Sample/Program.cs | 2 +- src/Tests/Attributes.cs | 96 -- src/Tests/Extensions/Attributes.cs | 172 +++ src/Tests/Extensions/LoggerFactory.cs | 29 + src/Tests/PipelineTests.cs | 62 ++ src/Tests/Tests.csproj | 4 +- src/WhatsApp/AnonymousDelegatingHandler.cs | 22 + src/WhatsApp/AzureFunctionsExtensions.cs | 48 +- src/WhatsApp/DelegatingHandler.cs | 41 + src/WhatsApp/IWhatsAppHandler.cs | 2 +- src/WhatsApp/JsonContext.cs | 62 ++ src/WhatsApp/LoggingHandler.cs | 79 ++ .../LoggingHandlerBuilderExtensions.cs | 37 + src/WhatsApp/Message.cs | 2 +- src/WhatsApp/Throw.cs | 992 ++++++++++++++++++ src/WhatsApp/WhatsApp.csproj | 1 + src/WhatsApp/WhatsAppClient.cs | 2 +- .../WhatsAppClientExtensions.ResolveMedia.cs | 4 +- src/WhatsApp/WhatsAppHandlerBuilder.cs | 87 ++ src/WhatsApp/WhatsAppSerializerContext.cs | 32 - 21 files changed, 1629 insertions(+), 165 deletions(-) delete mode 100644 src/Tests/Attributes.cs create mode 100644 src/Tests/Extensions/Attributes.cs create mode 100644 src/Tests/Extensions/LoggerFactory.cs create mode 100644 src/Tests/PipelineTests.cs create mode 100644 src/WhatsApp/AnonymousDelegatingHandler.cs create mode 100644 src/WhatsApp/DelegatingHandler.cs create mode 100644 src/WhatsApp/JsonContext.cs create mode 100644 src/WhatsApp/LoggingHandler.cs create mode 100644 src/WhatsApp/LoggingHandlerBuilderExtensions.cs create mode 100644 src/WhatsApp/Throw.cs create mode 100644 src/WhatsApp/WhatsAppHandlerBuilder.cs delete mode 100644 src/WhatsApp/WhatsAppSerializerContext.cs diff --git a/.netconfig b/.netconfig index 2e055fd..0aac527 100644 --- a/.netconfig +++ b/.netconfig @@ -126,14 +126,20 @@ etag = da7c0104131bd474b52fc9bc9f9bda6470e24ae38d4fb9f5c4f719bc01370ab5 weak -[file "src/Tests/Attributes.cs"] - url = https://github.com/devlooped/catbag/blob/main/Xunit/Attributes.cs - sha = 615c1e2521340dcd85132807b368a52ff53e4ba7 - - etag = ec1645067cc2319c2ce3304900c260eb8ec700d50b6d8d62285914a6c96e01f9 - weak [file ".github/actions/dotnet/action.yml"] url = https://github.com/devlooped/oss/blob/main/.github/actions/dotnet/action.yml sha = f2b690ce307acb76c5b8d7faec1a5b971a93653e etag = 27ea11baa2397b3ec9e643a935832da97719c4e44215cfd135c49cad4c29373f weak +[file "src/WhatsApp/Throw.cs"] + url = https://github.com/devlooped/catbag/blob/main/System/Throw.cs + sha = 48aeee281f63caabffeae14d7d84a6ece89e7907 + + etag = 1b3819a90c19d9de27b8c40e539934549aadd79ed1925cda8e8af6061eba3608 + weak +[file "src/Tests/Extensions/Attributes.cs"] + url = https://github.com/devlooped/catbag/blob/main/Xunit/Attributes.cs + etag = c77e7b435ce1df06fb60a3b0e15a0833d8e45d4d19f366c6184140ebb4814b1a + weak + sha = 40914971d4d6b42d6f8a90923b131136f7e609a5 + diff --git a/src/Sample/Program.cs b/src/Sample/Program.cs index 78cd9bb..dd3ec49 100644 --- a/src/Sample/Program.cs +++ b/src/Sample/Program.cs @@ -32,7 +32,7 @@ WriteIndented = true }); -builder.UseWhatsApp, JsonSerializerOptions>(async (client, logger, options, message) => +builder.UseWhatsApp, JsonSerializerOptions>(async (client, logger, options, message, cancellation) => { logger.LogInformation("💬 Received message: {Message}", message); diff --git a/src/Tests/Attributes.cs b/src/Tests/Attributes.cs deleted file mode 100644 index 60b5c50..0000000 --- a/src/Tests/Attributes.cs +++ /dev/null @@ -1,96 +0,0 @@ -using Microsoft.Extensions.Configuration; - -namespace Xunit; - -public class SecretsFactAttribute : FactAttribute -{ - public static IConfiguration Configuration { get; set; } = new ConfigurationBuilder() - .AddEnvironmentVariables() - .AddUserSecrets() - .Build(); - - public SecretsFactAttribute(params string[] secrets) - { - var missing = new HashSet(); - - foreach (var secret in secrets) - { - if (string.IsNullOrEmpty(Configuration[secret])) - missing.Add(secret); - } - - if (missing.Count > 0) - Skip = "Missing user secrets: " + string.Join(',', missing); - } -} - -public class LocalFactAttribute : SecretsFactAttribute -{ - public LocalFactAttribute(params string[] secrets) : base(secrets) - { - if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))) - Skip = "Non-CI test"; - } -} - -public class CIFactAttribute : FactAttribute -{ - public CIFactAttribute() - { - if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))) - Skip = "CI-only test"; - } -} - -public class SecretsTheoryAttribute : TheoryAttribute -{ - public SecretsTheoryAttribute(params string[] secrets) - { - var missing = new HashSet(); - - foreach (var secret in secrets) - { - if (string.IsNullOrEmpty(SecretsFactAttribute.Configuration[secret])) - missing.Add(secret); - } - - if (missing.Count > 0) - Skip = "Missing user secrets: " + string.Join(',', missing); - } -} - -public class LocalTheoryAttribute : SecretsTheoryAttribute -{ - public LocalTheoryAttribute(params string[] secrets) : base(secrets) - { - if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))) - Skip = "Non-CI test"; - } -} - -public class CITheoryAttribute : SecretsTheoryAttribute -{ - public CITheoryAttribute(params string[] secrets) : base(secrets) - { - if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))) - Skip = "CI-only test"; - } -} - -public class DebuggerFactAttribute : FactAttribute -{ - public DebuggerFactAttribute() - { - if (!System.Diagnostics.Debugger.IsAttached) - Skip = "Only running in the debugger"; - } -} - -public class DebuggerTheoryAttribute : TheoryAttribute -{ - public DebuggerTheoryAttribute() - { - if (!System.Diagnostics.Debugger.IsAttached) - Skip = "Only running in the debugger"; - } -} diff --git a/src/Tests/Extensions/Attributes.cs b/src/Tests/Extensions/Attributes.cs new file mode 100644 index 0000000..a98cd3b --- /dev/null +++ b/src/Tests/Extensions/Attributes.cs @@ -0,0 +1,172 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Configuration; + +namespace Xunit; + +public class SecretsFactAttribute : FactAttribute +{ + public static IConfiguration Configuration { get; set; } = new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddUserSecrets() + .Build(); + + public SecretsFactAttribute(params string[] secrets) + { + var missing = new HashSet(); + + foreach (var secret in secrets) + { + if (string.IsNullOrEmpty(Configuration[secret])) + missing.Add(secret); + } + + if (missing.Count > 0) + Skip = "Missing user secrets: " + string.Join(',', missing); + } +} + +public class LocalFactAttribute : SecretsFactAttribute +{ + public LocalFactAttribute(params string[] secrets) : base(secrets) + { + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))) + Skip = "Non-CI test"; + } +} + +public class CIFactAttribute : FactAttribute +{ + public CIFactAttribute() + { + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))) + Skip = "CI-only test"; + } +} + +public class RuntimeFactAttribute : FactAttribute +{ + /// + /// Use nameof(OSPLatform.Windows|Linux|OSX|FreeBSD) + /// + public RuntimeFactAttribute(string osPlatform) + { + if (osPlatform != null && !RuntimeInformation.IsOSPlatform(OSPlatform.Create(osPlatform))) + Skip = $"Only running on {osPlatform}."; + } + + public RuntimeFactAttribute(Architecture architecture) + { + if (RuntimeInformation.ProcessArchitecture != architecture) + Skip = $"Requires {architecture} but was {RuntimeInformation.ProcessArchitecture}."; + } + + /// + /// Empty constructor for use in combination with RuntimeIdentifier property. + /// + public RuntimeFactAttribute() { } + + /// + /// Sets the runtime identifier the test requires to run. + /// + public string? RuntimeIdentifier + { + get => RuntimeInformation.RuntimeIdentifier; + set + { + if (value != null && RuntimeInformation.RuntimeIdentifier != value) + Skip += $"Requires {value} but was {RuntimeInformation.RuntimeIdentifier}."; + } + } +} + +public class RuntimeTheoryAttribute : TheoryAttribute +{ + /// + /// Use nameof(OSPLatform.Windows|Linux|OSX|FreeBSD) + /// + public RuntimeTheoryAttribute(string osPlatform) + { + if (osPlatform != null && !RuntimeInformation.IsOSPlatform(OSPlatform.Create(osPlatform))) + Skip = $"Only running on {osPlatform}."; + } + + public RuntimeTheoryAttribute(Architecture architecture) + { + if (RuntimeInformation.ProcessArchitecture != architecture) + Skip = $"Requires {architecture} but was {RuntimeInformation.ProcessArchitecture}."; + } + + /// + /// Empty constructor for use in combination with RuntimeIdentifier property. + /// + public RuntimeTheoryAttribute() { } + + /// + /// Sets the runtime identifier the test requires to run. + /// + public string? RuntimeIdentifier + { + get => RuntimeInformation.RuntimeIdentifier; + set + { + if (value != null && RuntimeInformation.RuntimeIdentifier != value) + Skip += $"Requires {value} but was {RuntimeInformation.RuntimeIdentifier}."; + } + } +} + +public class SecretsTheoryAttribute : TheoryAttribute +{ + public SecretsTheoryAttribute(params string[] secrets) + { + var missing = new HashSet(); + + foreach (var secret in secrets) + { + if (string.IsNullOrEmpty(SecretsFactAttribute.Configuration[secret])) + missing.Add(secret); + } + + if (missing.Count > 0) + Skip = "Missing user secrets: " + string.Join(',', missing); + } +} + +public class LocalTheoryAttribute : SecretsTheoryAttribute +{ + public LocalTheoryAttribute(params string[] secrets) : base(secrets) + { + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))) + Skip = "Non-CI test"; + } +} + +public class CITheoryAttribute : SecretsTheoryAttribute +{ + public CITheoryAttribute(params string[] secrets) : base(secrets) + { + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))) + Skip = "CI-only test"; + } +} + +public class DebuggerFactAttribute : FactAttribute +{ + public DebuggerFactAttribute() + { + if (!System.Diagnostics.Debugger.IsAttached) + Skip = "Only running in the debugger"; + } +} + +public class DebuggerTheoryAttribute : TheoryAttribute +{ + public DebuggerTheoryAttribute() + { + if (!System.Diagnostics.Debugger.IsAttached) + Skip = "Only running in the debugger"; + } +} diff --git a/src/Tests/Extensions/LoggerFactory.cs b/src/Tests/Extensions/LoggerFactory.cs new file mode 100644 index 0000000..ecffe00 --- /dev/null +++ b/src/Tests/Extensions/LoggerFactory.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +public static class LoggerFactoryExtensions +{ + public static ILoggerFactory AsLoggerFactory(this ITestOutputHelper output) => new LoggerFactory(output); +} + +public class LoggerFactory(ITestOutputHelper output) : ILoggerFactory +{ + public ILogger CreateLogger(string categoryName) => new TestOutputLogger(output, categoryName); + public void AddProvider(ILoggerProvider provider) { } + public void Dispose() { } + + // create ilogger implementation over testoutputhelper + public class TestOutputLogger(ITestOutputHelper output, string categoryName) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null!; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (formatter == null) throw new ArgumentNullException(nameof(formatter)); + if (state == null) throw new ArgumentNullException(nameof(state)); + output.WriteLine($"{logLevel}: {categoryName}: {formatter(state, exception)}"); + } + } +} diff --git a/src/Tests/PipelineTests.cs b/src/Tests/PipelineTests.cs new file mode 100644 index 0000000..b1994d6 --- /dev/null +++ b/src/Tests/PipelineTests.cs @@ -0,0 +1,62 @@ +namespace Devlooped.WhatsApp; + +public class PipelineTests(ITestOutputHelper output) +{ + readonly static Service service = new("1234", "1234"); + readonly static User user = new("kzu", "5678"); + + [Fact] + public async Task CanBuildEmptyPipeline() + { + var builder = new WhatsAppHandlerBuilder(); + + var handler = builder.Build(); + + await handler.HandleAsync(new ReactionMessage("1234", service, user, 0, "🗽")); + } + + [Fact] + public async Task CanBuildDecoratingPipeline() + { + var called = false; + + var pipeline = new WhatsAppHandlerBuilder() + .Use((message, inner, cancellation) => + { + called = true; + return inner.HandleAsync(message, cancellation); + }) + .Build(); + + await pipeline.HandleAsync(new ReactionMessage("1234", service, user, 0, "🗽")); + + Assert.True(called); + } + + [Fact] + public async Task CanBuildLoggingPipeline() + { + var after = false; + var before = false; + + var pipeline = new WhatsAppHandlerBuilder() + .Use((message, inner, cancellation) => + { + after = true; + Assert.True(before); + return inner.HandleAsync(message, cancellation); + }) + .UseLogging(output.AsLoggerFactory()) + .Use((message, inner, cancellation) => + { + before = true; + Assert.False(after); + return inner.HandleAsync(message, cancellation); + }) + .Build(); + + await pipeline.HandleAsync(new ReactionMessage("1234", service, user, 0, "🗽")); + + Assert.True(after); + } +} diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj index a1ffd7e..ce7389d 100644 --- a/src/Tests/Tests.csproj +++ b/src/Tests/Tests.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -12,6 +12,7 @@ + @@ -31,6 +32,7 @@ + diff --git a/src/WhatsApp/AnonymousDelegatingHandler.cs b/src/WhatsApp/AnonymousDelegatingHandler.cs new file mode 100644 index 0000000..4bac2f6 --- /dev/null +++ b/src/WhatsApp/AnonymousDelegatingHandler.cs @@ -0,0 +1,22 @@ + +namespace Devlooped.WhatsApp; + +/// Represents a delegating chat client that wraps an inner handler with implementation provided by a delegate. +class AnonymousDelegatingHandler : DelegatingHandler +{ + /// The delegate to use as the implementation of . + readonly Func handlerFunc; + + /// + /// Initializes a new instance of the class. + /// + /// The inner handler. + /// A delegate that provides the implementation for + public AnonymousDelegatingHandler( + IWhatsAppHandler innerHandler, + Func handlerFunc) : base(innerHandler) + => this.handlerFunc = Throw.IfNull(handlerFunc); + + public override Task HandleAsync(Message message, CancellationToken cancellation = default) + => handlerFunc(message, InnerHandler, cancellation); +} \ No newline at end of file diff --git a/src/WhatsApp/AzureFunctionsExtensions.cs b/src/WhatsApp/AzureFunctionsExtensions.cs index 499faff..3edeaaa 100644 --- a/src/WhatsApp/AzureFunctionsExtensions.cs +++ b/src/WhatsApp/AzureFunctionsExtensions.cs @@ -77,7 +77,7 @@ public static IFunctionsWorkerApplicationBuilder UseWhatsApp(this IFun /// /// Configure the WhatsApp handler for Azure Functions. /// - public static IFunctionsWorkerApplicationBuilder UseWhatsApp(this IFunctionsWorkerApplicationBuilder builder, Func handler) + public static IFunctionsWorkerApplicationBuilder UseWhatsApp(this IFunctionsWorkerApplicationBuilder builder, Func handler) { builder.Services.AddSingleton(services => new AnonymousWhatsAppHandler(services, handler)); ConfigureServices(builder.Services); @@ -87,7 +87,7 @@ public static IFunctionsWorkerApplicationBuilder UseWhatsApp(this IFunctionsWork /// /// Configure the WhatsApp handler for Azure Functions. /// - public static IFunctionsWorkerApplicationBuilder UseWhatsApp(this IFunctionsWorkerApplicationBuilder builder, Func handler) + public static IFunctionsWorkerApplicationBuilder UseWhatsApp(this IFunctionsWorkerApplicationBuilder builder, Func handler) { builder.Services.AddSingleton(services => new SimpleAnonymousWhatsAppHandler(handler)); ConfigureServices(builder.Services); @@ -97,7 +97,7 @@ public static IFunctionsWorkerApplicationBuilder UseWhatsApp(this IFunctionsWork /// /// Configure the WhatsApp handler for Azure Functions. /// - public static IFunctionsWorkerApplicationBuilder UseWhatsApp(this IFunctionsWorkerApplicationBuilder builder, Func handler) + public static IFunctionsWorkerApplicationBuilder UseWhatsApp(this IFunctionsWorkerApplicationBuilder builder, Func handler) where TService : notnull { builder.Services.AddSingleton(services => new AnonymousWhatsAppHandler(services.GetRequiredService(), handler)); @@ -108,7 +108,7 @@ public static IFunctionsWorkerApplicationBuilder UseWhatsApp(this IFun /// /// Configure the WhatsApp handler for Azure Functions. /// - public static IFunctionsWorkerApplicationBuilder UseWhatsApp(this IFunctionsWorkerApplicationBuilder builder, Func handler) + public static IFunctionsWorkerApplicationBuilder UseWhatsApp(this IFunctionsWorkerApplicationBuilder builder, Func handler) where TService1 : notnull where TService2 : notnull { @@ -120,7 +120,7 @@ public static IFunctionsWorkerApplicationBuilder UseWhatsApp /// Configure the WhatsApp handler for Azure Functions. /// - public static IFunctionsWorkerApplicationBuilder UseWhatsApp(this IFunctionsWorkerApplicationBuilder builder, Func handler) + public static IFunctionsWorkerApplicationBuilder UseWhatsApp(this IFunctionsWorkerApplicationBuilder builder, Func handler) where TService1 : notnull where TService2 : notnull where TService3 : notnull @@ -133,7 +133,7 @@ public static IFunctionsWorkerApplicationBuilder UseWhatsApp /// Configure the WhatsApp handler for Azure Functions. /// - public static IFunctionsWorkerApplicationBuilder UseWhatsApp(this IFunctionsWorkerApplicationBuilder builder, Func handler) + public static IFunctionsWorkerApplicationBuilder UseWhatsApp(this IFunctionsWorkerApplicationBuilder builder, Func handler) where TService1 : notnull where TService2 : notnull where TService3 : notnull @@ -147,7 +147,7 @@ public static IFunctionsWorkerApplicationBuilder UseWhatsApp /// Configure the WhatsApp handler for Azure Functions. /// - public static IFunctionsWorkerApplicationBuilder UseWhatsApp(this IFunctionsWorkerApplicationBuilder builder, Func handler) + public static IFunctionsWorkerApplicationBuilder UseWhatsApp(this IFunctionsWorkerApplicationBuilder builder, Func handler) where TService1 : notnull where TService2 : notnull where TService3 : notnull @@ -162,7 +162,7 @@ public static IFunctionsWorkerApplicationBuilder UseWhatsApp /// Configure the WhatsApp handler for Azure Functions. /// - public static IFunctionsWorkerApplicationBuilder UseWhatsApp(this IFunctionsWorkerApplicationBuilder builder, Func handler) + public static IFunctionsWorkerApplicationBuilder UseWhatsApp(this IFunctionsWorkerApplicationBuilder builder, Func handler) where TService1 : notnull where TService2 : notnull where TService3 : notnull @@ -175,43 +175,43 @@ public static IFunctionsWorkerApplicationBuilder UseWhatsApp handler) : IWhatsAppHandler + class SimpleAnonymousWhatsAppHandler(Func handler) : IWhatsAppHandler { - public Task HandleAsync(Message message) => handler(message); + public Task HandleAsync(Message message, CancellationToken cancellation = default) => handler(message, cancellation); } - class AnonymousWhatsAppHandler(IServiceProvider services, Func handler) : IWhatsAppHandler + class AnonymousWhatsAppHandler(IServiceProvider services, Func handler) : IWhatsAppHandler { - public Task HandleAsync(Message message) => handler(services, message); + public Task HandleAsync(Message message, CancellationToken cancellation = default) => handler(services, message, cancellation); } - class AnonymousWhatsAppHandler(TService service, Func handler) : IWhatsAppHandler + class AnonymousWhatsAppHandler(TService service, Func handler) : IWhatsAppHandler { - public Task HandleAsync(Message message) => handler(service, message); + public Task HandleAsync(Message message, CancellationToken cancellation = default) => handler(service, message, cancellation); } - class AnonymousWhatsAppHandler(TService1 service1, TService2 service2, Func handler) : IWhatsAppHandler + class AnonymousWhatsAppHandler(TService1 service1, TService2 service2, Func handler) : IWhatsAppHandler { - public Task HandleAsync(Message message) => handler(service1, service2, message); + public Task HandleAsync(Message message, CancellationToken cancellation = default) => handler(service1, service2, message, cancellation); } - class AnonymousWhatsAppHandler(TService1 service1, TService2 service2, TService3 service3, Func handler) : IWhatsAppHandler + class AnonymousWhatsAppHandler(TService1 service1, TService2 service2, TService3 service3, Func handler) : IWhatsAppHandler { - public Task HandleAsync(Message message) => handler(service1, service2, service3, message); + public Task HandleAsync(Message message, CancellationToken cancellation = default) => handler(service1, service2, service3, message, cancellation); } - class AnonymousWhatsAppHandler(TService1 service1, TService2 service2, TService3 service3, TService4 service4, Func handler) : IWhatsAppHandler + class AnonymousWhatsAppHandler(TService1 service1, TService2 service2, TService3 service3, TService4 service4, Func handler) : IWhatsAppHandler { - public Task HandleAsync(Message message) => handler(service1, service2, service3, service4, message); + public Task HandleAsync(Message message, CancellationToken cancellation = default) => handler(service1, service2, service3, service4, message, cancellation); } - class AnonymousWhatsAppHandler(TService1 service1, TService2 service2, TService3 service3, TService4 service4, TService5 service5, Func handler) : IWhatsAppHandler + class AnonymousWhatsAppHandler(TService1 service1, TService2 service2, TService3 service3, TService4 service4, TService5 service5, Func handler) : IWhatsAppHandler { - public Task HandleAsync(Message message) => handler(service1, service2, service3, service4, service5, message); + public Task HandleAsync(Message message, CancellationToken cancellation = default) => handler(service1, service2, service3, service4, service5, message, cancellation); } - class AnonymousWhatsAppHandler(TService1 service1, TService2 service2, TService3 service3, TService4 service4, TService5 service5, TService6 service6, Func handler) : IWhatsAppHandler + class AnonymousWhatsAppHandler(TService1 service1, TService2 service2, TService3 service3, TService4 service4, TService5 service5, TService6 service6, Func handler) : IWhatsAppHandler { - public Task HandleAsync(Message message) => handler(service1, service2, service3, service4, service5, service6, message); + public Task HandleAsync(Message message, CancellationToken cancellation = default) => handler(service1, service2, service3, service4, service5, service6, message, cancellation); } } \ No newline at end of file diff --git a/src/WhatsApp/DelegatingHandler.cs b/src/WhatsApp/DelegatingHandler.cs new file mode 100644 index 0000000..74f376c --- /dev/null +++ b/src/WhatsApp/DelegatingHandler.cs @@ -0,0 +1,41 @@ +namespace Devlooped.WhatsApp; + +/// +/// Provides an optional base class for an that passes through calls to another instance. +/// +/// +/// This is recommended as a base type when building handlers that can be chained around an underlying . +/// The default implementation simply passes each call to the inner handler instance. +/// +public class DelegatingHandler : IWhatsAppHandler, IDisposable +{ + /// + /// Initializes a new instance of the class. + /// + /// The wrapped handler instance. + public DelegatingHandler(IWhatsAppHandler innerHandler) + => InnerHandler = Throw.IfNull(innerHandler); + + /// Gets the inner . + protected IWhatsAppHandler InnerHandler { get; } + + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// + public virtual Task HandleAsync(Message message, CancellationToken cancellation = default) => InnerHandler.HandleAsync(message, cancellation); + + /// Provides a mechanism for releasing unmanaged resources. + /// if being called from ; otherwise, . + protected virtual void Dispose(bool disposing) + { + if (disposing && InnerHandler is IDisposable disposable) + { + disposable.Dispose(); + } + } +} diff --git a/src/WhatsApp/IWhatsAppHandler.cs b/src/WhatsApp/IWhatsAppHandler.cs index f7b66d3..758b22d 100644 --- a/src/WhatsApp/IWhatsAppHandler.cs +++ b/src/WhatsApp/IWhatsAppHandler.cs @@ -25,5 +25,5 @@ public interface IWhatsAppHandler /// After the max dequeue retries, the message will be moved to the whatsapp-poison /// queue. /// - Task HandleAsync(Message message); + Task HandleAsync(Message message, CancellationToken cancellation = default); } diff --git a/src/WhatsApp/JsonContext.cs b/src/WhatsApp/JsonContext.cs new file mode 100644 index 0000000..4cf5a2c --- /dev/null +++ b/src/WhatsApp/JsonContext.cs @@ -0,0 +1,62 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Devlooped.WhatsApp; + +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + UseStringEnumConverter = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, + WriteIndented = true + )] +[JsonSerializable(typeof(Message))] +[JsonSerializable(typeof(ContentMessage))] +[JsonSerializable(typeof(ErrorMessage))] +[JsonSerializable(typeof(InteractiveMessage))] +[JsonSerializable(typeof(ReactionMessage))] +[JsonSerializable(typeof(StatusMessage))] +[JsonSerializable(typeof(UnsupportedMessage))] +[JsonSerializable(typeof(MediaReference))] +[JsonSerializable(typeof(GraphMethodException))] +[JsonSerializable(typeof(ErrorResponse))] +[JsonSerializable(typeof(SendResponse))] +[JsonSerializable(typeof(MessageId))] +partial class JsonContext : JsonSerializerContext +{ + static readonly Lazy options = new Lazy(() => CreateDefaultOptions()); + + public static JsonSerializerOptions DefaultOptions { get => options.Value; } + + [UnconditionalSuppressMessage("AotAnalysis", "IL3050", Justification = "DefaultJsonTypeInfoResolver is only used when reflection-based serialization is enabled")] + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "DefaultJsonTypeInfoResolver is only used when reflection-based serialization is enabled")] + static JsonSerializerOptions CreateDefaultOptions() + { + JsonSerializerOptions options = new(Default.Options) + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + WriteIndented = true, + }; + + if (JsonSerializer.IsReflectionEnabledByDefault) + { + // If reflection-based serialization is enabled by default, use it as a fallback for all other types. + // Also turn on string-based enum serialization for all unknown enums. + options.TypeInfoResolverChain.Add(new DefaultJsonTypeInfoResolver()); + options.Converters.Add(new JsonStringEnumConverter()); + } + + options.MakeReadOnly(); + return options; + } +} + +record ErrorResponse(GraphMethodException Error); + +record SendResponse(MessageId[] Messages); + +record MessageId(string Id); \ No newline at end of file diff --git a/src/WhatsApp/LoggingHandler.cs b/src/WhatsApp/LoggingHandler.cs new file mode 100644 index 0000000..eee9158 --- /dev/null +++ b/src/WhatsApp/LoggingHandler.cs @@ -0,0 +1,79 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Devlooped.WhatsApp; + +public partial class LoggingHandler(IWhatsAppHandler innerHandler, ILogger logger) : DelegatingHandler(innerHandler) +{ + JsonSerializerOptions options = JsonContext.DefaultOptions; + + /// Gets or sets JSON serialization options to use when serializing logging data. + public JsonSerializerOptions JsonSerializerOptions + { + get => options; + set => options = Throw.IfNull(value); + } + + public override async Task HandleAsync(Message message, CancellationToken cancellation = default) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + if (logger.IsEnabled(LogLevel.Trace)) + LogInvokedSensitive(nameof(HandleAsync), AsJson(message, options)); + else + LogInvoked(nameof(HandleAsync)); + } + + try + { + await base.HandleAsync(message, cancellation); + if (logger.IsEnabled(LogLevel.Debug)) + LogCompleted(nameof(HandleAsync)); + } + catch (OperationCanceledException) + { + LogInvocationCanceled(nameof(HandleAsync)); + throw; + } + catch (Exception ex) + { + LogInvocationFailed(nameof(HandleAsync), ex); + throw; + } + } + + /// Serializes as JSON for logging purposes. + static string AsJson(T value, JsonSerializerOptions options) + { + if (options.TryGetTypeInfo(typeof(T), out var typeInfo) is true) + { + try + { + return JsonSerializer.Serialize(value, typeInfo); + } + catch + { + } + } + + // If we're unable to get a type info for the value, or if we fail to serialize, + // return an empty JSON object. We do not want lack of type info to disrupt application behavior with exceptions. + return "{}"; + } + + [LoggerMessage(LogLevel.Debug, "{MethodName} invoked.")] + private partial void LogInvoked(string methodName); + + [LoggerMessage(LogLevel.Trace, "{MethodName} invoked: {Message}.")] + private partial void LogInvokedSensitive(string methodName, string message); + + [LoggerMessage(LogLevel.Debug, "{MethodName} completed.")] + private partial void LogCompleted(string methodName); + + [LoggerMessage(LogLevel.Debug, "{MethodName} canceled.")] + private partial void LogInvocationCanceled(string methodName); + + [LoggerMessage(LogLevel.Error, "{MethodName} failed.")] + private partial void LogInvocationFailed(string methodName, Exception error); + +} diff --git a/src/WhatsApp/LoggingHandlerBuilderExtensions.cs b/src/WhatsApp/LoggingHandlerBuilderExtensions.cs new file mode 100644 index 0000000..6fd925b --- /dev/null +++ b/src/WhatsApp/LoggingHandlerBuilderExtensions.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Devlooped.WhatsApp; + +/// +/// Provides extensions for configuring instances. +/// +public static class LoggingHandlerBuilderExtensions +{ + /// + /// Adds a logging handler to the pipeline. + /// + /// The handler builder. + /// The logger factory. + /// The updated handler builder. + public static WhatsAppHandlerBuilder UseLogging( + this WhatsAppHandlerBuilder builder, + ILoggerFactory? loggerFactory = null, + Action? configure = null) + { + _ = Throw.IfNull(builder); + + return builder.Use((inner, services) => + { + loggerFactory ??= services.GetRequiredService(); + // If the factory we resolve is for the null logger, just return the inner handler since no logging will happen anyway. + if (loggerFactory == NullLoggerFactory.Instance) + return inner; + + var handler = new LoggingHandler(inner, loggerFactory.CreateLogger()); + configure?.Invoke(handler); + return handler; + }); + } +} diff --git a/src/WhatsApp/Message.cs b/src/WhatsApp/Message.cs index 37728d0..7e9739a 100644 --- a/src/WhatsApp/Message.cs +++ b/src/WhatsApp/Message.cs @@ -226,7 +226,7 @@ .value.statuses[0] as $status | var jq = await Devlooped.JQ.ExecuteAsync(json, JQ); if (!string.IsNullOrEmpty(jq)) - return JsonSerializer.Deserialize(jq, WhatsAppSerializerContext.Default.Message); + return JsonSerializer.Deserialize(jq, JsonContext.Default.Message); // NOTE: unsupported payloads would not generate a JQ output, so we can safely ignore them. return default; diff --git a/src/WhatsApp/Throw.cs b/src/WhatsApp/Throw.cs new file mode 100644 index 0000000..4477c17 --- /dev/null +++ b/src/WhatsApp/Throw.cs @@ -0,0 +1,992 @@ +// +#region License +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// Adapted from https://github.com/dotnet/extensions/blob/main/src/Shared/Throw/Throw.cs +#endregion + +#nullable enable +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +#pragma warning disable CA1716 +namespace System; +#pragma warning restore CA1716 + +/// +/// Defines static methods used to throw exceptions. +/// +/// +/// The main purpose is to reduce code size, improve performance, and standardize exception +/// messages. +/// +[SuppressMessage("Minor Code Smell", "S4136:Method overloads should be grouped together", Justification = "Doesn't work with the region layout")] +[SuppressMessage("Minor Code Smell", "S2333:Partial is gratuitous in this context", Justification = "Some projects add additional partial parts.")] +[SuppressMessage("Design", "CA1716", Justification = "Not part of an API")] + +#if !SHARED_PROJECT +[ExcludeFromCodeCoverage] +#endif + +internal static partial class Throw +{ + #region For Object + + /// + /// Throws an if the specified argument is . + /// + /// Argument type to be checked for . + /// Object to be checked for . + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [return: NotNull] + public static T IfNull([NotNull] T argument, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument is null) + { + ArgumentNullException(paramName); + } + + return argument; + } + + /// + /// Throws an if the specified argument is , + /// or if the specified member is . + /// + /// Argument type to be checked for . + /// Member type to be checked for . + /// Argument to be checked for . + /// Object member to be checked for . + /// The name of the parameter being checked. + /// The name of the member. + /// The original value of . + /// + /// + /// Throws.IfNullOrMemberNull(myObject, myObject?.MyProperty) + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [return: NotNull] + public static TMember IfNullOrMemberNull( + [NotNull] TParameter argument, + [NotNull] TMember member, + [CallerArgumentExpression(nameof(argument))] string paramName = "", + [CallerArgumentExpression(nameof(member))] string memberName = "") + { + if (argument is null) + { + ArgumentNullException(paramName); + } + + if (member is null) + { + ArgumentException(paramName, $"Member {memberName} of {paramName} is null"); + } + + return member; + } + + /// + /// Throws an if the specified member is . + /// + /// Argument type. + /// Member type to be checked for . + /// Argument to which member belongs. + /// Object member to be checked for . + /// The name of the parameter being checked. + /// The name of the member. + /// The original value of . + /// + /// + /// Throws.IfMemberNull(myObject, myObject.MyProperty) + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [return: NotNull] + [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Analyzer isn't seeing the reference to 'argument' in the attribute")] + public static TMember IfMemberNull( + TParameter argument, + [NotNull] TMember member, + [CallerArgumentExpression(nameof(argument))] string paramName = "", + [CallerArgumentExpression(nameof(member))] string memberName = "") + where TParameter : notnull + { + if (member is null) + { + ArgumentException(paramName, $"Member {memberName} of {paramName} is null"); + } + + return member; + } + + #endregion + + #region For String + + /// + /// Throws either an or an + /// if the specified string is or whitespace respectively. + /// + /// String to be checked for or whitespace. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [return: NotNull] + public static string IfNullOrWhitespace([NotNull] string? argument, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { +#if !NETCOREAPP3_1_OR_GREATER + if (argument == null) + { + ArgumentNullException(paramName); + } +#endif + + if (string.IsNullOrWhiteSpace(argument)) + { + if (argument == null) + { + ArgumentNullException(paramName); + } + else + { + ArgumentException(paramName, "Argument is whitespace"); + } + } + + return argument; + } + + /// + /// Throws an if the string is , + /// or if it is empty. + /// + /// String to be checked for or empty. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [return: NotNull] + public static string IfNullOrEmpty([NotNull] string? argument, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { +#if !NETCOREAPP3_1_OR_GREATER + if (argument == null) + { + ArgumentNullException(paramName); + } +#endif + + if (string.IsNullOrEmpty(argument)) + { + if (argument == null) + { + ArgumentNullException(paramName); + } + else + { + ArgumentException(paramName, "Argument is an empty string"); + } + } + + return argument; + } + + #endregion + + #region For Buffer + + /// + /// Throws an if the argument's buffer size is less than the required buffer size. + /// + /// The actual buffer size. + /// The required buffer size. + /// The name of the parameter to be checked. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void IfBufferTooSmall(int bufferSize, int requiredSize, string paramName = "") + { + if (bufferSize < requiredSize) + { + ArgumentException(paramName, $"Buffer too small, needed a size of {requiredSize} but got {bufferSize}"); + } + } + + #endregion + + #region For Enums + + /// + /// Throws an if the enum value is not valid. + /// + /// The argument to evaluate. + /// The name of the parameter being checked. + /// The type of the enumeration. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T IfOutOfRange(T argument, [CallerArgumentExpression(nameof(argument))] string paramName = "") + where T : struct, Enum + { +#if NET5_0_OR_GREATER + if (!Enum.IsDefined(argument)) +#else + if (!Enum.IsDefined(typeof(T), argument)) +#endif + { + ArgumentOutOfRangeException(paramName, $"{argument} is an invalid value for enum type {typeof(T)}"); + } + + return argument; + } + + #endregion + + #region For Collections + + /// + /// Throws an if the collection is , + /// or if it is empty. + /// + /// The collection to evaluate. + /// The name of the parameter being checked. + /// The type of objects in the collection. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [return: NotNull] + + // The method has actually 100% coverage, but due to a bug in the code coverage tool, + // a lower number is reported. Therefore, we temporarily exclude this method + // from the coverage measurements. Once the bug in the code coverage tool is fixed, + // the exclusion attribute can be removed. + [ExcludeFromCodeCoverage] + public static IEnumerable IfNullOrEmpty([NotNull] IEnumerable? argument, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument == null) + { + ArgumentNullException(paramName); + } + else + { + switch (argument) + { + case ICollection collection: + if (collection.Count == 0) + { + ArgumentException(paramName, "Collection is empty"); + } + + break; + case IReadOnlyCollection readOnlyCollection: + if (readOnlyCollection.Count == 0) + { + ArgumentException(paramName, "Collection is empty"); + } + + break; + default: + using (IEnumerator enumerator = argument.GetEnumerator()) + { + if (!enumerator.MoveNext()) + { + ArgumentException(paramName, "Collection is empty"); + } + } + + break; + } + } + + return argument; + } + + #endregion + + #region Exceptions + + /// + /// Throws an . + /// + /// The name of the parameter that caused the exception. +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void ArgumentNullException(string paramName) + => throw new ArgumentNullException(paramName); + + /// + /// Throws an . + /// + /// The name of the parameter that caused the exception. + /// A message that describes the error. +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void ArgumentNullException(string paramName, string? message) + => throw new ArgumentNullException(paramName, message); + + /// + /// Throws an . + /// + /// The name of the parameter that caused the exception. +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void ArgumentOutOfRangeException(string paramName) + => throw new ArgumentOutOfRangeException(paramName); + + /// + /// Throws an . + /// + /// The name of the parameter that caused the exception. + /// A message that describes the error. +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void ArgumentOutOfRangeException(string paramName, string? message) + => throw new ArgumentOutOfRangeException(paramName, message); + + /// + /// Throws an . + /// + /// The name of the parameter that caused the exception. + /// The value of the argument that caused this exception. + /// A message that describes the error. +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void ArgumentOutOfRangeException(string paramName, object? actualValue, string? message) + => throw new ArgumentOutOfRangeException(paramName, actualValue, message); + + /// + /// Throws an . + /// + /// The name of the parameter that caused the exception. + /// A message that describes the error. +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void ArgumentException(string paramName, string? message) + => throw new ArgumentException(message, paramName); + + /// + /// Throws an . + /// + /// The name of the parameter that caused the exception. + /// A message that describes the error. + /// The exception that is the cause of the current exception. + /// + /// If the is not a , the current exception is raised in a catch + /// block that handles the inner exception. + /// +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void ArgumentException(string paramName, string? message, Exception? innerException) + => throw new ArgumentException(message, paramName, innerException); + + /// + /// Throws an . + /// + /// A message that describes the error. +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void InvalidOperationException(string message) + => throw new InvalidOperationException(message); + + /// + /// Throws an . + /// + /// A message that describes the error. + /// The exception that is the cause of the current exception. +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void InvalidOperationException(string message, Exception? innerException) + => throw new InvalidOperationException(message, innerException); + + #endregion + + #region For Integer + + /// + /// Throws an if the specified number is less than min. + /// + /// Number to be expected being less than min. + /// The number that must be less than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IfLessThan(int argument, int min, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument < min) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument less than minimum value {min}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is greater than max. + /// + /// Number to be expected being greater than max. + /// The number that must be greater than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IfGreaterThan(int argument, int max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument > max) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument greater than maximum value {max}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is less or equal than min. + /// + /// Number to be expected being less or equal than min. + /// The number that must be less or equal than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IfLessThanOrEqual(int argument, int min, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument <= min) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument less or equal than minimum value {min}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is greater or equal than max. + /// + /// Number to be expected being greater or equal than max. + /// The number that must be greater or equal than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IfGreaterThanOrEqual(int argument, int max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument >= max) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument greater or equal than maximum value {max}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is not in the specified range. + /// + /// Number to be expected being greater or equal than max. + /// The lower bound of the allowed range of argument values. + /// The upper bound of the allowed range of argument values. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IfOutOfRange(int argument, int min, int max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument < min || argument > max) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument not in the range [{min}..{max}]"); + } + + return argument; + } + + /// + /// Throws an if the specified number is equal to 0. + /// + /// Number to be expected being not equal to zero. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IfZero(int argument, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument == 0) + { + ArgumentOutOfRangeException(paramName, "Argument is zero"); + } + + return argument; + } + + #endregion + + #region For Unsigned Integer + + /// + /// Throws an if the specified number is less than min. + /// + /// Number to be expected being less than min. + /// The number that must be less than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint IfLessThan(uint argument, uint min, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument < min) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument less than minimum value {min}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is greater than max. + /// + /// Number to be expected being greater than max. + /// The number that must be greater than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint IfGreaterThan(uint argument, uint max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument > max) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument greater than maximum value {max}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is less or equal than min. + /// + /// Number to be expected being less or equal than min. + /// The number that must be less or equal than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint IfLessThanOrEqual(uint argument, uint min, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument <= min) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument less or equal than minimum value {min}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is greater or equal than max. + /// + /// Number to be expected being greater or equal than max. + /// The number that must be greater or equal than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint IfGreaterThanOrEqual(uint argument, uint max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument >= max) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument greater or equal than maximum value {max}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is not in the specified range. + /// + /// Number to be expected being greater or equal than max. + /// The lower bound of the allowed range of argument values. + /// The upper bound of the allowed range of argument values. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint IfOutOfRange(uint argument, uint min, uint max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument < min || argument > max) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument not in the range [{min}..{max}]"); + } + + return argument; + } + + /// + /// Throws an if the specified number is equal to 0. + /// + /// Number to be expected being not equal to zero. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint IfZero(uint argument, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument == 0U) + { + ArgumentOutOfRangeException(paramName, "Argument is zero"); + } + + return argument; + } + + #endregion + + #region For Long + + /// + /// Throws an if the specified number is less than min. + /// + /// Number to be expected being less than min. + /// The number that must be less than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long IfLessThan(long argument, long min, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument < min) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument less than minimum value {min}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is greater than max. + /// + /// Number to be expected being greater than max. + /// The number that must be greater than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long IfGreaterThan(long argument, long max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument > max) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument greater than maximum value {max}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is less or equal than min. + /// + /// Number to be expected being less or equal than min. + /// The number that must be less or equal than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long IfLessThanOrEqual(long argument, long min, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument <= min) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument less or equal than minimum value {min}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is greater or equal than max. + /// + /// Number to be expected being greater or equal than max. + /// The number that must be greater or equal than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long IfGreaterThanOrEqual(long argument, long max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument >= max) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument greater or equal than maximum value {max}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is not in the specified range. + /// + /// Number to be expected being greater or equal than max. + /// The lower bound of the allowed range of argument values. + /// The upper bound of the allowed range of argument values. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long IfOutOfRange(long argument, long min, long max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument < min || argument > max) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument not in the range [{min}..{max}]"); + } + + return argument; + } + + /// + /// Throws an if the specified number is equal to 0. + /// + /// Number to be expected being not equal to zero. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long IfZero(long argument, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument == 0L) + { + ArgumentOutOfRangeException(paramName, "Argument is zero"); + } + + return argument; + } + + #endregion + + #region For Unsigned Long + + /// + /// Throws an if the specified number is less than min. + /// + /// Number to be expected being less than min. + /// The number that must be less than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong IfLessThan(ulong argument, ulong min, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument < min) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument less than minimum value {min}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is greater than max. + /// + /// Number to be expected being greater than max. + /// The number that must be greater than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong IfGreaterThan(ulong argument, ulong max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument > max) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument greater than maximum value {max}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is less or equal than min. + /// + /// Number to be expected being less or equal than min. + /// The number that must be less or equal than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong IfLessThanOrEqual(ulong argument, ulong min, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument <= min) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument less or equal than minimum value {min}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is greater or equal than max. + /// + /// Number to be expected being greater or equal than max. + /// The number that must be greater or equal than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong IfGreaterThanOrEqual(ulong argument, ulong max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument >= max) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument greater or equal than maximum value {max}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is not in the specified range. + /// + /// Number to be expected being greater or equal than max. + /// The lower bound of the allowed range of argument values. + /// The upper bound of the allowed range of argument values. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong IfOutOfRange(ulong argument, ulong min, ulong max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument < min || argument > max) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument not in the range [{min}..{max}]"); + } + + return argument; + } + + /// + /// Throws an if the specified number is equal to 0. + /// + /// Number to be expected being not equal to zero. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong IfZero(ulong argument, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument == 0UL) + { + ArgumentOutOfRangeException(paramName, "Argument is zero"); + } + + return argument; + } + + #endregion + + #region For Double + + /// + /// Throws an if the specified number is less than min. + /// + /// Number to be expected being less than min. + /// The number that must be less than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double IfLessThan(double argument, double min, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + // strange conditional needed in order to handle NaN values correctly +#pragma warning disable S1940 // Boolean checks should not be inverted + if (!(argument >= min)) +#pragma warning restore S1940 // Boolean checks should not be inverted + { + ArgumentOutOfRangeException(paramName, argument, $"Argument less than minimum value {min}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is greater than max. + /// + /// Number to be expected being greater than max. + /// The number that must be greater than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double IfGreaterThan(double argument, double max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + // strange conditional needed in order to handle NaN values correctly +#pragma warning disable S1940 // Boolean checks should not be inverted + if (!(argument <= max)) +#pragma warning restore S1940 // Boolean checks should not be inverted + { + ArgumentOutOfRangeException(paramName, argument, $"Argument greater than maximum value {max}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is less or equal than min. + /// + /// Number to be expected being less or equal than min. + /// The number that must be less or equal than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double IfLessThanOrEqual(double argument, double min, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + // strange conditional needed in order to handle NaN values correctly +#pragma warning disable S1940 // Boolean checks should not be inverted + if (!(argument > min)) +#pragma warning restore S1940 // Boolean checks should not be inverted + { + ArgumentOutOfRangeException(paramName, argument, $"Argument less or equal than minimum value {min}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is greater or equal than max. + /// + /// Number to be expected being greater or equal than max. + /// The number that must be greater or equal than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double IfGreaterThanOrEqual(double argument, double max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + // strange conditional needed in order to handle NaN values correctly +#pragma warning disable S1940 // Boolean checks should not be inverted + if (!(argument < max)) +#pragma warning restore S1940 // Boolean checks should not be inverted + { + ArgumentOutOfRangeException(paramName, argument, $"Argument greater or equal than maximum value {max}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is not in the specified range. + /// + /// Number to be expected being greater or equal than max. + /// The lower bound of the allowed range of argument values. + /// The upper bound of the allowed range of argument values. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double IfOutOfRange(double argument, double min, double max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + // strange conditional needed in order to handle NaN values correctly + if (!(min <= argument && argument <= max)) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument not in the range [{min}..{max}]"); + } + + return argument; + } + + /// + /// Throws an if the specified number is equal to 0. + /// + /// Number to be expected being not equal to zero. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double IfZero(double argument, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { +#pragma warning disable S1244 // Floating point numbers should not be tested for equality + if (argument == 0.0) +#pragma warning restore S1244 // Floating point numbers should not be tested for equality + { + ArgumentOutOfRangeException(paramName, "Argument is zero"); + } + + return argument; + } + + #endregion +} diff --git a/src/WhatsApp/WhatsApp.csproj b/src/WhatsApp/WhatsApp.csproj index 1c966d6..8263738 100644 --- a/src/WhatsApp/WhatsApp.csproj +++ b/src/WhatsApp/WhatsApp.csproj @@ -15,6 +15,7 @@ + diff --git a/src/WhatsApp/WhatsAppClient.cs b/src/WhatsApp/WhatsAppClient.cs index 7ce94ba..9258974 100644 --- a/src/WhatsApp/WhatsAppClient.cs +++ b/src/WhatsApp/WhatsAppClient.cs @@ -60,7 +60,7 @@ public HttpClient CreateHttp(string numberId) throw new HttpRequestException(error, null, result.StatusCode); } - var response = await result.Content.ReadFromJsonAsync(WhatsAppSerializerContext.Default.SendResponse); + var response = await result.Content.ReadFromJsonAsync(JsonContext.Default.SendResponse); return response?.Messages?.FirstOrDefault()?.Id; } diff --git a/src/WhatsApp/WhatsAppClientExtensions.ResolveMedia.cs b/src/WhatsApp/WhatsAppClientExtensions.ResolveMedia.cs index 966b906..6d95518 100644 --- a/src/WhatsApp/WhatsAppClientExtensions.ResolveMedia.cs +++ b/src/WhatsApp/WhatsAppClientExtensions.ResolveMedia.cs @@ -40,12 +40,12 @@ public static async Task ResolveMediaAsync(this IWhatsAppClient await response.Content.LoadIntoBufferAsync(); if (!response.IsSuccessStatusCode && - await response.Content.ReadFromJsonAsync(WhatsAppSerializerContext.Default.ErrorResponse, cancellation) is { } error) + await response.Content.ReadFromJsonAsync(JsonContext.Default.ErrorResponse, cancellation) is { } error) throw error.Error; response.EnsureSuccessStatusCode(); - return await response.Content.ReadFromJsonAsync(WhatsAppSerializerContext.Default.MediaReference, cancellation) ?? + return await response.Content.ReadFromJsonAsync(JsonContext.Default.MediaReference, cancellation) ?? throw new InvalidOperationException("Failed to deserialize media reference."); } } diff --git a/src/WhatsApp/WhatsAppHandlerBuilder.cs b/src/WhatsApp/WhatsAppHandlerBuilder.cs new file mode 100644 index 0000000..923ffb9 --- /dev/null +++ b/src/WhatsApp/WhatsAppHandlerBuilder.cs @@ -0,0 +1,87 @@ +namespace Devlooped.WhatsApp; + +public class WhatsAppHandlerBuilder +{ + readonly IWhatsAppHandler inner = EmptyHandler.Default; + List>? factories; + + public IWhatsAppHandler Build(IServiceProvider? services = default) + { + services ??= EmptyServiceProvider.Default; + var handler = inner; + + // Matches behavior of M.E.AI chat client builder + if (factories is not null) + { + foreach (var factory in factories) + { + handler = factory(handler!, services); + if (handler is null) + { + Throw.InvalidOperationException( + $"The {nameof(WhatsAppHandlerBuilder)} entry at index {factories.IndexOf(factory)} returned null. " + + $"Ensure that the callbacks passed to {nameof(Use)} return non-null {nameof(IWhatsAppHandler)} instances."); + } + } + } + + return handler!; + } + + /// Adds a factory for an intermediate handler to the handler pipeline. + /// The handler factory function. + /// The updated instance. + /// is . + public WhatsAppHandlerBuilder Use(Func handlerFactory) + { + _ = Throw.IfNull(handlerFactory); + + return Use((innerClient, _) => handlerFactory(innerClient)); + } + + /// Adds a factory for an intermediate chat client to the chat client pipeline. + /// The handler factory function. + /// The updated instance. + /// is . + public WhatsAppHandlerBuilder Use(Func handlerFactory) + { + _ = Throw.IfNull(handlerFactory); + + (factories ??= []).Add(handlerFactory); + return this; + } + + /// + /// Adds to the handler pipeline an anonymous delegating handler based on a delegate that provides + /// an implementation for . + /// + /// A delegate that provides the implementation for + /// The updated instance. + /// + /// This overload can be used when the anonymous implementation needs to provide pre-processing and/or post-processing, but doesn't + /// need to interact with the results of the operation, which will come from the inner client. + /// + /// is . + public WhatsAppHandlerBuilder Use(Func handlerFunc) + { + _ = Throw.IfNull(handlerFunc); + + return Use((innerClient, _) => new AnonymousDelegatingHandler(innerClient, handlerFunc)); + } + + class EmptyHandler : IWhatsAppHandler + { + public static IWhatsAppHandler Default { get; } = new EmptyHandler(); + + EmptyHandler() { } + + public Task HandleAsync(Message message, CancellationToken cancellation = default) => Task.CompletedTask; + } + + class EmptyServiceProvider : IServiceProvider + { + public static IServiceProvider Default { get; } = new EmptyServiceProvider(); + EmptyServiceProvider() { } + public object? GetService(Type serviceType) => null; + } +} diff --git a/src/WhatsApp/WhatsAppSerializerContext.cs b/src/WhatsApp/WhatsAppSerializerContext.cs deleted file mode 100644 index 8926589..0000000 --- a/src/WhatsApp/WhatsAppSerializerContext.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Devlooped.WhatsApp; - -[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - UseStringEnumConverter = true, - UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, - PropertyNameCaseInsensitive = true, - PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, - WriteIndented = true - )] -[JsonSerializable(typeof(Message))] -[JsonSerializable(typeof(ContentMessage))] -[JsonSerializable(typeof(ErrorMessage))] -[JsonSerializable(typeof(InteractiveMessage))] -[JsonSerializable(typeof(ReactionMessage))] -[JsonSerializable(typeof(StatusMessage))] -[JsonSerializable(typeof(UnsupportedMessage))] -[JsonSerializable(typeof(MediaReference))] -[JsonSerializable(typeof(GraphMethodException))] -[JsonSerializable(typeof(ErrorResponse))] -[JsonSerializable(typeof(SendResponse))] -[JsonSerializable(typeof(MessageId))] -partial class WhatsAppSerializerContext : JsonSerializerContext { } - -record ErrorResponse(GraphMethodException Error); - -record SendResponse(MessageId[] Messages); - -record MessageId(string Id); \ No newline at end of file