Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ samples/ # Sample applications
## Technology Stack

- **Language**: C# on .NET 10
- **Test framework**: xUnit with AutoFixture, FluentAssertions, and Moq
- **Test framework**: xUnit with AutoFixture, Shouldly, and Moq
- **Serialization**: Newtonsoft.Json
- **Versioning**: Nerdbank.GitVersioning (`version.json`)
- **Package management**: Central package management via `Directory.Packages.props`
Expand Down
13 changes: 7 additions & 6 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" />
<PackageVersion Include="Azure.Core" Version="1.51.1" />
<PackageVersion Include="Azure.Core" Version="1.53.0" />
<PackageVersion Include="Azure.Identity" Version="[1.15.0, 2.0]" />
<PackageVersion Include="coverlet.collector" Version="8.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageVersion>
<PackageVersion Include="FluentAssertions" Version="7.2.2" />
<PackageVersion Include="Microsoft.Azure.Cosmos" Version="3.57.1" />
<PackageVersion Include="JsonSchema.Net" Version="9.1.4" />
<PackageVersion Include="Microsoft.Azure.Cosmos" Version="3.58.0" />
<PackageVersion Include="Nerdbank.GitVersioning" Version="3.9.50">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand All @@ -37,11 +37,12 @@
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.5" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.5" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageVersion Include="MongoDB.Driver" Version="3.7.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageVersion Include="MongoDB.Driver" Version="3.7.1" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<PackageVersion Include="Papst.EventStore" Version="[5.4.0,5.5)" />
<PackageVersion Include="Shouldly" Version="4.3.0" />
<PackageVersion Include="System.Formats.Asn1" Version="10.0.1" />
<PackageVersion Include="System.Linq.Async" Version="7.0.0" />
<PackageVersion Include="Testcontainers" Version="4.11.0" />
Expand All @@ -53,4 +54,4 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageVersion>
</ItemGroup>
</Project>
</Project>
1 change: 1 addition & 0 deletions Papst.EventStore.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
</Folder>
<Folder Name="/Samples/">
<Project Path="samples/SampleFileEventStore/SampleFileEventStore.csproj" />
<Project Path="samples/SampleEventCatalog/SampleEventCatalog.csproj" />
<Project Path="samples/SampleSqlServerEfCoreEventStore/SampleSqlServerEfCoreEventStore.csproj" />
</Folder>
<Folder Name="/Solution Items/">
Expand Down
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,60 @@ Please refer to the documentation in the relevant implementation sources:
* [Azure Cosmos](./src/Papst.EventStore.AzureCosmos/README.md)
* [Entity Framework Core](./src/Papst.EventStore.EntityFrameworkCore/README.md)

## Event Catalog

The **Event Catalog** provides a queryable registry of all events associated with a given entity type, including metadata (description, constraints) and a compile-time generated JSON Schema. This is useful for documentation, API discovery, and runtime introspection.

### Registering Events for the Catalog

Use the generic `EventNameAttribute<TEntity>` to associate an event with an entity:

```csharp
[EventName<User>("UserCreated", Description = "Raised when a new user is created", Constraints = new[] { "Create" })]
public record UserCreatedEvent(string Name, string Email);

[EventName<User>("UserRenamed", Description = "Raised when a user changes their name", Constraints = new[] { "Update" })]
public record UserRenamedEvent(string NewName);
```

Events can also be discovered automatically via aggregator registrations (`EventAggregatorBase<TEntity, TEvent>` / `IEventAggregator<TEntity, TEvent>`).

The same event name may exist for different entity types — the catalog tracks them independently per entity.

### Code Generation

When the `Papst.EventStore.CodeGeneration` package is referenced, the source generator automatically emits an `AddCodeGeneratedEventCatalog()` extension method alongside the existing `AddCodeGeneratedEvents()`:

```csharp
var services = new ServiceCollection();
services.AddCodeGeneratedEvents();
services.AddCodeGeneratedEventCatalog();
```

### Querying the Catalog

Resolve `IEventCatalog` from DI and use the async API:

```csharp
var catalog = serviceProvider.GetRequiredService<IEventCatalog>();

// List all events for an entity
IReadOnlyList<EventCatalogEntry> events = await catalog.ListEvents<User>();

// Filter by name and/or constraints
IReadOnlyList<EventCatalogEntry> filtered = await catalog.ListEvents<User>(
name: "UserCreated",
constraints: new[] { "Create" }
);

// Get event details including JSON Schema (global lookup)
EventCatalogEventDetails? details = await catalog.GetEventDetails("UserCreated");

// Get event details scoped to a specific entity (for duplicate event names across entities)
EventCatalogEventDetails? scoped = await catalog.GetEventDetails<User>("UserCreated");
```

A full working sample is available at [`samples/SampleEventCatalog/`](./samples/SampleEventCatalog/).

# Changelog

Expand Down
37 changes: 37 additions & 0 deletions samples/SampleEventCatalog/Events.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using Papst.EventStore.Aggregation.EventRegistration;

namespace SampleEventCatalog;

