Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -0,0 +1,70 @@
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
namespace Examples.Billing.Microservice.Endpoints;
/// <summary>
/// Request model for creating an invoice.
/// </summary>
public sealed record CreateInvoiceRequest
{
public required string CustomerId { get; init; }
public required decimal Amount { get; init; }
public string? Description { get; init; }
public List<LineItem> LineItems { get; init; } = [];
}
/// <summary>
/// Line item for an invoice.
/// </summary>
public sealed record LineItem
{
public required string Description { get; init; }
public required decimal Amount { get; init; }
public int Quantity { get; init; } = 1;
}
/// <summary>
/// Response model after creating an invoice.
/// </summary>
public sealed record CreateInvoiceResponse
{
public required string InvoiceId { get; init; }
public required DateTime CreatedAt { get; init; }
public required string Status { get; init; }
}
/// <summary>
/// Endpoint for creating a new invoice.
/// Demonstrates a typed endpoint with JSON request/response.
/// </summary>
[StellaEndpoint("POST", "/invoices", TimeoutSeconds = 30)]
public sealed class CreateInvoiceEndpoint : IStellaEndpoint<CreateInvoiceRequest, CreateInvoiceResponse>
{
private readonly ILogger<CreateInvoiceEndpoint> _logger;
public CreateInvoiceEndpoint(ILogger<CreateInvoiceEndpoint> logger)
{
_logger = logger;
}
public Task<CreateInvoiceResponse> HandleAsync(
CreateInvoiceRequest request,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"Creating invoice for customer {CustomerId} with amount {Amount}",
request.CustomerId,
request.Amount);
// Simulate invoice creation
var invoiceId = $"INV-{Guid.NewGuid():N}".ToUpperInvariant()[..16];
return Task.FromResult(new CreateInvoiceResponse
{
InvoiceId = invoiceId,
CreatedAt = DateTime.UtcNow,
Status = "draft"
});
}
}

View File

@@ -0,0 +1,58 @@
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
namespace Examples.Billing.Microservice.Endpoints;
/// <summary>
/// Request model for getting an invoice.
/// </summary>
public sealed record GetInvoiceRequest
{
public required string Id { get; init; }
}
/// <summary>
/// Response model for an invoice.
/// </summary>
public sealed record GetInvoiceResponse
{
public required string InvoiceId { get; init; }
public required string CustomerId { get; init; }
public required decimal Amount { get; init; }
public required string Status { get; init; }
public required DateTime CreatedAt { get; init; }
public DateTime? PaidAt { get; init; }
}
/// <summary>
/// Endpoint for retrieving an invoice by ID.
/// Demonstrates a GET endpoint with path parameters.
/// </summary>
[StellaEndpoint("GET", "/invoices/{id}", TimeoutSeconds = 10, RequiredClaims = ["invoices:read"])]
public sealed class GetInvoiceEndpoint : IStellaEndpoint<GetInvoiceRequest, GetInvoiceResponse>
{
private readonly ILogger<GetInvoiceEndpoint> _logger;
public GetInvoiceEndpoint(ILogger<GetInvoiceEndpoint> logger)
{
_logger = logger;
}
public Task<GetInvoiceResponse> HandleAsync(
GetInvoiceRequest request,
CancellationToken cancellationToken)
{
_logger.LogInformation("Fetching invoice {InvoiceId}", request.Id);
// Simulate invoice lookup
return Task.FromResult(new GetInvoiceResponse
{
InvoiceId = request.Id,
CustomerId = "CUST-001",
Amount = 199.99m,
Status = "paid",
CreatedAt = DateTime.UtcNow.AddDays(-7),
PaidAt = DateTime.UtcNow.AddDays(-1)
});
}
}

View File

@@ -0,0 +1,60 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
namespace Examples.Billing.Microservice.Endpoints;
/// <summary>
/// Endpoint for uploading attachments to an invoice.
/// Demonstrates streaming upload using IRawStellaEndpoint.
/// </summary>
[StellaEndpoint("POST", "/invoices/{id}/attachments", SupportsStreaming = true, TimeoutSeconds = 300)]
public sealed class UploadAttachmentEndpoint : IRawStellaEndpoint
{
private readonly ILogger<UploadAttachmentEndpoint> _logger;
public UploadAttachmentEndpoint(ILogger<UploadAttachmentEndpoint> logger)
{
_logger = logger;
}
public async Task<RawResponse> HandleAsync(
RawRequestContext context,
CancellationToken cancellationToken)
{
var invoiceId = context.PathParameters.GetValueOrDefault("id") ?? "unknown";
var contentType = context.Headers["Content-Type"] ?? "application/octet-stream";
_logger.LogInformation(
"Uploading attachment for invoice {InvoiceId}, Content-Type: {ContentType}",
invoiceId,
contentType);
// Read the streamed body
long totalBytes = 0;
var buffer = new byte[8192];
int bytesRead;
while ((bytesRead = await context.Body.ReadAsync(buffer, cancellationToken)) > 0)
{
totalBytes += bytesRead;
// In a real implementation, you would write to storage here
}
_logger.LogInformation(
"Received {TotalBytes} bytes for invoice {InvoiceId}",
totalBytes,
invoiceId);
// Return success response
var response = new
{
invoiceId,
attachmentId = $"ATT-{Guid.NewGuid():N}"[..16].ToUpperInvariant(),
size = totalBytes,
uploadedAt = DateTime.UtcNow
};
return RawResponse.Ok(JsonSerializer.Serialize(response));
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Microservice\StellaOps.Microservice.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Transport.InMemory\StellaOps.Router.Transport.InMemory.csproj" />
<!-- Reference the source generator -->
<ProjectReference Include="..\..\__Libraries\StellaOps.Microservice.SourceGen\StellaOps.Microservice.SourceGen.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
<Content Include="microservice.yaml" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,40 @@
using Examples.Billing.Microservice.Endpoints;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using StellaOps.Microservice;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Transport.InMemory;
var builder = Host.CreateApplicationBuilder(args);
// Configure the Stella microservice
builder.Services.AddStellaMicroservice(options =>
{
options.ServiceName = "billing";
options.Version = "1.0.0";
options.Region = "demo";
options.InstanceId = $"billing-{Environment.MachineName}";
options.ConfigFilePath = "microservice.yaml";
options.Routers =
[
new RouterEndpointConfig
{
Host = "localhost",
Port = 5100,
TransportType = TransportType.InMemory
}
];
});
// Register endpoint handlers
builder.Services.AddScoped<CreateInvoiceEndpoint>();
builder.Services.AddScoped<GetInvoiceEndpoint>();
builder.Services.AddScoped<UploadAttachmentEndpoint>();
// Add in-memory transport
builder.Services.AddInMemoryTransport();
var host = builder.Build();
Console.WriteLine("Billing microservice starting...");
await host.RunAsync();

View File

@@ -0,0 +1,21 @@
# Microservice YAML Configuration for Billing Service
# Overrides code-defined endpoint settings
endpoints:
# Override timeout for invoice creation
- method: POST
path: /invoices
timeout: 45s # Allow more time for complex invoice creation
# Override streaming settings for file upload
- method: POST
path: /invoices/{id}/attachments
timeout: 5m # Allow large file uploads
streaming: true
# Add claim requirements for getting invoices
- method: GET
path: /invoices/{id}
requiringClaims:
- type: "scope"
value: "invoices:read"

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Gateway\StellaOps.Router.Gateway.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Transport.InMemory\StellaOps.Router.Transport.InMemory.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Config\StellaOps.Router.Config.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="router.yaml" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,50 @@
using StellaOps.Router.Gateway;
using StellaOps.Router.Gateway.Authorization;
using StellaOps.Router.Gateway.DependencyInjection;
using StellaOps.Router.Config;
using StellaOps.Router.Transport.InMemory;
var builder = WebApplication.CreateBuilder(args);
// Router configuration from YAML
builder.Services.AddRouterConfig(options =>
{
options.ConfigPath = "router.yaml";
options.EnableHotReload = true;
});
// Router gateway services
builder.Services.AddRouterGateway(builder.Configuration);
// In-memory transport for demo (can switch to TCP/TLS for production)
builder.Services.AddInMemoryTransport();
// Authority integration (no-op for demo)
builder.Services.AddNoOpAuthorityIntegration();
// Required for app.UseAuthentication() even when running without a real auth scheme (demo/tests).
builder.Services.AddAuthentication();
var app = builder.Build();
// Middleware pipeline
app.UseForwardedHeaders();
app.UseAuthentication();
app.UseClaimsAuthorization();
// Map OpenAPI endpoints
app.MapRouterOpenApi();
// Simple health endpoint
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));
// Router gateway middleware (endpoint resolution, routing decision, dispatch)
app.UseRouterGateway();
app.Run();
// Partial class for WebApplicationFactory integration testing
namespace Examples.Gateway
{
public partial class Program { }
}

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"Examples.Gateway": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:58839;http://localhost:58840"
}
}
}

View File

@@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"GatewayNode": {
"Region": "demo",
"NodeId": "gw-demo-01"
}
}

View File

@@ -0,0 +1,50 @@
# Router Configuration for Example Gateway
# This file configures how the gateway routes requests to microservices
gateway:
nodeId: "gw-demo-01"
region: "demo"
listenPort: 8080
# Payload limits
payloadLimits:
maxRequestBodyBytes: 10485760 # 10 MB
maxStreamingChunkBytes: 65536 # 64 KB
# Health monitoring
healthMonitoring:
staleThreshold: "00:00:30"
checkInterval: "00:00:05"
# Transport configuration
transports:
# In-memory transport (for demo)
inMemory:
enabled: true
# TCP transport (production)
# tcp:
# enabled: true
# port: 5100
# backlog: 100
# TLS transport (production with encryption)
# tls:
# enabled: true
# port: 5101
# certificatePath: "certs/gateway.pfx"
# certificatePassword: "demo"
# Routing configuration
routing:
# Default routing algorithm
algorithm: "round-robin"
# Region affinity (prefer local microservices)
regionAffinity: true
affinityWeight: 0.8
# Logging
logging:
level: "Information"
requestLogging: true

View File

@@ -0,0 +1,64 @@
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
namespace Examples.Inventory.Microservice.Endpoints;
/// <summary>
/// Request model for getting a single inventory item.
/// </summary>
public sealed record GetItemRequest
{
public required string Sku { get; init; }
}
/// <summary>
/// Response model for a single inventory item with details.
/// </summary>
public sealed record GetItemResponse
{
public required string Sku { get; init; }
public required string Name { get; init; }
public required string Description { get; init; }
public required string Category { get; init; }
public required int QuantityOnHand { get; init; }
public required int ReorderPoint { get; init; }
public required decimal UnitPrice { get; init; }
public required string Location { get; init; }
public required DateTime LastUpdated { get; init; }
}
/// <summary>
/// Endpoint for getting a single inventory item by SKU.
/// Demonstrates path parameter extraction.
/// </summary>
[StellaEndpoint("GET", "/items/{sku}", TimeoutSeconds = 10)]
public sealed class GetItemEndpoint : IStellaEndpoint<GetItemRequest, GetItemResponse>
{
private readonly ILogger<GetItemEndpoint> _logger;
public GetItemEndpoint(ILogger<GetItemEndpoint> logger)
{
_logger = logger;
}
public Task<GetItemResponse> HandleAsync(
GetItemRequest request,
CancellationToken cancellationToken)
{
_logger.LogInformation("Fetching inventory item {Sku}", request.Sku);
// Simulate item lookup
return Task.FromResult(new GetItemResponse
{
Sku = request.Sku,
Name = "Widget A",
Description = "A high-quality widget for general purpose use",
Category = "widgets",
QuantityOnHand = 100,
ReorderPoint = 25,
UnitPrice = 9.99m,
Location = "Warehouse A, Aisle 3, Shelf 2",
LastUpdated = DateTime.UtcNow.AddHours(-2)
});
}
}

View File

