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