// Entity types
public record User(Guid Id, string Name, string Email, bool IsActive);
public record Order(Guid Id, Guid UserId, decimal Total, OrderStatus Status, List<OrderItem> Items);
public record OrderItem(string ProductName, int Quantity, decimal Price);

public enum OrderStatus
{
Pending,
Confirmed,
Shipped,
Delivered,
Cancelled
}

// User events
[EventName<User>("UserCreated", Description = "Raised when a new user is created", Constraints = new[] { "Create" })]
public record UserCreatedEvent(string Name, string Email);

[EventName<User>("UserRenamed", Description = "Raised when a user changes their name", Constraints = new[] { "Update" })]
public record UserRenamedEvent(string NewName);

[EventName<User>("UserDeactivated", Description = "Raised when a user is deactivated", Constraints = new[] { "Update", "Admin" })]
public record UserDeactivatedEvent(string Reason);

// Order events
[EventName<Order>("OrderPlaced", Description = "Raised when a new order is placed", Constraints = new[] { "Create" })]
public record OrderPlacedEvent(Guid UserId, List<OrderItem> Items, decimal Total);

[EventName<Order>("OrderStatusChanged", Description = "Raised when order status changes", Constraints = new[] { "Update" })]
public record OrderStatusChangedEvent(OrderStatus NewStatus);

[EventName<Order>("OrderCancelled", Description = "Raised when an order is cancelled", Constraints = new[] { "Delete" })]
public record OrderCancelledEvent(string CancellationReason, DateTime CancelledAt);
222 changes: 222 additions & 0 deletions samples/SampleEventCatalog/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
using Json.Schema;
using Microsoft.Extensions.DependencyInjection;
using Papst.EventStore.EventCatalog;
using System.Text.Json;
using System.Text.Json.Nodes;

namespace SampleEventCatalog;