@@ -0,0 +1,107 @@
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
namespace Examples.Inventory.Microservice.Endpoints;
/// <summary>
/// Request model for listing inventory items.
/// </summary>
public sealed record ListItemsRequest
{
public int Page { get; init; } = 1;
public int PageSize { get; init; } = 20;
public string? Category { get; init; }
}
/// <summary>
/// Response model for listing inventory items.
/// </summary>
public sealed record ListItemsResponse
{
public required List<InventoryItem> Items { get; init; }
public required int TotalCount { get; init; }
public required int Page { get; init; }
public required int PageSize { get; init; }
}
/// <summary>
/// Inventory item model.
/// </summary>
public sealed record InventoryItem
{
public required string Sku { get; init; }
public required string Name { get; init; }
public required string Category { get; init; }
public required int QuantityOnHand { get; init; }
public required decimal UnitPrice { get; init; }
}
/// <summary>
/// Endpoint for listing inventory items.
/// Demonstrates pagination and filtering.
/// </summary>
[StellaEndpoint("GET", "/items", TimeoutSeconds = 15)]
public sealed class ListItemsEndpoint : IStellaEndpoint<ListItemsRequest, ListItemsResponse>
{
private readonly ILogger<ListItemsEndpoint> _logger;
public ListItemsEndpoint(ILogger<ListItemsEndpoint> logger)
{
_logger = logger;
}
public Task<ListItemsResponse> HandleAsync(
ListItemsRequest request,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"Listing inventory items - Page: {Page}, PageSize: {PageSize}, Category: {Category}",
request.Page,
request.PageSize,
request.Category ?? "(all)");
// Simulate item list
var items = new List<InventoryItem>
{
new()
{
Sku = "SKU-001",
Name = "Widget A",
Category = "widgets",
QuantityOnHand = 100,
UnitPrice = 9.99m
},
new()
{
Sku = "SKU-002",
Name = "Widget B",
Category = "widgets",
QuantityOnHand = 50,
UnitPrice = 14.99m
},
new()
{
Sku = "SKU-003",
Name = "Gadget X",
Category = "gadgets",
QuantityOnHand = 25,
UnitPrice = 29.99m
}
};
// Filter by category if specified
if (!string.IsNullOrWhiteSpace(request.Category))
{
items = items.Where(i =>
i.Category.Equals(request.Category, StringComparison.OrdinalIgnoreCase)).ToList();
}
return Task.FromResult(new ListItemsResponse
{
Items = items,
TotalCount = items.Count,
Page = request.Page,
PageSize = request.PageSize
});
}
}

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Microservice\StellaOps.Microservice.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Transport.InMemory\StellaOps.Router.Transport.InMemory.csproj" />
<!-- Reference the source generator -->
<ProjectReference Include="..\..\__Libraries\StellaOps.Microservice.SourceGen\StellaOps.Microservice.SourceGen.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,38 @@
using Examples.Inventory.Microservice.Endpoints;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using StellaOps.Microservice;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Transport.InMemory;
var builder = Host.CreateApplicationBuilder(args);
// Configure the Stella microservice
builder.Services.AddStellaMicroservice(options =>
{
options.ServiceName = "inventory";
options.Version = "1.0.0";
options.Region = "demo";
options.InstanceId = $"inventory-{Environment.MachineName}";
options.Routers =
[
new RouterEndpointConfig
{
Host = "localhost",
Port = 5100,
TransportType = TransportType.InMemory
}
];
});
// Register endpoint handlers
builder.Services.AddScoped<ListItemsEndpoint>();
builder.Services.AddScoped<GetItemEndpoint>();
// Add in-memory transport
builder.Services.AddInMemoryTransport();
var host = builder.Build();
Console.WriteLine("Inventory microservice starting...");
await host.RunAsync();

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Examples.MultiTransport.Gateway</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Gateway\StellaOps.Router.Gateway.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Transport.InMemory\StellaOps.Router.Transport.InMemory.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Transport.Tcp\StellaOps.Router.Transport.Tcp.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Transport.Tls\StellaOps.Router.Transport.Tls.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Transport.RabbitMq\StellaOps.Router.Transport.RabbitMq.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Config\StellaOps.Router.Config.csproj" />
</ItemGroup>
<ItemGroup>
<!-- router.yaml needs explicit copy since it's not auto-included -->
<Content Include="router.yaml" CopyToOutputDirectory="PreserveNewest" />
<!-- appsettings.json is auto-included by SDK.Web - just update copy behavior -->
<Content Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
<Content Update="appsettings.Development.json" CopyToOutputDirectory="PreserveNewest" Condition="Exists('appsettings.Development.json')" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,101 @@
// ----------------------------------------------------------------------------
// Examples.MultiTransport.Gateway
//
// Demonstrates a production-ready API Gateway that supports multiple transports:
// - InMemory (development/testing)
// - TCP (high-performance internal communication)
// - TLS/mTLS (secure communication with certificate auth)
// - RabbitMQ (distributed, queue-based messaging)
//
// The gateway aggregates OpenAPI specs from all connected microservices,
// handles routing decisions, authorization, and request dispatch.
// ----------------------------------------------------------------------------
using StellaOps.Router.Gateway;
using StellaOps.Router.Gateway.Authorization;
using StellaOps.Router.Gateway.DependencyInjection;
using StellaOps.Router.Config;
using StellaOps.Router.Transport.InMemory;
var builder = WebApplication.CreateBuilder(args);
// ----------------------------------------------------------------------------
// Router Configuration
// ----------------------------------------------------------------------------
builder.Services.AddRouterConfig(options =>
{
options.ConfigPath = "router.yaml";
options.EnableHotReload = true; // Hot-reload config changes without restart
});
// ----------------------------------------------------------------------------
// Gateway Core Services
// ----------------------------------------------------------------------------
builder.Services.AddRouterGateway(builder.Configuration);
// ----------------------------------------------------------------------------
// Transport Registration
// The gateway accepts connections from microservices using any transport
// ----------------------------------------------------------------------------
// InMemory: Zero-latency for local development and testing
builder.Services.AddInMemoryTransport();
// Additional transports can be enabled when infrastructure is available:
//
// TCP: High-performance binary protocol for internal services
// builder.Services.AddTcpTransportServer(options => { options.Port = 5100; });
//
// TLS: Encrypted communication with optional mutual TLS
// builder.Services.AddTlsTransportServer(options =>
// {
// options.Port = 5101;
// options.RequireClientCertificate = false;
// });
//
// RabbitMQ: Distributed messaging for cross-datacenter communication
// builder.Services.AddRabbitMqTransportServer(options =>
// {
// options.HostName = "localhost";
// options.Port = 5672;
// });
// ----------------------------------------------------------------------------
// Authorization - No-op for demo, integrate with Authority module in production
// ----------------------------------------------------------------------------
builder.Services.AddNoOpAuthorityIntegration();
builder.Services.AddAuthentication();
// ----------------------------------------------------------------------------
// OpenAPI / Swagger
// ----------------------------------------------------------------------------
builder.Services.AddEndpointsApiExplorer();
var app = builder.Build();
// ----------------------------------------------------------------------------
// Middleware Pipeline
// ----------------------------------------------------------------------------
app.UseForwardedHeaders();
app.UseAuthentication();
app.UseClaimsAuthorization();
// Aggregated OpenAPI from all microservices
app.MapRouterOpenApi();
// Health and readiness endpoints
app.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTime.UtcNow }));
app.MapGet("/ready", () => Results.Ok(new { ready = true }));
// Router gateway - handles all routing decisions and request dispatch
app.UseRouterGateway();
Console.WriteLine("Multi-Transport Gateway starting...");
Console.WriteLine("Transports enabled: InMemory (dev)");
Console.WriteLine("Configure additional transports (TCP, TLS, RabbitMQ) as needed");
app.Run();
namespace Examples.MultiTransport.Gateway
{
public partial class Program { }
}

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"Examples.MultiTransport.Gateway": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:64342;http://localhost:64343"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"StellaOps.Router": "Debug"
}
},
"AllowedHosts": "*",
"RabbitMQ": {
"Host": "localhost",
"Port": 5672,
"User": "guest",
"Password": "guest",
"VHost": "/"
},
"Router": {
"Gateway": {
"Name": "stella-gateway",
"Region": "us-east-1"
}
}
}

View File

@@ -0,0 +1,95 @@
# Router Gateway Configuration
# This configuration defines routing rules, transport settings, and security policies
gateway:
name: "stella-gateway"
region: "us-east-1"
# Request timeout defaults (can be overridden per-endpoint)
defaultTimeout: 30s
streamingTimeout: 300s
# Circuit breaker settings
circuitBreaker:
enabled: true
failureThreshold: 5
recoveryTimeout: 30s
samplingWindow: 60s
# Transport configurations
transports:
inmemory:
enabled: true
simulatedLatency: 0ms
tcp:
enabled: true
port: 5100
maxConnections: 1000
keepAliveInterval: 30s
bufferSize: 65536
tls:
enabled: true
port: 5101
requireClientCert: false
allowSelfSigned: true # Set false in production
minTlsVersion: "1.2"
rabbitmq:
enabled: true
host: localhost
port: 5672
virtualHost: /
prefetchCount: 10
autoRecovery: true
# Routing rules - define how requests are routed to services
routing:
rules:
# Route all /orders/* to order-service
- match:
path: "/orders/*"
target:
service: "order-service"
# Route all /notifications/* to notification-service
- match:
path: "/notifications/*"
target:
service: "notification-service"
# Route billing endpoints
- match:
path: "/invoices/*"
target:
service: "billing"
# Route inventory endpoints
- match:
path: "/inventory/*"
target:
service: "inventory"
# Load balancing
loadBalancing:
strategy: "round-robin" # Options: round-robin, least-connections, random
healthCheckInterval: 10s
unhealthyThreshold: 3
# Rate limiting (optional)
rateLimiting:
enabled: false
requestsPerSecond: 1000
burstSize: 100
# Observability
observability:
tracing:
enabled: true
samplingRate: 0.1 # 10% sampling in production
metrics:
enabled: true
endpoint: "/metrics"
logging:
level: "Information"

View File

