diff --git a/CODEOWNERS b/CODEOWNERS index 428aa766..f6f2c1a9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -41,3 +41,12 @@ /examples/rust/_ @Alirexaa /src/CommunityToolkit.Aspire.Hosting.Rust/_ @Alirexaa /tests/CommunityToolkit.Aspire.Hosting.Rust/_ @Alirexaa + +# CommunityToolkit.Aspire.EventStore +# CommunityToolkit.Aspire.Hosting.EventStore + +/examples/eventstore/_ @fredimachado +/src/CommunityToolkit.Aspire.Hosting.EventStore/_ @fredimachado +/tests/CommunityToolkit.Aspire.Hosting.EventStore.Tests/_ @fredimachado +/src/CommunityToolkit.Aspire.EventStore/_ @fredimachado +/tests/CommunityToolkit.Aspire.EventStore.Tests/_ @fredimachado diff --git a/CommunityToolkit.Aspire.sln b/CommunityToolkit.Aspire.sln index f2b23cca..624ca7a3 100644 --- a/CommunityToolkit.Aspire.sln +++ b/CommunityToolkit.Aspire.sln @@ -145,6 +145,22 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Aspire.Hos EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Aspire.Hosting.Bun.Tests", "tests\CommunityToolkit.Aspire.Hosting.Bun.Tests\CommunityToolkit.Aspire.Hosting.Bun.Tests.csproj", "{DA5DD2CB-51D9-429F-91F5-BF3D1A13A21A}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "eventstore", "eventstore", "{114DDF07-489A-419B-BE76-E5A289F12791}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Aspire.Hosting.EventStore", "src\CommunityToolkit.Aspire.Hosting.EventStore\CommunityToolkit.Aspire.Hosting.EventStore.csproj", "{B209275E-1CFF-4AF0-A65A-2895DD679775}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Aspire.EventStore", "src\CommunityToolkit.Aspire.EventStore\CommunityToolkit.Aspire.EventStore.csproj", "{AD230A69-F6AE-4A9B-B500-90516BA2E1C6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Aspire.Hosting.EventStore.Tests", "tests\CommunityToolkit.Aspire.Hosting.EventStore.Tests\CommunityToolkit.Aspire.Hosting.EventStore.Tests.csproj", "{FA34A40C-62C9-4A73-A39D-53A01243657C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Aspire.Hosting.EventStore.AppHost", "examples\eventstore\CommunityToolkit.Aspire.Hosting.EventStore.AppHost\CommunityToolkit.Aspire.Hosting.EventStore.AppHost.csproj", "{ED3E5B89-091C-4A0E-9A2B-946CA1A11557}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Aspire.Hosting.EventStore.ServiceDefaults", "examples\eventstore\CommunityToolkit.Aspire.Hosting.EventStore.ServiceDefaults\CommunityToolkit.Aspire.Hosting.EventStore.ServiceDefaults.csproj", "{0A4E5B43-155A-4FDA-A50F-0B86CF1705E7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Aspire.Hosting.EventStore.ApiService", "examples\eventstore\CommunityToolkit.Aspire.Hosting.EventStore.ApiService\CommunityToolkit.Aspire.Hosting.EventStore.ApiService.csproj", "{019D6506-9D68-41AD-A7A1-A27B2FFE1253}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Aspire.EventStore.Tests", "tests\CommunityToolkit.Aspire.EventStore.Tests\CommunityToolkit.Aspire.EventStore.Tests.csproj", "{C696480B-C2E0-4ACA-BD5E-A62BF8558024}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Aspire.Hosting.ActiveMQ", "src\CommunityToolkit.Aspire.Hosting.ActiveMQ\CommunityToolkit.Aspire.Hosting.ActiveMQ.csproj", "{56C3C409-10FF-4CA5-99AD-0D35C5418B2A}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "activemq", "activemq", "{BF4C4E57-DAAF-4EE5-B330-1923106BAE69}" @@ -339,18 +355,6 @@ Global {C7D057AF-E2A5-4E26-846E-A328A0F14A3C}.Debug|Any CPU.Build.0 = Debug|Any CPU {C7D057AF-E2A5-4E26-846E-A328A0F14A3C}.Release|Any CPU.ActiveCfg = Release|Any CPU {C7D057AF-E2A5-4E26-846E-A328A0F14A3C}.Release|Any CPU.Build.0 = Release|Any CPU - {4DCF987E-9071-4899-8B5F-5FDAF2BC77D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4DCF987E-9071-4899-8B5F-5FDAF2BC77D3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4DCF987E-9071-4899-8B5F-5FDAF2BC77D3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4DCF987E-9071-4899-8B5F-5FDAF2BC77D3}.Release|Any CPU.Build.0 = Release|Any CPU - {C686CEA0-8B89-470B-84A2-0264040DCDC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C686CEA0-8B89-470B-84A2-0264040DCDC8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C686CEA0-8B89-470B-84A2-0264040DCDC8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C686CEA0-8B89-470B-84A2-0264040DCDC8}.Release|Any CPU.Build.0 = Release|Any CPU - {5B825CF9-E8B8-4960-9330-648ED0323FE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5B825CF9-E8B8-4960-9330-648ED0323FE0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5B825CF9-E8B8-4960-9330-648ED0323FE0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5B825CF9-E8B8-4960-9330-648ED0323FE0}.Release|Any CPU.Build.0 = Release|Any CPU {6BC98146-279F-4DE5-9B6E-0F0C07507421}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6BC98146-279F-4DE5-9B6E-0F0C07507421}.Debug|Any CPU.Build.0 = Debug|Any CPU {6BC98146-279F-4DE5-9B6E-0F0C07507421}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -371,6 +375,18 @@ Global {2CC61B84-CF97-4CE7-A08F-2EECF4AEAD92}.Debug|Any CPU.Build.0 = Debug|Any CPU {2CC61B84-CF97-4CE7-A08F-2EECF4AEAD92}.Release|Any CPU.ActiveCfg = Release|Any CPU {2CC61B84-CF97-4CE7-A08F-2EECF4AEAD92}.Release|Any CPU.Build.0 = Release|Any CPU + {4DCF987E-9071-4899-8B5F-5FDAF2BC77D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4DCF987E-9071-4899-8B5F-5FDAF2BC77D3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4DCF987E-9071-4899-8B5F-5FDAF2BC77D3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4DCF987E-9071-4899-8B5F-5FDAF2BC77D3}.Release|Any CPU.Build.0 = Release|Any CPU + {C686CEA0-8B89-470B-84A2-0264040DCDC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C686CEA0-8B89-470B-84A2-0264040DCDC8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C686CEA0-8B89-470B-84A2-0264040DCDC8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C686CEA0-8B89-470B-84A2-0264040DCDC8}.Release|Any CPU.Build.0 = Release|Any CPU + {5B825CF9-E8B8-4960-9330-648ED0323FE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5B825CF9-E8B8-4960-9330-648ED0323FE0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5B825CF9-E8B8-4960-9330-648ED0323FE0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5B825CF9-E8B8-4960-9330-648ED0323FE0}.Release|Any CPU.Build.0 = Release|Any CPU {6095E8B8-7F99-4A12-B7E2-376F7EDD7435}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6095E8B8-7F99-4A12-B7E2-376F7EDD7435}.Debug|Any CPU.Build.0 = Debug|Any CPU {6095E8B8-7F99-4A12-B7E2-376F7EDD7435}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -383,6 +399,34 @@ Global {DA5DD2CB-51D9-429F-91F5-BF3D1A13A21A}.Debug|Any CPU.Build.0 = Debug|Any CPU {DA5DD2CB-51D9-429F-91F5-BF3D1A13A21A}.Release|Any CPU.ActiveCfg = Release|Any CPU {DA5DD2CB-51D9-429F-91F5-BF3D1A13A21A}.Release|Any CPU.Build.0 = Release|Any CPU + {B209275E-1CFF-4AF0-A65A-2895DD679775}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B209275E-1CFF-4AF0-A65A-2895DD679775}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B209275E-1CFF-4AF0-A65A-2895DD679775}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B209275E-1CFF-4AF0-A65A-2895DD679775}.Release|Any CPU.Build.0 = Release|Any CPU + {AD230A69-F6AE-4A9B-B500-90516BA2E1C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD230A69-F6AE-4A9B-B500-90516BA2E1C6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD230A69-F6AE-4A9B-B500-90516BA2E1C6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD230A69-F6AE-4A9B-B500-90516BA2E1C6}.Release|Any CPU.Build.0 = Release|Any CPU + {FA34A40C-62C9-4A73-A39D-53A01243657C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FA34A40C-62C9-4A73-A39D-53A01243657C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FA34A40C-62C9-4A73-A39D-53A01243657C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FA34A40C-62C9-4A73-A39D-53A01243657C}.Release|Any CPU.Build.0 = Release|Any CPU + {ED3E5B89-091C-4A0E-9A2B-946CA1A11557}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ED3E5B89-091C-4A0E-9A2B-946CA1A11557}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ED3E5B89-091C-4A0E-9A2B-946CA1A11557}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ED3E5B89-091C-4A0E-9A2B-946CA1A11557}.Release|Any CPU.Build.0 = Release|Any CPU + {0A4E5B43-155A-4FDA-A50F-0B86CF1705E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0A4E5B43-155A-4FDA-A50F-0B86CF1705E7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0A4E5B43-155A-4FDA-A50F-0B86CF1705E7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0A4E5B43-155A-4FDA-A50F-0B86CF1705E7}.Release|Any CPU.Build.0 = Release|Any CPU + {019D6506-9D68-41AD-A7A1-A27B2FFE1253}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {019D6506-9D68-41AD-A7A1-A27B2FFE1253}.Debug|Any CPU.Build.0 = Debug|Any CPU + {019D6506-9D68-41AD-A7A1-A27B2FFE1253}.Release|Any CPU.ActiveCfg = Release|Any CPU + {019D6506-9D68-41AD-A7A1-A27B2FFE1253}.Release|Any CPU.Build.0 = Release|Any CPU + {C696480B-C2E0-4ACA-BD5E-A62BF8558024}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C696480B-C2E0-4ACA-BD5E-A62BF8558024}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C696480B-C2E0-4ACA-BD5E-A62BF8558024}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C696480B-C2E0-4ACA-BD5E-A62BF8558024}.Release|Any CPU.Build.0 = Release|Any CPU {56C3C409-10FF-4CA5-99AD-0D35C5418B2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {56C3C409-10FF-4CA5-99AD-0D35C5418B2A}.Debug|Any CPU.Build.0 = Debug|Any CPU {56C3C409-10FF-4CA5-99AD-0D35C5418B2A}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -461,10 +505,6 @@ Global {4AE83D68-EA10-473D-BD26-19C5928A8620} = {8519CC01-1370-47C8-AD94-B0F326B1563F} {79EF8E85-1DFC-42B5-BDE3-72639F25848C} = {4AE83D68-EA10-473D-BD26-19C5928A8620} {C7D057AF-E2A5-4E26-846E-A328A0F14A3C} = {899F0713-7FC6-4750-BAFC-AC650B35B453} - {4DCF987E-9071-4899-8B5F-5FDAF2BC77D3} = {414151D4-7009-4E78-A5C6-D99EBD1E67D1} - {DDDAABA3-D8F0-47C6-98E0-AB57F28404CF} = {8519CC01-1370-47C8-AD94-B0F326B1563F} - {C686CEA0-8B89-470B-84A2-0264040DCDC8} = {DDDAABA3-D8F0-47C6-98E0-AB57F28404CF} - {5B825CF9-E8B8-4960-9330-648ED0323FE0} = {899F0713-7FC6-4750-BAFC-AC650B35B453} {6BC98146-279F-4DE5-9B6E-0F0C07507421} = {414151D4-7009-4E78-A5C6-D99EBD1E67D1} {662514C8-EAED-4EAB-91CE-893D4DE2469A} = {8519CC01-1370-47C8-AD94-B0F326B1563F} {1E753568-E34B-4E93-93F8-43764171725D} = {662514C8-EAED-4EAB-91CE-893D4DE2469A} @@ -472,10 +512,22 @@ Global {373472DA-BAEB-44B6-915D-1EF3DA845797} = {899F0713-7FC6-4750-BAFC-AC650B35B453} {79CBF217-CED1-4BB2-9A72-37D2429F83B8} = {899F0713-7FC6-4750-BAFC-AC650B35B453} {2CC61B84-CF97-4CE7-A08F-2EECF4AEAD92} = {79CBF217-CED1-4BB2-9A72-37D2429F83B8} + {4DCF987E-9071-4899-8B5F-5FDAF2BC77D3} = {414151D4-7009-4E78-A5C6-D99EBD1E67D1} + {DDDAABA3-D8F0-47C6-98E0-AB57F28404CF} = {8519CC01-1370-47C8-AD94-B0F326B1563F} + {C686CEA0-8B89-470B-84A2-0264040DCDC8} = {DDDAABA3-D8F0-47C6-98E0-AB57F28404CF} + {5B825CF9-E8B8-4960-9330-648ED0323FE0} = {899F0713-7FC6-4750-BAFC-AC650B35B453} {6095E8B8-7F99-4A12-B7E2-376F7EDD7435} = {414151D4-7009-4E78-A5C6-D99EBD1E67D1} {A7614F2B-E810-412E-91E7-8B6272DD5DBB} = {8519CC01-1370-47C8-AD94-B0F326B1563F} {36FC2579-582A-4DAF-9B20-AB33331624C6} = {A7614F2B-E810-412E-91E7-8B6272DD5DBB} {DA5DD2CB-51D9-429F-91F5-BF3D1A13A21A} = {899F0713-7FC6-4750-BAFC-AC650B35B453} + {114DDF07-489A-419B-BE76-E5A289F12791} = {8519CC01-1370-47C8-AD94-B0F326B1563F} + {B209275E-1CFF-4AF0-A65A-2895DD679775} = {414151D4-7009-4E78-A5C6-D99EBD1E67D1} + {AD230A69-F6AE-4A9B-B500-90516BA2E1C6} = {414151D4-7009-4E78-A5C6-D99EBD1E67D1} + {FA34A40C-62C9-4A73-A39D-53A01243657C} = {899F0713-7FC6-4750-BAFC-AC650B35B453} + {ED3E5B89-091C-4A0E-9A2B-946CA1A11557} = {114DDF07-489A-419B-BE76-E5A289F12791} + {0A4E5B43-155A-4FDA-A50F-0B86CF1705E7} = {114DDF07-489A-419B-BE76-E5A289F12791} + {019D6506-9D68-41AD-A7A1-A27B2FFE1253} = {114DDF07-489A-419B-BE76-E5A289F12791} + {C696480B-C2E0-4ACA-BD5E-A62BF8558024} = {899F0713-7FC6-4750-BAFC-AC650B35B453} {56C3C409-10FF-4CA5-99AD-0D35C5418B2A} = {414151D4-7009-4E78-A5C6-D99EBD1E67D1} {BF4C4E57-DAAF-4EE5-B330-1923106BAE69} = {8519CC01-1370-47C8-AD94-B0F326B1563F} {CF6CF1C3-EBE1-41CE-9B34-0C2F931AEA12} = {BF4C4E57-DAAF-4EE5-B330-1923106BAE69} diff --git a/Directory.Packages.props b/Directory.Packages.props index 6b5b171d..41381236 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,7 @@ + @@ -48,6 +49,8 @@ + + diff --git a/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ApiService/Account.cs b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ApiService/Account.cs new file mode 100644 index 00000000..c42c385d --- /dev/null +++ b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ApiService/Account.cs @@ -0,0 +1,105 @@ +using System.Text.Json.Serialization; + +namespace CommunityToolkit.Aspire.Hosting.EventStore.ApiService; + +public class Account +{ + public Guid Id { get; private set; } + public string? Name { get; private set; } + public decimal Balance { get; private set; } + + [JsonIgnore] + public int Version { get; private set; } = -1; + + [NonSerialized] + private readonly Queue uncommittedEvents = new(); + + public static Account Create(Guid id, string name) + => new(id, name); + + public void Deposit(decimal amount) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(amount, 0, nameof(amount)); + + var @event = new AccountFundsDeposited(Id, amount); + + uncommittedEvents.Enqueue(@event); + Apply(@event); + } + + public void Withdraw(decimal amount) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(amount, 0, nameof(amount)); + ArgumentOutOfRangeException.ThrowIfGreaterThan(amount, Balance, nameof(amount)); + + var @event = new AccountFundsWithdrew(Id, amount); + + uncommittedEvents.Enqueue(@event); + Apply(@event); + } + + public void When(object @event) + { + switch (@event) + { + case AccountCreated accountCreated: + Apply(accountCreated); + break; + case AccountFundsDeposited accountFundsDeposited: + Apply(accountFundsDeposited); + break; + case AccountFundsWithdrew accountFundsWithdrew: + Apply(accountFundsWithdrew); + break; + } + } + + public object[] DequeueUncommittedEvents() + { + var dequeuedEvents = uncommittedEvents.ToArray(); + + uncommittedEvents.Clear(); + + return dequeuedEvents; + } + + private Account() + { + } + + private Account(Guid id, string name) + { + if (id == Guid.Empty) + { + throw new ArgumentException("Id cannot be empty.", nameof(id)); + } + ArgumentException.ThrowIfNullOrWhiteSpace(name, nameof(name)); + + var @event = new AccountCreated(id, name); + + uncommittedEvents.Enqueue(@event); + Apply(@event); + } + + private void Apply(AccountCreated @event) + { + Version++; + + Id = @event.Id; + Name = @event.Name; + } + + private void Apply(AccountFundsDeposited @event) + { + Version++; + + Balance += @event.Amount; + } + + private void Apply(AccountFundsWithdrew @event) + { + Version++; + + Balance -= @event.Amount; + } +} diff --git a/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ApiService/AccountEvents.cs b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ApiService/AccountEvents.cs new file mode 100644 index 00000000..14aad68e --- /dev/null +++ b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ApiService/AccountEvents.cs @@ -0,0 +1,7 @@ +namespace CommunityToolkit.Aspire.Hosting.EventStore.ApiService; + +public record AccountCreated(Guid Id, string Name); + +public record AccountFundsDeposited(Guid Id, decimal Amount); + +public record AccountFundsWithdrew(Guid Id, decimal Amount); diff --git a/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ApiService/CommunityToolkit.Aspire.Hosting.EventStore.ApiService.csproj b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ApiService/CommunityToolkit.Aspire.Hosting.EventStore.ApiService.csproj new file mode 100644 index 00000000..75fb8674 --- /dev/null +++ b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ApiService/CommunityToolkit.Aspire.Hosting.EventStore.ApiService.csproj @@ -0,0 +1,18 @@ + + + + enable + enable + + + + + + + + + + + + + diff --git a/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ApiService/EventStoreExtensions.cs b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ApiService/EventStoreExtensions.cs new file mode 100644 index 00000000..eee3158f --- /dev/null +++ b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ApiService/EventStoreExtensions.cs @@ -0,0 +1,76 @@ +using EventStore.Client; +using System.Text.Json; +using System.Text; + +namespace CommunityToolkit.Aspire.Hosting.EventStore.ApiService; + +public static class EventStoreExtensions +{ + public static async Task GetAccount(this EventStoreClient eventStore, Guid id, CancellationToken cancellationToken) + { + var readResult = eventStore.ReadStreamAsync( + Direction.Forwards, + $"account-{id:N}", + StreamPosition.Start, + cancellationToken: cancellationToken + ); + + var readState = await readResult.ReadState; + if (readState == ReadState.StreamNotFound) + { + return null; + } + + var account = (Account)Activator.CreateInstance(typeof(Account), true)!; + + await foreach (var resolvedEvent in readResult) + { + var @event = resolvedEvent.Deserialize(); + + account.When(@event!); + } + + return account; + } + + public static async Task AppendAcountEvents(this EventStoreClient eventStore, Account account, CancellationToken cancellationToken) + { + var events = account.DequeueUncommittedEvents(); + + var eventsToAppend = events + .Select(@event => @event.Serialize()).ToArray(); + + var expectedVersion = account.Version - events.Length; + await eventStore.AppendToStreamAsync( + $"account-{account.Id:N}", + expectedVersion == 0 ? StreamRevision.None : StreamRevision.FromInt64(expectedVersion), + eventsToAppend, + cancellationToken: cancellationToken + ); + } + + private static object? Deserialize(this ResolvedEvent resolvedEvent) + { + var eventClrTypeName = JsonDocument.Parse(resolvedEvent.Event.Metadata) + .RootElement + .GetProperty("EventClrTypeName") + .GetString(); + + return JsonSerializer.Deserialize( + Encoding.UTF8.GetString(resolvedEvent.Event.Data.Span), + Type.GetType(eventClrTypeName!)!); + } + + private static EventData Serialize(this object @event) + { + return new EventData( + Uuid.NewUuid(), + @event.GetType().Name, + data: Encoding.UTF8.GetBytes(JsonSerializer.Serialize(@event)), + metadata: Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new Dictionary + { + { "EventClrTypeName", @event.GetType().AssemblyQualifiedName! } + })) + ); + } +} diff --git a/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ApiService/Program.cs b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ApiService/Program.cs new file mode 100644 index 00000000..c87a791a --- /dev/null +++ b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ApiService/Program.cs @@ -0,0 +1,81 @@ +using CommunityToolkit.Aspire.Hosting.EventStore.ApiService; +using EventStore.Client; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.AddEventStoreClient("eventstore"); + +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +app.UseHttpsRedirection(); + +app.MapDefaultEndpoints(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapPost("/account/create", async (EventStoreClient eventStore, CancellationToken cancellationToken) => +{ + var account = Account.Create(Guid.NewGuid(), "John Doe"); + + account.Deposit(100); + + await eventStore.AppendAcountEvents(account, cancellationToken); + + return Results.Created($"/account/{account.Id}", account); +}); + +app.MapGet("/account/{id:guid}", async (Guid id, EventStoreClient eventStore, CancellationToken cancellationToken) => +{ + var account = await eventStore.GetAccount(id, cancellationToken); + if (account is null) + { + return Results.NotFound(); + } + + return TypedResults.Ok(account); +}); + +app.MapPost("/account/{id:guid}/deposit", async (Guid id, DepositRequest request, EventStoreClient eventStore, CancellationToken cancellationToken) => +{ + var account = await eventStore.GetAccount(id, cancellationToken); + if (account is null) + { + return Results.NotFound(); + } + + account.Deposit(request.Amount); + + await eventStore.AppendAcountEvents(account, cancellationToken); + + return Results.Ok(); +}); + +app.MapPost("/account/{id:guid}/withdraw", async (Guid id, WithdrawRequest request, EventStoreClient eventStore, CancellationToken cancellationToken) => +{ + var account = await eventStore.GetAccount(id, cancellationToken); + if (account is null) + { + return Results.NotFound(); + } + + account.Withdraw(request.Amount); + + await eventStore.AppendAcountEvents(account, cancellationToken); + + return Results.Ok(); +}); + +app.Run(); + +public record DepositRequest(decimal Amount); +public record WithdrawRequest(decimal Amount); diff --git a/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ApiService/Properties/launchSettings.json b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ApiService/Properties/launchSettings.json new file mode 100644 index 00000000..08fc4374 --- /dev/null +++ b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ApiService/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:38959", + "sslPort": 44303 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5279", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7015;http://localhost:5279", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ApiService/appsettings.json b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ApiService/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ApiService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.AppHost/CommunityToolkit.Aspire.Hosting.EventStore.AppHost.csproj b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.AppHost/CommunityToolkit.Aspire.Hosting.EventStore.AppHost.csproj new file mode 100644 index 00000000..b620b267 --- /dev/null +++ b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.AppHost/CommunityToolkit.Aspire.Hosting.EventStore.AppHost.csproj @@ -0,0 +1,21 @@ + + + + + Exe + enable + enable + true + 9ea31b5e-317f-4692-8a61-e60ac7ec0d0a + + + + + + + + + + + + diff --git a/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.AppHost/Program.cs b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.AppHost/Program.cs new file mode 100644 index 00000000..3bbc58f7 --- /dev/null +++ b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.AppHost/Program.cs @@ -0,0 +1,11 @@ +using Projects; + +var builder = DistributedApplication.CreateBuilder(args); + +var eventstore = builder.AddEventStore("eventstore", 22113); + +builder.AddProject("apiservice") + .WithReference(eventstore) + .WaitFor(eventstore); + +builder.Build().Run(); diff --git a/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.AppHost/Properties/launchSettings.json b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..f996ed79 --- /dev/null +++ b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17038;http://localhost:15090", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21125", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22133" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15090", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19068", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20178" + } + } + } +} diff --git a/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.AppHost/appsettings.json b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.AppHost/appsettings.json new file mode 100644 index 00000000..31c092aa --- /dev/null +++ b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ServiceDefaults/CommunityToolkit.Aspire.Hosting.EventStore.ServiceDefaults.csproj b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ServiceDefaults/CommunityToolkit.Aspire.Hosting.EventStore.ServiceDefaults.csproj new file mode 100644 index 00000000..caa6344d --- /dev/null +++ b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ServiceDefaults/CommunityToolkit.Aspire.Hosting.EventStore.ServiceDefaults.csproj @@ -0,0 +1,21 @@ + + + + enable + enable + true + + + + + + + + + + + + + + + diff --git a/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ServiceDefaults/Extensions.cs b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..1081a52f --- /dev/null +++ b/examples/eventstore/CommunityToolkit.Aspire.Hosting.EventStore.ServiceDefaults/Extensions.cs @@ -0,0 +1,117 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; +// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation() + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/src/CommunityToolkit.Aspire.EventStore/AspireEventStoreExtensions.cs b/src/CommunityToolkit.Aspire.EventStore/AspireEventStoreExtensions.cs new file mode 100644 index 00000000..29bcef4e --- /dev/null +++ b/src/CommunityToolkit.Aspire.EventStore/AspireEventStoreExtensions.cs @@ -0,0 +1,116 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire; +using CommunityToolkit.Aspire.EventStore; +using EventStore.Client; +using EventStore.Client.Extensions.OpenTelemetry; +using HealthChecks.EventStore.gRPC; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Provides extension methods for registering EventStore-related services in an . +/// +public static class AspireEventStoreExtensions +{ + private const string DefaultConfigSectionName = "Aspire:EventStore:Client"; + + /// + /// Registers as a singleton in the services provided by the . + /// + /// The to read config from and add services to. + /// The connection name to use to find a connection string. + /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. + public static void AddEventStoreClient( + this IHostApplicationBuilder builder, + string connectionName, + Action? configureSettings = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNullOrEmpty(connectionName); + AddEventStoreClient(builder, DefaultConfigSectionName, configureSettings, connectionName, serviceKey: null); + } + + /// + /// Registers as a keyed singleton for the given in the services provided by the . + /// + /// The to read config from and add services to. + /// The connection name to use to find a connection string. + /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. + public static void AddKeyedEventStoreClient( + this IHostApplicationBuilder builder, + string name, + Action? configureSettings = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNullOrEmpty(name); + AddEventStoreClient(builder, $"{DefaultConfigSectionName}:{name}", configureSettings, connectionName: name, serviceKey: name); + } + + private static void AddEventStoreClient( + this IHostApplicationBuilder builder, + string configurationSectionName, + Action? configureSettings, + string connectionName, + string? serviceKey) + { + ArgumentNullException.ThrowIfNull(builder); + + var settings = new EventStoreSettings(); + builder.Configuration.GetSection(configurationSectionName).Bind(settings); + + if (builder.Configuration.GetConnectionString(connectionName) is string connectionString) + { + settings.ConnectionString = connectionString; + } + + configureSettings?.Invoke(settings); + + if (serviceKey is null) + { + builder.Services.AddSingleton(ConfigureEventStoreClient); + } + else + { + builder.Services.AddKeyedSingleton(serviceKey, (sp, key) => ConfigureEventStoreClient(sp)); + } + + if (!settings.DisableTracing) + { + builder.Services.AddOpenTelemetry() + .WithTracing(traceBuilder => traceBuilder.AddEventStoreClientInstrumentation()); + } + + if (!settings.DisableHealthChecks) + { + var healthCheckName = serviceKey is null ? "EventStore.Client" : $"EventStore.Client_{connectionName}"; + + builder.TryAddHealthCheck(new HealthCheckRegistration( + healthCheckName, + sp => new EventStoreHealthCheck(settings.ConnectionString!), + failureStatus: default, + tags: default, + timeout: settings.HealthCheckTimeout)); + } + + EventStoreClient ConfigureEventStoreClient(IServiceProvider serviceProvider) + { + if (settings.ConnectionString is not null) + { + var eventStoreClientSettings = EventStoreClientSettings.Create(settings.ConnectionString!); + return new EventStoreClient(eventStoreClientSettings); + } + else + { + throw new InvalidOperationException( + $"An EventStore could not be configured. Ensure valid connection information was provided in 'ConnectionStrings:{connectionName}' or either " + + $"{nameof(settings.ConnectionString)} must be provided " + + $"in the '{configurationSectionName}' configuration section."); + } + } + } +} diff --git a/src/CommunityToolkit.Aspire.EventStore/CommunityToolkit.Aspire.EventStore.csproj b/src/CommunityToolkit.Aspire.EventStore/CommunityToolkit.Aspire.EventStore.csproj new file mode 100644 index 00000000..31e7ca06 --- /dev/null +++ b/src/CommunityToolkit.Aspire.EventStore/CommunityToolkit.Aspire.EventStore.csproj @@ -0,0 +1,19 @@ + + + + EventStore client + An EventStore client that integrates with Aspire, including health checks, logging, and telemetry. + + + + + + + + + + + + + + diff --git a/src/CommunityToolkit.Aspire.EventStore/EventStoreSettings.cs b/src/CommunityToolkit.Aspire.EventStore/EventStoreSettings.cs new file mode 100644 index 00000000..3c341e7e --- /dev/null +++ b/src/CommunityToolkit.Aspire.EventStore/EventStoreSettings.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace CommunityToolkit.Aspire.EventStore; + +/// +/// Provides the client configuration settings for connecting to an EventStore server using EventStoreClient. +/// +public sealed class EventStoreSettings +{ + /// + /// Gets or sets the connection string. + /// + public string? ConnectionString { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the EventStore health check is disabled or not. + /// + /// + /// The default value is . + /// + public bool DisableHealthChecks { get; set; } + + /// + /// Gets or sets the timeout duration for the health check. + /// + /// + /// The default value is . + /// + public TimeSpan? HealthCheckTimeout { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is disabled or not. + /// + /// + /// The default value is . + /// + public bool DisableTracing { get; set; } +} diff --git a/src/CommunityToolkit.Aspire.EventStore/PublicAPI.Shipped.txt b/src/CommunityToolkit.Aspire.EventStore/PublicAPI.Shipped.txt new file mode 100644 index 00000000..897e7c3c --- /dev/null +++ b/src/CommunityToolkit.Aspire.EventStore/PublicAPI.Shipped.txt @@ -0,0 +1,14 @@ +#nullable enable +CommunityToolkit.Aspire.EventStore.EventStoreSettings +CommunityToolkit.Aspire.EventStore.EventStoreSettings.EventStoreSettings() -> void +CommunityToolkit.Aspire.EventStore.EventStoreSettings.ConnectionString.get -> string? +CommunityToolkit.Aspire.EventStore.EventStoreSettings.ConnectionString.set -> void +CommunityToolkit.Aspire.EventStore.EventStoreSettings.DisableHealthChecks.get -> bool +CommunityToolkit.Aspire.EventStore.EventStoreSettings.DisableHealthChecks.set -> void +CommunityToolkit.Aspire.EventStore.EventStoreSettings.HealthCheckTimeout.get -> System.TimeSpan? +CommunityToolkit.Aspire.EventStore.EventStoreSettings.HealthCheckTimeout.set -> void +CommunityToolkit.Aspire.EventStore.EventStoreSettings.DisableTracing.get -> bool +CommunityToolkit.Aspire.EventStore.EventStoreSettings.DisableTracing.set -> void +Microsoft.Extensions.Hosting.AspireEventStoreExtensions +static Microsoft.Extensions.Hosting.AspireEventStoreExtensions.AddKeyedEventStoreClient(this Microsoft.Extensions.Hosting.IHostApplicationBuilder! builder, string! name, System.Action? configureSettings = null) -> void +static Microsoft.Extensions.Hosting.AspireEventStoreExtensions.AddEventStoreClient(this Microsoft.Extensions.Hosting.IHostApplicationBuilder! builder, string! connectionName, System.Action? configureSettings = null) -> void diff --git a/src/CommunityToolkit.Aspire.EventStore/PublicAPI.Unshipped.txt b/src/CommunityToolkit.Aspire.EventStore/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..074c6ad1 --- /dev/null +++ b/src/CommunityToolkit.Aspire.EventStore/PublicAPI.Unshipped.txt @@ -0,0 +1,2 @@ +#nullable enable + diff --git a/src/CommunityToolkit.Aspire.EventStore/README.md b/src/CommunityToolkit.Aspire.EventStore/README.md new file mode 100644 index 00000000..edad8df0 --- /dev/null +++ b/src/CommunityToolkit.Aspire.EventStore/README.md @@ -0,0 +1,114 @@ +# CommunityToolkit.Aspire.EventStore + +Registers an [EventStoreClient](https://github.com/EventStore/EventStore-Client-Dotnet) in the DI container for connecting to an EventStore. + +## Getting started + +### Prerequisites + +- EventStore cluster. + +### Install the package + +Install the .NET Aspire EventStore Client library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package CommunityToolkit.Aspire.EventStore +``` + +## Usage example + +In the _Program.cs_ file of your project, call the `AddEventStoreClient` extension method to register an `EventStoreClient` for use via the dependency injection container. The method takes a connection name parameter. + +```csharp +builder.AddEventStoreClient("eventstore"); +``` + +## Configuration + +The .NET Aspire EventStore Client integration provides multiple options to configure the server connection based on the requirements and conventions of your project. + +### Use a connection string + +When using a connection string from the `ConnectionStrings` configuration section, you can provide the name of the connection string when calling `builder.AddEventStoreClient()`: + +```csharp +builder.AddEventStoreClient("eventstore"); +``` + +And then the connection string will be retrieved from the `ConnectionStrings` configuration section: + +```json +{ + "ConnectionStrings": { + "eventstore": "esdb://localhost:22113?tls=false" + } +} +``` + +### Use configuration providers + +The .NET Aspire EventStore Client integration supports [Microsoft.Extensions.Configuration](https://learn.microsoft.com/dotnet/api/microsoft.extensions.configuration). It loads the `EventStoreSettings` from configuration by using the `Aspire:EventStore:Client` key. Example `appsettings.json` that configures some of the options: + +```json +{ + "Aspire": { + "EventStore": { + "Client": { + "ConnectionString": "esdb://localhost:22113?tls=false", + "DisableHealthChecks": true + } + } + } +} +``` + +### Use inline delegates + +Also you can pass the `Action configureSettings` delegate to set up some or all the options inline, for example to set the API key from code: + +```csharp +builder.AddEventStoreClient("eventstore", settings => settings.DisableHealthChecks = true); +``` + +## AppHost extensions + +In your AppHost project, install the `CommunityToolkit.Aspire.Hosting.EventStore` library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package CommunityToolkit.Aspire.Hosting.EventStore +``` + +Then, in the _Program.cs_ file of `AppHost`, register EventStore and consume the connection using the following methods: + +```csharp +var eventstore = builder.AddEventStore("eventstore"); + +var myService = builder.AddProject() + .WithReference(eventstore); +``` + +The `WithReference` method configures a connection in the `MyService` project named `eventstore`. In the _Program.cs_ file of `MyService`, the EventStore connection can be consumed using: + +```csharp +builder.AddEventStoreClient("eventstore"); +``` + +Then, in your service, inject `EventStoreClient` and use it to interact with the EventStore API: + +```csharp +public class MyService(EventStoreClient eventStoreClient) +{ + // ... +} +``` + +## Additional documentation + +- https://github.com/EventStore/EventStore-Client-Dotnet +- https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-eventstore + +## Feedback & contributing + +https://github.com/CommunityToolkit/Aspire + diff --git a/src/CommunityToolkit.Aspire.Hosting.EventStore/CommunityToolkit.Aspire.Hosting.EventStore.csproj b/src/CommunityToolkit.Aspire.Hosting.EventStore/CommunityToolkit.Aspire.Hosting.EventStore.csproj new file mode 100644 index 00000000..39e36ef1 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.EventStore/CommunityToolkit.Aspire.Hosting.EventStore.csproj @@ -0,0 +1,20 @@ + + + + hosting eventstore + EventStore support for .NET Aspire. + + + + + + + + + + + + + + + diff --git a/src/CommunityToolkit.Aspire.Hosting.EventStore/EventStoreBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.EventStore/EventStoreBuilderExtensions.cs new file mode 100644 index 00000000..e2795933 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.EventStore/EventStoreBuilderExtensions.cs @@ -0,0 +1,146 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Utils; +using CommunityToolkit.Aspire.Hosting.EventStore; +using HealthChecks.EventStore.gRPC; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding EventStore resources to the application model. +/// +public static class EventStoreBuilderExtensions +{ + private const string DataTargetFolder = "/var/lib/eventstore"; + + /// + /// Adds an EventStore resource to the application model. A container is used for local development. + /// The default image is and the tag is . + /// + /// The . + /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. + /// The port on which the EventStore endpoint will be exposed. + /// A reference to the . + /// + /// + /// Add an EventStore container to the application model and reference it in a .NET project. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var eventstore = builder.AddEventStore("eventstore"); + /// var api = builder.AddProject<Projects.Api>("api") + /// .WithReference(eventstore); + /// + /// builder.Build().Run(); + /// + /// + /// + public static IResourceBuilder AddEventStore(this IDistributedApplicationBuilder builder, [ResourceName] string name, int? port = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(name); + + var eventStoreResource = new EventStoreResource(name); + + string? connectionString = null; + + builder.Eventing.Subscribe(eventStoreResource, async (@event, cancellationToken) => + { + connectionString = await eventStoreResource.ConnectionStringExpression + .GetValueAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{eventStoreResource.Name}' resource but the connection string was null."); + }); + + var healthCheckKey = $"{name}_check"; + builder.Services.AddHealthChecks() + .Add(new HealthCheckRegistration( + healthCheckKey, + sp => new EventStoreHealthCheck(connectionString!), + failureStatus: default, + tags: default, + timeout: default)); + + return builder + .AddResource(eventStoreResource) + .WithHttpEndpoint(port: port, targetPort: EventStoreResource.DefaultHttpPort, name: EventStoreResource.HttpEndpointName) + .WithImage(EventStoreContainerImageTags.Image, EventStoreContainerImageTags.Tag) + .WithImageRegistry(EventStoreContainerImageTags.Registry) + .WithEnvironment(ConfigureEventStoreContainer) + .WithHealthCheck(healthCheckKey); + } + + /// + /// Adds a named volume for the data folder to a EventStore container resource. + /// + /// The resource builder. + /// The name of the volume. Defaults to an auto-generated name based on the application and resource names. + /// The . + /// + /// + /// Add an EventStore container to the application model and reference it in a .NET project. Additionally, in this + /// example a data volume is added to the container to allow data to be persisted across container restarts. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var eventstore = builder.AddEventStore("eventstore") + /// .WithDataVolume(); + /// var api = builder.AddProject<Projects.Api>("api") + /// .WithReference(eventstore); + /// + /// builder.Build().Run(); + /// + /// + /// + public static IResourceBuilder WithDataVolume(this IResourceBuilder builder, string? name = null) + { + ArgumentNullException.ThrowIfNull(builder); + +#pragma warning disable CTASPIRE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + return builder.WithVolume(name ?? VolumeNameGenerator.CreateVolumeName(builder, "data"), DataTargetFolder); +#pragma warning restore CTASPIRE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + } + + /// + /// Adds a bind mount for the data folder to a EventStore container resource. + /// + /// The resource builder. + /// The source directory on the host to mount into the container. + /// The . + /// + /// + /// Add an EventStore container to the application model and reference it in a .NET project. Additionally, in this + /// example a bind mount is added to the container to allow data to be persisted across container restarts. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var eventstore = builder.AddEventStore("eventstore") + /// .WithDataBindMount("./data/eventstore/data"); + /// var api = builder.AddProject<Projects.Api>("api") + /// .WithReference(eventstore); + /// + /// builder.Build().Run(); + /// + /// + /// + public static IResourceBuilder WithDataBindMount(this IResourceBuilder builder, string source) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(source); + + return builder.WithBindMount(source, DataTargetFolder); + } + + private static void ConfigureEventStoreContainer(EnvironmentCallbackContext context) + { + context.EnvironmentVariables.Add("EVENTSTORE_CLUSTER_SIZE", "1"); + context.EnvironmentVariables.Add("EVENTSTORE_RUN_PROJECTIONS", "All"); + context.EnvironmentVariables.Add("EVENTSTORE_START_STANDARD_PROJECTIONS", "true"); + context.EnvironmentVariables.Add("EVENTSTORE_NODE_PORT", $"{EventStoreResource.DefaultHttpPort}"); + context.EnvironmentVariables.Add("EVENTSTORE_INSECURE", "true"); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.EventStore/EventStoreContainerImageTags.cs b/src/CommunityToolkit.Aspire.Hosting.EventStore/EventStoreContainerImageTags.cs new file mode 100644 index 00000000..056b4bbb --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.EventStore/EventStoreContainerImageTags.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace CommunityToolkit.Aspire.Hosting.EventStore; + +internal static class EventStoreContainerImageTags +{ + public const string Registry = "docker.io"; + public const string Image = "eventstore/eventstore"; + public const string Tag = "24.10"; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.EventStore/EventStoreResource.cs b/src/CommunityToolkit.Aspire.Hosting.EventStore/EventStoreResource.cs new file mode 100644 index 00000000..a0969d78 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.EventStore/EventStoreResource.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A resource that represents an EventStore container. +/// +/// The name of the resource. +public class EventStoreResource(string name) : ContainerResource(name), IResourceWithConnectionString +{ + internal const string HttpEndpointName = "http"; + internal const int DefaultHttpPort = 2113; + + private EndpointReference? _primaryEndpoint; + + /// + /// Gets the primary endpoint for the EventStore server. + /// + public EndpointReference PrimaryEndpoint => _primaryEndpoint ??= new(this, HttpEndpointName); + + /// + /// Gets the connection string for the EventStore server. + /// + public ReferenceExpression ConnectionStringExpression => + ReferenceExpression.Create( + $"esdb://{PrimaryEndpoint.Property(EndpointProperty.Host)}:{PrimaryEndpoint.Property(EndpointProperty.Port)}?tls=false"); +} diff --git a/src/CommunityToolkit.Aspire.Hosting.EventStore/PublicAPI.Shipped.txt b/src/CommunityToolkit.Aspire.Hosting.EventStore/PublicAPI.Shipped.txt new file mode 100644 index 00000000..793ae86c --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.EventStore/PublicAPI.Shipped.txt @@ -0,0 +1,9 @@ +#nullable enable +Aspire.Hosting.ApplicationModel.EventStoreResource +Aspire.Hosting.ApplicationModel.EventStoreResource.EventStoreResource(string! name) -> void +Aspire.Hosting.ApplicationModel.EventStoreResource.PrimaryEndpoint.get -> Aspire.Hosting.ApplicationModel.EndpointReference! +Aspire.Hosting.ApplicationModel.EventStoreResource.ConnectionStringExpression.get -> Aspire.Hosting.ApplicationModel.ReferenceExpression! +Aspire.Hosting.EventStoreBuilderExtensions +static Aspire.Hosting.EventStoreBuilderExtensions.AddEventStore(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, int? port = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.EventStoreBuilderExtensions.WithDataVolume(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string? name = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.EventStoreBuilderExtensions.WithDataBindMount(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! source) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! diff --git a/src/CommunityToolkit.Aspire.Hosting.EventStore/PublicAPI.Unshipped.txt b/src/CommunityToolkit.Aspire.Hosting.EventStore/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..074c6ad1 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.EventStore/PublicAPI.Unshipped.txt @@ -0,0 +1,2 @@ +#nullable enable + diff --git a/src/CommunityToolkit.Aspire.Hosting.EventStore/README.md b/src/CommunityToolkit.Aspire.Hosting.EventStore/README.md new file mode 100644 index 00000000..518b6509 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.EventStore/README.md @@ -0,0 +1,37 @@ +# CommunityToolkit.Aspire.Hosting.EventStore library + +Provides extension methods and resource definitions for the .NET Aspire app host to support running [EventStore](https://www.eventstore.com) containers. + +## Getting Started + +### Install the package + +In your app host project, install the package using the following command: + +```dotnetcli +dotnet add package CommunityToolkit.Aspire.Hosting.EventStore +``` + +### Example usage + +Then, in the _Program.cs_ file of app host, add a EventStore resource and consume the connection using the following methods: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var eventstore = builder.AddEventStore("eventstore"); + +var myService = builder.AddProject() + .WithReference(eventstore); + +builder.Build().Run(); +``` + +## Additional Information + +https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-eventstore + +## Feedback & contributing + +https://github.com/CommunityToolkit/Aspire + diff --git a/tests/CommunityToolkit.Aspire.EventStore.Tests/AspireEventStoreClientExtensionsTest.cs b/tests/CommunityToolkit.Aspire.EventStore.Tests/AspireEventStoreClientExtensionsTest.cs new file mode 100644 index 00000000..adf1307b --- /dev/null +++ b/tests/CommunityToolkit.Aspire.EventStore.Tests/AspireEventStoreClientExtensionsTest.cs @@ -0,0 +1,89 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using EventStore.Client; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; + +namespace CommunityToolkit.Aspire.EventStore.Tests; + +public class AspireEventStoreClientExtensionsTest(EventStoreContainerFixture containerFixture) : IClassFixture +{ + private const string DefaultConnectionName = "eventstore"; + + private string DefaultConnectionString => + RequiresDockerAttribute.IsSupported ? containerFixture.GetConnectionString() : "esdb://localhost:2113?tls=false"; + + [Theory] + [InlineData(true)] + [InlineData(false)] + [RequiresDocker] + public async Task AddEventStoreClient_HealthCheckShouldBeRegisteredWhenEnabled(bool useKeyed) + { + var key = DefaultConnectionName; + + var builder = CreateBuilder(DefaultConnectionString); + + if (useKeyed) + { + builder.AddKeyedEventStoreClient(key, settings => + { + settings.DisableHealthChecks = false; + }); + } + else + { + builder.AddEventStoreClient(DefaultConnectionName, settings => + { + settings.DisableHealthChecks = false; + }); + } + + using var host = builder.Build(); + + var healthCheckService = host.Services.GetRequiredService(); + + var healthCheckReport = await healthCheckService.CheckHealthAsync(); + + var healthCheckName = useKeyed ? $"EventStore.Client_{key}" : "EventStore.Client"; + + Assert.Contains(healthCheckReport.Entries, x => x.Key == healthCheckName); + } + + [Fact] + public void CanAddMultipleKeyedServices() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:eventstore1", "esdb://localhost:22113?tls=false"), + new KeyValuePair("ConnectionStrings:eventstore2", "esdb://localhost:22114?tls=false"), + new KeyValuePair("ConnectionStrings:eventstore3", "esdb://localhost:22115?tls=false"), + ]); + + builder.AddEventStoreClient("eventstore1"); + builder.AddKeyedEventStoreClient("eventstore2"); + builder.AddKeyedEventStoreClient("eventstore3"); + + using var host = builder.Build(); + + var client1 = host.Services.GetRequiredService(); + var client2 = host.Services.GetRequiredKeyedService("eventstore2"); + var client3 = host.Services.GetRequiredKeyedService("eventstore3"); + + Assert.NotSame(client1, client2); + Assert.NotSame(client1, client3); + Assert.NotSame(client2, client3); + } + + private static HostApplicationBuilder CreateBuilder(string connectionString) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair($"ConnectionStrings:{DefaultConnectionName}", connectionString) + ]); + return builder; + } +} diff --git a/tests/CommunityToolkit.Aspire.EventStore.Tests/CommunityToolkit.Aspire.EventStore.Tests.csproj b/tests/CommunityToolkit.Aspire.EventStore.Tests/CommunityToolkit.Aspire.EventStore.Tests.csproj new file mode 100644 index 00000000..fd0b3c3b --- /dev/null +++ b/tests/CommunityToolkit.Aspire.EventStore.Tests/CommunityToolkit.Aspire.EventStore.Tests.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/tests/CommunityToolkit.Aspire.EventStore.Tests/ConfigurationTests.cs b/tests/CommunityToolkit.Aspire.EventStore.Tests/ConfigurationTests.cs new file mode 100644 index 00000000..e82a370e --- /dev/null +++ b/tests/CommunityToolkit.Aspire.EventStore.Tests/ConfigurationTests.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace CommunityToolkit.Aspire.EventStore.Tests; + +public class ConfigurationTests +{ + [Fact] + public void ConnectionStringIsNullByDefault() => + Assert.Null(new EventStoreSettings().ConnectionString); + + [Fact] + public void HealthChecksEnabledByDefault() => + Assert.False(new EventStoreSettings().DisableHealthChecks); + + [Fact] + public void HealthCheckTimeoutNullByDefault() => + Assert.Null(new EventStoreSettings().HealthCheckTimeout); + + [Fact] + public void DisableTracingIsFalseByDefault() => + Assert.False(new EventStoreSettings().DisableTracing); +} diff --git a/tests/CommunityToolkit.Aspire.EventStore.Tests/ConformanceTests.cs b/tests/CommunityToolkit.Aspire.EventStore.Tests/ConformanceTests.cs new file mode 100644 index 00000000..ec263722 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.EventStore.Tests/ConformanceTests.cs @@ -0,0 +1,92 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using Aspire.Components.ConformanceTests; +using EventStore.Client; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +namespace CommunityToolkit.Aspire.EventStore.Tests; + +public class ConformanceTests(EventStoreContainerFixture containerFixture) : ConformanceTests, IClassFixture +{ + private readonly EventStoreContainerFixture _containerFixture = containerFixture; + + protected override ServiceLifetime ServiceLifetime => ServiceLifetime.Singleton; + + protected override string ActivitySourceName => string.Empty; + + protected override string[] RequiredLogCategories => []; + + protected override bool CanConnectToServer => RequiresDockerAttribute.IsSupported; + + protected override bool SupportsKeyedRegistrations => true; + + protected override void PopulateConfiguration(ConfigurationManager configuration, string? key = null) + { + var connectionString = RequiresDockerAttribute.IsSupported + ? $"{_containerFixture.GetConnectionString()}" + : "esdb://localhost:22113?tls=false"; + + configuration.AddInMemoryCollection( + [ + new KeyValuePair($"Aspire:EventStore:Client:ConnectionString", $"{connectionString}"), + new KeyValuePair($"ConnectionStrings:{key}", $"{connectionString}") + ]); + } + + protected override void RegisterComponent(HostApplicationBuilder builder, Action? configure = null, string? key = null) + { + if (key is null) + { + builder.AddEventStoreClient("eventstore", configureSettings: configure); + } + else + { + builder.AddKeyedEventStoreClient(key, configureSettings: configure); + } + } + + protected override string ValidJsonConfig => """ + { + "Aspire": { + "EventStore": { + "Client": { + "ConnectionString": "esdb://localhost:22113?tls=false", + "DisableHealthChecks": "false" + } + } + } + } + """; + + protected override (string json, string error)[] InvalidJsonToErrorMessage => new[] + { + ("""{"Aspire": { "EventStore":{ "Client": { "ConnectionString": 3 }}}}""", "Value is \"integer\" but should be \"string\"") + }; + + protected override void SetHealthCheck(EventStoreSettings options, bool enabled) + { + options.DisableHealthChecks = !enabled; + } + + protected override void SetMetrics(EventStoreSettings options, bool enabled) + { + throw new NotImplementedException(); + } + + protected override void SetTracing(EventStoreSettings options, bool enabled) + { + throw new NotImplementedException(); + } + + protected override void TriggerActivity(EventStoreClient service) + { + using var source = new CancellationTokenSource(100); + + var readResult = service.ReadAllAsync(direction: Direction.Backwards, position: Position.End, maxCount: 1); + + readResult.Messages.ToArrayAsync().GetAwaiter().GetResult(); + } +} diff --git a/tests/CommunityToolkit.Aspire.EventStore.Tests/EventStoreClientPublicApiTests.cs b/tests/CommunityToolkit.Aspire.EventStore.Tests/EventStoreClientPublicApiTests.cs new file mode 100644 index 00000000..19fe11f0 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.EventStore.Tests/EventStoreClientPublicApiTests.cs @@ -0,0 +1,88 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace CommunityToolkit.Aspire.EventStore.Tests; + +public class EventStoreClientPublicApiTests +{ + [Fact] + public void AddEventStoreClientShouldThrowWhenBuilderIsNull() + { + IHostApplicationBuilder builder = null!; + + var connectionName = "eventstore"; + + var action = () => builder.AddEventStoreClient(connectionName); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void AddEventStoreClientShouldThrowWhenNameIsNull() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + string connectionName = null!; + + var action = () => builder.AddEventStoreClient(connectionName); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(connectionName), exception.ParamName); + } + + [Fact] + public void AddEventStoreClientShouldThrowWhenNameIsEmpty() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + string connectionName = ""; + + var action = () => builder.AddEventStoreClient(connectionName); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(connectionName), exception.ParamName); + } + + [Fact] + public void AddKeyedEventStoreClientShouldThrowWhenBuilderIsNull() + { + IHostApplicationBuilder builder = null!; + + var connectionName = "eventstore"; + + var action = () => builder.AddKeyedEventStoreClient(connectionName); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void AddKeyedEventStoreClientShouldThrowWhenNameIsNull() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + string name = null!; + + var action = () => builder.AddKeyedEventStoreClient(name); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(name), exception.ParamName); + } + + [Fact] + public void AddKeyedEventStoreClientShouldThrowWhenNameIsEmpty() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + string name = ""; + + var action = () => builder.AddKeyedEventStoreClient(name); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(name), exception.ParamName); + } +} diff --git a/tests/CommunityToolkit.Aspire.EventStore.Tests/EventStoreContainerFixture.cs b/tests/CommunityToolkit.Aspire.EventStore.Tests/EventStoreContainerFixture.cs new file mode 100644 index 00000000..e8f07a8d --- /dev/null +++ b/tests/CommunityToolkit.Aspire.EventStore.Tests/EventStoreContainerFixture.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using CommunityToolkit.Aspire.Hosting.EventStore; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; + +namespace CommunityToolkit.Aspire.EventStore.Tests; + +public sealed class EventStoreContainerFixture : IAsyncLifetime +{ + public IContainer? Container { get; private set; } + + public string GetConnectionString() + { + if (Container is null) + { + throw new InvalidOperationException("The test container was not initialized."); + } + var endpoint = new UriBuilder("esdb", Container.Hostname, Container.GetMappedPublicPort(2113)).ToString(); + return $"{endpoint}?tls=false"; + } + + public async Task InitializeAsync() + { + if (RequiresDockerAttribute.IsSupported) + { + Container = new ContainerBuilder() + .WithImage($"{EventStoreContainerImageTags.Registry}/{EventStoreContainerImageTags.Image}:{EventStoreContainerImageTags.Tag}") + .WithPortBinding(2113, true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPort(2113))) + .WithEnvironment("EVENTSTORE_CLUSTER_SIZE", "1") + .WithEnvironment("EVENTSTORE_NODE_PORT", "2113") + .WithEnvironment("EVENTSTORE_INSECURE", "true") + .Build(); + + await Container.StartAsync(); + } + } + + public async Task DisposeAsync() + { + if (Container is not null) + { + await Container.DisposeAsync(); + } + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.EventStore.Tests/AddEventStoreTests.cs b/tests/CommunityToolkit.Aspire.Hosting.EventStore.Tests/AddEventStoreTests.cs new file mode 100644 index 00000000..1707a2fc --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.EventStore.Tests/AddEventStoreTests.cs @@ -0,0 +1,90 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting; +using System.Net.Sockets; + +namespace CommunityToolkit.Aspire.Hosting.EventStore.Tests; + +public class AddEventStoreTests +{ + [Fact] + public async Task AddEventStoreContainerWithDefaultsAddsAnnotationMetadata() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + var eventstore = appBuilder.AddEventStore("eventstore"); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var containerResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("eventstore", containerResource.Name); + + var endpoints = containerResource.Annotations.OfType(); + Assert.Single(endpoints); + + var primaryEndpoint = Assert.Single(endpoints, e => e.Name == "http"); + Assert.Equal(EventStoreResource.DefaultHttpPort, primaryEndpoint.TargetPort); + Assert.False(primaryEndpoint.IsExternal); + Assert.Equal("http", primaryEndpoint.Name); + Assert.Null(primaryEndpoint.Port); + Assert.Equal(ProtocolType.Tcp, primaryEndpoint.Protocol); + Assert.Equal("http", primaryEndpoint.Transport); + Assert.Equal("http", primaryEndpoint.UriScheme); + + var containerAnnotation = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal(EventStoreContainerImageTags.Tag, containerAnnotation.Tag); + Assert.Equal(EventStoreContainerImageTags.Image, containerAnnotation.Image); + Assert.Equal(EventStoreContainerImageTags.Registry, containerAnnotation.Registry); + + var config = await eventstore.Resource.GetEnvironmentVariableValuesAsync(); + + Assert.Collection(config, + env => + { + Assert.Equal("EVENTSTORE_CLUSTER_SIZE", env.Key); + Assert.Equal("1", env.Value); + }, + env => + { + Assert.Equal("EVENTSTORE_RUN_PROJECTIONS", env.Key); + Assert.Equal("All", env.Value); + }, + env => + { + Assert.Equal("EVENTSTORE_START_STANDARD_PROJECTIONS", env.Key); + Assert.Equal("true", env.Value); + }, + env => + { + Assert.Equal("EVENTSTORE_NODE_PORT", env.Key); + Assert.Equal($"{EventStoreResource.DefaultHttpPort}", env.Value); + }, + ext => + { + Assert.Equal("EVENTSTORE_INSECURE", ext.Key); + Assert.Equal("true", ext.Value); + }); + } + + [Fact] + public async Task EventStoreCreatesConnectionString() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var eventstore = appBuilder + .AddEventStore("eventstore") + .WithEndpoint("http", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 22113)); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var connectionStringResource = Assert.Single(appModel.Resources.OfType()) as IResourceWithConnectionString; + var connectionString = await connectionStringResource.GetConnectionStringAsync(); + + Assert.Equal("esdb://localhost:22113?tls=false", connectionString); + Assert.Equal("esdb://{eventstore.bindings.http.host}:{eventstore.bindings.http.port}?tls=false", connectionStringResource.ConnectionStringExpression.ValueExpression); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.EventStore.Tests/AppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.EventStore.Tests/AppHostTests.cs new file mode 100644 index 00000000..8b9a9a4d --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.EventStore.Tests/AppHostTests.cs @@ -0,0 +1,112 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using CommunityToolkit.Aspire.Testing; +using FluentAssertions; +using Projects; +using System.Net.Http.Json; + +namespace CommunityToolkit.Aspire.Hosting.EventStore.Tests; + +[RequiresDocker] +public class AppHostTests(AspireIntegrationTestFixture fixture) : IClassFixture> +{ + [Fact] + public async Task ResourceStartsAndRespondsOk() + { + var resourceName = "eventstore"; + await fixture.ResourceNotificationService + .WaitForResourceHealthyAsync(resourceName) + .WaitAsync(TimeSpan.FromMinutes(1)); + + var httpClient = fixture.CreateHttpClient(resourceName); + + var response = await httpClient.GetAsync("/"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ApiServiceCreateAccount() + { + var resourceName = "apiservice"; + await fixture.ResourceNotificationService + .WaitForResourceHealthyAsync("eventstore") + .WaitAsync(TimeSpan.FromMinutes(1)); + await fixture.ResourceNotificationService + .WaitForResourceHealthyAsync(resourceName) + .WaitAsync(TimeSpan.FromMinutes(1)); + + var httpClient = fixture.CreateHttpClient(resourceName); + + var createResponse = await httpClient.PostAsJsonAsync("/account/create", new { }); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var location = createResponse.Headers.Location; + + var getResponse = await httpClient.GetAsync(location); + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var account = await getResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(account); + Assert.Equal("John Doe", account.Name); + Assert.Equal(100, account.Balance); + } + + [Fact] + public async Task ApiServiceCreateAccountAndDeposit() + { + var resourceName = "apiservice"; + await fixture.ResourceNotificationService + .WaitForResourceHealthyAsync(resourceName) + .WaitAsync(TimeSpan.FromMinutes(1)); + + var httpClient = fixture.CreateHttpClient(resourceName); + + var createResponse = await httpClient.PostAsJsonAsync("/account/create", new { }); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var location = createResponse.Headers.Location; + + var depositResponse = await httpClient.PostAsJsonAsync($"{location!}/deposit", new { Amount = 50 }); + depositResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var getResponse = await httpClient.GetAsync(location); + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var account = await getResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(account); + Assert.Equal("John Doe", account.Name); + Assert.Equal(150, account.Balance); + } + + [Fact] + public async Task ApiServiceCreateAccountAndWithdraw() + { + var resourceName = "apiservice"; + await fixture.ResourceNotificationService + .WaitForResourceHealthyAsync(resourceName) + .WaitAsync(TimeSpan.FromMinutes(1)); + + var httpClient = fixture.CreateHttpClient(resourceName); + + var createResponse = await httpClient.PostAsJsonAsync("/account/create", new { }); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var location = createResponse.Headers.Location; + + var depositResponse = await httpClient.PostAsJsonAsync($"{location!}/withdraw", new { Amount = 90 }); + depositResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var getResponse = await httpClient.GetAsync(location); + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var account = await getResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(account); + Assert.Equal("John Doe", account.Name); + Assert.Equal(10, account.Balance); + } + + public record AccountDto(Guid Id, string Name, decimal Balance); +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.EventStore.Tests/CommunityToolkit.Aspire.Hosting.EventStore.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.EventStore.Tests/CommunityToolkit.Aspire.Hosting.EventStore.Tests.csproj new file mode 100644 index 00000000..60df07d6 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.EventStore.Tests/CommunityToolkit.Aspire.Hosting.EventStore.Tests.csproj @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/tests/CommunityToolkit.Aspire.Hosting.EventStore.Tests/EventStoreFunctionalTests.cs b/tests/CommunityToolkit.Aspire.Hosting.EventStore.Tests/EventStoreFunctionalTests.cs new file mode 100644 index 00000000..f20a2833 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.EventStore.Tests/EventStoreFunctionalTests.cs @@ -0,0 +1,265 @@ +using Aspire.Components.Common.Tests; +using Aspire.Hosting; +using Aspire.Hosting.Utils; +using EventStore.Client; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using System.Text; +using System.Text.Json; +using Xunit.Abstractions; + +namespace CommunityToolkit.Aspire.Hosting.EventStore.Tests; + +[RequiresDocker] +public class EventStoreFunctionalTests(ITestOutputHelper testOutputHelper) +{ + public const string TestStreamNamePrefix = "account-"; + public const string TestAccountName = "John Doe"; + + [Fact] + public async Task VerifyEventStoreResource() + { + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(testOutputHelper); + + var eventstore = builder.AddEventStore("eventstore"); + + using var app = builder.Build(); + + await app.StartAsync(); + + var rns = app.Services.GetRequiredService(); + + await rns.WaitForResourceHealthyAsync(eventstore.Resource.Name, default); + + var hostBuilder = Host.CreateApplicationBuilder(); + + hostBuilder.Configuration[$"ConnectionStrings:{eventstore.Resource.Name}"] = await eventstore.Resource.ConnectionStringExpression.GetValueAsync(default); + + hostBuilder.AddEventStoreClient(eventstore.Resource.Name); + + using var host = hostBuilder.Build(); + + await host.StartAsync(); + + var eventStoreClient = host.Services.GetRequiredService(); + + var id = await CreateTestDataAsync(eventStoreClient); + await VerifyTestDataAsync(eventStoreClient, id); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) + { + string? volumeName = null; + string? bindMountPath = null; + Guid? id = null; + + try + { + using var builder1 = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(testOutputHelper); + var eventstore1 = builder1.AddEventStore("eventstore"); + + if (useVolume) + { + // Use a deterministic volume name to prevent them from exhausting the machines if deletion fails +#pragma warning disable CTASPIRE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + volumeName = VolumeNameGenerator.CreateVolumeName(eventstore1, nameof(WithDataShouldPersistStateBetweenUsages)); +#pragma warning restore CTASPIRE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + + // if the volume already exists (because of a crashing previous run), delete it + DockerUtils.AttemptDeleteDockerVolume(volumeName, throwOnFailure: true); + eventstore1.WithDataVolume(volumeName); + } + else + { + bindMountPath = Directory.CreateTempSubdirectory().FullName; + + if (!OperatingSystem.IsWindows()) + { + // Change permissions for non-root accounts (container user account) + const UnixFileMode OwnershipPermissions = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; + + File.SetUnixFileMode(bindMountPath, OwnershipPermissions); + } + + eventstore1.WithDataBindMount(bindMountPath); + } + + using (var app = builder1.Build()) + { + await app.StartAsync(); + + var rns = app.Services.GetRequiredService(); + + await rns.WaitForResourceHealthyAsync(eventstore1.Resource.Name, default); + + try + { + var hostBuilder = Host.CreateApplicationBuilder(); + + hostBuilder.Configuration[$"ConnectionStrings:{eventstore1.Resource.Name}"] = await eventstore1.Resource.ConnectionStringExpression.GetValueAsync(default); + + hostBuilder.AddEventStoreClient(eventstore1.Resource.Name); + + using (var host = hostBuilder.Build()) + { + await host.StartAsync(); + + var eventStoreClient = host.Services.GetRequiredService(); + id = await CreateTestDataAsync(eventStoreClient); + await VerifyTestDataAsync(eventStoreClient, id.Value); + } + } + finally + { + // Stops the container, or the Volume would still be in use + await app.StopAsync(); + } + } + + using var builder2 = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(testOutputHelper); + var eventstore2 = builder2.AddEventStore("eventstore"); + + if (useVolume) + { + eventstore2.WithDataVolume(volumeName); + } + else + { + //EventStore shutdown can be slightly delayed, so second instance might fail to start when using the same bind mount before shutdown. + await Task.Delay(TimeSpan.FromSeconds(5)); + eventstore2.WithDataBindMount(bindMountPath!); + } + + using (var app = builder2.Build()) + { + await app.StartAsync(); + + var rns = app.Services.GetRequiredService(); + + await rns.WaitForResourceHealthyAsync(eventstore1.Resource.Name, default); + + try + { + var hostBuilder = Host.CreateApplicationBuilder(); + + hostBuilder.Configuration[$"ConnectionStrings:{eventstore2.Resource.Name}"] = await eventstore2.Resource.ConnectionStringExpression.GetValueAsync(default); + + hostBuilder.AddEventStoreClient(eventstore2.Resource.Name); + + using (var host = hostBuilder.Build()) + { + await host.StartAsync(); + var eventStoreClient = host.Services.GetRequiredService(); + + await VerifyTestDataAsync(eventStoreClient, id.Value); + } + } + finally + { + // Stops the container, or the Volume would still be in use + await app.StopAsync(); + } + } + + } + finally + { + if (volumeName is not null) + { + DockerUtils.AttemptDeleteDockerVolume(volumeName); + } + + if (bindMountPath is not null) + { + try + { + Directory.Delete(bindMountPath, recursive: true); + } + catch + { + // Don't fail test if we can't clean the temporary folder + } + } + } + } + + [Fact] + public async Task VerifyWaitForEventStoreBlocksDependentResources() + { + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(testOutputHelper); + + var healthCheckTcs = new TaskCompletionSource(); + builder.Services.AddHealthChecks().AddAsyncCheck("blocking_check", () => + { + return healthCheckTcs.Task; + }); + + var resource = builder.AddEventStore("resource") + .WithHealthCheck("blocking_check"); + + var dependentResource = builder.AddContainer("nginx", "mcr.microsoft.com/cbl-mariner/base/nginx", "1.22") + .WaitFor(resource); + + using var app = builder.Build(); + + var pendingStart = app.StartAsync(cts.Token); + + var rns = app.Services.GetRequiredService(); + + await rns.WaitForResourceAsync(resource.Resource.Name, KnownResourceStates.Running, cts.Token); + + await rns.WaitForResourceAsync(dependentResource.Resource.Name, KnownResourceStates.Waiting, cts.Token); + + healthCheckTcs.SetResult(HealthCheckResult.Healthy()); + + await rns.WaitForResourceHealthyAsync(resource.Resource.Name, cts.Token); + + await rns.WaitForResourceAsync(dependentResource.Resource.Name, KnownResourceStates.Running, cts.Token); + + await pendingStart; + + await app.StopAsync(); + } + + private static async Task CreateTestDataAsync(EventStoreClient eventStoreClient) + { + var id = Guid.NewGuid(); + var accountCreated = new AccountCreated(id, TestAccountName); + var data = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(accountCreated)); + var eventData = new EventData(Uuid.NewUuid(), nameof(AccountCreated), data); + var streamName = $"{TestStreamNamePrefix}{id}"; + + var writeResult = await eventStoreClient.AppendToStreamAsync(streamName, StreamRevision.None, [eventData]); + Assert.NotNull(writeResult); + + return id; + } + + private static async Task VerifyTestDataAsync(EventStoreClient eventStoreClient, Guid id) + { + var streamName = $"{TestStreamNamePrefix}{id}"; + + var readResult = eventStoreClient.ReadStreamAsync(Direction.Forwards, streamName, StreamPosition.Start); + Assert.NotNull(readResult); + + var readState = await readResult.ReadState; + Assert.Equal(ReadState.Ok, readState); + + await foreach (var resolvedEvent in readResult) + { + var @event = JsonSerializer.Deserialize(Encoding.UTF8.GetString(resolvedEvent.Event.Data.Span)); + Assert.NotNull(@event); + Assert.Equal(id, @event.Id); + Assert.Equal(TestAccountName, @event.Name); + } + } + + private sealed record AccountCreated(Guid Id, string Name); +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.EventStore.Tests/EventStorePublicApiTests.cs b/tests/CommunityToolkit.Aspire.Hosting.EventStore.Tests/EventStorePublicApiTests.cs new file mode 100644 index 00000000..4546d29d --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.EventStore.Tests/EventStorePublicApiTests.cs @@ -0,0 +1,140 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.EventStore.Tests; + +public class EventStorePublicApiTests +{ + [Fact] + public void AddEventStoreShouldThrowWhenBuilderIsNull() + { + IDistributedApplicationBuilder builder = null!; + const string name = "eventstore"; + + var action = () => builder.AddEventStore(name); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void AddEventStoreShouldThrowWhenNameIsNull() + { + var builder = new DistributedApplicationBuilder([]); + string name = null!; + + var action = () => builder.AddEventStore(name); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(name), exception.ParamName); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void WithDataShouldThrowWhenBuilderIsNull(bool useVolume) + { + IResourceBuilder builder = null!; + + Func>? action = null; + + if (useVolume) + { + action = () => builder.WithDataVolume(); + } + else + { + const string source = "/data"; + + action = () => builder.WithDataBindMount(source); + } + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void WithDataBindMountShouldThrowWhenSourceIsNull() + { + var builder = new DistributedApplicationBuilder([]); + var eventstore = builder.AddEventStore("eventstore"); + + string source = null!; + + var action = () => eventstore.WithDataBindMount(source); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(source), exception.ParamName); + } + + [Fact] + public void WithDataVolumeShouldAddMountAnnotation() + { + var builder = new DistributedApplicationBuilder([]); + var eventstore = builder.AddEventStore("eventstore") + .WithDataVolume(name: null); + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = appModel.Resources.OfType().SingleOrDefault(); + + Assert.NotNull(resource); + Assert.Equal("eventstore", resource.Name); + + Assert.True(resource.TryGetLastAnnotation(out ContainerMountAnnotation? mountAnnotation)); + Assert.EndsWith("-data", mountAnnotation.Source); + Assert.Equal("/var/lib/eventstore", mountAnnotation.Target); + } + + [Fact] + public void WithNamedDataVolumeShouldAddMountAnnotation() + { + var builder = new DistributedApplicationBuilder([]); + var eventstore = builder.AddEventStore("eventstore") + .WithDataVolume("mydata"); + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = appModel.Resources.OfType().SingleOrDefault(); + + Assert.NotNull(resource); + Assert.Equal("eventstore", resource.Name); + + Assert.True(resource.TryGetLastAnnotation(out ContainerMountAnnotation? mountAnnotation)); + Assert.Equal("mydata", mountAnnotation.Source); + Assert.Equal("/var/lib/eventstore", mountAnnotation.Target); + } + + [Fact] + public void WithDataBindMountShouldAddMountAnnotation() + { + var builder = new DistributedApplicationBuilder([]); + var eventstore = builder.AddEventStore("eventstore") + .WithDataBindMount("./mydata"); + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = appModel.Resources.OfType().SingleOrDefault(); + + Assert.NotNull(resource); + Assert.Equal("eventstore", resource.Name); + + Assert.True(resource.TryGetLastAnnotation(out ContainerMountAnnotation? mountAnnotation)); + Assert.EndsWith("mydata", mountAnnotation.Source); + Assert.Equal("/var/lib/eventstore", mountAnnotation.Target); + } + + [Fact] + public void EventStoreResourceCtorShouldThrowWhenNameIsNull() + { + var builder = new DistributedApplicationBuilder([]); + const string name = null!; + + var action = () => new EventStoreResource(name); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(name), exception.ParamName); + } +}