public class Program
{
public static async Task Main(string[] args)
{
var services = new ServiceCollection();

// Register both generated events and catalog
services.AddCodeGeneratedEvents();
services.AddCodeGeneratedEventCatalog();

var serviceProvider = services.BuildServiceProvider();
var catalog = serviceProvider.GetRequiredService<IEventCatalog>();

Console.WriteLine("=== Event Catalog Sample ===");
Console.WriteLine();

// List all events for User entity
Console.WriteLine("--- Events for User entity ---");
var userEvents = await catalog.ListEvents<User>();
foreach (var entry in userEvents)
{
Console.WriteLine($" Event: {entry.EventName}");
Console.WriteLine($" Description: {entry.Description ?? "(none)"}");
Console.WriteLine($" Constraints: {(entry.Constraints != null ? string.Join(", ", entry.Constraints) : "(none)")}");
Console.WriteLine();
}

// List all events for Order entity
Console.WriteLine("--- Events for Order entity ---");
var orderEvents = await catalog.ListEvents<Order>();
foreach (var entry in orderEvents)
{
Console.WriteLine($" Event: {entry.EventName}");
Console.WriteLine($" Description: {entry.Description ?? "(none)"}");
Console.WriteLine($" Constraints: {(entry.Constraints != null ? string.Join(", ", entry.Constraints) : "(none)")}");
Console.WriteLine();
}

// Filter events by constraint
Console.WriteLine("--- User events with 'Update' constraint ---");
var updateEvents = await catalog.ListEvents<User>(constraints: new[] { "Update" });
foreach (var entry in updateEvents)
{
Console.WriteLine($" Event: {entry.EventName} ({entry.Description})");
}
Console.WriteLine();

// Filter events by name
Console.WriteLine("--- Looking up 'UserCreated' event ---");
var filtered = await catalog.ListEvents<User>(name: "UserCreated");
foreach (var entry in filtered)
{
Console.WriteLine($" Found: {entry.EventName} - {entry.Description}");
}
Console.WriteLine();

// Get event details with JSON schema (global lookup)
Console.WriteLine("--- Event Details with JSON Schema ---");
var details = await catalog.GetEventDetails("OrderPlaced");
if (details != null)
{
Console.WriteLine($" Event: {details.EventName}");
Console.WriteLine($" Description: {details.Description}");
Console.WriteLine($" Constraints: {(details.Constraints != null ? string.Join(", ", details.Constraints) : "(none)")}");
Console.WriteLine($" JSON Schema: {details.JsonSchema}");
}
Console.WriteLine();

// Get event details scoped to entity
Console.WriteLine("--- Entity-scoped Event Details for User/UserCreated ---");
var userCreatedDetails = await catalog.GetEventDetails<User>("UserCreated");
if (userCreatedDetails != null)
{
Console.WriteLine($" Event: {userCreatedDetails.EventName}");
Console.WriteLine($" Description: {userCreatedDetails.Description}");
Console.WriteLine($" JSON Schema: {userCreatedDetails.JsonSchema}");
}

Console.WriteLine();

// --- JSON Schema: build a JSON object and validate it ---
await DemonstrateSchemaValidationAsync(catalog);
}

private static async Task DemonstrateSchemaValidationAsync(IEventCatalog catalog)
{
Console.WriteLine("=== JSON Schema Validation Demo ===");
Console.WriteLine();

var details = await catalog.GetEventDetails<User>("UserCreated");
if (details is null)
{
Console.WriteLine(" Event 'UserCreated' not found in catalog.");
return;
}

Console.WriteLine($" Schema for '{details.EventName}':");
Console.WriteLine($" {FormatJson(details.JsonSchema)}");
Console.WriteLine();

var schema = JsonSchema.FromText(details.JsonSchema);

// --- Valid object ---
var validEvent = new JsonObject
{
["name"] = "Alice",
["email"] = "alice@example.com"
};

var validResult = schema.Evaluate(ToJsonElement(validEvent), new EvaluationOptions { OutputFormat = OutputFormat.List });

Console.WriteLine(" Valid event object:");
Console.WriteLine($" {validEvent.ToJsonString()}");
Console.WriteLine($" Validation: {(validResult.IsValid ? "✓ PASSED" : "✗ FAILED")}");
Console.WriteLine();

// --- Invalid: wrong type for 'name' ---
var invalidTypeEvent = new JsonObject
{
["name"] = 42,
["email"] = "bob@example.com"
};

var invalidTypeResult = schema.Evaluate(ToJsonElement(invalidTypeEvent), new EvaluationOptions { OutputFormat = OutputFormat.List });

Console.WriteLine(" Invalid event object (wrong type for 'name'):");
Console.WriteLine($" {invalidTypeEvent.ToJsonString()}");
Console.WriteLine($" Validation: {(invalidTypeResult.IsValid ? "✓ PASSED" : "✗ FAILED")}");
if (!invalidTypeResult.IsValid)
{
foreach (var error in (invalidTypeResult.Details ?? []).Where(d => !d.IsValid && d.Errors is not null))
{
foreach (var (key, message) in error.Errors!)
{
Console.WriteLine($" - [{error.InstanceLocation}] {key}: {message}");
}
}
}
Console.WriteLine();

// --- Invalid: extra strictness demo using OrderPlaced ---
var orderDetails = await catalog.GetEventDetails<Order>("OrderPlaced");
if (orderDetails is null) return;

Console.WriteLine($" Schema for 'OrderPlaced':");
Console.WriteLine($" {FormatJson(orderDetails.JsonSchema)}");
Console.WriteLine();

var orderSchema = JsonSchema.FromText(orderDetails.JsonSchema);

// Valid OrderPlaced object
var validOrder = new JsonObject
{
["userId"] = Guid.NewGuid().ToString(),
["items"] = new JsonArray
{
new JsonObject
{
["productName"] = "Widget",
["quantity"] = 2,
["price"] = 9.99
}
},
["total"] = 19.98
};

var validOrderResult = orderSchema.Evaluate(ToJsonElement(validOrder), new EvaluationOptions { OutputFormat = OutputFormat.List });

Console.WriteLine(" Valid OrderPlaced object:");
Console.WriteLine($" {validOrder.ToJsonString()}");
Console.WriteLine($" Validation: {(validOrderResult.IsValid ? "✓ PASSED" : "✗ FAILED")}");
Console.WriteLine();

// Invalid: total is a string instead of number
var invalidOrder = new JsonObject
{
["userId"] = Guid.NewGuid().ToString(),
["items"] = new JsonArray(),
["total"] = "not-a-number"
};

var invalidOrderResult = orderSchema.Evaluate(ToJsonElement(invalidOrder), new EvaluationOptions { OutputFormat = OutputFormat.List });

Console.WriteLine(" Invalid OrderPlaced object ('total' is a string):");
Console.WriteLine($" {invalidOrder.ToJsonString()}");
Console.WriteLine($" Validation: {(invalidOrderResult.IsValid ? "✓ PASSED" : "✗ FAILED")}");
if (!invalidOrderResult.IsValid)
{
foreach (var error in (invalidOrderResult.Details ?? []).Where(d => !d.IsValid && d.Errors is not null))
{
foreach (var (key, message) in error.Errors!)
{
Console.WriteLine($" - [{error.InstanceLocation}] {key}: {message}");
}
}
}
}

private static JsonElement ToJsonElement(JsonNode node)
=> JsonSerializer.Deserialize<JsonElement>(node.ToJsonString());

private static string FormatJson(string json)
{
try
{
var doc = JsonDocument.Parse(json);
return JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true });
}
catch
{
return json;
}
}
}
18 changes: 18 additions & 0 deletions samples/SampleEventCatalog/SampleEventCatalog.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Papst.EventStore.InMemory\Papst.EventStore.InMemory.csproj" />
<ProjectReference Include="..\..\src\Papst.EventStore\Papst.EventStore.csproj" />
<ProjectReference Include="..\..\src\Papst.EventStore.Aggregation.EventRegistration\Papst.EventStore.Aggregation.EventRegistration.csproj" />
<ProjectReference Include="..\..\src\Papst.EventStore.CodeGeneration\Papst.EventStore.CodeGeneration.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="JsonSchema.Net" />
</ItemGroup>
</Project>
Loading
Loading