Skip to content

Commit

Permalink
Added example of closing the books with Marten assuming that open and…
Browse files Browse the repository at this point in the history
… close are seprate actions
  • Loading branch information
oskardudycz committed Feb 15, 2024
1 parent 0bcbc01 commit e4bfb29
Show file tree
Hide file tree
Showing 14 changed files with 565 additions and 0 deletions.
1 change: 1 addition & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>12</LangVersion>
</PropertyGroup>
</Project>
17 changes: 17 additions & 0 deletions EventSourcing.NetCore.sln
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Helpdesk.Api", "Sample\Help
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Helpdesk.Api.Tests", "Sample\Helpdesk.Wolverine\Helpdesk.Api.Tests\Helpdesk.Api.Tests.csproj", "{F88E97D2-729B-45E0-A27E-20700FA2AEF0}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ClosingTheBooks", "ClosingTheBooks", "{C8F02DB9-5FEA-46C8-95E3-BB4255CB0667}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PointOfSales", "Sample\ClosingTheBooks\PointOfSales\PointOfSales.csproj", "{F40DF11A-354C-4438-B674-B95B9A401C33}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PointOfSales.Api", "Sample\ClosingTheBooks\PointOfSales.Api\PointOfSales.Api.csproj", "{CE15C7EC-85CA-44B8-B13B-206E308E8EF8}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -974,6 +980,14 @@ Global
{F88E97D2-729B-45E0-A27E-20700FA2AEF0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F88E97D2-729B-45E0-A27E-20700FA2AEF0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F88E97D2-729B-45E0-A27E-20700FA2AEF0}.Release|Any CPU.Build.0 = Release|Any CPU
{F40DF11A-354C-4438-B674-B95B9A401C33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F40DF11A-354C-4438-B674-B95B9A401C33}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F40DF11A-354C-4438-B674-B95B9A401C33}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F40DF11A-354C-4438-B674-B95B9A401C33}.Release|Any CPU.Build.0 = Release|Any CPU
{CE15C7EC-85CA-44B8-B13B-206E308E8EF8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CE15C7EC-85CA-44B8-B13B-206E308E8EF8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CE15C7EC-85CA-44B8-B13B-206E308E8EF8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CE15C7EC-85CA-44B8-B13B-206E308E8EF8}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -1153,6 +1167,9 @@ Global
{FF4C07DA-9E1F-4FC6-8772-AB82795A3951} = {A7186B6B-D56D-4AEF-B6B7-FAA827764C34}
{D2080C98-8F26-4727-9D49-B68915A4C715} = {FF4C07DA-9E1F-4FC6-8772-AB82795A3951}
{F88E97D2-729B-45E0-A27E-20700FA2AEF0} = {FF4C07DA-9E1F-4FC6-8772-AB82795A3951}
{C8F02DB9-5FEA-46C8-95E3-BB4255CB0667} = {A7186B6B-D56D-4AEF-B6B7-FAA827764C34}
{F40DF11A-354C-4438-B674-B95B9A401C33} = {C8F02DB9-5FEA-46C8-95E3-BB4255CB0667}
{CE15C7EC-85CA-44B8-B13B-206E308E8EF8} = {C8F02DB9-5FEA-46C8-95E3-BB4255CB0667}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {A5F55604-2FF3-43B7-B657-4F18E6E95D3B}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;

namespace PointOfSales.Api.Core;

public static class ETagExtensions
{
public static int ToExpectedVersion(string? eTag)
{
if (eTag is null)
throw new ArgumentNullException(nameof(eTag));

var value = EntityTagHeaderValue.Parse(eTag).Tag.Value;

if (value is null)
throw new ArgumentNullException(nameof(eTag));

return int.Parse(value.Substring(1, value.Length - 2));
}
}