@@ -0,0 +1,221 @@
// ----------------------------------------------------------------------------
// BroadcastNotificationEndpoint
//
// Demonstrates:
// - IRawStellaEndpoint for streaming response
// - Bulk notification delivery with progress
// - NDJSON streaming format
// - Long-running operation with heartbeat
// ----------------------------------------------------------------------------
using System.Text;
using System.Text.Json;
using Examples.NotificationService.Models;
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
namespace Examples.NotificationService.Endpoints;
/// <summary>
/// Streaming endpoint for broadcasting notifications to multiple recipients.
/// Returns progress updates as NDJSON stream.
/// </summary>
[StellaEndpoint("POST", "/notifications/broadcast", SupportsStreaming = true, TimeoutSeconds = 600)]
public sealed class BroadcastNotificationEndpoint : IRawStellaEndpoint
{
private readonly ILogger<BroadcastNotificationEndpoint> _logger;
public BroadcastNotificationEndpoint(ILogger<BroadcastNotificationEndpoint> logger)
{
_logger = logger;
}
public async Task<RawResponse> HandleAsync(
RawRequestContext context,
CancellationToken cancellationToken)
{
// Parse the broadcast request from body
using var reader = new StreamReader(context.Body, Encoding.UTF8);
var requestJson = await reader.ReadToEndAsync(cancellationToken);
var request = JsonSerializer.Deserialize<BroadcastRequest>(requestJson, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (request == null || string.IsNullOrEmpty(request.Title))
{
return RawResponse.BadRequest("Invalid broadcast request");
}
_logger.LogInformation(
"Starting broadcast. Type: {Type}, Recipients: {Count}, CorrelationId: {CorrelationId}",
request.Type, request.RecipientIds?.Length ?? 0, context.CorrelationId);
// Create response stream for NDJSON progress updates
var stream = new MemoryStream();
var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true);
var broadcastId = $"BCAST-{DateTime.UtcNow:yyyyMMdd}-{Guid.NewGuid():N}"[..26].ToUpperInvariant();
var recipients = request.RecipientIds ?? GenerateSimulatedRecipients(request.RecipientCount ?? 100);
var totalRecipients = recipients.Length;
var deliveredCount = 0;
var failedCount = 0;
try
{
// Write initial progress
await WriteProgressAsync(writer, new BroadcastProgress
{
BroadcastId = broadcastId,
Status = "started",
TotalRecipients = totalRecipients,
DeliveredCount = 0,
FailedCount = 0,
PercentComplete = 0,
Timestamp = DateTimeOffset.UtcNow
}, cancellationToken);
// Process recipients in batches
const int batchSize = 10;
var random = new Random();
for (var i = 0; i < recipients.Length; i += batchSize)
{
cancellationToken.ThrowIfCancellationRequested();
var batch = recipients.Skip(i).Take(batchSize).ToArray();
// Simulate sending to each recipient in batch
foreach (var recipientId in batch)
{
// Simulate occasional failures (5%)
if (random.NextDouble() < 0.05)
{
failedCount++;
_logger.LogDebug("Failed to deliver to {RecipientId}", recipientId);
}
else
{
deliveredCount++;
}
}
// Simulate processing time
await Task.Delay(TimeSpan.FromMilliseconds(50 + random.Next(100)), cancellationToken);
// Write progress update
var percentComplete = (int)((i + batch.Length) * 100.0 / totalRecipients);
await WriteProgressAsync(writer, new BroadcastProgress
{
BroadcastId = broadcastId,
Status = "in_progress",
TotalRecipients = totalRecipients,
DeliveredCount = deliveredCount,
FailedCount = failedCount,
PercentComplete = percentComplete,
CurrentBatch = i / batchSize + 1,
TotalBatches = (totalRecipients + batchSize - 1) / batchSize,
Timestamp = DateTimeOffset.UtcNow
}, cancellationToken);
}
// Write completion
await WriteProgressAsync(writer, new BroadcastProgress
{
BroadcastId = broadcastId,
Status = "completed",
TotalRecipients = totalRecipients,
DeliveredCount = deliveredCount,
FailedCount = failedCount,
PercentComplete = 100,
Timestamp = DateTimeOffset.UtcNow,
Summary = new BroadcastSummary
{
SuccessRate = (decimal)deliveredCount / totalRecipients * 100,
Duration = TimeSpan.FromSeconds(totalRecipients * 0.015) // Simulated
}
}, cancellationToken);
_logger.LogInformation(
"Broadcast {BroadcastId} completed. Delivered: {Delivered}, Failed: {Failed}",
broadcastId, deliveredCount, failedCount);
}
catch (OperationCanceledException)
{
await WriteProgressAsync(writer, new BroadcastProgress
{
BroadcastId = broadcastId,
Status = "cancelled",
TotalRecipients = totalRecipients,
DeliveredCount = deliveredCount,
FailedCount = failedCount,
PercentComplete = (int)(deliveredCount * 100.0 / totalRecipients),
Timestamp = DateTimeOffset.UtcNow
}, CancellationToken.None);
_logger.LogWarning("Broadcast {BroadcastId} cancelled", broadcastId);
}
await writer.FlushAsync(CancellationToken.None);
stream.Position = 0;
var headers = new HeaderCollection();
headers.Set("Content-Type", "application/x-ndjson");
headers.Set("X-Broadcast-Id", broadcastId);
return new RawResponse
{
StatusCode = 200,
Headers = headers,
Body = stream
};
}
private static async Task WriteProgressAsync(StreamWriter writer, BroadcastProgress progress, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(progress, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
await writer.WriteLineAsync(json);
await writer.FlushAsync(cancellationToken);
}
private static string[] GenerateSimulatedRecipients(int count)
{
return Enumerable.Range(1, count)
.Select(i => $"USR-{i:D6}")
.ToArray();
}
private sealed record BroadcastRequest
{
public required string Type { get; init; }
public required string Title { get; init; }
public required string Body { get; init; }
public NotificationPriority Priority { get; init; } = NotificationPriority.Normal;
public DeliveryChannel[] Channels { get; init; } = [DeliveryChannel.InApp];
public string[]? RecipientIds { get; init; }
public int? RecipientCount { get; init; }
}
private sealed record BroadcastProgress
{
public required string BroadcastId { get; init; }
public required string Status { get; init; }
public int TotalRecipients { get; init; }
public int DeliveredCount { get; init; }
public int FailedCount { get; init; }
public int PercentComplete { get; init; }
public int? CurrentBatch { get; init; }
public int? TotalBatches { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public BroadcastSummary? Summary { get; init; }
}
private sealed record BroadcastSummary
{
public decimal SuccessRate { get; init; }
public TimeSpan Duration { get; init; }
}
}

View File

@@ -0,0 +1,150 @@
// ----------------------------------------------------------------------------
// GetNotificationsEndpoint
//
// Demonstrates:
// - List endpoint with pagination
// - Multiple filter parameters
// - Cursor-based pagination
// - Response headers for pagination metadata
// ----------------------------------------------------------------------------
using Examples.NotificationService.Models;
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
namespace Examples.NotificationService.Endpoints;
/// <summary>
/// Request to list notifications for a user.
/// </summary>
public sealed record GetNotificationsRequest
{
public required string UserId { get; init; }
public NotificationStatus? Status { get; init; }
public NotificationPriority? MinPriority { get; init; }
public string? Type { get; init; }
public bool? Unread { get; init; }
public string? Cursor { get; init; }
public int Limit { get; init; } = 20;
}
/// <summary>
/// Response with paginated notifications.
/// </summary>
public sealed record GetNotificationsResponse
{
public required NotificationSummary[] Notifications { get; init; }
public required int TotalCount { get; init; }
public required int UnreadCount { get; init; }
public string? NextCursor { get; init; }
public bool HasMore { get; init; }
}
/// <summary>
/// Endpoint for listing user notifications with filters and pagination.
/// </summary>
[StellaEndpoint("GET", "/notifications/user/{userId}", TimeoutSeconds = 15, RequiredClaims = ["notifications:read"])]
[ValidateSchema(
ValidateResponse = true,
Summary = "List user notifications",
Description = "Retrieves notifications for a user with filtering and cursor-based pagination.",
Tags = ["notifications", "list"])]
public sealed class GetNotificationsEndpoint : IStellaEndpoint<GetNotificationsRequest, GetNotificationsResponse>
{
private readonly ILogger<GetNotificationsEndpoint> _logger;
// Simulated notification store
private static readonly List<NotificationSummary> NotificationStore = GenerateNotifications();
public GetNotificationsEndpoint(ILogger<GetNotificationsEndpoint> logger)
{
_logger = logger;
}
public Task<GetNotificationsResponse> HandleAsync(
GetNotificationsRequest request,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"Fetching notifications for user {UserId}. Filters - Status: {Status}, MinPriority: {Priority}, Type: {Type}, Unread: {Unread}",
request.UserId, request.Status, request.MinPriority, request.Type, request.Unread);
// Apply filters
var query = NotificationStore.AsEnumerable();
if (request.Status.HasValue)
{
query = query.Where(n => n.Status == request.Status.Value);
}
if (request.MinPriority.HasValue)
{
query = query.Where(n => n.Priority >= request.MinPriority.Value);
}
if (!string.IsNullOrEmpty(request.Type))
{
query = query.Where(n => n.Type.StartsWith(request.Type, StringComparison.OrdinalIgnoreCase));
}
if (request.Unread == true)
{
query = query.Where(n => !n.IsRead);
}
var filtered = query.ToList();
var totalCount = filtered.Count;
var unreadCount = filtered.Count(n => !n.IsRead);
// Apply cursor-based pagination
var startIndex = 0;
if (!string.IsNullOrEmpty(request.Cursor))
{
startIndex = filtered.FindIndex(n => n.Id == request.Cursor) + 1;
if (startIndex <= 0) startIndex = 0;
}
var page = filtered
.Skip(startIndex)
.Take(request.Limit + 1) // Take one extra to check for more
.ToArray();
var hasMore = page.Length > request.Limit;
var notifications = page.Take(request.Limit).ToArray();
var nextCursor = hasMore ? notifications.LastOrDefault()?.Id : null;
_logger.LogDebug(
"Returning {Count} notifications for user {UserId}. Total: {Total}, Unread: {Unread}",
notifications.Length, request.UserId, totalCount, unreadCount);
return Task.FromResult(new GetNotificationsResponse
{
Notifications = notifications,
TotalCount = totalCount,
UnreadCount = unreadCount,
NextCursor = nextCursor,
HasMore = hasMore
});
}
private static List<NotificationSummary> GenerateNotifications()
{
var random = new Random(42); // Fixed seed for reproducibility
var types = new[] { "order.confirmed", "order.shipped", "payment.received", "security.login", "message.received" };
var priorities = Enum.GetValues<NotificationPriority>();
var statuses = new[] { NotificationStatus.Delivered, NotificationStatus.Pending };
return Enumerable.Range(1, 100)
.Select(i => new NotificationSummary
{
Id = $"NOTIF-{20241228 - (i % 30):D8}-{i:D4}",
Type = types[i % types.Length],
Title = $"Notification #{i}",
Priority = priorities[i % priorities.Length],
Status = statuses[i % statuses.Length],
CreatedAt = DateTimeOffset.UtcNow.AddHours(-i * 2),
IsRead = i % 3 == 0
})
.ToList();
}
}

View File

@@ -0,0 +1,96 @@
// ----------------------------------------------------------------------------
// MarkNotificationsReadEndpoint
//
// Demonstrates:
// - Batch operation endpoint
// - PATCH semantics for partial updates
// - Conditional responses
// ----------------------------------------------------------------------------
using Examples.NotificationService.Models;
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
namespace Examples.NotificationService.Endpoints;
/// <summary>
/// Request to mark notifications as read.
/// </summary>
public sealed record MarkNotificationsReadRequest
{
public required string UserId { get; init; }
public string[]? NotificationIds { get; init; }
public bool MarkAll { get; init; }
}
/// <summary>
/// Response after marking notifications.
/// </summary>
public sealed record MarkNotificationsReadResponse
{
public required int MarkedCount { get; init; }
public required int RemainingUnread { get; init; }
public DateTimeOffset MarkedAt { get; init; } = DateTimeOffset.UtcNow;
}
/// <summary>
/// Endpoint for marking notifications as read.
/// Supports both specific IDs and mark-all operations.
/// </summary>
[StellaEndpoint("PATCH", "/notifications/mark-read", TimeoutSeconds = 15, RequiredClaims = ["notifications:write"])]
[ValidateSchema(
ValidateRequest = true,
ValidateResponse = true,
Summary = "Mark notifications read",
Description = "Marks one or more notifications as read. Can mark specific IDs or all unread.",
Tags = ["notifications", "update"])]
public sealed class MarkNotificationsReadEndpoint : IStellaEndpoint<MarkNotificationsReadRequest, MarkNotificationsReadResponse>
{
private readonly ILogger<MarkNotificationsReadEndpoint> _logger;
public MarkNotificationsReadEndpoint(ILogger<MarkNotificationsReadEndpoint> logger)
{
_logger = logger;
}
public Task<MarkNotificationsReadResponse> HandleAsync(
MarkNotificationsReadRequest request,
CancellationToken cancellationToken)
{
var markedCount = 0;
var remainingUnread = 0;
if (request.MarkAll)
{
// Simulate marking all as read
markedCount = new Random().Next(10, 50);
remainingUnread = 0;
_logger.LogInformation(
"Marked all notifications as read for user {UserId}. Count: {Count}",
request.UserId, markedCount);
}
else if (request.NotificationIds?.Length > 0)
{
markedCount = request.NotificationIds.Length;
remainingUnread = new Random().Next(5, 20);
_logger.LogInformation(
"Marked {Count} notifications as read for user {UserId}",
markedCount, request.UserId);
}
else
{
_logger.LogWarning(
"No notifications to mark for user {UserId}",
request.UserId);
}
return Task.FromResult(new MarkNotificationsReadResponse
{
MarkedCount = markedCount,
RemainingUnread = remainingUnread,
MarkedAt = DateTimeOffset.UtcNow
});
}
}

View File

@@ -0,0 +1,128 @@
// ----------------------------------------------------------------------------
// SendNotificationEndpoint
//
// Demonstrates:
// - IStellaEndpoint<TRequest, TResponse> typed endpoint
// - Request/Response schema validation
// - Priority-based routing metadata
// - Idempotency key support
// ----------------------------------------------------------------------------
using Examples.NotificationService.Models;
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
namespace Examples.NotificationService.Endpoints;
/// <summary>
/// Request to send a notification.
/// </summary>
public sealed record SendNotificationRequest
{
public required string RecipientId { get; init; }
public required string Type { get; init; }
public required string Title { get; init; }
public required string Body { get; init; }
public NotificationPriority Priority { get; init; } = NotificationPriority.Normal;
public DeliveryChannel[]? Channels { get; init; }
public DateTimeOffset? ScheduledFor { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
public string? GroupKey { get; init; }
public string? IdempotencyKey { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Response after sending a notification.
/// </summary>
public sealed record SendNotificationResponse
{
public required string NotificationId { get; init; }
public required NotificationStatus Status { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? ScheduledFor { get; init; }
public bool IsDuplicate { get; init; }
}
/// <summary>
/// Endpoint for sending a single notification.
/// Supports idempotency keys to prevent duplicate sends.
/// </summary>
[StellaEndpoint("POST", "/notifications", TimeoutSeconds = 30)]
[ValidateSchema(
ValidateRequest = true,
ValidateResponse = true,
Summary = "Send notification",
Description = "Sends a notification to a single recipient. Supports scheduling, priority, and multi-channel delivery.",
Tags = ["notifications", "send"])]
public sealed class SendNotificationEndpoint : IStellaEndpoint<SendNotificationRequest, SendNotificationResponse>
{
private readonly ILogger<SendNotificationEndpoint> _logger;
// Simulated idempotency cache
private static readonly Dictionary<string, string> IdempotencyCache = new();
private static readonly object CacheLock = new();
public SendNotificationEndpoint(ILogger<SendNotificationEndpoint> logger)
{
_logger = logger;
}
public Task<SendNotificationResponse> HandleAsync(
SendNotificationRequest request,
CancellationToken cancellationToken)
{
// Check idempotency key
if (!string.IsNullOrEmpty(request.IdempotencyKey))
{
lock (CacheLock)
{
if (IdempotencyCache.TryGetValue(request.IdempotencyKey, out var existingId))
{
_logger.LogInformation(
"Duplicate notification request with idempotency key {Key}. Returning existing {NotificationId}",
request.IdempotencyKey, existingId);
return Task.FromResult(new SendNotificationResponse
{
NotificationId = existingId,
Status = NotificationStatus.Queued,
CreatedAt = DateTimeOffset.UtcNow,
ScheduledFor = request.ScheduledFor,
IsDuplicate = true
});
}
}
}
// Generate notification ID
var notificationId = $"NOTIF-{DateTime.UtcNow:yyyyMMdd}-{Guid.NewGuid():N}"[..28].ToUpperInvariant();
// Determine delivery status
var status = request.ScheduledFor.HasValue && request.ScheduledFor > DateTimeOffset.UtcNow
? NotificationStatus.Pending
: NotificationStatus.Queued;
_logger.LogInformation(
"Notification {NotificationId} created for recipient {RecipientId}. Priority: {Priority}, Status: {Status}",
notificationId, request.RecipientId, request.Priority, status);
// Store idempotency key
if (!string.IsNullOrEmpty(request.IdempotencyKey))
{
lock (CacheLock)
{
IdempotencyCache[request.IdempotencyKey] = notificationId;
}
}
return Task.FromResult(new SendNotificationResponse
{
NotificationId = notificationId,
Status = status,
CreatedAt = DateTimeOffset.UtcNow,
ScheduledFor = request.ScheduledFor,
IsDuplicate = false
});
}
}

View File

@@ -0,0 +1,194 @@
// ----------------------------------------------------------------------------
// SendTemplatedNotificationEndpoint
//
// Demonstrates:
// - Template-based notification rendering
// - Localization support
// - User preference checking
// - Complex request validation
// ----------------------------------------------------------------------------
using Examples.NotificationService.Models;
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
namespace Examples.NotificationService.Endpoints;
/// <summary>
/// Request to send a templated notification.
/// </summary>
public sealed record SendTemplatedNotificationRequest
{
public required string RecipientId { get; init; }
public required string TemplateId { get; init; }
public Dictionary<string, object>? TemplateData { get; init; }
public NotificationPriority? PriorityOverride { get; init; }
public DeliveryChannel[]? ChannelOverride { get; init; }
public string? LocaleOverride { get; init; }
public DateTimeOffset? ScheduledFor { get; init; }
public string? GroupKey { get; init; }
}
/// <summary>
/// Response after sending a templated notification.
/// </summary>
public sealed record SendTemplatedNotificationResponse
{
public required string NotificationId { get; init; }
public required string TemplateId { get; init; }
public required string RenderedTitle { get; init; }
public required string RenderedBody { get; init; }
public required string Locale { get; init; }
public required NotificationStatus Status { get; init; }
public required DeliveryChannel[] Channels { get; init; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
}
/// <summary>
/// Endpoint for sending templated notifications with localization.
/// </summary>
[StellaEndpoint("POST", "/notifications/template", TimeoutSeconds = 30)]
[ValidateSchema(
ValidateRequest = true,
ValidateResponse = true,
Summary = "Send templated notification",
Description = "Sends a notification using a pre-defined template with variable substitution and localization.",
Tags = ["notifications", "templates"])]
public sealed class SendTemplatedNotificationEndpoint : IStellaEndpoint<SendTemplatedNotificationRequest, SendTemplatedNotificationResponse>
{
private readonly ILogger<SendTemplatedNotificationEndpoint> _logger;
// Simulated template store
private static readonly Dictionary<string, NotificationTemplate> Templates = new()
{
["order-confirmed"] = new NotificationTemplate
{
Id = "order-confirmed",
Name = "Order Confirmed",
Category = "orders",
TitleTemplate = "Order {{orderId}} Confirmed",
BodyTemplate = "Your order {{orderId}} for {{itemCount}} items totaling ${{total}} has been confirmed. Expected delivery: {{deliveryDate}}.",
DefaultChannels = [DeliveryChannel.Email, DeliveryChannel.Push],
DefaultPriority = NotificationPriority.High,
LocalizedTitles = new Dictionary<string, string>
{
["es"] = "Pedido {{orderId}} Confirmado",
["fr"] = "Commande {{orderId}} Confirmee",
["de"] = "Bestellung {{orderId}} Bestatigt"
},
LocalizedBodies = new Dictionary<string, string>
{
["es"] = "Tu pedido {{orderId}} de {{itemCount}} articulos por ${{total}} ha sido confirmado.",
["fr"] = "Votre commande {{orderId}} de {{itemCount}} articles pour ${{total}} a ete confirmee.",
["de"] = "Ihre Bestellung {{orderId}} uber {{itemCount}} Artikel fur ${{total}} wurde bestatigt."
}
},
["security-alert"] = new NotificationTemplate
{
Id = "security-alert",
Name = "Security Alert",
Category = "security",
TitleTemplate = "Security Alert: {{alertType}}",
BodyTemplate = "A security event was detected: {{description}}. Time: {{timestamp}}. Location: {{location}}. If this wasn't you, please secure your account immediately.",
DefaultChannels = [DeliveryChannel.Email, DeliveryChannel.Sms, DeliveryChannel.Push],
DefaultPriority = NotificationPriority.Critical
},
["welcome"] = new NotificationTemplate
{
Id = "welcome",
Name = "Welcome",
Category = "onboarding",
TitleTemplate = "Welcome to {{appName}}, {{userName}}!",
BodyTemplate = "Hi {{userName}}, welcome to {{appName}}! We're excited to have you on board. Get started by exploring your dashboard.",
DefaultChannels = [DeliveryChannel.Email],
DefaultPriority = NotificationPriority.Normal
}
};
// Simulated user preferences
private static readonly Dictionary<string, NotificationPreferences> UserPreferences = new()
{
["USR-001"] = new NotificationPreferences
{
UserId = "USR-001",
PreferredLocale = "en",
ChannelEnabled = new Dictionary<DeliveryChannel, bool>
{
[DeliveryChannel.Sms] = false
}
},
["USR-002"] = new NotificationPreferences
{
UserId = "USR-002",
PreferredLocale = "es"
}
};
public SendTemplatedNotificationEndpoint(ILogger<SendTemplatedNotificationEndpoint> logger)
{
_logger = logger;
}
public Task<SendTemplatedNotificationResponse> HandleAsync(
SendTemplatedNotificationRequest request,
CancellationToken cancellationToken)
{
// Lookup template
if (!Templates.TryGetValue(request.TemplateId, out var template))
{
throw new InvalidOperationException($"Template '{request.TemplateId}' not found");
}
// Get user preferences
var prefs = UserPreferences.GetValueOrDefault(request.RecipientId)
?? new NotificationPreferences { UserId = request.RecipientId };
// Determine locale
var locale = request.LocaleOverride ?? prefs.PreferredLocale;
// Render template
var title = RenderTemplate(
template.LocalizedTitles?.GetValueOrDefault(locale) ?? template.TitleTemplate,
request.TemplateData);
var body = RenderTemplate(
template.LocalizedBodies?.GetValueOrDefault(locale) ?? template.BodyTemplate,
request.TemplateData);
// Determine channels (filter by user preferences)
var channels = (request.ChannelOverride ?? template.DefaultChannels)
.Where(c => prefs.ChannelEnabled.GetValueOrDefault(c, true))
.ToArray();
// Generate notification ID
var notificationId = $"NOTIF-{DateTime.UtcNow:yyyyMMdd}-{Guid.NewGuid():N}"[..28].ToUpperInvariant();
_logger.LogInformation(
"Templated notification {NotificationId} created using template {TemplateId}. " +
"Locale: {Locale}, Channels: [{Channels}]",
notificationId, template.Id, locale, string.Join(", ", channels));
return Task.FromResult(new SendTemplatedNotificationResponse
{
NotificationId = notificationId,
TemplateId = template.Id,
RenderedTitle = title,
RenderedBody = body,
Locale = locale,
Status = NotificationStatus.Queued,
Channels = channels,
CreatedAt = DateTimeOffset.UtcNow
});
}
private static string RenderTemplate(string template, Dictionary<string, object>? data)
{
if (data == null) return template;
var result = template;
foreach (var (key, value) in data)
{
result = result.Replace($"{{{{{key}}}}}", value?.ToString() ?? string.Empty);
}
return result;
}
}

View File

@@ -0,0 +1,256 @@
// ----------------------------------------------------------------------------
// SubscribeNotificationsEndpoint
//
// Demonstrates:
// - Server-Sent Events (SSE) for real-time push
// - Long-lived connections with automatic reconnection
// - Event filtering by user and type
// - Heartbeat for connection health
// ----------------------------------------------------------------------------
using System.Text;
using System.Text.Json;
using Examples.NotificationService.Models;
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
namespace Examples.NotificationService.Endpoints;
/// <summary>
/// SSE streaming endpoint for real-time notification delivery.
/// Clients subscribe and receive notifications as they occur.
/// </summary>
[StellaEndpoint("GET", "/notifications/subscribe", SupportsStreaming = true, TimeoutSeconds = 3600)]
public sealed class SubscribeNotificationsEndpoint : IRawStellaEndpoint
{
private readonly ILogger<SubscribeNotificationsEndpoint> _logger;
// Simulated notification types for demo
private static readonly string[] NotificationTypes =
[
"order.confirmed",
"order.shipped",
"payment.received",
"security.login",
"system.maintenance",
"message.received",
"task.assigned",
"task.completed"
];
public SubscribeNotificationsEndpoint(ILogger<SubscribeNotificationsEndpoint> logger)
{
_logger = logger;
}
public async Task<RawResponse> HandleAsync(
RawRequestContext context,
CancellationToken cancellationToken)
{
// Parse subscription parameters
var userId = context.QueryParameters.GetValueOrDefault("userId") ?? "anonymous";
var typesParam = context.QueryParameters.GetValueOrDefault("types");
var typeFilter = typesParam?.Split(',').ToHashSet();
var lastEventId = context.Headers["Last-Event-ID"];
_logger.LogInformation(
"SSE subscription established. UserId: {UserId}, Types: {Types}, LastEventId: {LastEventId}, CorrelationId: {CorrelationId}",
userId, typesParam ?? "all", lastEventId ?? "none", context.CorrelationId);
var stream = new MemoryStream();
var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true);
try
{
// Send connection confirmation
await WriteEventAsync(writer, "connected", new
{
userId,
subscribedAt = DateTimeOffset.UtcNow,
correlationId = context.CorrelationId,
message = "Connected to notification stream"
}, cancellationToken);
// If there's a lastEventId, simulate catching up
if (!string.IsNullOrEmpty(lastEventId))
{
await WriteEventAsync(writer, "replay_complete", new
{
fromEventId = lastEventId,
replayedCount = 0,
message = "No missed events to replay"
}, cancellationToken);
}
var random = new Random();
var eventCount = 0;
var lastHeartbeat = DateTimeOffset.UtcNow;
while (!cancellationToken.IsCancellationRequested)
{
// Generate event every 2-8 seconds
await Task.Delay(TimeSpan.FromSeconds(2 + random.NextDouble() * 6), cancellationToken);
// Send heartbeat every 30 seconds
if (DateTimeOffset.UtcNow - lastHeartbeat > TimeSpan.FromSeconds(30))
{
await WriteEventAsync(writer, "heartbeat", new
{
timestamp = DateTimeOffset.UtcNow,
eventsDelivered = eventCount
}, cancellationToken, retry: 5000);
lastHeartbeat = DateTimeOffset.UtcNow;
continue;
}
// Generate random notification
var notificationType = NotificationTypes[random.Next(NotificationTypes.Length)];
// Apply type filter
if (typeFilter != null && !typeFilter.Contains(notificationType))
{
continue;
}
var eventId = $"EVT-{DateTime.UtcNow:yyyyMMddHHmmss}-{random.Next(10000):D4}";
var notification = GenerateNotification(notificationType, userId, random);
await WriteEventAsync(writer, notificationType, notification, cancellationToken, eventId: eventId);
eventCount++;
_logger.LogDebug(
"Sent notification {EventId} ({Type}) to user {UserId}",
eventId, notificationType, userId);
}
}
catch (OperationCanceledException)
{
_logger.LogInformation(
"SSE subscription closed for user {UserId}. Events delivered: {Count}, CorrelationId: {CorrelationId}",
userId, 0, context.CorrelationId);
await WriteEventAsync(writer, "disconnected", new
{
userId,
disconnectedAt = DateTimeOffset.UtcNow,
reason = "client_disconnect"
}, CancellationToken.None);
}
await writer.FlushAsync(CancellationToken.None);
stream.Position = 0;
var headers = new HeaderCollection();
headers.Set("Content-Type", "text/event-stream");
headers.Set("Cache-Control", "no-cache, no-store, must-revalidate");
headers.Set("Connection", "keep-alive");
headers.Set("X-Accel-Buffering", "no"); // For nginx
return new RawResponse
{
StatusCode = 200,
Headers = headers,
Body = stream
};
}
private static async Task WriteEventAsync(
StreamWriter writer,
string eventType,
object data,
CancellationToken cancellationToken,
string? eventId = null,
int? retry = null)
{
if (eventId != null)
{
await writer.WriteLineAsync($"id: {eventId}");
}
if (retry.HasValue)
{
await writer.WriteLineAsync($"retry: {retry.Value}");
}
await writer.WriteLineAsync($"event: {eventType}");
await writer.WriteLineAsync($"data: {JsonSerializer.Serialize(data)}");
await writer.WriteLineAsync(); // Empty line marks end of event
await writer.FlushAsync(cancellationToken);
}
private static object GenerateNotification(string type, string userId, Random random)
{
var baseNotification = new
{
id = $"NOTIF-{Guid.NewGuid():N}"[..20],
type,
userId,
timestamp = DateTimeOffset.UtcNow,
priority = type.Contains("security") ? "critical" : type.Contains("order") ? "high" : "normal",
read = false
};
return type switch
{
"order.confirmed" => new
{
baseNotification.id,
baseNotification.type,
baseNotification.userId,
baseNotification.timestamp,
baseNotification.priority,
baseNotification.read,
title = "Order Confirmed",
body = $"Your order ORD-{random.Next(10000, 99999)} has been confirmed",
data = new { orderId = $"ORD-{random.Next(10000, 99999)}", total = Math.Round(50 + random.NextDouble() * 200, 2) }
},
"order.shipped" => new
{
baseNotification.id,
baseNotification.type,
baseNotification.userId,
baseNotification.timestamp,
baseNotification.priority,
baseNotification.read,
title = "Order Shipped",
body = "Your order has been shipped and is on its way!",
data = new { trackingNumber = $"TRK{random.Next(100000000, 999999999)}", carrier = "FedEx" }
},
"security.login" => new
{
baseNotification.id,
baseNotification.type,
baseNotification.userId,
baseNotification.timestamp,
baseNotification.priority,
baseNotification.read,
title = "New Login Detected",
body = "A new login was detected from a new device",
data = new { device = "Chrome on Windows", location = "New York, US", ip = "192.168.1.100" }
},
"message.received" => new
{
baseNotification.id,
baseNotification.type,
baseNotification.userId,
baseNotification.timestamp,
baseNotification.priority,
baseNotification.read,
title = "New Message",
body = $"You have a new message from User-{random.Next(100, 999)}",
data = new { senderId = $"USR-{random.Next(100, 999)}", preview = "Hey, just wanted to check in..." }
},
_ => new
{
baseNotification.id,
baseNotification.type,
baseNotification.userId,
baseNotification.timestamp,
baseNotification.priority,
baseNotification.read,
title = "System Notification",
body = "A system event occurred",
data = (object?)null
}
};
}
}

View File

@@ -0,0 +1,133 @@
// ----------------------------------------------------------------------------
// UpdatePreferencesEndpoint
//
// Demonstrates:
// - PUT semantics for full resource replacement
// - Complex nested request validation
// - User-specific configuration storage
// ----------------------------------------------------------------------------
using Examples.NotificationService.Models;
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
namespace Examples.NotificationService.Endpoints;
/// <summary>
/// Request to update notification preferences.
/// </summary>
public sealed record UpdatePreferencesRequest
{
public required string UserId { get; init; }
public bool GlobalEnabled { get; init; } = true;
public Dictionary<string, bool>? TypeEnabled { get; init; }
public Dictionary<DeliveryChannel, bool>? ChannelEnabled { get; init; }
public string PreferredLocale { get; init; } = "en";
public string? Timezone { get; init; }
public string? QuietHoursStart { get; init; }
public string? QuietHoursEnd { get; init; }
public int MaxNotificationsPerHour { get; init; } = 100;
}
/// <summary>
/// Response with updated preferences.
/// </summary>
public sealed record UpdatePreferencesResponse
{
public required NotificationPreferences Preferences { get; init; }
public required DateTimeOffset UpdatedAt { get; init; }
public string[]? Warnings { get; init; }
}
/// <summary>
/// Endpoint for updating user notification preferences.
/// </summary>
[StellaEndpoint("PUT", "/notifications/preferences/{userId}", TimeoutSeconds = 15, RequiredClaims = ["notifications:preferences"])]
[ValidateSchema(
ValidateRequest = true,
ValidateResponse = true,
Summary = "Update notification preferences",
Description = "Updates a user's notification preferences including channels, types, locale, and quiet hours.",
Tags = ["notifications", "preferences"])]
public sealed class UpdatePreferencesEndpoint : IStellaEndpoint<UpdatePreferencesRequest, UpdatePreferencesResponse>
{
private readonly ILogger<UpdatePreferencesEndpoint> _logger;
private static readonly string[] ValidLocales = ["en", "es", "fr", "de", "it", "pt", "ja", "zh", "ko", "ru"];
public UpdatePreferencesEndpoint(ILogger<UpdatePreferencesEndpoint> logger)
{
_logger = logger;
}
public Task<UpdatePreferencesResponse> HandleAsync(
UpdatePreferencesRequest request,
CancellationToken cancellationToken)
{
var warnings = new List<string>();
// Validate locale
if (!ValidLocales.Contains(request.PreferredLocale))
{
warnings.Add($"Locale '{request.PreferredLocale}' may not be fully supported. Falling back to 'en'.");
}
// Parse quiet hours
TimeOnly? quietStart = null;
TimeOnly? quietEnd = null;
if (!string.IsNullOrEmpty(request.QuietHoursStart) && TimeOnly.TryParse(request.QuietHoursStart, out var qs))
{
quietStart = qs;
}
if (!string.IsNullOrEmpty(request.QuietHoursEnd) && TimeOnly.TryParse(request.QuietHoursEnd, out var qe))
{
quietEnd = qe;
}
// Validate quiet hours consistency
if (quietStart.HasValue != quietEnd.HasValue)
{
warnings.Add("Quiet hours require both start and end times. Quiet hours will be disabled.");
quietStart = null;
quietEnd = null;
}
// Validate rate limit
if (request.MaxNotificationsPerHour < 1)
{
warnings.Add("MaxNotificationsPerHour must be at least 1. Using minimum value.");
}
else if (request.MaxNotificationsPerHour > 1000)
{
warnings.Add("MaxNotificationsPerHour capped at 1000 to prevent abuse.");
}
var preferences = new NotificationPreferences
{
UserId = request.UserId,
GlobalEnabled = request.GlobalEnabled,
TypeEnabled = request.TypeEnabled ?? new Dictionary<string, bool>(),
ChannelEnabled = request.ChannelEnabled ?? new Dictionary<DeliveryChannel, bool>(),
PreferredLocale = ValidLocales.Contains(request.PreferredLocale) ? request.PreferredLocale : "en",
Timezone = request.Timezone,
QuietHoursStart = quietStart,
QuietHoursEnd = quietEnd,
MaxNotificationsPerHour = Math.Clamp(request.MaxNotificationsPerHour, 1, 1000)
};
_logger.LogInformation(
"Updated preferences for user {UserId}. GlobalEnabled: {Enabled}, Locale: {Locale}, Channels: {Channels}",
request.UserId,
preferences.GlobalEnabled,
preferences.PreferredLocale,
string.Join(", ", preferences.ChannelEnabled.Where(kv => kv.Value).Select(kv => kv.Key)));
return Task.FromResult(new UpdatePreferencesResponse
{
Preferences = preferences,
UpdatedAt = DateTimeOffset.UtcNow,
Warnings = warnings.Count > 0 ? warnings.ToArray() : null
});
}
}

View File

@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Examples.NotificationService</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<ItemGroup>
<!-- Core microservice framework -->
<ProjectReference Include="..\..\__Libraries\StellaOps.Microservice\StellaOps.Microservice.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
<!-- Multiple transports for different delivery patterns -->
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Transport.InMemory\StellaOps.Router.Transport.InMemory.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Transport.Tcp\StellaOps.Router.Transport.Tcp.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Transport.RabbitMq\StellaOps.Router.Transport.RabbitMq.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Transport.Udp\StellaOps.Router.Transport.Udp.csproj" />
<!-- Messaging for persistent queue-based delivery -->
<ProjectReference Include="..\..\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Messaging.Transport.InMemory\StellaOps.Messaging.Transport.InMemory.csproj" />
<!-- Source generator for compile-time endpoint discovery -->
<ProjectReference Include="..\..\__Libraries\StellaOps.Microservice.SourceGen\StellaOps.Microservice.SourceGen.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
<Content Include="microservice.yaml" CopyToOutputDirectory="PreserveNewest" />
<Content Include="templates\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,171 @@
// ----------------------------------------------------------------------------
// Notification Models
//
// Demonstrates:
// - Rich domain models for notifications
// - Priority-based routing
// - Multi-channel delivery
// - Template support
// ----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace Examples.NotificationService.Models;
/// <summary>
/// Notification priority levels for routing decisions.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum NotificationPriority
{
Low = 0,
Normal = 1,
High = 2,
Critical = 3
}
/// <summary>
/// Delivery channel for notifications.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum DeliveryChannel
{
InApp,
Email,
Sms,
Push,
Webhook,
Slack,
Teams
}
/// <summary>
/// Status of a notification.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum NotificationStatus
{
Pending,
Queued,
Sending,
Delivered,
Failed,
Cancelled,
Expired
}
/// <summary>
/// Core notification entity.
/// </summary>
public sealed record Notification
{
public required string Id { get; init; }
public required string RecipientId { get; init; }
public required string Type { get; init; }
public required string Title { get; init; }
public required string Body { get; init; }
public NotificationPriority Priority { get; init; } = NotificationPriority.Normal;
public required DeliveryChannel[] Channels { get; init; }
public NotificationStatus Status { get; init; } = NotificationStatus.Pending;
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset? ScheduledFor { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
public DateTimeOffset? DeliveredAt { get; init; }
public string? GroupKey { get; init; }
public string? TemplateId { get; init; }
public Dictionary<string, object>? TemplateData { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Summary of a notification for list views.
/// </summary>
public sealed record NotificationSummary
{
public required string Id { get; init; }
public required string Type { get; init; }
public required string Title { get; init; }
public NotificationPriority Priority { get; init; }
public NotificationStatus Status { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public bool IsRead { get; init; }
}
/// <summary>
/// Delivery attempt record for a notification.
/// </summary>
public sealed record DeliveryAttempt
{
public required string Id { get; init; }
public required string NotificationId { get; init; }
public required DeliveryChannel Channel { get; init; }
public required DateTimeOffset AttemptedAt { get; init; }
public bool Success { get; init; }
public string? ErrorCode { get; init; }
public string? ErrorMessage { get; init; }
public int RetryCount { get; init; }
}
/// <summary>
/// Notification template for rendering.
/// </summary>
public sealed record NotificationTemplate
{
public required string Id { get; init; }
public required string Name { get; init; }
public required string Category { get; init; }
public required string TitleTemplate { get; init; }
public required string BodyTemplate { get; init; }
public DeliveryChannel[] DefaultChannels { get; init; } = [];
public NotificationPriority DefaultPriority { get; init; } = NotificationPriority.Normal;
public Dictionary<string, string>? LocalizedTitles { get; init; }
public Dictionary<string, string>? LocalizedBodies { get; init; }
}
/// <summary>
/// User preferences for notifications.
/// </summary>
public sealed record NotificationPreferences
{
public required string UserId { get; init; }
public bool GlobalEnabled { get; init; } = true;
public Dictionary<string, bool> TypeEnabled { get; init; } = new();
public Dictionary<DeliveryChannel, bool> ChannelEnabled { get; init; } = new();
public string PreferredLocale { get; init; } = "en";
public string? Timezone { get; init; }
public TimeOnly? QuietHoursStart { get; init; }
public TimeOnly? QuietHoursEnd { get; init; }
public int MaxNotificationsPerHour { get; init; } = 100;
}
/// <summary>
/// Broadcast notification for multiple recipients.
/// </summary>
public sealed record BroadcastNotification
{
public required string Id { get; init; }
public required string Type { get; init; }
public required string Title { get; init; }
public required string Body { get; init; }
public NotificationPriority Priority { get; init; } = NotificationPriority.Normal;
public DeliveryChannel[] Channels { get; init; } = [DeliveryChannel.InApp];
public string[]? RecipientIds { get; init; }
public string? RecipientFilter { get; init; }
public int TotalRecipients { get; init; }
public int DeliveredCount { get; init; }
public int FailedCount { get; init; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
}
/// <summary>
/// Real-time notification event for streaming.
/// </summary>
public sealed record NotificationEvent
{
public required string EventId { get; init; }
public required string EventType { get; init; }
public required string NotificationId { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public string? RecipientId { get; init; }
public Dictionary<string, object>? Payload { get; init; }
}

View File

@@ -0,0 +1,100 @@
// ----------------------------------------------------------------------------
// Examples.NotificationService
//
// A comprehensive notification microservice demonstrating:
// - Multiple transport patterns (TCP, RabbitMQ, UDP broadcast)
// - Typed and streaming endpoints
// - Template-based notifications with localization
// - Real-time SSE subscription
// - Batch broadcast operations
// - Priority-based routing
// - User preference management
//
// This example showcases the full capabilities of the StellaOps.Microservice
// framework for building production-grade notification systems.
// ----------------------------------------------------------------------------
using Examples.NotificationService.Endpoints;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Transport.InMemory;
var builder = Host.CreateApplicationBuilder(args);
// Configure logging
builder.Logging.AddConsole();
builder.Logging.SetMinimumLevel(LogLevel.Debug);
// ----------------------------------------------------------------------------
// Stella Microservice Configuration
// ----------------------------------------------------------------------------
builder.Services.AddStellaMicroservice(options =>
{
options.ServiceName = "notification-service";
options.Version = "1.0.0";
options.Region = "us-east-1";
options.InstanceId = $"notify-{Environment.MachineName}-{Guid.NewGuid():N}"[..32];
options.ConfigFilePath = "microservice.yaml";
// Service metadata
options.ServiceDescription = "Multi-channel notification service with templates, broadcast, and real-time streaming";
// Heartbeat configuration
options.HeartbeatInterval = TimeSpan.FromSeconds(10);
options.ReconnectBackoffInitial = TimeSpan.FromSeconds(1);
options.ReconnectBackoffMax = TimeSpan.FromMinutes(1);
// Multi-router configuration
options.Routers =
[
// Primary: InMemory for development
new RouterEndpointConfig
{
Host = "localhost",
Port = 5100,
TransportType = TransportType.InMemory
}
];
});
// ----------------------------------------------------------------------------
// Endpoint Handlers - Registered for DI
// ----------------------------------------------------------------------------
builder.Services.AddScoped<SendNotificationEndpoint>();
builder.Services.AddScoped<SendTemplatedNotificationEndpoint>();
builder.Services.AddScoped<BroadcastNotificationEndpoint>();
builder.Services.AddScoped<SubscribeNotificationsEndpoint>();
builder.Services.AddScoped<GetNotificationsEndpoint>();
builder.Services.AddScoped<MarkNotificationsReadEndpoint>();
builder.Services.AddScoped<UpdatePreferencesEndpoint>();
// ----------------------------------------------------------------------------
// Transport Registration
// ----------------------------------------------------------------------------
// InMemory for development/testing
builder.Services.AddInMemoryTransport();
// Additional transports can be enabled when infrastructure is available:
// builder.Services.AddTcpTransportClient(options => { options.Host = "localhost"; options.Port = 5200; });
// builder.Services.AddRabbitMqTransportClient(options => { options.HostName = "localhost"; });
// builder.Services.AddUdpTransportClient(options => { options.Port = 5201; });
var host = builder.Build();
Console.WriteLine("╔══════════════════════════════════════════════════════════════════╗");
Console.WriteLine("║ Notification Service v1.0.0 - Starting... ║");
Console.WriteLine("╠══════════════════════════════════════════════════════════════════╣");
Console.WriteLine("║ Endpoints: ║");
Console.WriteLine("║ POST /notifications Send notification ║");
Console.WriteLine("║ POST /notifications/template Send templated notif ║");
Console.WriteLine("║ POST /notifications/broadcast Broadcast (streaming) ║");
Console.WriteLine("║ GET /notifications/subscribe SSE subscription ║");
Console.WriteLine("║ GET /notifications/user/{id} List user notifications ║");
Console.WriteLine("║ PATCH /notifications/mark-read Mark as read ║");
Console.WriteLine("║ PUT /notifications/preferences Update preferences ║");
Console.WriteLine("╚══════════════════════════════════════════════════════════════════╝");
await host.RunAsync();

View File

@@ -0,0 +1,185 @@
# Notification Service Microservice Configuration
# This file configures the service runtime behavior for multi-transport notification delivery
service:
name: notification-service
version: 1.0.0
region: us-east-1
description: "Multi-channel notification service with templates, broadcast, and real-time streaming"
# Connection to router gateways
routers:
# Primary TCP connection for synchronous request/response
- host: localhost
port: 5100
transport: tcp
priority: 1
options:
keepAlive: true
connectTimeout: 5s
sendTimeout: 30s
receiveTimeout: 300s
# RabbitMQ for reliable async messaging and queuing
- host: localhost
port: 5672
transport: rabbitmq
priority: 2
options:
virtualHost: /
prefetchCount: 50
exchange: stellaops.notifications
exchangeType: topic
deadLetterExchange: stellaops.notifications.dlx
autoRecovery: true
recoveryInterval: 5s
# UDP for broadcast notifications (one-way)
- host: localhost
port: 5201
transport: udp
priority: 3
options:
broadcast: true
multicastGroup: 239.255.0.1
# Health and lifecycle
health:
heartbeatInterval: 10s
healthCheckPath: /health
readinessCheckPath: /ready
livenessCheckPath: /live
# Reconnection behavior
resilience:
initialBackoff: 1s
maxBackoff: 60s
maxRetries: -1 # Infinite retries
circuitBreaker:
enabled: true
failureThreshold: 5
successThreshold: 2
timeout: 30s
# Priority queue configuration
priorityQueues:
enabled: true
levels:
critical: 4
high: 3
normal: 2
low: 1
routing:
critical:
transport: rabbitmq
queue: notifications.critical
ttl: 5m
high:
transport: tcp
queue: notifications.high
ttl: 15m
normal:
transport: tcp
queue: notifications.normal
ttl: 1h
low:
transport: rabbitmq
queue: notifications.low
ttl: 24h
# Rate limiting
rateLimiting:
enabled: true
defaultLimit: 1000
windowSize: 1h
perUser:
enabled: true
limit: 100
windowSize: 1h
perChannel:
sms: 10
email: 50
push: 200
inApp: 1000
# Batch processing configuration
batch:
maxBatchSize: 1000
batchTimeout: 5s
parallelism: 10
retryPolicy:
maxAttempts: 3
backoff: exponential
initialDelay: 1s
maxDelay: 30s
# Logging
logging:
level: Information
format: json
includeScopes: true
maskedFields:
- password
- apiKey
- token
- phoneNumber
# Metrics
metrics:
enabled: true
prefix: notification_service
labels:
environment: development
histogramBuckets:
delivery_latency_ms: [10, 50, 100, 250, 500, 1000, 2500, 5000, 10000]
counters:
- notifications_sent_total
- notifications_failed_total
- notifications_delivered_total
- broadcasts_completed_total
# Template configuration
templates:
directory: templates
cacheEnabled: true
cacheTtl: 5m
supportedLocales:
- en
- es
- fr
- de
- it
- pt
- ja
- zh
- ko
- ru
defaultLocale: en
# Delivery channel configuration
channels:
inApp:
enabled: true
ttl: 30d
email:
enabled: true
provider: smtp
fromAddress: noreply@example.com
replyToAddress: support@example.com
sms:
enabled: true
provider: twilio
maxLength: 160
push:
enabled: true
providers:
- fcm
- apns
webhook:
enabled: true
timeout: 10s
retries: 3
slack:
enabled: false
teams:
enabled: false

View File

@@ -0,0 +1,95 @@
// ----------------------------------------------------------------------------
// CancelOrderEndpoint
//
// Demonstrates:
// - DELETE operation
// - Response-only typed endpoint (no request body needed)
// - Admin-level authorization
// ----------------------------------------------------------------------------
using Examples.OrderService.Models;
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
namespace Examples.OrderService.Endpoints;
/// <summary>
/// Request to cancel an order.
/// </summary>
public sealed record CancelOrderRequest
{
public required string OrderId { get; init; }
public string? CancellationReason { get; init; }
}
/// <summary>
/// Response after cancellation.
/// </summary>
public sealed record CancelOrderResponse
{
public required string OrderId { get; init; }
public required bool Cancelled { get; init; }
public required DateTimeOffset CancelledAt { get; init; }
public decimal? RefundAmount { get; init; }
public string? Message { get; init; }
}
/// <summary>
/// Endpoint for cancelling orders.
/// Requires orders:delete claim - typically admin only.
/// </summary>
[StellaEndpoint("DELETE", "/orders/{id}", TimeoutSeconds = 15, RequiredClaims = ["orders:delete"])]
[ValidateSchema(
ValidateResponse = true,
Summary = "Cancel order",
Description = "Cancels an order if it hasn't been shipped. Triggers refund process if payment was captured.",
Tags = ["orders", "write", "cancel"])]
public sealed class CancelOrderEndpoint : IStellaEndpoint<CancelOrderRequest, CancelOrderResponse>
{
private readonly ILogger<CancelOrderEndpoint> _logger;
public CancelOrderEndpoint(ILogger<CancelOrderEndpoint> logger)
{
_logger = logger;
}
public Task<CancelOrderResponse> HandleAsync(
CancelOrderRequest request,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"Cancelling order {OrderId}. Reason: {Reason}",
request.OrderId, request.CancellationReason ?? "Not specified");
// Simulate checking if order can be cancelled
var canCancel = !request.OrderId.Contains("SHIPPED");
if (!canCancel)
{
return Task.FromResult(new CancelOrderResponse
{
OrderId = request.OrderId,
Cancelled = false,
CancelledAt = DateTimeOffset.UtcNow,
RefundAmount = null,
Message = "Cannot cancel order that has already shipped"
});
}
// Simulate refund calculation
var refundAmount = 82.64m; // Full refund
_logger.LogInformation(
"Order {OrderId} cancelled. Refund amount: ${RefundAmount}",
request.OrderId, refundAmount);
return Task.FromResult(new CancelOrderResponse
{
OrderId = request.OrderId,
Cancelled = true,
CancelledAt = DateTimeOffset.UtcNow,
RefundAmount = refundAmount,
Message = "Order cancelled successfully. Refund will be processed within 5-7 business days."
});
}
}

View File

@@ -0,0 +1,124 @@
// ----------------------------------------------------------------------------
// CreateOrderEndpoint
//
// Demonstrates:
// - Typed endpoint with IStellaEndpoint<TRequest, TResponse>
// - JSON Schema validation with [ValidateSchema]
// - OpenAPI metadata (summary, description, tags)
// - Request timeout configuration
// ----------------------------------------------------------------------------
using Examples.OrderService.Models;
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
namespace Examples.OrderService.Endpoints;
/// <summary>
/// Request to create a new order.
/// </summary>
public sealed record CreateOrderRequest
{
public required string CustomerId { get; init; }
public required List<OrderItemRequest> Items { get; init; }
public required ShippingAddress ShippingAddress { get; init; }
public OrderPriority Priority { get; init; } = OrderPriority.Normal;
public string? Notes { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Order item in create request.
/// </summary>
public sealed record OrderItemRequest
{
public required string ProductId { get; init; }
public required int Quantity { get; init; }
public string? Notes { get; init; }
}
/// <summary>
/// Response after creating an order.
/// </summary>
public sealed record CreateOrderResponse
{
public required string OrderId { get; init; }
public required OrderStatus Status { get; init; }
public required decimal EstimatedTotal { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required string TrackingUrl { get; init; }
}
/// <summary>
/// Endpoint for creating new orders with full validation.
/// </summary>
[StellaEndpoint("POST", "/orders", TimeoutSeconds = 30)]
[ValidateSchema(
ValidateRequest = true,
ValidateResponse = true,
Summary = "Create a new order",
Description = "Creates a new order with the specified items and shipping address. " +
"Returns the order ID and estimated total for payment processing.",
Tags = ["orders", "write"])]
public sealed class CreateOrderEndpoint : IStellaEndpoint<CreateOrderRequest, CreateOrderResponse>
{
private readonly ILogger<CreateOrderEndpoint> _logger;
// Simulated product catalog for pricing
private static readonly Dictionary<string, (string Name, decimal Price)> Products = new()
{
["PROD-001"] = ("Widget Pro", 29.99m),
["PROD-002"] = ("Gadget Elite", 49.99m),
["PROD-003"] = ("Super Component", 15.99m),
["PROD-004"] = ("Mega Bundle", 99.99m),
["PROD-005"] = ("Basic Kit", 9.99m)
};
public CreateOrderEndpoint(ILogger<CreateOrderEndpoint> logger)
{
_logger = logger;
}
public Task<CreateOrderResponse> HandleAsync(
CreateOrderRequest request,
CancellationToken cancellationToken)
{
var orderId = $"ORD-{DateTime.UtcNow:yyyyMMdd}-{Guid.NewGuid():N}"[..24].ToUpperInvariant();
_logger.LogInformation(
"Creating order {OrderId} for customer {CustomerId} with {ItemCount} items",
orderId, request.CustomerId, request.Items.Count);
// Calculate estimated total
var subtotal = 0m;
foreach (var item in request.Items)
{
if (Products.TryGetValue(item.ProductId, out var product))
{
subtotal += product.Price * item.Quantity;
}
else
{
_logger.LogWarning("Unknown product {ProductId}, using default price", item.ProductId);
subtotal += 19.99m * item.Quantity;
}
}
var tax = Math.Round(subtotal * 0.08m, 2); // 8% tax
var shipping = request.Priority == OrderPriority.Urgent ? 25.00m : 5.99m;
var total = subtotal + tax + shipping;
_logger.LogInformation(
"Order {OrderId} total: ${Total} (subtotal: ${Subtotal}, tax: ${Tax}, shipping: ${Shipping})",
orderId, total, subtotal, tax, shipping);
return Task.FromResult(new CreateOrderResponse
{
OrderId = orderId,
Status = OrderStatus.Pending,
EstimatedTotal = total,
CreatedAt = DateTimeOffset.UtcNow,
TrackingUrl = $"https://track.example.com/orders/{orderId}"
});
}
}

View File

@@ -0,0 +1,112 @@
// ----------------------------------------------------------------------------
// ExportOrdersEndpoint
//
// Demonstrates:
// - IRawStellaEndpoint for streaming responses
// - SupportsStreaming = true for large payloads
// - Extended timeout for long operations
// - CSV generation with chunked output
// ----------------------------------------------------------------------------
using System.Text;
using Examples.OrderService.Models;
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
namespace Examples.OrderService.Endpoints;
/// <summary>
/// Streaming endpoint for exporting orders to CSV.
/// Uses raw endpoint for full control over response stream.
/// </summary>
[StellaEndpoint("GET", "/orders/export", SupportsStreaming = true, TimeoutSeconds = 300)]
public sealed class ExportOrdersEndpoint : IRawStellaEndpoint
{
private readonly ILogger<ExportOrdersEndpoint> _logger;
public ExportOrdersEndpoint(ILogger<ExportOrdersEndpoint> logger)
{
_logger = logger;
}
public async Task<RawResponse> HandleAsync(
RawRequestContext context,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"Starting order export. CorrelationId: {CorrelationId}",
context.CorrelationId);
// Parse query parameters
var format = context.QueryParameters.GetValueOrDefault("format", "csv");
var fromDate = context.QueryParameters.TryGetValue("from", out var fromStr)
? DateTimeOffset.Parse(fromStr)
: DateTimeOffset.UtcNow.AddMonths(-1);
_logger.LogInformation(
"Export parameters - Format: {Format}, From: {FromDate}",
format, fromDate);
// Create response stream
var stream = new MemoryStream();
var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true);
// Write CSV header
await writer.WriteLineAsync("OrderId,CustomerId,Status,ItemCount,Total,CreatedAt");
// Generate sample data in chunks (simulating large dataset)
const int totalRecords = 1000;
const int chunkSize = 100;
for (var chunk = 0; chunk < totalRecords / chunkSize; chunk++)
{
cancellationToken.ThrowIfCancellationRequested();
for (var i = 0; i < chunkSize; i++)
{
var recordNum = (chunk * chunkSize) + i + 1;
var order = GenerateSampleOrder(recordNum, fromDate);
await writer.WriteLineAsync(
$"{order.Id},{order.CustomerId},{order.Status},{order.ItemCount},{order.Total:F2},{order.CreatedAt:O}");
}
await writer.FlushAsync(cancellationToken);
// Simulate processing time
await Task.Delay(10, cancellationToken);
_logger.LogDebug("Exported chunk {Chunk}/{Total}", chunk + 1, totalRecords / chunkSize);
}
await writer.FlushAsync(cancellationToken);
stream.Position = 0;
_logger.LogInformation("Export complete. Total records: {Count}", totalRecords);
// Return streaming response with appropriate headers
var headers = new HeaderCollection();
headers.Set("Content-Type", "text/csv; charset=utf-8");
headers.Set("Content-Disposition", $"attachment; filename=\"orders-export-{DateTime.UtcNow:yyyyMMdd}.csv\"");
headers.Set("X-Total-Records", totalRecords.ToString());
return new RawResponse
{
StatusCode = 200,
Headers = headers,
Body = stream
};
}
private static OrderSummary GenerateSampleOrder(int index, DateTimeOffset fromDate)
{
return new OrderSummary
{
Id = $"ORD-{20241201 + (index % 30):D8}-{index:D4}",
CustomerId = $"CUST-{(index % 100) + 1:D5}",
Status = (OrderStatus)(index % 5),
ItemCount = (index % 5) + 1,
Total = 25m + (index * 2.5m) % 500,
CreatedAt = fromDate.AddHours(index)
};
}
}

View File

@@ -0,0 +1,126 @@
// ----------------------------------------------------------------------------
// GetOrderEndpoint
//
// Demonstrates:
// - Path parameters extraction from {id}
// - Authorization with RequiredClaims
// - Response-only endpoint pattern
// ----------------------------------------------------------------------------
using Examples.OrderService.Models;
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
namespace Examples.OrderService.Endpoints;
/// <summary>
/// Request for getting an order by ID.
/// </summary>
public sealed record GetOrderRequest
{
public required string OrderId { get; init; }
}
/// <summary>
/// Full order response.
/// </summary>
public sealed record GetOrderResponse
{
public required Order Order { get; init; }
public List<OrderEvent> RecentEvents { get; init; } = [];
}
/// <summary>
/// Endpoint for retrieving order details.
/// Requires orders:read claim for authorization.
/// </summary>
[StellaEndpoint("GET", "/orders/{id}", TimeoutSeconds = 10, RequiredClaims = ["orders:read"])]
[ValidateSchema(
ValidateResponse = true,
Summary = "Get order by ID",
Description = "Retrieves complete order details including items, shipping info, and recent events.",
Tags = ["orders", "read"])]
public sealed class GetOrderEndpoint : IStellaEndpoint<GetOrderRequest, GetOrderResponse>
{
private readonly ILogger<GetOrderEndpoint> _logger;
public GetOrderEndpoint(ILogger<GetOrderEndpoint> logger)
{
_logger = logger;
}
public Task<GetOrderResponse> HandleAsync(
GetOrderRequest request,
CancellationToken cancellationToken)
{
_logger.LogInformation("Fetching order {OrderId}", request.OrderId);
// Simulated order lookup
var order = new Order
{
Id = request.OrderId,
CustomerId = "CUST-12345",
Status = OrderStatus.Processing,
Priority = OrderPriority.Normal,
Items =
[
new OrderItem
{
ProductId = "PROD-001",
ProductName = "Widget Pro",
Quantity = 2,
UnitPrice = 29.99m,
Discount = 5.00m
},
new OrderItem
{
ProductId = "PROD-003",
ProductName = "Super Component",
Quantity = 1,
UnitPrice = 15.99m,
Discount = 0m
}
],
ShippingAddress = new ShippingAddress
{
Street = "123 Main Street",
City = "Anytown",
State = "CA",
PostalCode = "90210",
Country = "US"
},
Subtotal = 70.97m,
Tax = 5.68m,
ShippingCost = 5.99m,
Total = 82.64m,
CreatedAt = DateTimeOffset.UtcNow.AddDays(-2),
UpdatedAt = DateTimeOffset.UtcNow.AddHours(-4)
};
var events = new List<OrderEvent>
{
new()
{
EventId = Guid.NewGuid().ToString("N")[..16],
OrderId = request.OrderId,
EventType = "order.created",
Timestamp = order.CreatedAt,
Description = "Order created"
},
new()
{
EventId = Guid.NewGuid().ToString("N")[..16],
OrderId = request.OrderId,
EventType = "order.confirmed",
Timestamp = order.CreatedAt.AddMinutes(5),
Description = "Payment confirmed, order processing"
}
};
return Task.FromResult(new GetOrderResponse
{
Order = order,
RecentEvents = events
});
}
}

View File

@@ -0,0 +1,117 @@
// ----------------------------------------------------------------------------
// ListOrdersEndpoint
//
// Demonstrates:
// - Pagination pattern
// - Query parameter handling
// - List response with metadata
// ----------------------------------------------------------------------------
using Examples.OrderService.Models;
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
namespace Examples.OrderService.Endpoints;
/// <summary>
/// Request for listing orders with pagination.
/// </summary>
public sealed record ListOrdersRequest
{
public string? CustomerId { get; init; }
public OrderStatus? Status { get; init; }
public int Page { get; init; } = 1;
public int PageSize { get; init; } = 20;
public string SortBy { get; init; } = "createdAt";
public bool Descending { get; init; } = true;
}
/// <summary>
/// Order summary for list views.
/// </summary>
public sealed record OrderSummary
{
public required string Id { get; init; }
public required string CustomerId { get; init; }
public required OrderStatus Status { get; init; }
public required int ItemCount { get; init; }
public required decimal Total { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// Paginated list response.
/// </summary>
public sealed record ListOrdersResponse
{
public required List<OrderSummary> Orders { get; init; }
public required int TotalCount { get; init; }
public required int Page { get; init; }
public required int PageSize { get; init; }
public required int TotalPages { get; init; }
public bool HasNextPage => Page < TotalPages;
public bool HasPreviousPage => Page > 1;
}
/// <summary>
/// Endpoint for listing orders with filtering and pagination.
/// </summary>
[StellaEndpoint("GET", "/orders", TimeoutSeconds = 15, RequiredClaims = ["orders:read"])]
[ValidateSchema(
ValidateResponse = true,
Summary = "List orders",
Description = "Returns a paginated list of orders with optional filtering by customer and status.",
Tags = ["orders", "read", "list"])]
public sealed class ListOrdersEndpoint : IStellaEndpoint<ListOrdersRequest, ListOrdersResponse>
{
private readonly ILogger<ListOrdersEndpoint> _logger;
public ListOrdersEndpoint(ILogger<ListOrdersEndpoint> logger)
{
_logger = logger;
}
public Task<ListOrdersResponse> HandleAsync(
ListOrdersRequest request,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"Listing orders - Page: {Page}, Size: {Size}, Customer: {Customer}, Status: {Status}",
request.Page, request.PageSize, request.CustomerId ?? "all", request.Status?.ToString() ?? "all");
// Simulated data
var orders = Enumerable.Range(1, 50).Select(i => new OrderSummary
{
Id = $"ORD-{20241215 + i:D8}-{i:D4}",
CustomerId = request.CustomerId ?? $"CUST-{(i % 10) + 1:D5}",
Status = (OrderStatus)(i % 5),
ItemCount = (i % 5) + 1,
Total = 50m + (i * 10.5m),
CreatedAt = DateTimeOffset.UtcNow.AddDays(-i)
}).ToList();
// Apply filters
if (request.Status.HasValue)
{
orders = orders.Where(o => o.Status == request.Status.Value).ToList();
}
var totalCount = orders.Count;
var totalPages = (int)Math.Ceiling((double)totalCount / request.PageSize);
// Apply pagination
orders = orders
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.ToList();
return Task.FromResult(new ListOrdersResponse
{
Orders = orders,
TotalCount = totalCount,
Page = request.Page,
PageSize = request.PageSize,
TotalPages = totalPages
});
}
}

View File

@@ -0,0 +1,194 @@
// ----------------------------------------------------------------------------
// OrderEventsEndpoint
//
// Demonstrates:
// - Server-Sent Events (SSE) streaming pattern
// - Long-lived connections with heartbeat
// - Real-time event delivery
// - Graceful cancellation handling
// ----------------------------------------------------------------------------
using System.Text;
using System.Text.Json;
using Examples.OrderService.Models;
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
namespace Examples.OrderService.Endpoints;
/// <summary>
/// SSE streaming endpoint for real-time order events.
/// Clients connect and receive order updates in real-time.
/// </summary>
[StellaEndpoint("GET", "/orders/events", SupportsStreaming = true, TimeoutSeconds = 3600)]
public sealed class OrderEventsEndpoint : IRawStellaEndpoint
{
private readonly ILogger<OrderEventsEndpoint> _logger;
// Event types that can be streamed
private static readonly string[] EventTypes =
[
"order.created",
"order.confirmed",
"order.processing",
"order.shipped",
"order.delivered",
"order.cancelled",
"order.refunded",
"order.updated"
];
public OrderEventsEndpoint(ILogger<OrderEventsEndpoint> logger)
{
_logger = logger;
}
public async Task<RawResponse> HandleAsync(
RawRequestContext context,
CancellationToken cancellationToken)
{
// Parse query parameters for filtering
var customerId = context.QueryParameters.GetValueOrDefault("customerId");
var eventTypes = context.QueryParameters.TryGetValue("events", out var eventsParam)
? eventsParam.Split(',').ToHashSet()
: null;
_logger.LogInformation(
"SSE connection established. CorrelationId: {CorrelationId}, CustomerId: {CustomerId}",
context.CorrelationId, customerId ?? "all");
var stream = new MemoryStream();
var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true);
try
{
// Send initial connection event
await WriteEventAsync(writer, "connected", new
{
message = "Connected to order events stream",
timestamp = DateTimeOffset.UtcNow,
correlationId = context.CorrelationId
}, cancellationToken);
var eventCount = 0;
var random = new Random();
// Simulate real-time event stream
while (!cancellationToken.IsCancellationRequested)
{
// Random delay between events (1-5 seconds)
await Task.Delay(TimeSpan.FromSeconds(1 + random.NextDouble() * 4), cancellationToken);
// Generate random event
var eventType = EventTypes[random.Next(EventTypes.Length)];
// Apply event type filter if specified
if (eventTypes != null && !eventTypes.Contains(eventType))
{
continue;
}
var orderEvent = new OrderEvent
{
EventId = Guid.NewGuid().ToString("N")[..16],
OrderId = $"ORD-{DateTime.UtcNow:yyyyMMdd}-{random.Next(1000, 9999)}",
EventType = eventType,
Timestamp = DateTimeOffset.UtcNow,
UserId = $"USR-{random.Next(100, 999)}",
Description = GetEventDescription(eventType),
Data = GetEventData(eventType, random)
};
await WriteEventAsync(writer, eventType, orderEvent, cancellationToken);
eventCount++;
_logger.LogDebug(
"Sent event {EventId} ({EventType}) for order {OrderId}",
orderEvent.EventId, eventType, orderEvent.OrderId);
// Send heartbeat every 30 seconds
if (eventCount % 10 == 0)
{
await WriteEventAsync(writer, "heartbeat", new
{
timestamp = DateTimeOffset.UtcNow,
eventsDelivered = eventCount
}, cancellationToken);
}
}
}
catch (OperationCanceledException)
{
_logger.LogInformation(
"SSE connection closed. CorrelationId: {CorrelationId}",
context.CorrelationId);
// Send goodbye event
await WriteEventAsync(writer, "disconnected", new
{
message = "Connection closed",
timestamp = DateTimeOffset.UtcNow
}, CancellationToken.None);
}
await writer.FlushAsync(CancellationToken.None);
stream.Position = 0;
var headers = new HeaderCollection();
headers.Set("Content-Type", "text/event-stream");
headers.Set("Cache-Control", "no-cache");
headers.Set("Connection", "keep-alive");
return new RawResponse
{
StatusCode = 200,
Headers = headers,
Body = stream
};
}
private static async Task WriteEventAsync(
StreamWriter writer,
string eventType,
object data,
CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(data);
await writer.WriteLineAsync($"event: {eventType}");
await writer.WriteLineAsync($"data: {json}");
await writer.WriteLineAsync(); // Empty line marks end of event
await writer.FlushAsync(cancellationToken);
}
private static string GetEventDescription(string eventType) => eventType switch
{
"order.created" => "New order created",
"order.confirmed" => "Payment confirmed, order accepted",
"order.processing" => "Order is being processed",
"order.shipped" => "Order has been shipped",
"order.delivered" => "Order delivered to customer",
"order.cancelled" => "Order was cancelled",
"order.refunded" => "Refund processed",
"order.updated" => "Order details updated",
_ => "Order event"
};
private static Dictionary<string, object>? GetEventData(string eventType, Random random) => eventType switch
{
"order.shipped" => new Dictionary<string, object>
{
["trackingNumber"] = $"TRK{random.Next(100000000, 999999999)}",
["carrier"] = new[] { "UPS", "FedEx", "USPS", "DHL" }[random.Next(4)]
},
"order.refunded" => new Dictionary<string, object>
{
["refundAmount"] = Math.Round(50 + random.NextDouble() * 200, 2),
["refundMethod"] = "original_payment"
},
"order.cancelled" => new Dictionary<string, object>
{
["reason"] = new[] { "customer_request", "payment_failed", "out_of_stock" }[random.Next(3)]
},
_ => null
};
}

View File

@@ -0,0 +1,116 @@
// ----------------------------------------------------------------------------
// UpdateOrderStatusEndpoint
//
// Demonstrates:
// - PATCH operation for partial updates
// - Path parameters with request body
// - Write authorization claims
// - State machine validation
// ----------------------------------------------------------------------------
using Examples.OrderService.Models;
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
namespace Examples.OrderService.Endpoints;
/// <summary>
/// Request to update order status.
/// </summary>
public sealed record UpdateOrderStatusRequest
{
public required string OrderId { get; init; }
public required OrderStatus NewStatus { get; init; }
public string? Reason { get; init; }
public string? TrackingNumber { get; init; }
}
/// <summary>
/// Response after status update.
/// </summary>
public sealed record UpdateOrderStatusResponse
{
public required string OrderId { get; init; }
public required OrderStatus PreviousStatus { get; init; }
public required OrderStatus CurrentStatus { get; init; }
public required DateTimeOffset UpdatedAt { get; init; }
public string? Message { get; init; }
}
/// <summary>
/// Endpoint for updating order status with state machine validation.
/// </summary>
[StellaEndpoint("PATCH", "/orders/{id}/status", TimeoutSeconds = 10, RequiredClaims = ["orders:write"])]
[ValidateSchema(
ValidateRequest = true,
ValidateResponse = true,
Summary = "Update order status",
Description = "Updates the status of an order. Status transitions are validated against allowed state machine rules.",
Tags = ["orders", "write", "status"])]
public sealed class UpdateOrderStatusEndpoint : IStellaEndpoint<UpdateOrderStatusRequest, UpdateOrderStatusResponse>
{
private readonly ILogger<UpdateOrderStatusEndpoint> _logger;
// Valid state transitions
private static readonly Dictionary<OrderStatus, OrderStatus[]> ValidTransitions = new()
{
[OrderStatus.Pending] = [OrderStatus.Confirmed, OrderStatus.Cancelled],
[OrderStatus.Confirmed] = [OrderStatus.Processing, OrderStatus.Cancelled],
[OrderStatus.Processing] = [OrderStatus.Shipped, OrderStatus.Cancelled],
[OrderStatus.Shipped] = [OrderStatus.Delivered],
[OrderStatus.Delivered] = [OrderStatus.Refunded],
[OrderStatus.Cancelled] = [],
[OrderStatus.Refunded] = []
};
public UpdateOrderStatusEndpoint(ILogger<UpdateOrderStatusEndpoint> logger)
{
_logger = logger;
}
public Task<UpdateOrderStatusResponse> HandleAsync(
UpdateOrderStatusRequest request,
CancellationToken cancellationToken)
{
// Simulate current status lookup
var currentStatus = OrderStatus.Processing;
_logger.LogInformation(
"Updating order {OrderId} status: {CurrentStatus} -> {NewStatus}",
request.OrderId, currentStatus, request.NewStatus);
// Validate state transition
if (!IsValidTransition(currentStatus, request.NewStatus))
{
_logger.LogWarning(
"Invalid status transition for order {OrderId}: {Current} -> {New}",
request.OrderId, currentStatus, request.NewStatus);
// In real implementation, throw an exception that maps to 422
return Task.FromResult(new UpdateOrderStatusResponse
{
OrderId = request.OrderId,
PreviousStatus = currentStatus,
CurrentStatus = currentStatus, // Unchanged
UpdatedAt = DateTimeOffset.UtcNow,
Message = $"Invalid transition: {currentStatus} cannot transition to {request.NewStatus}"
});
}
return Task.FromResult(new UpdateOrderStatusResponse
{
OrderId = request.OrderId,
PreviousStatus = currentStatus,
CurrentStatus = request.NewStatus,
UpdatedAt = DateTimeOffset.UtcNow,
Message = request.NewStatus == OrderStatus.Shipped
? $"Shipped with tracking: {request.TrackingNumber}"
: null
});
}
private static bool IsValidTransition(OrderStatus current, OrderStatus target)
{
return ValidTransitions.TryGetValue(current, out var allowed) && allowed.Contains(target);
}
}

View File

@@ -0,0 +1,134 @@
// ----------------------------------------------------------------------------
// UploadOrderDocumentEndpoint
//
// Demonstrates:
// - IRawStellaEndpoint for streaming request body
// - File upload handling with chunked reading
// - Path parameters from URL
// - Content-Type header inspection
// - Large file handling with progress tracking
// ----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
using System.Security.Cryptography;
using System.Text.Json;
namespace Examples.OrderService.Endpoints;
/// <summary>
/// Streaming endpoint for uploading documents to orders.
/// Handles large file uploads with chunked processing.
/// </summary>
[StellaEndpoint("POST", "/orders/{id}/documents", SupportsStreaming = true, TimeoutSeconds = 600)]
public sealed class UploadOrderDocumentEndpoint : IRawStellaEndpoint
{
private readonly ILogger<UploadOrderDocumentEndpoint> _logger;
// Allowed document types
private static readonly HashSet<string> AllowedContentTypes =
[
"application/pdf",
"image/png",
"image/jpeg",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"text/csv"
];
public UploadOrderDocumentEndpoint(ILogger<UploadOrderDocumentEndpoint> logger)
{
_logger = logger;
}
public async Task<RawResponse> HandleAsync(
RawRequestContext context,
CancellationToken cancellationToken)
{
// Extract path parameters
if (!context.PathParameters.TryGetValue("id", out var orderId))
{
return RawResponse.BadRequest("Order ID is required");
}
// Validate content type
var contentType = context.Headers["Content-Type"]?.Split(';')[0].Trim() ?? "application/octet-stream";
if (!AllowedContentTypes.Contains(contentType))
{
return RawResponse.BadRequest($"Content type '{contentType}' is not allowed. Allowed types: {string.Join(", ", AllowedContentTypes)}");
}
// Get filename from Content-Disposition header if present
var filename = "document";
if (context.Headers["Content-Disposition"] is { } disposition)
{
var filenameStart = disposition.IndexOf("filename=", StringComparison.OrdinalIgnoreCase);
if (filenameStart >= 0)
{
filename = disposition[(filenameStart + 9)..].Trim('"', ' ');
}
}
_logger.LogInformation(
"Starting document upload for order {OrderId}. CorrelationId: {CorrelationId}, ContentType: {ContentType}, Filename: {Filename}",
orderId, context.CorrelationId, contentType, filename);
// Stream the upload with progress tracking
var documentId = $"DOC-{Guid.NewGuid():N}"[..20].ToUpperInvariant();
long totalBytes = 0;
var buffer = new byte[64 * 1024]; // 64KB buffer
int bytesRead;
using var sha256 = SHA256.Create();
try
{
while ((bytesRead = await context.Body.ReadAsync(buffer, cancellationToken)) > 0)
{
totalBytes += bytesRead;
// Update hash
sha256.TransformBlock(buffer, 0, bytesRead, null, 0);
// Log progress every 1MB
if (totalBytes % (1024 * 1024) == 0)
{
_logger.LogDebug(
"Upload progress for {DocumentId}: {Bytes:N0} bytes",
documentId, totalBytes);
}
// In real implementation, write to storage here
}
sha256.TransformFinalBlock([], 0, 0);
var checksum = Convert.ToHexString(sha256.Hash ?? []).ToLowerInvariant();
_logger.LogInformation(
"Document upload complete. DocumentId: {DocumentId}, Size: {Size} bytes, Checksum: {Checksum}",
documentId, totalBytes, checksum);
// Return success response
var response = new
{
documentId,
orderId,
filename,
contentType,
sizeBytes = totalBytes,
checksum,
uploadedAt = DateTimeOffset.UtcNow,
downloadUrl = $"https://storage.example.com/documents/{documentId}"
};
return RawResponse.Ok(JsonSerializer.Serialize(response));
}
catch (OperationCanceledException)
{
_logger.LogWarning(
"Document upload cancelled. DocumentId: {DocumentId}, BytesReceived: {Bytes}",
documentId, totalBytes);
return RawResponse.Error(499, "Upload cancelled by client");
}
}
}

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Examples.OrderService</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Microservice\StellaOps.Microservice.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Transport.InMemory\StellaOps.Router.Transport.InMemory.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Transport.Tcp\StellaOps.Router.Transport.Tcp.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Transport.RabbitMq\StellaOps.Router.Transport.RabbitMq.csproj" />
<!-- Reference the source generator for compile-time endpoint discovery -->
<ProjectReference Include="..\..\__Libraries\StellaOps.Microservice.SourceGen\StellaOps.Microservice.SourceGen.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
<Content Include="microservice.yaml" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,111 @@
// ----------------------------------------------------------------------------
// Order Domain Models
// These models are used for request/response serialization and schema generation
// ----------------------------------------------------------------------------
namespace Examples.OrderService.Models;
/// <summary>
/// Order status enumeration.
/// </summary>
public enum OrderStatus
{
Pending,
Confirmed,
Processing,
Shipped,
Delivered,
Cancelled,
Refunded
}
/// <summary>
/// Order priority levels.
/// </summary>
public enum OrderPriority
{
Low,
Normal,
High,
Urgent
}
/// <summary>
/// Represents an order line item.
/// </summary>
public sealed record OrderItem
{
public required string ProductId { get; init; }
public required string ProductName { get; init; }
public required int Quantity { get; init; }
public required decimal UnitPrice { get; init; }
public decimal Discount { get; init; }
public string? Notes { get; init; }
public decimal LineTotal => (UnitPrice * Quantity) - Discount;
}
/// <summary>
/// Shipping address information.
/// </summary>
public sealed record ShippingAddress
{
public required string Street { get; init; }
public string? Street2 { get; init; }
public required string City { get; init; }
public required string State { get; init; }
public required string PostalCode { get; init; }
public required string Country { get; init; }
public string? Phone { get; init; }
public string? Instructions { get; init; }
}
/// <summary>
/// Payment information.
/// </summary>
public sealed record PaymentInfo
{
public required string Method { get; init; } // card, bank, crypto
public string? Last4 { get; init; }
public string? TransactionId { get; init; }
public DateTimeOffset? ProcessedAt { get; init; }
}
/// <summary>
/// Full order entity.
/// </summary>
public sealed record Order
{
public required string Id { get; init; }
public required string CustomerId { get; init; }
public required OrderStatus Status { get; init; }
public OrderPriority Priority { get; init; } = OrderPriority.Normal;
public required List<OrderItem> Items { get; init; }
public required ShippingAddress ShippingAddress { get; init; }
public PaymentInfo? Payment { get; init; }
public decimal Subtotal { get; init; }
public decimal Tax { get; init; }
public decimal ShippingCost { get; init; }
public decimal Total { get; init; }
public string? Notes { get; init; }
public string? TrackingNumber { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
public DateTimeOffset? ShippedAt { get; init; }
public DateTimeOffset? DeliveredAt { get; init; }
public Dictionary<string, string> Metadata { get; init; } = new();
}
/// <summary>
/// Order audit event.
/// </summary>
public sealed record OrderEvent
{
public required string EventId { get; init; }
public required string OrderId { get; init; }
public required string EventType { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public string? UserId { get; init; }
public string? Description { get; init; }
public Dictionary<string, object>? Data { get; init; }
}

View File

@@ -0,0 +1,117 @@
// ----------------------------------------------------------------------------
// Examples.OrderService
//
// Demonstrates an advanced microservice with:
// - Multiple endpoint types (typed, raw/streaming)
// - JSON Schema validation with [ValidateSchema]
// - Authorization with RequiredClaims
// - Multiple transport support (TCP, RabbitMQ)
// - Proper error handling and logging
// - Correlation ID propagation
// ----------------------------------------------------------------------------
using Examples.OrderService.Endpoints;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Transport.InMemory;
using StellaOps.Router.Transport.Tcp;
using StellaOps.Router.Transport.RabbitMq;
var builder = Host.CreateApplicationBuilder(args);
// Configure logging
builder.Logging.AddConsole();
builder.Logging.SetMinimumLevel(LogLevel.Debug);
// ----------------------------------------------------------------------------
// Stella Microservice Configuration
// ----------------------------------------------------------------------------
builder.Services.AddStellaMicroservice(options =>
{
options.ServiceName = "order-service";
options.Version = "2.0.0";
options.Region = "us-east-1";
options.InstanceId = $"order-{Environment.MachineName}-{Guid.NewGuid():N}"[..32];
options.ConfigFilePath = "microservice.yaml";
// Service metadata for OpenAPI
options.ServiceDescription = "Order management microservice with full CRUD, streaming exports, and event publishing";
options.ContactInfo = "orders-team@example.com";
// Heartbeat configuration
options.HeartbeatInterval = TimeSpan.FromSeconds(10);
options.ReconnectBackoffInitial = TimeSpan.FromSeconds(1);
options.ReconnectBackoffMax = TimeSpan.FromMinutes(1);
// Multi-router configuration for high availability
options.Routers =
[
// Primary: TCP for low latency
new RouterEndpointConfig
{
Host = "localhost",
Port = 5100,
TransportType = TransportType.Tcp
},
// Fallback: RabbitMQ for reliability
new RouterEndpointConfig
{
Host = "localhost",
Port = 5672,
TransportType = TransportType.RabbitMq
}
];
});
// ----------------------------------------------------------------------------
// Endpoint Handlers - Registered for DI
// ----------------------------------------------------------------------------
builder.Services.AddScoped<CreateOrderEndpoint>();
builder.Services.AddScoped<GetOrderEndpoint>();
builder.Services.AddScoped<ListOrdersEndpoint>();
builder.Services.AddScoped<UpdateOrderStatusEndpoint>();
builder.Services.AddScoped<CancelOrderEndpoint>();
builder.Services.AddScoped<ExportOrdersEndpoint>();
builder.Services.AddScoped<UploadOrderDocumentEndpoint>();
builder.Services.AddScoped<OrderEventsEndpoint>();
// ----------------------------------------------------------------------------
// Transport Registration
// ----------------------------------------------------------------------------
// InMemory for development/testing
builder.Services.AddInMemoryTransport();
// TCP transport (commented out for demo - enable when router gateway is available)
// builder.Services.AddTcpTransportClient(options =>
// {
// options.Host = "localhost";
// options.Port = 5100;
// });
// RabbitMQ transport (commented out for demo - enable when RabbitMQ is available)
// builder.Services.AddRabbitMqTransportClient(options =>
// {
// options.HostName = "localhost";
// options.Port = 5672;
// });
var host = builder.Build();
Console.WriteLine("╔══════════════════════════════════════════════════════════════╗");
Console.WriteLine("║ Order Service v2.0.0 - Starting... ║");
Console.WriteLine("╠══════════════════════════════════════════════════════════════╣");
Console.WriteLine("║ Endpoints: ║");
Console.WriteLine("║ POST /orders Create order ║");
Console.WriteLine("║ GET /orders/{id} Get order by ID ║");
Console.WriteLine("║ GET /orders List orders (paginated) ║");
Console.WriteLine("║ PATCH /orders/{id}/status Update order status ║");
Console.WriteLine("║ DELETE /orders/{id} Cancel order ║");
Console.WriteLine("║ GET /orders/export Stream export (CSV) ║");
Console.WriteLine("║ POST /orders/{id}/docs Upload document (streaming) ║");
Console.WriteLine("║ GET /orders/events SSE event stream ║");
Console.WriteLine("╚══════════════════════════════════════════════════════════════╝");
await host.RunAsync();

View File

@@ -0,0 +1,47 @@
# Order Service Microservice Configuration
# This file configures the service runtime behavior
service:
name: order-service
version: 2.0.0
region: us-east-1
description: "Order management microservice with CRUD, streaming, and events"
# Connection to router gateways
routers:
- host: localhost
port: 5100
transport: tcp
priority: 1
- host: localhost
port: 5672
transport: rabbitmq
priority: 2
options:
virtualHost: /
prefetchCount: 10
# Health and lifecycle
health:
heartbeatInterval: 10s
healthCheckPath: /health
# Reconnection behavior
resilience:
initialBackoff: 1s
maxBackoff: 60s
maxRetries: -1 # Infinite retries
# Logging
logging:
level: Information
format: json
includeScopes: true
# Metrics
metrics:
enabled: true
prefix: order_service
labels:
environment: development