Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -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"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
40
src/Router/examples/Examples.Billing.Microservice/Program.cs
Normal file
40
src/Router/examples/Examples.Billing.Microservice/Program.cs
Normal 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();
|
||||
@@ -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"
|
||||
18
src/Router/examples/Examples.Gateway/Examples.Gateway.csproj
Normal file
18
src/Router/examples/Examples.Gateway/Examples.Gateway.csproj
Normal 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>
|
||||
50
src/Router/examples/Examples.Gateway/Program.cs
Normal file
50
src/Router/examples/Examples.Gateway/Program.cs
Normal 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 { }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"Examples.Gateway": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:58839;http://localhost:58840"
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/Router/examples/Examples.Gateway/appsettings.json
Normal file
13
src/Router/examples/Examples.Gateway/appsettings.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"GatewayNode": {
|
||||
"Region": "demo",
|
||||
"NodeId": "gw-demo-01"
|
||||
}
|
||||
}
|
||||
50
src/Router/examples/Examples.Gateway/router.yaml
Normal file
50
src/Router/examples/Examples.Gateway/router.yaml
Normal 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
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
101
src/Router/examples/Examples.MultiTransport.Gateway/Program.cs
Normal file
101
src/Router/examples/Examples.MultiTransport.Gateway/Program.cs
Normal 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 { }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"Examples.MultiTransport.Gateway": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:64342;http://localhost:64343"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
100
src/Router/examples/Examples.NotificationService/Program.cs
Normal file
100
src/Router/examples/Examples.NotificationService/Program.cs
Normal 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();
|
||||
@@ -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
|
||||
@@ -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."
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
111
src/Router/examples/Examples.OrderService/Models/Order.cs
Normal file
111
src/Router/examples/Examples.OrderService/Models/Order.cs
Normal 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; }
|
||||
}
|
||||
117
src/Router/examples/Examples.OrderService/Program.cs
Normal file
117
src/Router/examples/Examples.OrderService/Program.cs
Normal 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();
|
||||
47
src/Router/examples/Examples.OrderService/microservice.yaml
Normal file
47
src/Router/examples/Examples.OrderService/microservice.yaml
Normal 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
|
||||
Reference in New Issue
Block a user