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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,7 @@ _NCrunch*
BenchmarkDotNet.Artifacts/
# Semantic Release version file
.release-version

# Node.js (React chat client)
node_modules/
tsconfig.tsbuildinfo
8 changes: 8 additions & 0 deletions Trax.Samples.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@
<Project Path="samples/EphemeralWorkers/Trax.Samples.ContentShield.Api/Trax.Samples.ContentShield.Api.csproj" />
<Project Path="samples/EphemeralWorkers/Trax.Samples.ContentShield.Runner/Trax.Samples.ContentShield.Runner.csproj" />
</Folder>
<Folder Name="/samples/ChatService/">
<Project Path="samples/ChatService/Trax.Samples.ChatService.Data/Trax.Samples.ChatService.Data.csproj" />
<Project Path="samples/ChatService/Trax.Samples.ChatService/Trax.Samples.ChatService.csproj" />
<Project Path="samples/ChatService/Trax.Samples.ChatService.Api/Trax.Samples.ChatService.Api.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Trax.Samples.ChatService.Tests/Trax.Samples.ChatService.Tests.csproj" />
</Folder>
<Folder Name="/templates/">
<Project Path="templates/Trax.Samples.Templates.csproj" />
</Folder>
Expand Down
124 changes: 124 additions & 0 deletions samples/ChatService/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Trax Chat Service Sample

A single-server chat application demonstrating how Trax lifecycle hooks drive domain-specific real-time GraphQL subscriptions. When a chat mutation train completes, a custom `ITrainLifecycleHook` publishes the result to a room-scoped HotChocolate topic, and any client subscribed to that room receives the event via WebSocket.

## Architecture

```
ChatService/
├── Trax.Samples.ChatService.Data/ EF Core entities, DbContext, migrations (chat schema)
├── Trax.Samples.ChatService/ Trains, lifecycle hook, subscription types, auth
├── Trax.Samples.ChatService.Api/ Single-server ASP.NET Core host
└── Trax.Samples.ChatService.Client/ React + TypeScript frontend (Apollo Client)
```

Everything runs in one process — no scheduler, no workers. The data layer uses a separate `chat` schema that coexists with Trax's `trax` schema in the same Postgres database.

## Requirements

