Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ jobs:
Hosting.Golang.Tests,
Hosting.Java.Tests,
Hosting.k6.Tests,
Hosting.KurrentDB.Tests,
Hosting.LavinMQ.Tests,
Hosting.MailPit.Tests,
Hosting.McpInspector.Tests,
Expand Down Expand Up @@ -60,6 +61,7 @@ jobs:
# Client integration tests
EventStore.Tests,
GoFeatureFlag.Tests,
KurrentDB.Tests,
MassTransit.RabbitMQ.Tests,
Meilisearch.Tests,
Microsoft.Data.Sqlite.Tests,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using System.Text.Json.Serialization;

namespace CommunityToolkit.Aspire.Hosting.KurrentDB.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<object> 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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace CommunityToolkit.Aspire.Hosting.KurrentDB.ApiService;

public record AccountCreated(Guid Id, string Name);

public record AccountFundsDeposited(Guid Id, decimal Amount);

public record AccountFundsWithdrew(Guid Id, decimal Amount);
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Swashbuckle.AspNetCore" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\src\CommunityToolkit.Aspire.KurrentDB\CommunityToolkit.Aspire.KurrentDB.csproj" />
<ProjectReference Include="..\CommunityToolkit.Aspire.Hosting.KurrentDB.ServiceDefaults\CommunityToolkit.Aspire.Hosting.KurrentDB.ServiceDefaults.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using EventStore.Client;
using System.Text.Json;
using System.Text;

namespace CommunityToolkit.Aspire.Hosting.KurrentDB.ApiService;

public static class KurrentDBExtensions
{
public static async Task<Account?> 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<string, string>
{
{ "EventClrTypeName", @event.GetType().AssemblyQualifiedName! }
}))
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using CommunityToolkit.Aspire.Hosting.KurrentDB.ApiService;
using EventStore.Client;

var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();

builder.AddKurrentDBClient("kurrentdb");

// 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);
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="$(AspireAppHostSdkVersion)" />

<PropertyGroup>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireHost>true</IsAspireHost>
<UserSecretsId>9ea31b5e-317f-4692-8a61-e60ac7ec0d0a</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\src\CommunityToolkit.Aspire.Hosting.KurrentDB\CommunityToolkit.Aspire.Hosting.KurrentDB.csproj" IsAspireProjectResource="false" />
<ProjectReference Include="..\CommunityToolkit.Aspire.Hosting.KurrentDB.ApiService\CommunityToolkit.Aspire.Hosting.KurrentDB.ApiService.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Projects;

var builder = DistributedApplication.CreateBuilder(args);

var kurrentdb = builder.AddKurrentDB("kurrentdb", 22113);

builder.AddProject<CommunityToolkit_Aspire_Hosting_KurrentDB_ApiService>("apiservice")
.WithReference(kurrentdb)
.WaitFor(kurrentdb);

builder.Build().Run();
Loading
Loading