[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public class FromIfMatchHeaderAttribute: FromHeaderAttribute
{
public FromIfMatchHeaderAttribute()
{
Name = "If-Match";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using Marten;

namespace Helpdesk.Api.Core.Marten;

public static class DocumentSessionExtensions
{
public static Task Add<T>(this IDocumentSession documentSession, string id, object[] events, CancellationToken ct)
where T : class
{
if (events.Length == 0)
return Task.CompletedTask;

documentSession.Events.StartStream<T>(id, events);
return documentSession.SaveChangesAsync(token: ct);
}

public static Task GetAndUpdate<T>(
this IDocumentSession documentSession,
string id,
int version,
Func<T, object[]> handle,
CancellationToken ct
) where T : class =>
documentSession.Events.WriteToAggregate<T>(id, version, stream =>
{
var result = handle(stream.Aggregate);
if (result.Length != 0)
stream.AppendMany(result);
}, ct);
}
21 changes: 21 additions & 0 deletions Sample/ClosingTheBooks/PointOfSales.Api/PointOfSales.Api.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0"/>
<PackageReference Include="Marten" Version="7.0.0-beta.5"/>
<PackageReference Include="Marten.AspNetCore" Version="7.0.0-beta.5"/>
<PackageReference Include="Marten.CommandLine" Version="7.0.0-beta.5" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.5.0"/>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\PointOfSales\PointOfSales.csproj" />
</ItemGroup>
</Project>
166 changes: 166 additions & 0 deletions Sample/ClosingTheBooks/PointOfSales.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
using System.Text.Json.Serialization;
using Helpdesk.Api.Core.Marten;
using JasperFx.CodeGeneration;
using Marten;
using Marten.AspNetCore;
using Marten.Events;
using Marten.Events.Daemon.Resiliency;
using Marten.Events.Projections;
using Marten.Schema.Identity;
using Marten.Services.Json;
using Oakton;
using PointOfSales.Api.Core;
using PointOfSales.CashierShifts;
using PointOfSales.CashRegister;
using Weasel.Core;
using static Microsoft.AspNetCore.Http.TypedResults;
using static PointOfSales.CashierShifts.CashierShiftDecider;
using static PointOfSales.CashRegister.CashRegisterDecider;
using static PointOfSales.CashierShifts.CashierShiftCommand;
using static PointOfSales.CashierShifts.CashierShiftEvent;
using static PointOfSales.Api.Core.ETagExtensions;
using static System.DateTimeOffset;
using JsonOptions = Microsoft.AspNetCore.Http.Json.JsonOptions;

var builder = WebApplication.CreateBuilder(args);


builder.Services
.AddEndpointsApiExplorer()
.AddSwaggerGen()
.AddMarten(options =>
{
var schemaName = Environment.GetEnvironmentVariable("SchemaName") ?? "PointOfSales";
options.Events.DatabaseSchemaName = schemaName;
options.DatabaseSchemaName = schemaName;
options.Connection(builder.Configuration.GetConnectionString("PointOfSales") ??
throw new InvalidOperationException());
options.UseDefaultSerialization(
EnumStorage.AsString,
nonPublicMembersStorage: NonPublicMembersStorage.All,
serializerType: SerializerType.SystemTextJson
);
// THIS IS IMPORTANT!
options.Events.StreamIdentity = StreamIdentity.AsString;
options.Projections.LiveStreamAggregation<CashierShift>();
options.Projections.LiveStreamAggregation<CashRegister>();
options.Projections.Add<CashierShiftTrackerProjection>(ProjectionLifecycle.Async);
})
.OptimizeArtifactWorkflow(TypeLoadMode.Static)
.UseLightweightSessions()
.AddAsyncDaemon(DaemonMode.Solo);

builder.Services
.Configure<JsonOptions>(o => o.SerializerOptions.Converters.Add(new JsonStringEnumConverter()))
.Configure<Microsoft.AspNetCore.Mvc.JsonOptions>(o =>
o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));

builder.Host.ApplyOaktonExtensions();

var app = builder.Build();

app.MapPost("/api/cash-registers/{cashRegisterId}",
async (
IDocumentSession documentSession,
string cashRegisterId,
CancellationToken ct) =>
{
await documentSession.Add<CashRegister>(cashRegisterId,
Decide(new InitializeCashRegister(cashRegisterId, Now)), ct);
return Created($"/api/cash-registers/{cashRegisterId}", cashRegisterId);
}
);

app.MapPost("/api/cash-registers/{cashRegisterId}/cashier-shifts/open",
async (
IDocumentSession documentSession,
string cashRegisterId,
OpenShiftRequest body,
CancellationToken ct
) =>
{
var lastClosedShift = await documentSession.GetLastCashierShift(cashRegisterId);
var result = Decide(new OpenShift(cashRegisterId, body.CashierId, Now), lastClosedShift);
if (result.Length == 0 || result[0] is not ShiftOpened opened)
throw new InvalidOperationException("Cannot Open Shift");
await documentSession.Add<CashierShift>(opened.CashierShiftId, result, ct);
return Created(
$"/api/cash-registers/{cashRegisterId}/cashier-shifts/{opened.CashierShiftId.ShiftNumber}",
cashRegisterId
);
}
);

app.MapPost("/api/cash-registers/{cashRegisterId}/cashier-shifts/{shiftNumber:int}/transactions",
(
IDocumentSession documentSession,
string cashRegisterId,
int shiftNumber,
RegisterTransactionRequest body,
[FromIfMatchHeader] string eTag,
CancellationToken ct
) =>
{
var cashierShiftId = new CashierShiftId(cashRegisterId, shiftNumber);
var transactionId = CombGuidIdGeneration.NewGuid().ToString();
return documentSession.GetAndUpdate<CashierShift>(cashierShiftId, ToExpectedVersion(eTag),
state => Decide(new RegisterTransaction(cashierShiftId, transactionId, body.Amount, Now), state), ct);
}
);

app.MapPost("/api/cash-registers/{cashRegisterId}/cashier-shifts/{shiftNumber:int}/close",
(
IDocumentSession documentSession,
string cashRegisterId,
int shiftNumber,
CloseShiftRequest body,
[FromIfMatchHeader] string eTag,
CancellationToken ct
) =>
{
var cashierShiftId = new CashierShiftId(cashRegisterId, shiftNumber);
return documentSession.GetAndUpdate<CashierShift>(cashierShiftId, ToExpectedVersion(eTag),
state => Decide(new CloseShift(cashierShiftId, body.DeclaredTender, Now), state), ct);
}
);

app.MapGet("/api/cash-registers/{cashRegisterId}/cashier-shifts/{shiftNumber:int}",
(HttpContext context, IQuerySession querySession, string cashRegisterId, int shiftNumber) =>
querySession.Json.WriteById<CashierShiftDetails>(new CashierShiftId(cashRegisterId, shiftNumber), context)
);


if (app.Environment.IsDevelopment())
{
app.UseSwagger()
.UseSwaggerUI();
}

return await app.RunOaktonCommands(args);

public record OpenShiftRequest(
string CashierId
);

public record RegisterTransactionRequest(
decimal Amount
);


public record CloseShiftRequest(
decimal DeclaredTender
);


public partial class Program
{
}
18 changes: 18 additions & 0 deletions Sample/ClosingTheBooks/PointOfSales/CashRegister/CashRegister.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace PointOfSales.CashRegister;

public static class CashRegisterId
{
public static string From(string workstation) =>
$"urn:cash_register:{workstation}";
}

public record CashRegister(string Id)
{
public static CashRegister Create(CashRegisterInitialized @event) =>
new(@event.CashRegisterId);
}

public record CashRegisterInitialized(
string CashRegisterId,
DateTimeOffset InitializedAt
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace PointOfSales.CashRegister;

public record InitializeCashRegister(
string CashRegisterId,
DateTimeOffset Now
);

public static class CashRegisterDecider
{
public static object[] Decide(InitializeCashRegister command) =>
[new CashRegisterInitialized(command.CashRegisterId, command.Now)];
}
Loading

0 comments on commit e4bfb29

Please sign in to comment.