- [.NET 10 SDK](https://dotnet.microsoft.com/download)
- [Node.js 20+](https://nodejs.org/) (for the React client)
- Docker (for PostgreSQL)

## Running

```bash
# 1. Start PostgreSQL
cd Trax.Samples && docker compose up -d

# 2. Pack local Trax packages (if not already done)
./pack-local.sh

# 3. Start the API
dotnet run --project samples/ChatService/Trax.Samples.ChatService.Api

# 4. Start the React client (separate terminal)
cd samples/ChatService/Trax.Samples.ChatService.Client
npm install
npm run dev
```

- GraphQL IDE (Banana Cake Pop): http://localhost:5210/trax/graphql
- React client: http://localhost:5173

## Authentication

Fake API key authentication via `X-Api-Key` header (for demonstration only):

| API Key | User ID | Display Name |
|---------------|-----------|--------------|
| `alice-key` | `alice` | Alice |
| `bob-key` | `bob` | Bob |
| `charlie-key` | `charlie` | Charlie |

The React client provides a dropdown to switch between users. Open multiple browser tabs to simulate different users chatting in real time.

## Quick Walkthrough (GraphQL IDE)

```graphql
# 1. Create a room (as Alice — set X-Api-Key: alice-key)
mutation {
dispatch {
createChatRoom(input: { name: "General", userId: "alice", displayName: "Alice" }) {
externalId
output { chatRoomId name }
}
}
}

# 2. Join the room (as Bob — set X-Api-Key: bob-key)
mutation {
dispatch {
joinChatRoom(input: { chatRoomId: "<id>", userId: "bob", displayName: "Bob" }) {
externalId
output { joinedAt }
}
}
}

# 3. Subscribe to real-time events (in a second tab)
subscription {
onChatEvent(chatRoomId: "<id>") {
eventType
payload
timestamp
}
}

# 4. Send a message — the subscription tab receives it
mutation {
dispatch {
sendMessage(input: { chatRoomId: "<id>", senderUserId: "alice", content: "Hello!" }) {
externalId
output { messageId content sentAt }
}
}
}

# 5. Query chat history
{
discover {
getChatHistory(input: { chatRoomId: "<id>" }) {
messages { senderDisplayName content sentAt }
}
}
}
```

## How the Lifecycle Hook Works

1. Chat mutation trains (`CreateChatRoom`, `JoinChatRoom`, `SendMessage`) are decorated with `[TraxBroadcast]`, which causes lifecycle hooks to fire on completion.
2. `ChatLifecycleHook` implements `ITrainLifecycleHook` and checks `metadata.Name` against a map of chat train interfaces.
3. On match, it parses `metadata.Output` (serialized JSON) to extract the `chatRoomId`.
4. It publishes a `ChatSubscriptionEvent` to the HotChocolate topic `"ChatRoom:{chatRoomId}"`.
5. Clients subscribed via `onChatEvent(chatRoomId: "...")` receive the event in real time.

This coexists with the built-in `GraphQLSubscriptionHook` — both hooks fire for `[TraxBroadcast]` trains.

## Tests

```bash
dotnet test tests/Trax.Samples.ChatService.Tests
```

31 tests covering lifecycle hook behavior (10 unit) and all train steps (21 integration using EF Core in-memory provider).
123 changes: 123 additions & 0 deletions samples/ChatService/Trax.Samples.ChatService.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// ─────────────────────────────────────────────────────────────────────────────
// Trax Chat Service — GraphQL API with Real-Time Subscriptions
//
// A single-server chat application powered by HotChocolate GraphQL.
// Demonstrates how Trax lifecycle hooks can drive domain-specific real-time
// subscriptions: when a SendMessage train completes, the ChatLifecycleHook
// publishes the message to a room-scoped topic, and any client subscribed
// to that room receives the event via WebSocket.
//
// Authentication: fake API key via X-Api-Key header (for demonstration only)
// alice-key → user "alice" (display name: Alice)
// bob-key → user "bob" (display name: Bob)
// charlie-key → user "charlie" (display name: Charlie)
//
// Prerequisites:
// 1. Start Postgres: cd Trax.Samples && docker compose up -d
// 2. Pack local: ./pack-local.sh
// 3. Start API: dotnet run --project samples/ChatService/Trax.Samples.ChatService.Api
//
// Try it:
// Open http://localhost:5210/trax/graphql in a browser for Banana Cake Pop IDE
//
// # Create a chat room (as Alice)
// mutation { dispatch { createChatRoom(input: { name: "General", userId: "alice", displayName: "Alice" }) { externalId output { chatRoomId name } } } }
//
// # Join the room (as Bob)
// mutation { dispatch { joinChatRoom(input: { chatRoomId: "<id>", userId: "bob", displayName: "Bob" }) { externalId output { joinedAt } } } }
//
// # Send a message
// mutation { dispatch { sendMessage(input: { chatRoomId: "<id>", senderUserId: "alice", content: "Hello!" }) { externalId output { messageId content sentAt } } } }
//
// # Subscribe to real-time chat events (in Banana Cake Pop):
// subscription { onChatEvent(chatRoomId: "<id>") { eventType payload timestamp } }
//
// # Query chat history
// { discover { getChatHistory(input: { chatRoomId: "<id>" }) { messages { senderDisplayName content sentAt } } } }
// ─────────────────────────────────────────────────────────────────────────────

using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Trax.Api.Extensions;
using Trax.Api.GraphQL.Extensions;
using Trax.Effect.Data.Postgres.Extensions;
using Trax.Effect.Extensions;
using Trax.Effect.Provider.Json.Extensions;
using Trax.Effect.Provider.Parameter.Extensions;
using Trax.Mediator.Extensions;
using Trax.Samples.ChatService.Auth;
using Trax.Samples.ChatService.Data;
using Trax.Samples.ChatService.Hooks;
using Trax.Samples.ChatService.Subscriptions;

var builder = WebApplication.CreateBuilder(args);

var traxConnectionString =
builder.Configuration.GetConnectionString("TraxDatabase")
?? throw new InvalidOperationException("Connection string 'TraxDatabase' not found.");

var chatConnectionString =
builder.Configuration.GetConnectionString("ChatDatabase")
?? throw new InvalidOperationException("Connection string 'ChatDatabase' not found.");

builder.Services.AddLogging(logging => logging.AddConsole());

// ── Chat data layer ─────────────────────────────────────────────────────────
builder.Services.AddDbContext<ChatDbContext>(options => options.UseNpgsql(chatConnectionString));

// ── Authentication — fake API key for demonstration ─────────────────────────
builder
.Services.AddAuthentication(ApiKeyDefaults.AuthenticationScheme)
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthHandler>(
ApiKeyDefaults.AuthenticationScheme,
null
);

builder.Services.AddAuthorization();

// ── Register Trax Effect + Mediator + ChatLifecycleHook ─────────────────────
builder.Services.AddTrax(trax =>
trax.AddEffects(effects =>
effects
.UsePostgres(traxConnectionString)
.AddJson()
.SaveTrainParameters()
.AddLifecycleHook<ChatLifecycleHookFactory>()
)
.AddMediator(typeof(ChatLifecycleHookFactory).Assembly)
);

// ── Register GraphQL API + chat subscriptions ───────────────────────────────
builder.Services.AddTraxGraphQL();
builder.Services.AddGraphQLServer("trax").AddTypeExtension<ChatSubscriptions>();

builder.Services.AddHealthChecks().AddTraxHealthCheck();

// ── CORS — allow React dev server ────────────────────────────────────────
builder.Services.AddCors(options =>
options.AddDefaultPolicy(policy =>
policy
.WithOrigins("http://localhost:5173")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()
)
);

var app = builder.Build();

// ── Auto-migrate chat schema ────────────────────────────────────────────────
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ChatDbContext>();
await db.Database.MigrateAsync();
}

// ── Map endpoints ───────────────────────────────────────────────────────────
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.UseTraxGraphQL();
app.MapHealthChecks("/trax/health");

app.Run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Trax.Samples.ChatService\Trax.Samples.ChatService.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Trax.Effect.Provider.Json" Version="1.*" />
<PackageReference Include="Trax.Effect.Provider.Parameter" Version="1.*" />
<PackageReference Include="Trax.Api" Version="1.*" />
<PackageReference Include="Trax.Api.GraphQL" Version="1.*" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
19 changes: 19 additions & 0 deletions samples/ChatService/Trax.Samples.ChatService.Api/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"ConnectionStrings": {
"TraxDatabase": "Host=localhost;Port=5432;Database=trax;Username=trax;Password=trax123",
"ChatDatabase": "Host=localhost;Port=5432;Database=trax;Username=trax;Password=trax123"
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:5210"
}
}
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
12 changes: 12 additions & 0 deletions samples/ChatService/Trax.Samples.ChatService.Client/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Trax Chat</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Loading
Loading