diff --git a/.github/workflows/publish-to-nuget.yml b/.github/workflows/publish-to-nuget.yml index 557316d..98b2037 100644 --- a/.github/workflows/publish-to-nuget.yml +++ b/.github/workflows/publish-to-nuget.yml @@ -11,12 +11,17 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - folder: [Bones, Bones.Akka, Bones.AspNetCore, Bones.Converters, Bones.Flow, Bones.Grpc, Bones.Tests, Bones.X509, Bones.Akka.Monitoring, Bones.Monitoring] + folder: [Bones, Bones.Akka, Bones.Akka.Tests, Bones.AspNetCore, Bones.Converters, Bones.Flow, Bones.Grpc, Bones.Selectors, Bones.Tests, Bones.X509, Bones.Akka.Monitoring, Bones.Monitoring] steps: # Checking out repository - name: Checkout 🛎️ uses: actions/checkout@v2.3.1 + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + # pack file to publish to NuGet - name: Create a NuGet Package 🔧 run: | diff --git a/BACKPORT.md b/BACKPORT.md new file mode 100644 index 0000000..4cfeed4 --- /dev/null +++ b/BACKPORT.md @@ -0,0 +1,179 @@ +# Backport des composants DAT'Acquisition vers Bones NuGet + +## Contexte + +DAT'Acquisition utilise Bones via un git submodule local qui a diverge du repo GitHub/NuGet. +Ce document recense chaque ajout/modification necessaire pour pouvoir supprimer le submodule +et utiliser exclusivement les packages NuGet Bones. + +Aucun composant backporté n'est specifique a DAT'Acquisition — tous sont des utilitaires generiques. + +--- + +## 1. Bones.Akka — InitializeActor + +**Action** : AJOUTER `InitializeActor.cs` + +**Justification** : Pattern standard Akka pour les acteurs necessitant une initialisation asynchrone +avec stash des messages et retry avec backoff lineaire. Utilise par 32 acteurs dans DAT'Acquisition. +Ce pattern est documente dans la litterature Akka et n'a aucun couplage metier. + +--- + +## 2. Bones.Akka — RestartException + +**Action** : AJOUTER `Exceptions/RestartException.cs` + +**Justification** : Exception marker pour les strategies de supervision Akka +("si RestartException → restart l'acteur"). Pattern standard de supervision, 15 fichiers l'utilisent. + +--- + +## 3. Bones.Akka — DisableMessage + +**Action** : AJOUTER `Messages/DisableMessage.cs` (singleton) + +**Justification** : Message de controle lifecycle acteur complementaire a RestartMessage. +Pattern singleton pour eviter les allocations inutiles (best practice Akka pour les messages sans donnees). + +--- + +## 4. Bones.Akka — RestartMessage + +**Action** : MODIFIER `Messages/RestartMessage.cs` — ajouter le pattern singleton + +**Justification** : Best practice Akka — les messages sans donnees doivent etre des singletons +pour eviter les allocations. 7 fichiers utilisent `RestartMessage.Instance`. + +--- + +## 5. Bones.Akka — Creator delegates + +**Action** : MODIFIER `Creator.cs` — ajouter la covariance `out T` sur `Creator` + +**Justification** : La covariance permet d'utiliser un `Creator` la ou un +`Creator` est attendu, ce qui est essentiel pour le pattern `AddCreator()`. +On garde `IActorContext` (plus generique que `IUntypedActorContext`) et `RootCreator` du NuGet actuel. + +--- + +## 6. Bones.Akka — DependencyInjector + +**Action** : MODIFIER `DI/DependencyInjector.cs` — ajouter la double registration dans `AddCreator()` + +**Justification** : Quand on enregistre `AddCreator()`, il faut +pouvoir resoudre aussi bien `Creator` que `Creator` (pour les tests). +Le NuGet actuel n'enregistre que l'interface. + +--- + +## 7. Bones.Converters — ToFloat, ToHalf, ToInt/ToUInt sans startIndex, StringConverter + +**Action** : MODIFIER `EndianBitConverter.cs` + AJOUTER `StringConverter.cs` + +**Justification** : +- `ToFloat(byte[])` : dispatch automatique vers Half/Single/Double selon la taille — essentiel pour les protocoles industriels ou la taille du registre varie +- `ToHalf(byte[], int)` : decodage IEEE 754 half-precision (16 bits) — standard float16 +- `ToInt(byte[])` / `ToUInt(byte[])` sans startIndex : surcharges de commodite + support des tailles impaires (3, 5, 6, 7 octets) — les registres Modbus et protocoles IoT utilisent des tailles non standard +- `StringConverter` : conversion hex string ↔ byte array — utilitaire de base pour le debug protocole + +Tout est du pur calcul binaire sans couplage metier. + +--- + +## 8. Bones.Flow — ChainOfResponsibility + +**Action** : AJOUTER `Core/ChainOfResponsibility/` (4 fichiers) + interfaces (3 fichiers) + +**Justification** : Implementation du design pattern Chain of Responsibility, integre au systeme +de pipeline Bones.Flow. Deux variantes : `` (sans resultat) et `` (avec resultat). +Implemente `IMiddleware` pour etre composable avec Pipeline via `.With(cor)` / `.Add(cor)`. +Utilise par 15 fichiers dans DAT'Acquisition (handlers de contexte, publishers gateway). +Le pattern est un GoF classique, l'implementation ne contient aucun couplage metier. + +--- + +## 9. Bones.Grpc — Interceptors et Extensions + +**Action** : AJOUTER `DI/DependencyInjector.cs`, +`Extensions/ByteStringExtensions.cs`, `Interceptors/DeadlineInterceptor.cs`, +`Interceptors/StreamDeadlineInterceptor.cs` + +**Justification** : Infrastructure gRPC standard : +- `DeadlineInterceptor` : ajoute un deadline de 5s aux appels unaires — best practice gRPC pour eviter les appels pendants +- `StreamDeadlineInterceptor` : deadline de 30min pour le streaming serveur +- `ByteStringExtensions` : conversion `byte[] → ByteString` (commodite Protobuf) +- Le `NotFoundInterceptor` existe deja dans le NuGet + +Chaque applicatif definit sa propre methode d'extension pour composer les interceptors souhaites. + +Tout est de l'infrastructure gRPC generique. + +--- + +## 10. Bones.Selectors — Projet complet + +**Action** : AJOUTER le projet `Bones.Selectors/` (6 fichiers) + +**Justification** : Utilitaire de selection de donnees dans JSON (dot-path) et XML (XPath). +Pattern generique utilise pour l'extraction de valeurs dans des payloads de protocoles. +Aucun couplage metier — c'est l'equivalent d'un JSONPath simplifie. + +--- + +## 11. Bones.Akka.Tests — Helpers de test + +**Action** : AJOUTER `AkkaTestClass.cs`, `AkkaActorWrapper.cs`, `ProxyNodeActor.cs` +dans `src/Bones.Akka.Tests/` (librairie, pas projet de test) + +**Justification** : Infrastructure de test Akka reutilisable : +- `AkkaTestClass` : classe de base xUnit/TestKit avec helpers (`CreateProxyCreator`, `CreateLogger`, `CreateWrappedActor`) +- `AkkaActorWrapper` : wrapper pour verifier l'existence d'acteurs/enfants dans les tests +- `ProxyNodeActor` : acteur proxy qui forward vers un TestProbe — pattern standard pour mocker les enfants + +Le projet de test actuel sur NuGet est un placeholder vide (`Test1() { }`). + +--- + +## 12. Bones.Akka — BaseManager (depuis DAT-Foundation) + +**Action** : AJOUTER `BaseManager.cs` + +**Justification** : Pattern generique de routage de messages vers des acteurs enfants dynamiques. +L'acteur parent recoit un message, extrait un nom d'enfant (abstract `GetChildName`), cree +l'enfant via Creator s'il n'existe pas, puis forward le message. Ce pattern est utilise +dans DAT-Foundation (DevicesManagerActor, etc.) et correspond exactement a ce que font +DevicesManager, PollersManager, etc. dans DAT-Acquisition — sans la formalisation. + +Pattern GoF Mediator/Router, completement generique. + +--- + +## 13. Bones.Akka — DebouncedActor (depuis DAT-Foundation) + +**Action** : AJOUTER `DebouncedActor.cs` + +**Justification** : Classe de base pour les acteurs qui ont besoin de debouncer des messages +(ne traiter que le dernier message recu dans une fenetre de temps). Utilise les Timers Akka. +Pattern generique utile pour les gateways OPC-UA, les watchers de configuration, etc. + +Variante standalone (pas liee a BaseWorker) pour plus de flexibilite. + +--- + +## 14. FakeEntity — Pas de modification + +**Action** : AUCUNE — la version NuGet (generique `FakeEntity`) est superieure a la version locale (non-generique `FakeEntity`). +C'est DAT'Acquisition qui devra migrer vers `FakeEntity`. + +--- + +## Composant NON backporte + +### MicrosoftDependencyResolver.cs + +**Action** : NE PAS backporter + +**Justification** : Implementation custom de `IDependencyResolver` (Akka.DI.Core deprecated). +Le NuGet utilise deja `Akka.DependencyInjection` (API moderne). Le code local est 129 lignes +de plomberie que l'API standard rend inutile. diff --git a/benchmarks/Bones.Converters.Benchmarks/Bones.Converters.Benchmarks.csproj b/benchmarks/Bones.Converters.Benchmarks/Bones.Converters.Benchmarks.csproj index cf75546..3cc7121 100644 --- a/benchmarks/Bones.Converters.Benchmarks/Bones.Converters.Benchmarks.csproj +++ b/benchmarks/Bones.Converters.Benchmarks/Bones.Converters.Benchmarks.csproj @@ -1,6 +1,6 @@ - + @@ -8,7 +8,7 @@ Exe - net7.0 + net10.0 diff --git a/dev/demo.akka.monitoring/Demo.Akka.Monitoring.Console/Demo.Akka.Monitoring.Console.csproj b/dev/demo.akka.monitoring/Demo.Akka.Monitoring.Console/Demo.Akka.Monitoring.Console.csproj index 6321b0f..4acb6cc 100644 --- a/dev/demo.akka.monitoring/Demo.Akka.Monitoring.Console/Demo.Akka.Monitoring.Console.csproj +++ b/dev/demo.akka.monitoring/Demo.Akka.Monitoring.Console/Demo.Akka.Monitoring.Console.csproj @@ -2,14 +2,14 @@ Exe - net7.0 + net10.0 - - - - + + + + diff --git a/dev/demo.flow/Demo.Flow.Console/Demo.Flow.Console.csproj b/dev/demo.flow/Demo.Flow.Console/Demo.Flow.Console.csproj index 3c2bf88..3034a6d 100644 --- a/dev/demo.flow/Demo.Flow.Console/Demo.Flow.Console.csproj +++ b/dev/demo.flow/Demo.Flow.Console/Demo.Flow.Console.csproj @@ -2,14 +2,14 @@ Exe - net7.0 + net10.0 - - - - + + + + diff --git a/dev/demo.weaving/app/Demo.Core/Demo.Core.csproj b/dev/demo.weaving/app/Demo.Core/Demo.Core.csproj index 371f554..52c0ed8 100644 --- a/dev/demo.weaving/app/Demo.Core/Demo.Core.csproj +++ b/dev/demo.weaving/app/Demo.Core/Demo.Core.csproj @@ -1,16 +1,16 @@ - net7.0 + net10.0 enable enable - - - - + + + + diff --git a/dev/demo.weaving/app/Demo.Runtime/Demo.Runtime.csproj b/dev/demo.weaving/app/Demo.Runtime/Demo.Runtime.csproj index 14e981e..66d82e5 100644 --- a/dev/demo.weaving/app/Demo.Runtime/Demo.Runtime.csproj +++ b/dev/demo.weaving/app/Demo.Runtime/Demo.Runtime.csproj @@ -2,16 +2,16 @@ Exe - net7.0 + net10.0 enable enable - - - - + + + + diff --git a/dev/demo.weaving/shared/Demo.Domain/Demo.Domain.csproj b/dev/demo.weaving/shared/Demo.Domain/Demo.Domain.csproj index 3cf6ca1..0db5411 100644 --- a/dev/demo.weaving/shared/Demo.Domain/Demo.Domain.csproj +++ b/dev/demo.weaving/shared/Demo.Domain/Demo.Domain.csproj @@ -1,11 +1,11 @@ - net7.0 + net10.0 - + diff --git a/dev/dockerfiles/tests-dotnet.dockerfile b/dev/dockerfiles/tests-dotnet.dockerfile index f2529f3..83ccfda 100644 --- a/dev/dockerfiles/tests-dotnet.dockerfile +++ b/dev/dockerfiles/tests-dotnet.dockerfile @@ -13,7 +13,7 @@ RUN find . -type d -empty -delete # ---------------------------------------- -FROM mcr.microsoft.com/dotnet/sdk:7.0 +FROM mcr.microsoft.com/dotnet/sdk:10.0 WORKDIR /app diff --git a/src/Bones.Akka.Monitoring.Weaver.Fody/Bones.Akka.Monitoring.Weaver.Fody.csproj b/src/Bones.Akka.Monitoring.Weaver.Fody/Bones.Akka.Monitoring.Weaver.Fody.csproj index 393c24c..fd6403d 100644 --- a/src/Bones.Akka.Monitoring.Weaver.Fody/Bones.Akka.Monitoring.Weaver.Fody.csproj +++ b/src/Bones.Akka.Monitoring.Weaver.Fody/Bones.Akka.Monitoring.Weaver.Fody.csproj @@ -7,6 +7,6 @@ - + diff --git a/src/Bones.Akka.Monitoring.Weaver/Bones.Akka.Monitoring.Weaver.csproj b/src/Bones.Akka.Monitoring.Weaver/Bones.Akka.Monitoring.Weaver.csproj index 061b063..87f8ea9 100644 --- a/src/Bones.Akka.Monitoring.Weaver/Bones.Akka.Monitoring.Weaver.csproj +++ b/src/Bones.Akka.Monitoring.Weaver/Bones.Akka.Monitoring.Weaver.csproj @@ -1,13 +1,13 @@ - net7.0 + net10.0 ./nugets $(VERSION) - - + + diff --git a/src/Bones.Akka.Monitoring/Bones.Akka.Monitoring.csproj b/src/Bones.Akka.Monitoring/Bones.Akka.Monitoring.csproj index 718611b..74b5d9d 100644 --- a/src/Bones.Akka.Monitoring/Bones.Akka.Monitoring.csproj +++ b/src/Bones.Akka.Monitoring/Bones.Akka.Monitoring.csproj @@ -1,15 +1,15 @@ - net7.0 + net10.0 $(VERSION) dative-gpi dative-gpi - - - + + + diff --git a/src/Bones.Akka.Tests/AkkaActorWrapper.cs b/src/Bones.Akka.Tests/AkkaActorWrapper.cs new file mode 100644 index 0000000..11a8c7a --- /dev/null +++ b/src/Bones.Akka.Tests/AkkaActorWrapper.cs @@ -0,0 +1,64 @@ +using System; +using System.Threading.Tasks; +using Akka.Actor; + +namespace Bones.Akka.Tests +{ + public class AkkaActorWrapper + { + public IActorRef ActorRef; + + public AkkaActorWrapper(IActorRef actorRef) + { + ActorRef = actorRef; + } + + public async Task Exists(TimeSpan timeout) + { + try + { + var identity = await ActorRef.Ask(new Identify(null), timeout); + return identity.Subject != null; + } + catch + { + return false; + } + } + + public async Task HasChild(string name, TimeSpan timeout) + { + var selection = new ActorSelection(ActorRef, name); + + try + { + var actorRef = await selection.ResolveOne(timeout); + return true; + } + catch + { + return false; + } + } + + public async Task GetChildOrDefault(string name, TimeSpan timeout) + { + var selection = new ActorSelection(ActorRef, name); + + try + { + var actorRef = await selection.ResolveOne(timeout); + return actorRef; + } + catch + { + return default(IActorRef); + } + } + + public void Tell(object message, IActorRef sender) + { + ActorRef.Tell(message, sender); + } + } +} diff --git a/src/Bones.Akka.Tests/AkkaTestClass.cs b/src/Bones.Akka.Tests/AkkaTestClass.cs new file mode 100644 index 0000000..3e11e6a --- /dev/null +++ b/src/Bones.Akka.Tests/AkkaTestClass.cs @@ -0,0 +1,69 @@ +using System; +using System.Linq.Expressions; +using Akka.Actor; +using Akka.TestKit; +using Akka.TestKit.Xunit; +using Bones.Tests; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace Bones.Akka.Tests +{ + public class AkkaTestClass : TestKit + { + protected static readonly TimeSpan STANDARD_TIMEOUT = TimeSpan.FromMilliseconds(150); + + // https://gist.github.com/Havret/78409e91c9adf62aed3392574a3d0446 + protected TestScheduler Scheduler => (TestScheduler)Sys.Scheduler; + protected ITestOutputHelper _output; + + public AkkaTestClass(ITestOutputHelper output) + : base(@"akka.scheduler.implementation = ""Akka.TestKit.TestScheduler, Akka.TestKit""") + { + _output = output; + } + + public AkkaActorWrapper CreateWrappedActor( + Expression> constructor, + string name, + TestProbe parent = null + ) + where T : ActorBase + { + IActorRef actorRef; + + if (parent == null) + actorRef = Sys.ActorOf(Props.Create(constructor), name); + else + actorRef = parent.ChildActorOf(Props.Create(constructor), name); + + return new AkkaActorWrapper(actorRef); + } + + public AkkaActorWrapper CreateWrappedActor( + Expression> constructor, + string name, + IUntypedActorContext context + ) + where T : ActorBase + { + IActorRef actorRef; + + actorRef = context.ActorOf(Props.Create(constructor), name); + + return new AkkaActorWrapper(actorRef); + } + + public ILogger CreateLogger() + { + return new XunitLogger(_output); + } + + public Creator CreateProxyCreator(TestProbe probe) + { + return ctx => + Props.Create(() => new ProxyNodeActor(CreateLogger(), probe)); + } + + } +} diff --git a/src/Bones.Akka.Tests/Bones.Akka.Tests.csproj b/src/Bones.Akka.Tests/Bones.Akka.Tests.csproj new file mode 100644 index 0000000..1489d7a --- /dev/null +++ b/src/Bones.Akka.Tests/Bones.Akka.Tests.csproj @@ -0,0 +1,28 @@ + + + + netstandard2.0 + $(VERSION) + dative-gpi + dative-gpi + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Bones.Akka.Tests/ProxyNodeActor.cs b/src/Bones.Akka.Tests/ProxyNodeActor.cs new file mode 100644 index 0000000..cd2738a --- /dev/null +++ b/src/Bones.Akka.Tests/ProxyNodeActor.cs @@ -0,0 +1,19 @@ +using Akka.Actor; +using Microsoft.Extensions.Logging; + +namespace Bones.Akka.Tests +{ + public class ProxyNodeActor : ReceiveActor + { + private ILogger _logger; + private IActorRef _probe; + + public ProxyNodeActor(ILogger logger, IActorRef probe) + { + _logger = logger; + _probe = probe; + + ReceiveAny(m => _probe.Tell(m)); + } + } +} \ No newline at end of file diff --git a/src/Bones.Akka/ActorRefProvider.cs b/src/Bones.Akka/ActorRefProvider.cs deleted file mode 100644 index 1e4bbc0..0000000 --- a/src/Bones.Akka/ActorRefProvider.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Akka.Actor; - -namespace Bones.Akka -{ - public class ActorRefProvider : ActorRefProvider, IActorRefProvider - { - public ActorRefProvider(ActorSelection actorRefs) - : base(actorRefs) - { - } - } - - public abstract class ActorRefProvider : IActorRefProvider - { - public ActorSelection ActorRefs { get; } - protected ActorRefProvider(ActorSelection actorRefs) - { - ActorRefs = actorRefs; - } - } -} \ No newline at end of file diff --git a/src/Bones.Akka/BaseManager.cs b/src/Bones.Akka/BaseManager.cs new file mode 100644 index 0000000..36d5875 --- /dev/null +++ b/src/Bones.Akka/BaseManager.cs @@ -0,0 +1,65 @@ +using System; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using Akka.Actor; + +namespace Bones.Akka +{ + public abstract class BaseManager : ReceiveActor + { + protected ILogger Logger { get; } + protected IServiceProvider ServiceProvider { get; } + + public BaseManager(IServiceProvider sp, ILogger logger) + { + Logger = logger; + ServiceProvider = sp; + + Receive(OnMessageReceived); + + Logger.LogInformation("Started at {path}", Self.Path.ToString()); + } + + private void OnMessageReceived(TMessage message) + { + var childName = GetChildName(message); + + if (string.IsNullOrWhiteSpace(childName)) + { + Logger.LogWarning("Ignoring message because no child name was provided in message : {@message}", message); + return; + } + + var child = Context.Child(childName); + + if (child.IsNobody()) + { + using (var scope = ServiceProvider.CreateScope()) + { + var creator = scope.ServiceProvider.GetRequiredService>(); + + Logger.LogInformation("Creating child with name {childName}", childName); + child = Context.ActorOf(creator(Context), childName); + } + } + + child.Forward(message); + } + + protected abstract string GetChildName(TMessage message); + + protected override void PreRestart(Exception reason, object message) + { + Logger.LogError(reason, "Restarting actor at {path}", Self.Path.ToString()); + + base.PreRestart(reason, message); + } + + protected override void PostStop() + { + Logger.LogInformation("Stopped at {path}", Self.Path.ToString()); + } + } +} diff --git a/src/Bones.Akka/Bones.Akka.csproj b/src/Bones.Akka/Bones.Akka.csproj index 577cd2e..8cd7755 100644 --- a/src/Bones.Akka/Bones.Akka.csproj +++ b/src/Bones.Akka/Bones.Akka.csproj @@ -8,10 +8,11 @@ - - - - + + + + + diff --git a/src/Bones.Akka/Creator.cs b/src/Bones.Akka/Creator.cs index 227b35e..90edae2 100644 --- a/src/Bones.Akka/Creator.cs +++ b/src/Bones.Akka/Creator.cs @@ -4,6 +4,5 @@ namespace Bones.Akka { public delegate Props Creator(Type t, IActorContext context); - public delegate Props Creator(IActorContext context); - public delegate Props RootCreator(ActorSystem context); -} \ No newline at end of file + public delegate Props Creator(IActorContext context); +} diff --git a/src/Bones.Akka/DI/DependencyInjector.cs b/src/Bones.Akka/DI/DependencyInjector.cs index 28bca44..4853ba9 100644 --- a/src/Bones.Akka/DI/DependencyInjector.cs +++ b/src/Bones.Akka/DI/DependencyInjector.cs @@ -31,7 +31,7 @@ public static IServiceCollection AddAkka(this IServiceCollection services, strin return actorSystem; }); - services.AddScoped(sp => + services.AddSingleton(sp => { return (type, context) => DependencyResolver.For(context.System).Props(type); }); @@ -44,7 +44,7 @@ public static IServiceCollection AddCreator(this IServiceCollection serv { services.AddScoped(); - services.AddScoped>(sp => + services.AddSingleton>(sp => { return (context) => DependencyResolver.For(context.System).Props(); }); @@ -52,43 +52,23 @@ public static IServiceCollection AddCreator(this IServiceCollection serv return services; } - - public static IServiceCollection AddRootCreator(this IServiceCollection services) - where TActor : ActorBase - { - services.AddScoped(); - - services.AddSingleton>(sp => - { - return (context) => DependencyResolver.For(context).Props(); - }); - - return services; - } - public static IServiceCollection AddCreator(this IServiceCollection services) where TActor : ActorBase, TInterface { services.AddScoped(); - services.AddScoped>(sp => + services.AddSingleton>(sp => { return (context) => DependencyResolver.For(context.System).Props(); }); - return services; - } - - public static IServiceCollection AddActorRef(this IServiceCollection services, string pattern) - { - services.AddSingleton>(sp => { - var actorSystem = sp.GetRequiredService(); - return new ActorRefProvider( - actorSystem.ActorSelection(pattern) - ); + services.AddSingleton>(sp => + { + return (context) => DependencyResolver.For(context.System).Props(); }); return services; } + } } \ No newline at end of file diff --git a/src/Bones.Akka/DebouncedActor.cs b/src/Bones.Akka/DebouncedActor.cs new file mode 100644 index 0000000..335d4dd --- /dev/null +++ b/src/Bones.Akka/DebouncedActor.cs @@ -0,0 +1,36 @@ +using System; + +using Akka.Actor; + +namespace Bones.Akka +{ + public abstract class DebouncedActor : ReceiveActor, IWithTimers + { + const string DEBOUNCER = "DEBOUNCER"; + + public ITimerScheduler Timers { get; set; } + + protected void Debounce(TMessage message, TimeSpan? delay = null) + { + Timers.StartSingleTimer( + DEBOUNCER, + new DebouncedMessage { Content = message }, + delay ?? TimeSpan.FromMilliseconds(500) + ); + } + + protected void Debounce(string key, TMessage message, TimeSpan? delay = null) + { + Timers.StartSingleTimer( + key, + new DebouncedMessage { Content = message }, + delay ?? TimeSpan.FromMilliseconds(500) + ); + } + + protected class DebouncedMessage + { + public T Content { get; set; } + } + } +} diff --git a/src/Bones.Akka/Exceptions/RestartException.cs b/src/Bones.Akka/Exceptions/RestartException.cs new file mode 100644 index 0000000..88f372d --- /dev/null +++ b/src/Bones.Akka/Exceptions/RestartException.cs @@ -0,0 +1,9 @@ +using System; + +namespace Bones.Akka.Exceptions +{ + public class RestartException : Exception + { + + } +} diff --git a/src/Bones.Akka/IActorRefProvider.cs b/src/Bones.Akka/IActorRefProvider.cs deleted file mode 100644 index 0a3f2e5..0000000 --- a/src/Bones.Akka/IActorRefProvider.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Akka.Actor; - -namespace Bones.Akka -{ - public interface IActorRefProvider - { - ActorSelection ActorRefs { get; } - } - - public interface IActorRefProvider : IActorRefProvider - { - } -} \ No newline at end of file diff --git a/src/Bones.Akka/IActorRefProviderExtensions.cs b/src/Bones.Akka/IActorRefProviderExtensions.cs deleted file mode 100644 index 96a07d9..0000000 --- a/src/Bones.Akka/IActorRefProviderExtensions.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Akka.Actor; - -namespace Bones.Akka -{ - public static class IActorRefProviderExtensions - { - public static void Tell(this IActorRefProvider provider, object message, IActorRef sender) - { - provider.ActorRefs.Tell(message, sender); - } - - public static void Tell(this IActorRefProvider provider, object message) - { - provider.ActorRefs.Tell(message, ActorRefs.NoSender); - } - - public static Task Ask(this IActorRefProvider provider, object message, TimeSpan? timeout = null) - { - return provider.ActorRefs.Ask(message, timeout); - } - - public static Task Ask(this IActorRefProvider provider, object message, CancellationToken cancellationToken) - { - return provider.ActorRefs.Ask(message, cancellationToken); - } - - public static Task Ask(this IActorRefProvider provider, object message, TimeSpan? timeout, CancellationToken cancellationToken) - { - return provider.ActorRefs.Ask(message, timeout, cancellationToken); - } - - public static Task Ask(this IActorRefProvider provider, object message, TimeSpan? timeout = null) - { - return provider.ActorRefs.Ask(message, timeout); - } - - public static Task Ask(this IActorRefProvider provider, object message, CancellationToken cancellationToken) - { - return provider.ActorRefs.Ask(message, cancellationToken); - } - - public static Task Ask(this IActorRefProvider provider, object message, TimeSpan? timeout, CancellationToken cancellationToken) - { - return provider.ActorRefs.Ask(message, timeout, cancellationToken); - } - - public static Task Ask(this IActorRefProvider provider, Func messageFactory, TimeSpan? timeout, CancellationToken cancellationToken) - { - return provider.ActorRefs.Ask(messageFactory, timeout, cancellationToken); - } - } -} \ No newline at end of file diff --git a/src/Bones.Akka/InitializeActor.cs b/src/Bones.Akka/InitializeActor.cs new file mode 100644 index 0000000..7ab48bc --- /dev/null +++ b/src/Bones.Akka/InitializeActor.cs @@ -0,0 +1,100 @@ +using System; +using System.Threading.Tasks; + +using Microsoft.Extensions.Logging; + +using Akka.Actor; + +namespace Bones.Akka +{ + public abstract class InitializeActor : + ReceiveActor, + IWithUnboundedStash, + IWithTimers + { + protected const string INIT = "init"; + protected const int MAX_WAIT_TIME = 30; + + int ReinitializeCount { get; set; } + + protected IActorRef _self; + protected ILogger _logger; + public IStash Stash { get; set; } + public ITimerScheduler Timers { get; set; } + + public InitializeActor( + ILogger logger + ) + { + _logger = logger; + ReinitializeCount = 0; + Initializing(); + } + + // Initialize + + protected override void PreStart() + { + base.PreStart(); + Self.Tell(INIT); + } + + private void Initializing() + { + OnInitializing(); + + ReceiveAsync(StartInitialization, s => s == INIT); + ReceiveAny(m => Stash.Stash()); + } + + /// + /// Allows us to add custom receive handlers before the basic during initialization. + /// This method can be overridden in derived classes to set up additional message handlers. + /// + protected virtual void OnInitializing() + { + } + + private async Task StartInitialization(string init) + { + _self = Self; + + try + { + await Initialize(); + ReinitializeCount = 0; + Stash.UnstashAll(); + } + catch (Exception ex) + { + _logger.LogError(ex, "[{path}] An error occurred during initialization. Retrying...", Self.Path); + Reinitialize(); + } + } + + protected abstract Task Initialize(); + + // Reinitialize + + public void Reinitialize() + { + _logger.LogInformation("Retrying in {time} seconds", Math.Min(MAX_WAIT_TIME, 2 * ReinitializeCount)); + + Timers.StartSingleTimer( + INIT, + INIT, + TimeSpan.FromSeconds(Math.Min(MAX_WAIT_TIME, 2 * ReinitializeCount)) + ); + + ReinitializeCount++; + } + + // Lifecycle + + protected override void PostStop() + { + base.PostStop(); + Timers.CancelAll(); + } + } +} diff --git a/src/Bones.Akka/Messages/DisableMessage.cs b/src/Bones.Akka/Messages/DisableMessage.cs new file mode 100644 index 0000000..217652b --- /dev/null +++ b/src/Bones.Akka/Messages/DisableMessage.cs @@ -0,0 +1,15 @@ +namespace Bones.Akka.Messages +{ + public class DisableMessage + { + private static readonly DisableMessage _instance = new DisableMessage(); + public static DisableMessage Instance + { + get { return _instance; } + } + + static DisableMessage() { } + + private DisableMessage() { } + } +} diff --git a/src/Bones.Akka/Messages/RestartMessage.cs b/src/Bones.Akka/Messages/RestartMessage.cs index d1dd45b..b9d7d24 100644 --- a/src/Bones.Akka/Messages/RestartMessage.cs +++ b/src/Bones.Akka/Messages/RestartMessage.cs @@ -2,5 +2,14 @@ namespace Bones.Akka.Messages { public class RestartMessage { + private static readonly RestartMessage _instance = new RestartMessage(); + public static RestartMessage Instance + { + get { return _instance; } + } + + static RestartMessage() { } + + private RestartMessage() { } } -} \ No newline at end of file +} diff --git a/src/Bones.AspNetCore/Bones.AspNetCore.csproj b/src/Bones.AspNetCore/Bones.AspNetCore.csproj index b87c7f0..090af61 100644 --- a/src/Bones.AspNetCore/Bones.AspNetCore.csproj +++ b/src/Bones.AspNetCore/Bones.AspNetCore.csproj @@ -5,7 +5,7 @@ - net7.0 + net10.0 $(VERSION) dative-gpi dative-gpi diff --git a/src/Bones.Converters/Bones.Converters.csproj b/src/Bones.Converters/Bones.Converters.csproj index 87b1448..714b26a 100644 --- a/src/Bones.Converters/Bones.Converters.csproj +++ b/src/Bones.Converters/Bones.Converters.csproj @@ -8,7 +8,7 @@ - + diff --git a/src/Bones.Converters/EndianBitConverter.cs b/src/Bones.Converters/EndianBitConverter.cs index fa3f551..4f98060 100644 --- a/src/Bones.Converters/EndianBitConverter.cs +++ b/src/Bones.Converters/EndianBitConverter.cs @@ -165,23 +165,73 @@ public float ToSingle(byte[] value, int startIndex) } + public virtual long ToInt(byte[] value) + { + return ToInt(value, 0); + } + public virtual long ToInt(byte[] value, int startIndex) { - switch (value.Length) + if (startIndex < 0 || startIndex > value.Length) + throw new ArgumentOutOfRangeException(nameof(startIndex)); + + var remaining = value.Length - startIndex; + + switch (remaining) { case 0: return 0; + case 1: + return (sbyte)value[startIndex]; case 2: - return ToInt16(value, 0); + return ToInt16(value, startIndex); + case 3: + return ToInt32(PadBytes(value, startIndex, 3, 4, signExtend: true), 0); case 4: - return ToInt32(value, 0); + return ToInt32(value, startIndex); + case 5: + return ToInt64(PadBytes(value, startIndex, 5, 8, signExtend: true), 0); + case 6: + return ToInt64(PadBytes(value, startIndex, 6, 8, signExtend: true), 0); + case 7: + return ToInt64(PadBytes(value, startIndex, 7, 8, signExtend: true), 0); case 8: - return ToInt64(value, 0); + return ToInt64(value, startIndex); default: throw new NotImplementedException($"Data can't be converted to int - b64: {Convert.ToBase64String(value)} \tLength: {value.Length}"); } } + private byte[] PadBytes(byte[] value, int startIndex, int count, int targetWidth, bool signExtend) + { + var padded = new byte[targetWidth]; + var padding = targetWidth - count; + + byte fill = 0x00; + if (signExtend) + { + var msb = Endianness == Endianness.BigEndian + ? value[startIndex] + : value[startIndex + count - 1]; + fill = (byte)((msb & 0x80) != 0 ? 0xFF : 0x00); + } + + if (Endianness == Endianness.BigEndian) + { + for (var i = 0; i < padding; i++) + padded[i] = fill; + Array.Copy(value, startIndex, padded, padding, count); + } + else + { + Array.Copy(value, startIndex, padded, 0, count); + for (var i = count; i < targetWidth; i++) + padded[i] = fill; + } + + return padded; + } + /// /// Returns a 16-bit signed integer converted from two bytes at a specified position in a byte array. /// @@ -207,29 +257,92 @@ public virtual long ToInt(byte[] value, int startIndex) public abstract long ToInt64(byte[] value, int startIndex); - public virtual ulong ToUInt(byte[] value, int startIndex) + public virtual double ToFloatingPoint(byte[] value) { switch (value.Length) + { + case 0: + return 0; + case 2: + return ToHalf(value, 0); + case 4: + return ToSingle(value, 0); + case 8: + return ToDouble(value, 0); + default: + throw new NotImplementedException($"Data can't be converted to floating point - b64: {Convert.ToBase64String(value)} \tLength: {value.Length}"); + } + } + + [Obsolete("Use ToFloatingPoint instead.")] + public virtual double ToFloat(byte[] value) => ToFloatingPoint(value); + + public float ToHalf(byte[] value, int startIndex) + { + var intVal = ToUInt16(value, startIndex); + int sign = (intVal >> 15) & 0x0001; + int exponent = (intVal >> 10) & 0x001F; + int mantissa = intVal & 0x03FF; + + if (exponent == 0) + { + if (mantissa == 0) + return sign == 0 ? 0f : -0f; + // Subnormal + while ((mantissa & 0x0400) == 0) + { + mantissa <<= 1; + exponent--; + } + exponent++; + mantissa &= ~0x0400; + } + else if (exponent == 31) + { + return mantissa == 0 + ? (sign == 0 ? float.PositiveInfinity : float.NegativeInfinity) + : float.NaN; + } + + exponent = exponent + (127 - 15); + mantissa = mantissa << 13; + + int floatBits = (sign << 31) | (exponent << 23) | mantissa; + return new Int32SingleUnion(floatBits).AsSingle; + } + + public virtual ulong ToUInt(byte[] value) + { + return ToUInt(value, 0); + } + + public virtual ulong ToUInt(byte[] value, int startIndex) + { + if (startIndex < 0 || startIndex > value.Length) + throw new ArgumentOutOfRangeException(nameof(startIndex)); + + var remaining = value.Length - startIndex; + + switch (remaining) { case 0: return 0; case 1: - return (UInt16)(ToUInt16(value, 0) >> 8); + return value[startIndex]; case 2: - return ToUInt16(value, 0); + return ToUInt16(value, startIndex); case 3: - return ToUInt32(value, 0) >> 8; + return ToUInt32(PadBytes(value, startIndex, 3, 4, signExtend: false), 0); case 4: - return ToUInt32(value, 0); - + return ToUInt32(value, startIndex); case 5: - return ToUInt64(value, 0) >> 24; + return ToUInt64(PadBytes(value, startIndex, 5, 8, signExtend: false), 0); case 6: - return ToUInt64(value, 0) >> 16; + return ToUInt64(PadBytes(value, startIndex, 6, 8, signExtend: false), 0); case 7: - return ToUInt64(value, 0) >> 8; + return ToUInt64(PadBytes(value, startIndex, 7, 8, signExtend: false), 0); case 8: - return ToUInt64(value, 0); + return ToUInt64(value, startIndex); default: throw new NotImplementedException($"Data can't be convert to uint - b64: {Convert.ToBase64String(value)}"); } diff --git a/src/Bones.Converters/StringConverter.cs b/src/Bones.Converters/StringConverter.cs new file mode 100644 index 0000000..f8e9fbe --- /dev/null +++ b/src/Bones.Converters/StringConverter.cs @@ -0,0 +1,30 @@ +using System; +using System.Text; + +namespace Bones.Converters +{ + public static class StringConverter + { + public static byte[] ToBytes(string hex) + { + if (string.IsNullOrEmpty(hex)) + return Array.Empty(); + + if (hex.Length % 2 != 0) + throw new ArgumentException("Hex string must have an even number of characters.", nameof(hex)); + + var bytes = new byte[hex.Length / 2]; + for (var i = 0; i < hex.Length; i += 2) + bytes[i / 2] = Convert.ToByte(hex.Substring(i, 2), 16); + return bytes; + } + + public static string FromBytes(byte[] ba) + { + StringBuilder hex = new StringBuilder(ba.Length * 2); + foreach (byte b in ba) + hex.AppendFormat("{0:x2}", b); + return hex.ToString(); + } + } +} diff --git a/src/Bones.Flow/Bones.Flow.csproj b/src/Bones.Flow/Bones.Flow.csproj index 28cf400..3c42491 100644 --- a/src/Bones.Flow/Bones.Flow.csproj +++ b/src/Bones.Flow/Bones.Flow.csproj @@ -1,15 +1,15 @@ - net7.0 + net10.0 $(VERSION) dative-gpi dative-gpi - - + + diff --git a/src/Bones.Flow/Core/ChainOfResponsibility/ChainOfResponsibility.cs b/src/Bones.Flow/Core/ChainOfResponsibility/ChainOfResponsibility.cs new file mode 100644 index 0000000..cc12fa8 --- /dev/null +++ b/src/Bones.Flow/Core/ChainOfResponsibility/ChainOfResponsibility.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Bones.Flow.Core +{ + internal class ChainOfResponsibility : IChainOfResponsibility + where TRequest : IRequest + { + private List> _handlers = new List>(); + private ILogger> _logger; + + public ChainOfResponsibility( + ILogger> logger + ) + { + _logger = logger; + } + + public void Configure(List> handlers) + { + _handlers = handlers ?? throw new ArgumentNullException(nameof(handlers)); + } + + async Task ICommandHandler.HandleAsync(TRequest request, CancellationToken cancellationToken, bool commit) + { + foreach (var handler in _handlers) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + if (await handler.HandleAsync(request, cancellationToken)) + return; + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred in ChainOfResponsibility handler {handler}", handler.GetType().Name); + throw; + } + } + } + + async Task IMiddleware.HandleAsync(TRequest request, Func next, CancellationToken cancellationToken) + { + foreach (var handler in _handlers) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + if (await handler.HandleAsync(request, cancellationToken)) + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred in ChainOfResponsibility handler {handler}", handler.GetType().Name); + throw; + } + } + + await next(); + } + } +} diff --git a/src/Bones.Flow/Core/ChainOfResponsibility/ChainOfResponsibilityFactory.cs b/src/Bones.Flow/Core/ChainOfResponsibility/ChainOfResponsibilityFactory.cs new file mode 100644 index 0000000..66b3d6b --- /dev/null +++ b/src/Bones.Flow/Core/ChainOfResponsibility/ChainOfResponsibilityFactory.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.DependencyInjection; + +namespace Bones.Flow.Core +{ + internal class ChainOfResponsibilityFactory : IChainOfResponsibilityFactory + where TRequest : IRequest + { + private IServiceProvider _provider; + private List> _handlers; + + public ChainOfResponsibilityFactory(IServiceProvider provider) + { + _provider = provider; + _handlers = new List>(); + } + + public IChainOfResponsibilityFactory Add() + where THandler : IChainOfResponsibilityHandler + { + var handler = _provider.GetRequiredService(); + return Add(handler); + } + + public IChainOfResponsibilityFactory Add(THandler handler) + where THandler : IChainOfResponsibilityHandler + { + _handlers.Add(handler); + return this; + } + + public IChainOfResponsibility Build() + { + var chain = _provider.GetRequiredService>(); + chain.Configure(_handlers); + return chain; + } + } +} diff --git a/src/Bones.Flow/Core/ChainOfResponsibility/ResultChainOfResponsibility.cs b/src/Bones.Flow/Core/ChainOfResponsibility/ResultChainOfResponsibility.cs new file mode 100644 index 0000000..fca0687 --- /dev/null +++ b/src/Bones.Flow/Core/ChainOfResponsibility/ResultChainOfResponsibility.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Bones.Flow.Core +{ + internal class ResultChainOfResponsibility : IChainOfResponsibility + where TRequest : IRequest + { + private List> _handlers = new List>(); + private ILogger> _logger; + + public ResultChainOfResponsibility( + ILogger> logger + ) + { + _logger = logger; + } + + public void Configure(List> handlers) + { + _handlers = handlers ?? throw new ArgumentNullException(nameof(handlers)); + } + + async Task ICommandHandler.HandleAsync(TRequest request, CancellationToken cancellationToken, bool commit) + { + foreach (var handler in _handlers) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var (handled, result) = await handler.HandleAsync(request, cancellationToken); + if (handled) + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred in ChainOfResponsibility handler {handler}", handler.GetType().Name); + throw; + } + } + + return default; + } + + async Task IMiddleware.HandleAsync(TRequest request, Func> next, CancellationToken cancellationToken) + { + foreach (var handler in _handlers) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var (handled, result) = await handler.HandleAsync(request, cancellationToken); + if (handled) + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred in ChainOfResponsibility handler {handler}", handler.GetType().Name); + throw; + } + } + + return await next(); + } + } +} diff --git a/src/Bones.Flow/Core/ChainOfResponsibility/ResultChainOfResponsibilityFactory.cs b/src/Bones.Flow/Core/ChainOfResponsibility/ResultChainOfResponsibilityFactory.cs new file mode 100644 index 0000000..703f59e --- /dev/null +++ b/src/Bones.Flow/Core/ChainOfResponsibility/ResultChainOfResponsibilityFactory.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.DependencyInjection; + +namespace Bones.Flow.Core +{ + internal class ResultChainOfResponsibilityFactory : IChainOfResponsibilityFactory + where TRequest : IRequest + { + private IServiceProvider _provider; + private List> _handlers; + + public ResultChainOfResponsibilityFactory(IServiceProvider provider) + { + _provider = provider; + _handlers = new List>(); + } + + public IChainOfResponsibilityFactory Add() + where THandler : IChainOfResponsibilityHandler + { + var handler = _provider.GetRequiredService(); + return Add(handler); + } + + public IChainOfResponsibilityFactory Add(THandler handler) + where THandler : IChainOfResponsibilityHandler + { + _handlers.Add(handler); + return this; + } + + public IChainOfResponsibility Build() + { + var chain = _provider.GetRequiredService>(); + chain.Configure(_handlers); + return chain; + } + } +} diff --git a/src/Bones.Flow/Core/RequestResultPipeline/RequestResultPipelineFactory.cs b/src/Bones.Flow/Core/RequestResultPipeline/RequestResultPipelineFactory.cs index 79966ef..e7fcc03 100644 --- a/src/Bones.Flow/Core/RequestResultPipeline/RequestResultPipelineFactory.cs +++ b/src/Bones.Flow/Core/RequestResultPipeline/RequestResultPipelineFactory.cs @@ -51,7 +51,11 @@ public IPipeline Build() public IBuildablePipelineFactory Add() where TMiddleware : IMiddleware { var middleware = _provider.GetRequiredService(); + return Add(middleware); + } + public IBuildablePipelineFactory Add(TMiddleware middleware) where TMiddleware : IMiddleware + { _requestResultMiddlewares.Add(middleware); _middlewareTypes.Add(MiddlewareType.RequestResultMiddleware); @@ -61,7 +65,11 @@ public IBuildablePipelineFactory Add() where TMi public IPipelineFactory With() where TMiddleware : IMiddleware { var middleware = _provider.GetRequiredService(); + return With(middleware); + } + public IPipelineFactory With(TMiddleware middleware) where TMiddleware : IMiddleware + { _requestMiddlewares.Add(middleware); _middlewareTypes.Add(MiddlewareType.RequestMiddleware); diff --git a/src/Bones.Flow/DI/DependencyInjector.cs b/src/Bones.Flow/DI/DependencyInjector.cs index 6c38fa4..7fecacc 100644 --- a/src/Bones.Flow/DI/DependencyInjector.cs +++ b/src/Bones.Flow/DI/DependencyInjector.cs @@ -19,8 +19,13 @@ public static IServiceCollection AddFlow(this IServiceCollection services, Actio services.AddScoped(typeof(IPipelineFactory<,>), typeof(RequestResultPipelineFactory<,>)); services.AddScoped(typeof(IPipeline<,>), typeof(RequestResultPipeline<,>)); + services.AddScoped(typeof(IChainOfResponsibilityFactory<>), typeof(ChainOfResponsibilityFactory<>)); + services.AddScoped(typeof(IChainOfResponsibility<>), typeof(ChainOfResponsibility<>)); + services.AddScoped(typeof(IChainOfResponsibilityFactory<,>), typeof(ResultChainOfResponsibilityFactory<,>)); + services.AddScoped(typeof(IChainOfResponsibility<,>), typeof(ResultChainOfResponsibility<,>)); + services.AddMonitoring(BONES_FLOW_INSTRUMENTATION, configureMonitoringOptions); - + return services; } diff --git a/src/Bones.Flow/Extensions/ServiceProviderExtensions.cs b/src/Bones.Flow/Extensions/ServiceProviderExtensions.cs index 3b5c2c7..57da647 100644 --- a/src/Bones.Flow/Extensions/ServiceProviderExtensions.cs +++ b/src/Bones.Flow/Extensions/ServiceProviderExtensions.cs @@ -14,5 +14,17 @@ public static IPipelineFactory GetPipelineFactory>(); } + + public static IChainOfResponsibilityFactory GetChainOfResponsibilityFactory(this IServiceProvider provider) + where TRequest : IRequest + { + return provider.GetRequiredService>(); + } + + public static IChainOfResponsibilityFactory GetChainOfResponsibilityFactory(this IServiceProvider provider) + where TRequest : IRequest + { + return provider.GetRequiredService>(); + } } } \ No newline at end of file diff --git a/src/Bones.Flow/Interfaces/IChainOfResponsibility.cs b/src/Bones.Flow/Interfaces/IChainOfResponsibility.cs new file mode 100644 index 0000000..b1b3b6e --- /dev/null +++ b/src/Bones.Flow/Interfaces/IChainOfResponsibility.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace Bones.Flow +{ + public interface IChainOfResponsibility : ICommandHandler, IMiddleware + where TRequest : IRequest + { + void Configure(List> handlers); + } + + public interface IChainOfResponsibility : ICommandHandler, IMiddleware + where TRequest : IRequest + { + void Configure(List> handlers); + } +} diff --git a/src/Bones.Flow/Interfaces/IChainOfResponsibilityFactory.cs b/src/Bones.Flow/Interfaces/IChainOfResponsibilityFactory.cs new file mode 100644 index 0000000..9d91138 --- /dev/null +++ b/src/Bones.Flow/Interfaces/IChainOfResponsibilityFactory.cs @@ -0,0 +1,20 @@ +namespace Bones.Flow +{ + public interface IChainOfResponsibilityFactory where TRequest : IRequest + { + IChainOfResponsibilityFactory Add() + where THandler : IChainOfResponsibilityHandler; + IChainOfResponsibilityFactory Add(THandler handler) + where THandler : IChainOfResponsibilityHandler; + IChainOfResponsibility Build(); + } + + public interface IChainOfResponsibilityFactory where TRequest : IRequest + { + IChainOfResponsibilityFactory Add() + where THandler : IChainOfResponsibilityHandler; + IChainOfResponsibilityFactory Add(THandler handler) + where THandler : IChainOfResponsibilityHandler; + IChainOfResponsibility Build(); + } +} diff --git a/src/Bones.Flow/Interfaces/IChainOfResponsibilityHandler.cs b/src/Bones.Flow/Interfaces/IChainOfResponsibilityHandler.cs new file mode 100644 index 0000000..f58681a --- /dev/null +++ b/src/Bones.Flow/Interfaces/IChainOfResponsibilityHandler.cs @@ -0,0 +1,15 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Bones.Flow +{ + public interface IChainOfResponsibilityHandler where TRequest : IRequest + { + Task HandleAsync(TRequest request, CancellationToken cancellationToken); + } + + public interface IChainOfResponsibilityHandler where TRequest : IRequest + { + Task<(bool handled, TResult result)> HandleAsync(TRequest request, CancellationToken cancellationToken); + } +} diff --git a/src/Bones.Flow/Interfaces/IPipelineFactory.cs b/src/Bones.Flow/Interfaces/IPipelineFactory.cs index f537892..e9edf5b 100644 --- a/src/Bones.Flow/Interfaces/IPipelineFactory.cs +++ b/src/Bones.Flow/Interfaces/IPipelineFactory.cs @@ -24,11 +24,15 @@ IPipelineFactory OnFailure(THandler handler) public interface IPipelineFactory where TRequest: IRequest { - IBuildablePipelineFactory Add() + IBuildablePipelineFactory Add() + where TMiddleware : IMiddleware; + IBuildablePipelineFactory Add(TMiddleware middleware) where TMiddleware : IMiddleware; IPipelineFactory With() where TMiddleware : IMiddleware; + IPipelineFactory With(TMiddleware middleware) + where TMiddleware : IMiddleware; } public interface IBuildablePipelineFactory : IPipelineFactory diff --git a/src/Bones.Grpc/Bones.Grpc.csproj b/src/Bones.Grpc/Bones.Grpc.csproj index 11da5b2..6a62cf8 100644 --- a/src/Bones.Grpc/Bones.Grpc.csproj +++ b/src/Bones.Grpc/Bones.Grpc.csproj @@ -8,7 +8,10 @@ - + + + + diff --git a/src/Bones.Grpc/DI/DependencyInjector.cs b/src/Bones.Grpc/DI/DependencyInjector.cs new file mode 100644 index 0000000..1613504 --- /dev/null +++ b/src/Bones.Grpc/DI/DependencyInjector.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Bones.Grpc.DI +{ + public static class DependencyInjector + { + public static IServiceCollection AddBonesGrpc(this IServiceCollection services) + { + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + return services; + } + } +} \ No newline at end of file diff --git a/src/Bones.Grpc/Extensions/ByteStringExtensions.cs b/src/Bones.Grpc/Extensions/ByteStringExtensions.cs new file mode 100644 index 0000000..b312066 --- /dev/null +++ b/src/Bones.Grpc/Extensions/ByteStringExtensions.cs @@ -0,0 +1,14 @@ +using Google.Protobuf; + +namespace Bones.Grpc.Extensions { + public static class ByteStringExtensions + { + public static ByteString ToByteString(this byte[] byteArray) + { + if (byteArray == null || byteArray.Length == 0) + return ByteString.Empty; + + return ByteString.CopyFrom(byteArray); + } + } +} \ No newline at end of file diff --git a/src/Bones.Grpc/Interceptors/DeadlineInterceptor.cs b/src/Bones.Grpc/Interceptors/DeadlineInterceptor.cs new file mode 100644 index 0000000..c5d12b4 --- /dev/null +++ b/src/Bones.Grpc/Interceptors/DeadlineInterceptor.cs @@ -0,0 +1,38 @@ + +using System; +using Grpc.Core; +using Grpc.Core.Interceptors; + +namespace Bones.Grpc +{ + public class DeadlineInterceptor : Interceptor + { + private const double DEADLINE_IN_SECONDS = 5; + + public override AsyncUnaryCall AsyncUnaryCall(TRequest request, ClientInterceptorContext context, AsyncUnaryCallContinuation continuation) + { + var newContext = context; + var deadline = DateTime.UtcNow.AddSeconds(DEADLINE_IN_SECONDS); + + if ( + context.Options.CancellationToken == default && + (!context.Options.Deadline.HasValue || + context.Options.Deadline.Value >= deadline) + ) + { + var newOptions = context.Options.WithDeadline(deadline); + newContext = new ClientInterceptorContext(context.Method, context.Host, newOptions); + } + + var call = continuation(request, newContext); + + return new AsyncUnaryCall( + call.ResponseAsync, + call.ResponseHeadersAsync, + call.GetStatus, + call.GetTrailers, + call.Dispose + ); + } + } +} \ No newline at end of file diff --git a/src/Bones.Grpc/NotFoundInterceptor.cs b/src/Bones.Grpc/Interceptors/NotFoundInterceptor.cs similarity index 100% rename from src/Bones.Grpc/NotFoundInterceptor.cs rename to src/Bones.Grpc/Interceptors/NotFoundInterceptor.cs diff --git a/src/Bones.Grpc/Interceptors/StreamDeadlineInterceptor.cs b/src/Bones.Grpc/Interceptors/StreamDeadlineInterceptor.cs new file mode 100644 index 0000000..ab1fcc6 --- /dev/null +++ b/src/Bones.Grpc/Interceptors/StreamDeadlineInterceptor.cs @@ -0,0 +1,30 @@ +using System; +using Grpc.Core; +using Grpc.Core.Interceptors; + +namespace Bones.Grpc +{ + // https://stackoverflow.com/questions/63633332/grpc-what-are-the-best-practices-for-long-running-streaming + public class StreamDeadlineInterceptor : Interceptor + { + private const int STREAM_DEADLINE_IN_SECONDS = 1800; // 60s * 30min + + public override AsyncServerStreamingCall AsyncServerStreamingCall(TRequest request, ClientInterceptorContext context, AsyncServerStreamingCallContinuation continuation) + { + var newContext = context; + var deadline = DateTime.UtcNow.AddSeconds(STREAM_DEADLINE_IN_SECONDS); + + if ( + (!context.Options.Deadline.HasValue || + context.Options.Deadline.Value >= deadline) + ) + { + var newOptions = context.Options.WithDeadline(deadline); + newContext = new ClientInterceptorContext(context.Method, context.Host, newOptions); + } + + var response = base.AsyncServerStreamingCall(request, newContext, continuation); + return response; + } + } +} \ No newline at end of file diff --git a/src/Bones.Monitoring/Bones.Monitoring.csproj b/src/Bones.Monitoring/Bones.Monitoring.csproj index 9d1b85e..b641501 100644 --- a/src/Bones.Monitoring/Bones.Monitoring.csproj +++ b/src/Bones.Monitoring/Bones.Monitoring.csproj @@ -1,21 +1,21 @@ - net7.0 + net10.0 $(VERSION) dative-gpi dative-gpi - - - - + + + + - - + + - + diff --git a/src/Bones.Monitoring/Traces/TraceFactory.cs b/src/Bones.Monitoring/Traces/TraceFactory.cs index 9158c46..7a41887 100644 --- a/src/Bones.Monitoring/Traces/TraceFactory.cs +++ b/src/Bones.Monitoring/Traces/TraceFactory.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -10,10 +11,10 @@ public class TraceFactory : ITraceFactory private ILogger _logger; private IOptionsMonitor _options; - public TraceFactory(ILogger logger, IOptionsMonitor options) + public TraceFactory(ILogger logger, IServiceProvider provider) { _logger = logger; - _options = options; + _options = provider.GetService>(); } public ITrace Create(ActivitySource source, string name, ITrace parent = null) @@ -53,6 +54,9 @@ public ITrace Create(ActivitySource source, string name, ITrace parent = null) public ITrace Enrich(ITrace trace, object param, string optionsName) { + if (_options == null) + return trace; + var option = _options.Get(optionsName); if(option != null && option.SpanEnricher != null) option.SpanEnricher(trace, param); return trace; diff --git a/src/Bones.Selectors/Bones.Selectors.csproj b/src/Bones.Selectors/Bones.Selectors.csproj new file mode 100644 index 0000000..96c87dd --- /dev/null +++ b/src/Bones.Selectors/Bones.Selectors.csproj @@ -0,0 +1,16 @@ + + + + netstandard2.0 + $(VERSION) + dative-gpi + dative-gpi + + + + + + + + + diff --git a/src/Bones.Selectors/DI/DependencyInjector.cs b/src/Bones.Selectors/DI/DependencyInjector.cs new file mode 100644 index 0000000..bd61ec5 --- /dev/null +++ b/src/Bones.Selectors/DI/DependencyInjector.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using Bones.Selectors.Interfaces; + +namespace Bones.Selectors.DI +{ + public static class DependencyInjector + { + + public static IServiceCollection AddSelectors(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + + return services; + } + } +} \ No newline at end of file diff --git a/src/Bones.Selectors/Interfaces/IJsonSelector.cs b/src/Bones.Selectors/Interfaces/IJsonSelector.cs new file mode 100644 index 0000000..715bc34 --- /dev/null +++ b/src/Bones.Selectors/Interfaces/IJsonSelector.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.Text.Json; + +namespace Bones.Selectors.Interfaces +{ + public interface IJsonSelector + { + IEnumerable Select(JsonElement element, string selector); + } +} \ No newline at end of file diff --git a/src/Bones.Selectors/Interfaces/IXmlSelector.cs b/src/Bones.Selectors/Interfaces/IXmlSelector.cs new file mode 100644 index 0000000..7e597d1 --- /dev/null +++ b/src/Bones.Selectors/Interfaces/IXmlSelector.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.Xml; + +namespace Bones.Selectors.Interfaces +{ + public interface IXmlSelector + { + IEnumerable Select(XmlDocument document, string selector); + } +} \ No newline at end of file diff --git a/src/Bones.Selectors/SelectorException.cs b/src/Bones.Selectors/SelectorException.cs new file mode 100644 index 0000000..51672ff --- /dev/null +++ b/src/Bones.Selectors/SelectorException.cs @@ -0,0 +1,13 @@ +namespace Bones.Selectors +{ + [System.Serializable] + public class SelectorException : System.Exception + { + public SelectorException() { } + public SelectorException(string message) : base(message) { } + public SelectorException(string message, System.Exception inner) : base(message, inner) { } + protected SelectorException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + } +} \ No newline at end of file diff --git a/src/Bones.Selectors/Selectors/JsonSelector.cs b/src/Bones.Selectors/Selectors/JsonSelector.cs new file mode 100644 index 0000000..093b30a --- /dev/null +++ b/src/Bones.Selectors/Selectors/JsonSelector.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Bones.Selectors.Interfaces; + +namespace Bones.Selectors +{ + public class JsonSelector : IJsonSelector + { + private static readonly char JSON_QUERY_SEPARATOR = '.'; + private static readonly string JSON_QUERY_ENUMERATOR = "*"; + + public JsonSelector() { } + + public IEnumerable Select(JsonElement rootElement, string selector) + { + string[] paths = selector.Split(new char[] { JSON_QUERY_SEPARATOR }, StringSplitOptions.RemoveEmptyEntries); + IEnumerable selectedElements = BrowseJson(new[] { rootElement }, paths); + return GetRawElements(selectedElements); + } + + + + + + private IEnumerable BrowseJson(IEnumerable currentElements, IEnumerable query) + { + if (!query.Any()) return currentElements; + + List childrenOfCurrent = new List(); + string currentPath = query.First(); + + foreach (JsonElement current in currentElements) + { + try + { + if (IsIndexSelector(currentPath, out var index)) AddOneElement(childrenOfCurrent, current, index); + else if (IsArraySelector(currentPath)) AddAllElements(childrenOfCurrent, current); + else AddPropertyElement(childrenOfCurrent, current, currentPath); + } + catch (Exception e) + { + throw new SelectorException($"An error occurred while browsing ! Current step: {currentPath}", e); + } + } + + return BrowseJson(childrenOfCurrent, query.Skip(1)); + } + + private IEnumerable GetRawElements(IEnumerable selectedElements) + { + List results = new List(); + + foreach (JsonElement el in selectedElements) + { + try + { + results.Add(el.ToString()); + } + catch (Exception e) + { + throw new SelectorException($"An error occurred while getting raw text !", e); + } + } + + return results; + } + + // Helper Methods + + private bool IsIndexSelector(string selector, out int index) + { + return int.TryParse(selector, out index); + } + + private void AddOneElement(List elements, JsonElement currentElement, int index) + { + elements.Add(currentElement[index]); + } + + private bool IsArraySelector(string selector) + { + return JSON_QUERY_ENUMERATOR.Equals(selector); + } + + private void AddAllElements(List elements, JsonElement currentElement) + { + foreach (var arrayElt in currentElement.EnumerateArray()) + { + elements.Add(arrayElt); + } + } + + private void AddPropertyElement(List elements, JsonElement currentElement, string propertyName) + { + elements.Add(currentElement.GetProperty(propertyName)); + } + } +} \ No newline at end of file diff --git a/src/Bones.Selectors/Selectors/XmlSelector.cs b/src/Bones.Selectors/Selectors/XmlSelector.cs new file mode 100644 index 0000000..2d295fc --- /dev/null +++ b/src/Bones.Selectors/Selectors/XmlSelector.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Xml; +using Bones.Selectors.Interfaces; + +namespace Bones.Selectors +{ + public class XmlSelector : IXmlSelector + { + public XmlSelector() { } + + // https://docs.microsoft.com/en-us/dotnet/api/system.xml.xmldocument?view=net-6.0 + public IEnumerable Select(XmlDocument document, string selector) + { + var results = new List(); + + var root = document.DocumentElement; + if (root == null) + return results; + + var nodeList = root.SelectNodes(selector); + if (nodeList == null) + return results; + + foreach (XmlNode node in nodeList) + { + results.Add(node.InnerXml); + } + + return results; + } + } +} \ No newline at end of file diff --git a/src/Bones.Tests/Bones.Tests.csproj b/src/Bones.Tests/Bones.Tests.csproj index 9db2736..8edd5ee 100644 --- a/src/Bones.Tests/Bones.Tests.csproj +++ b/src/Bones.Tests/Bones.Tests.csproj @@ -8,7 +8,7 @@ - + diff --git a/src/Bones.X509/Bones.X509.csproj b/src/Bones.X509/Bones.X509.csproj index f5128ca..92a7017 100644 --- a/src/Bones.X509/Bones.X509.csproj +++ b/src/Bones.X509/Bones.X509.csproj @@ -1,7 +1,7 @@ - net7.0 + net10.0 $(VERSION) dative-gpi dative-gpi diff --git a/src/Bones/Bones.csproj b/src/Bones/Bones.csproj index b71c67c..fd7f07d 100644 --- a/src/Bones/Bones.csproj +++ b/src/Bones/Bones.csproj @@ -8,10 +8,10 @@ - - + + - + diff --git a/tests/Bones.Akka.Tests/Bones.Akka.Tests.csproj b/tests/Bones.Akka.Tests/Bones.Akka.Tests.csproj index 2736616..cd908db 100644 --- a/tests/Bones.Akka.Tests/Bones.Akka.Tests.csproj +++ b/tests/Bones.Akka.Tests/Bones.Akka.Tests.csproj @@ -1,16 +1,16 @@ - net7.0 + net10.0 false - - - - + + + + diff --git a/tests/Bones.Converters.Tests/Bones.Converters.Tests.csproj b/tests/Bones.Converters.Tests/Bones.Converters.Tests.csproj index b99636f..4f404eb 100644 --- a/tests/Bones.Converters.Tests/Bones.Converters.Tests.csproj +++ b/tests/Bones.Converters.Tests/Bones.Converters.Tests.csproj @@ -1,19 +1,19 @@ - net7.0 + net10.0 false - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/tests/Bones.Flow.Tests/Bones.Flow.Tests.csproj b/tests/Bones.Flow.Tests/Bones.Flow.Tests.csproj index cc9b82e..898fada 100644 --- a/tests/Bones.Flow.Tests/Bones.Flow.Tests.csproj +++ b/tests/Bones.Flow.Tests/Bones.Flow.Tests.csproj @@ -1,16 +1,16 @@ - net7.0 + net10.0 false - - - - + + + + diff --git a/tests/Bones.Flow.Tests/TestChainOfResponsibilityFactory.cs b/tests/Bones.Flow.Tests/TestChainOfResponsibilityFactory.cs new file mode 100644 index 0000000..50d5192 --- /dev/null +++ b/tests/Bones.Flow.Tests/TestChainOfResponsibilityFactory.cs @@ -0,0 +1,234 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Extensions.DependencyInjection; + +using Xunit; +using Xunit.Abstractions; + +using Bones.Tests.DI; + +namespace Bones.Flow.Tests +{ + public class TestChainOfResponsibilityFactory + { + private ServiceProvider _provider; + + public TestChainOfResponsibilityFactory(ITestOutputHelper output) + { + IServiceCollection serviceCollection = new ServiceCollection(); + + serviceCollection.AddFlow(); + serviceCollection.AddDebug(output); + + serviceCollection + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped(); + + _provider = serviceCollection.BuildServiceProvider(); + } + + // --- CoR standalone (sans résultat) --- + + [Fact] + public async Task ChainOfResponsibility_FirstMatchHandles() + { + using var scope = _provider.CreateScope(); + var sp = scope.ServiceProvider; + + var cor = sp.GetChainOfResponsibilityFactory() + .Add() + .Add() + .Build(); + + var cmd = new NumberCommand { Value = 4 }; + await cor.HandleAsync(cmd); + + Assert.Equal(nameof(EvenHandler), cmd.HandledBy); + } + + [Fact] + public async Task ChainOfResponsibility_SecondMatchHandles() + { + using var scope = _provider.CreateScope(); + var sp = scope.ServiceProvider; + + var cor = sp.GetChainOfResponsibilityFactory() + .Add() + .Add() + .Build(); + + var cmd = new NumberCommand { Value = 3 }; + await cor.HandleAsync(cmd); + + Assert.Equal(nameof(OddHandler), cmd.HandledBy); + } + + [Fact] + public async Task ChainOfResponsibility_NoMatch_DoesNotThrow() + { + using var scope = _provider.CreateScope(); + var sp = scope.ServiceProvider; + + var cor = sp.GetChainOfResponsibilityFactory() + .Add() + .Build(); + + var cmd = new NumberCommand { Value = 3 }; + await cor.HandleAsync(cmd); + + Assert.Null(cmd.HandledBy); + } + + // --- CoR standalone (avec résultat) --- + + [Fact] + public async Task ChainOfResponsibility_WithResult_FirstMatchReturns() + { + using var scope = _provider.CreateScope(); + var sp = scope.ServiceProvider; + + var cor = sp.GetChainOfResponsibilityFactory() + .Add() + .Add() + .Build(); + + var result = await cor.HandleAsync(new NumberQuery { Value = 4 }); + + Assert.Equal("even:4", result); + } + + [Fact] + public async Task ChainOfResponsibility_WithResult_NoMatch_ReturnsDefault() + { + using var scope = _provider.CreateScope(); + var sp = scope.ServiceProvider; + + var cor = sp.GetChainOfResponsibilityFactory() + .Add() + .Build(); + + var result = await cor.HandleAsync(new NumberQuery { Value = 3 }); + + Assert.Null(result); + } + + // --- CoR composée dans une Pipeline --- + + [Fact] + public async Task ChainOfResponsibility_ComposedInPipeline() + { + using var scope = _provider.CreateScope(); + var sp = scope.ServiceProvider; + + var cor = sp.GetChainOfResponsibilityFactory() + .Add() + .Add() + .Build(); + + var pipeline = sp.GetPipelineFactory() + .Add(cor) + .Add() + .Build(); + + var cmd = new NumberCommand { Value = 4 }; + await pipeline.HandleAsync(cmd); + + Assert.Equal(nameof(EvenHandler), cmd.HandledBy); + Assert.True(cmd.FallbackReached); + } + + [Fact] + public async Task ChainOfResponsibility_ResultComposedInPipeline() + { + using var scope = _provider.CreateScope(); + var sp = scope.ServiceProvider; + + var cor = sp.GetChainOfResponsibilityFactory() + .Add() + .Add() + .Build(); + + IQueryHandler pipeline = sp.GetPipelineFactory() + .Add(cor) + .Build(); + + var result = await pipeline.HandleAsync(new NumberQuery { Value = 7 }); + + Assert.Equal("odd:7", result); + } + + // --- Fixtures --- + + public class NumberCommand : IRequest + { + public int Value { get; set; } + public string HandledBy { get; set; } + public bool FallbackReached { get; set; } + } + + public class NumberQuery : IRequest + { + public int Value { get; set; } + } + + public class EvenHandler : IChainOfResponsibilityHandler + { + public Task HandleAsync(NumberCommand request, CancellationToken ct) + { + if (request.Value % 2 != 0) + return Task.FromResult(false); + + request.HandledBy = nameof(EvenHandler); + return Task.FromResult(true); + } + } + + public class OddHandler : IChainOfResponsibilityHandler + { + public Task HandleAsync(NumberCommand request, CancellationToken ct) + { + if (request.Value % 2 == 0) + return Task.FromResult(false); + + request.HandledBy = nameof(OddHandler); + return Task.FromResult(true); + } + } + + public class FallbackHandler : IMiddleware + { + public async Task HandleAsync(NumberCommand request, Func next, CancellationToken ct) + { + request.FallbackReached = true; + await next(); + } + } + + public class EvenResultHandler : IChainOfResponsibilityHandler + { + public Task<(bool handled, string result)> HandleAsync(NumberQuery request, CancellationToken ct) + { + if (request.Value % 2 != 0) + return Task.FromResult((false, (string)null)); + + return Task.FromResult((true, $"even:{request.Value}")); + } + } + + public class OddResultHandler : IChainOfResponsibilityHandler + { + public Task<(bool handled, string result)> HandleAsync(NumberQuery request, CancellationToken ct) + { + if (request.Value % 2 == 0) + return Task.FromResult((false, (string)null)); + + return Task.FromResult((true, $"odd:{request.Value}")); + } + } + } +} diff --git a/tests/Bones.Tests/Bones.Tests.csproj b/tests/Bones.Tests/Bones.Tests.csproj index b5017f4..1778b4d 100644 --- a/tests/Bones.Tests/Bones.Tests.csproj +++ b/tests/Bones.Tests/Bones.Tests.csproj @@ -1,17 +1,17 @@ - net7.0 + net10.0 false - - - - - + + + + + diff --git a/tests/Bones.X509.Tests/Bones.X509.Tests.csproj b/tests/Bones.X509.Tests/Bones.X509.Tests.csproj index c492b37..1510a05 100644 --- a/tests/Bones.X509.Tests/Bones.X509.Tests.csproj +++ b/tests/Bones.X509.Tests/Bones.X509.Tests.csproj @@ -1,19 +1,19 @@ - net7.0 + net10.0 false - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all