Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
- Implemented MigrationCategoryTests to validate migration categorization for startup, release, seed, and data migrations. - Added tests for edge cases, including null, empty, and whitespace migration names. - Created StartupMigrationHostTests to verify the behavior of the migration host with real PostgreSQL instances using Testcontainers. - Included tests for migration execution, schema creation, and handling of pending release migrations. - Added SQL migration files for testing: creating a test table, adding a column, a release migration, and seeding data.
1525 lines
48 KiB
Markdown
1525 lines
48 KiB
Markdown
# Step 27: Reference Example & Migration Skeleton
|
|
|
|
## Overview
|
|
|
|
This step provides a complete reference implementation demonstrating all router features, along with migration tooling and patterns for gradually transitioning existing monolithic services to the Stella Router microservice architecture.
|
|
|
|
## Goals
|
|
|
|
1. Create a fully-functional reference microservice with all features
|
|
2. Provide migration skeleton for existing ASP.NET Core services
|
|
3. Document step-by-step migration patterns
|
|
4. Provide code scaffolding tools for new services
|
|
5. Create compatibility shims for gradual adoption
|
|
|
|
## Reference Architecture
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
│ Reference Example Layout │
|
|
├─────────────────────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ src/ │
|
|
│ └── Examples/ │
|
|
│ └── StellaOps.Router.Examples/ │
|
|
│ ├── ReferenceService/ # Complete reference impl │
|
|
│ │ ├── Handlers/ # Endpoint handlers │
|
|
│ │ ├── Services/ # Business logic │
|
|
│ │ ├── Models/ # Domain models │
|
|
│ │ └── Program.cs # Host configuration │
|
|
│ │ │
|
|
│ ├── MigrationTemplates/ # Migration scaffolds │
|
|
│ │ ├── BasicMigration/ # Minimal migration │
|
|
│ │ ├── DualModeMigration/ # Parallel run mode │
|
|
│ │ └── GradualMigration/ # Incremental endpoint migration │
|
|
│ │ │
|
|
│ └── Scaffolding/ # Code generation tools │
|
|
│ ├── Templates/ # T4/Scriban templates │
|
|
│ └── Generator/ # CLI scaffolding tool │
|
|
│ │
|
|
└─────────────────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
## Reference Service Implementation
|
|
|
|
### Complete Reference Microservice
|
|
|
|
```csharp
|
|
// ReferenceService/Program.cs
|
|
using StellaOps.Router.Microservice;
|
|
using StellaOps.Router.Examples.ReferenceService;
|
|
|
|
var builder = StellaMicroservice.CreateBuilder(args);
|
|
|
|
// Configure all features
|
|
builder.Services.AddSingleton<IUserRepository, InMemoryUserRepository>();
|
|
builder.Services.AddSingleton<IProductCatalog, InMemoryProductCatalog>();
|
|
builder.Services.AddSingleton<IOrderService, OrderService>();
|
|
builder.Services.AddSingleton<INotificationService, NotificationService>();
|
|
|
|
// Add handlers
|
|
builder.AddHandler<UserHandler>();
|
|
builder.AddHandler<ProductHandler>();
|
|
builder.AddHandler<OrderHandler>();
|
|
builder.AddHandler<AdminHandler>();
|
|
builder.AddHandler<WebhookHandler>();
|
|
|
|
// Configure transports
|
|
builder.UseTransport("tcp", options =>
|
|
{
|
|
options.Host = "0.0.0.0";
|
|
options.Port = 9100;
|
|
});
|
|
|
|
// Enable dual exposure for direct HTTP access
|
|
builder.EnableDualExposure(http =>
|
|
{
|
|
http.Port = 8080;
|
|
http.DefaultClaims = new Dictionary<string, object>
|
|
{
|
|
["role"] = "internal-service",
|
|
["permissions"] = new[] { "read", "write" }
|
|
};
|
|
});
|
|
|
|
// Configure resilience
|
|
builder.ConfigureResilience(resilience =>
|
|
{
|
|
resilience.CircuitBreaker.FailureThreshold = 5;
|
|
resilience.CircuitBreaker.RecoveryTimeout = TimeSpan.FromSeconds(30);
|
|
resilience.Retry.MaxAttempts = 3;
|
|
resilience.Retry.BaseDelay = TimeSpan.FromMilliseconds(100);
|
|
});
|
|
|
|
// Configure observability
|
|
builder.ConfigureObservability(obs =>
|
|
{
|
|
obs.EnableMetrics = true;
|
|
obs.EnableTracing = true;
|
|
obs.ServiceName = "reference-service";
|
|
obs.OtlpEndpoint = "http://otel-collector:4317";
|
|
});
|
|
|
|
var host = builder.Build();
|
|
await host.RunAsync();
|
|
```
|
|
|
|
### User Management Handler
|
|
|
|
```csharp
|
|
// ReferenceService/Handlers/UserHandler.cs
|
|
namespace StellaOps.Router.Examples.ReferenceService.Handlers;
|
|
|
|
[StellaEndpoint(ServiceName = "users", Version = "v1")]
|
|
public class UserHandler : EndpointHandler
|
|
{
|
|
private readonly IUserRepository _repository;
|
|
private readonly ILogger<UserHandler> _logger;
|
|
|
|
public UserHandler(IUserRepository repository, ILogger<UserHandler> logger)
|
|
{
|
|
_repository = repository;
|
|
_logger = logger;
|
|
}
|
|
|
|
[StellaRoute("GET", "/users")]
|
|
[StellaAuth(RequiredClaims = ["user:list"])]
|
|
[StellaRateLimit(RequestsPerMinute = 100)]
|
|
public async Task<StellaResponse> ListUsers(
|
|
StellaRequestContext context,
|
|
[FromQuery("page")] int page = 1,
|
|
[FromQuery("size")] int pageSize = 20,
|
|
[FromQuery("search")] string? search = null)
|
|
{
|
|
_logger.LogInformation("Listing users: page={Page}, size={Size}, search={Search}",
|
|
page, pageSize, search);
|
|
|
|
var (users, total) = await _repository.ListAsync(page, pageSize, search);
|
|
|
|
return Ok(new UserListResponse
|
|
{
|
|
Users = users.Select(u => u.ToDto()).ToList(),
|
|
Page = page,
|
|
PageSize = pageSize,
|
|
TotalCount = total,
|
|
TotalPages = (int)Math.Ceiling((double)total / pageSize)
|
|
});
|
|
}
|
|
|
|
[StellaRoute("GET", "/users/{id}")]
|
|
[StellaAuth(RequiredClaims = ["user:read"])]
|
|
[StellaCache(Duration = 60, VaryByPath = true)]
|
|
public async Task<StellaResponse> GetUser(
|
|
StellaRequestContext context,
|
|
[FromPath("id")] Guid id)
|
|
{
|
|
var user = await _repository.GetByIdAsync(id);
|
|
|
|
if (user == null)
|
|
{
|
|
return NotFound(new ErrorResponse("USER_NOT_FOUND", $"User {id} not found"));
|
|
}
|
|
|
|
// Enrich response with claims-based data
|
|
var canViewPrivate = context.HasClaim("user:admin") ||
|
|
context.GetClaim<Guid>("user_id") == id;
|
|
|
|
return Ok(user.ToDto(includePrivate: canViewPrivate));
|
|
}
|
|
|
|
[StellaRoute("POST", "/users")]
|
|
[StellaAuth(RequiredClaims = ["user:create"])]
|
|
[StellaRateLimit(RequestsPerMinute = 10)]
|
|
public async Task<StellaResponse> CreateUser(
|
|
StellaRequestContext context,
|
|
[FromBody] CreateUserRequest request)
|
|
{
|
|
// Validate request
|
|
var validation = ValidateCreateRequest(request);
|
|
if (!validation.IsValid)
|
|
{
|
|
return BadRequest(new ValidationErrorResponse(validation.Errors));
|
|
}
|
|
|
|
// Check for duplicate email
|
|
var existing = await _repository.GetByEmailAsync(request.Email);
|
|
if (existing != null)
|
|
{
|
|
return Conflict(new ErrorResponse("EMAIL_EXISTS", "Email already registered"));
|
|
}
|
|
|
|
var user = new User
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Email = request.Email,
|
|
Name = request.Name,
|
|
Role = request.Role ?? "user",
|
|
CreatedAt = DateTime.UtcNow,
|
|
CreatedBy = context.GetClaim<Guid>("user_id")
|
|
};
|
|
|
|
await _repository.CreateAsync(user);
|
|
|
|
_logger.LogInformation("User created: {UserId} by {CreatedBy}", user.Id, user.CreatedBy);
|
|
|
|
return Created($"/users/{user.Id}", user.ToDto());
|
|
}
|
|
|
|
[StellaRoute("PUT", "/users/{id}")]
|
|
[StellaAuth(RequiredClaims = ["user:update"])]
|
|
public async Task<StellaResponse> UpdateUser(
|
|
StellaRequestContext context,
|
|
[FromPath("id")] Guid id,
|
|
[FromBody] UpdateUserRequest request)
|
|
{
|
|
var user = await _repository.GetByIdAsync(id);
|
|
if (user == null)
|
|
{
|
|
return NotFound(new ErrorResponse("USER_NOT_FOUND", $"User {id} not found"));
|
|
}
|
|
|
|
// Check authorization - users can update themselves, admins can update anyone
|
|
var currentUserId = context.GetClaim<Guid>("user_id");
|
|
var isAdmin = context.HasClaim("user:admin");
|
|
|
|
if (currentUserId != id && !isAdmin)
|
|
{
|
|
return Forbidden(new ErrorResponse("FORBIDDEN", "Cannot update other users"));
|
|
}
|
|
|
|
// Apply updates
|
|
if (request.Name != null) user.Name = request.Name;
|
|
if (request.Email != null) user.Email = request.Email;
|
|
if (request.Role != null && isAdmin) user.Role = request.Role;
|
|
|
|
user.UpdatedAt = DateTime.UtcNow;
|
|
user.UpdatedBy = currentUserId;
|
|
|
|
await _repository.UpdateAsync(user);
|
|
|
|
return Ok(user.ToDto());
|
|
}
|
|
|
|
[StellaRoute("DELETE", "/users/{id}")]
|
|
[StellaAuth(RequiredClaims = ["user:admin"])]
|
|
public async Task<StellaResponse> DeleteUser(
|
|
StellaRequestContext context,
|
|
[FromPath("id")] Guid id)
|
|
{
|
|
var user = await _repository.GetByIdAsync(id);
|
|
if (user == null)
|
|
{
|
|
return NotFound(new ErrorResponse("USER_NOT_FOUND", $"User {id} not found"));
|
|
}
|
|
|
|
// Soft delete
|
|
user.DeletedAt = DateTime.UtcNow;
|
|
user.DeletedBy = context.GetClaim<Guid>("user_id");
|
|
|
|
await _repository.UpdateAsync(user);
|
|
|
|
_logger.LogInformation("User deleted: {UserId} by {DeletedBy}", id, user.DeletedBy);
|
|
|
|
return NoContent();
|
|
}
|
|
|
|
private ValidationResult ValidateCreateRequest(CreateUserRequest request)
|
|
{
|
|
var errors = new List<ValidationError>();
|
|
|
|
if (string.IsNullOrWhiteSpace(request.Email))
|
|
errors.Add(new("email", "Email is required"));
|
|
else if (!IsValidEmail(request.Email))
|
|
errors.Add(new("email", "Invalid email format"));
|
|
|
|
if (string.IsNullOrWhiteSpace(request.Name))
|
|
errors.Add(new("name", "Name is required"));
|
|
else if (request.Name.Length < 2 || request.Name.Length > 100)
|
|
errors.Add(new("name", "Name must be 2-100 characters"));
|
|
|
|
return new ValidationResult(errors);
|
|
}
|
|
|
|
private static bool IsValidEmail(string email) =>
|
|
System.Text.RegularExpressions.Regex.IsMatch(email,
|
|
@"^[^@\s]+@[^@\s]+\.[^@\s]+$");
|
|
}
|
|
```
|
|
|
|
### Product Catalog Handler
|
|
|
|
```csharp
|
|
// ReferenceService/Handlers/ProductHandler.cs
|
|
namespace StellaOps.Router.Examples.ReferenceService.Handlers;
|
|
|
|
[StellaEndpoint(ServiceName = "products", Version = "v1")]
|
|
public class ProductHandler : EndpointHandler
|
|
{
|
|
private readonly IProductCatalog _catalog;
|
|
private readonly ILogger<ProductHandler> _logger;
|
|
|
|
public ProductHandler(IProductCatalog catalog, ILogger<ProductHandler> logger)
|
|
{
|
|
_catalog = catalog;
|
|
_logger = logger;
|
|
}
|
|
|
|
[StellaRoute("GET", "/products")]
|
|
[StellaRateLimit(RequestsPerMinute = 200)]
|
|
[StellaCache(Duration = 300, VaryByQuery = ["category", "brand"])]
|
|
public async Task<StellaResponse> ListProducts(
|
|
StellaRequestContext context,
|
|
[FromQuery("category")] string? category = null,
|
|
[FromQuery("brand")] string? brand = null,
|
|
[FromQuery("minPrice")] decimal? minPrice = null,
|
|
[FromQuery("maxPrice")] decimal? maxPrice = null,
|
|
[FromQuery("page")] int page = 1,
|
|
[FromQuery("size")] int pageSize = 20,
|
|
[FromQuery("sort")] string sort = "name")
|
|
{
|
|
var filter = new ProductFilter
|
|
{
|
|
Category = category,
|
|
Brand = brand,
|
|
MinPrice = minPrice,
|
|
MaxPrice = maxPrice
|
|
};
|
|
|
|
var sortOptions = ParseSortOptions(sort);
|
|
var (products, total) = await _catalog.SearchAsync(filter, sortOptions, page, pageSize);
|
|
|
|
return Ok(new ProductListResponse
|
|
{
|
|
Products = products.Select(p => p.ToDto()).ToList(),
|
|
Page = page,
|
|
PageSize = pageSize,
|
|
TotalCount = total,
|
|
Facets = await _catalog.GetFacetsAsync(filter)
|
|
});
|
|
}
|
|
|
|
[StellaRoute("GET", "/products/{id}")]
|
|
[StellaCache(Duration = 600, VaryByPath = true)]
|
|
public async Task<StellaResponse> GetProduct(
|
|
StellaRequestContext context,
|
|
[FromPath("id")] string id)
|
|
{
|
|
var product = await _catalog.GetByIdAsync(id);
|
|
|
|
if (product == null)
|
|
{
|
|
return NotFound(new ErrorResponse("PRODUCT_NOT_FOUND", $"Product {id} not found"));
|
|
}
|
|
|
|
// Track view for analytics (fire-and-forget)
|
|
_ = _catalog.RecordViewAsync(id, context.CorrelationId);
|
|
|
|
return Ok(product.ToDetailDto());
|
|
}
|
|
|
|
[StellaRoute("POST", "/products")]
|
|
[StellaAuth(RequiredClaims = ["product:create"])]
|
|
[StellaRateLimit(RequestsPerMinute = 30)]
|
|
public async Task<StellaResponse> CreateProduct(
|
|
StellaRequestContext context,
|
|
[FromBody] CreateProductRequest request)
|
|
{
|
|
var product = new Product
|
|
{
|
|
Id = GenerateProductId(request.Category),
|
|
Name = request.Name,
|
|
Description = request.Description,
|
|
Category = request.Category,
|
|
Brand = request.Brand,
|
|
Price = request.Price,
|
|
Sku = request.Sku,
|
|
Inventory = request.InitialInventory,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
await _catalog.CreateAsync(product);
|
|
|
|
return Created($"/products/{product.Id}", product.ToDto());
|
|
}
|
|
|
|
[StellaRoute("PUT", "/products/{id}/inventory")]
|
|
[StellaAuth(RequiredClaims = ["product:inventory"])]
|
|
public async Task<StellaResponse> UpdateInventory(
|
|
StellaRequestContext context,
|
|
[FromPath("id")] string id,
|
|
[FromBody] UpdateInventoryRequest request)
|
|
{
|
|
var product = await _catalog.GetByIdAsync(id);
|
|
if (product == null)
|
|
{
|
|
return NotFound(new ErrorResponse("PRODUCT_NOT_FOUND", $"Product {id} not found"));
|
|
}
|
|
|
|
var newQuantity = request.Operation switch
|
|
{
|
|
"set" => request.Quantity,
|
|
"add" => product.Inventory + request.Quantity,
|
|
"subtract" => product.Inventory - request.Quantity,
|
|
_ => throw new ArgumentException($"Invalid operation: {request.Operation}")
|
|
};
|
|
|
|
if (newQuantity < 0)
|
|
{
|
|
return BadRequest(new ErrorResponse("INSUFFICIENT_INVENTORY",
|
|
$"Cannot reduce inventory below 0. Current: {product.Inventory}"));
|
|
}
|
|
|
|
product.Inventory = newQuantity;
|
|
product.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _catalog.UpdateAsync(product);
|
|
|
|
return Ok(new { ProductId = id, NewInventory = newQuantity });
|
|
}
|
|
|
|
private static string GenerateProductId(string category)
|
|
{
|
|
var prefix = category.ToUpperInvariant()[..3];
|
|
var suffix = Guid.NewGuid().ToString("N")[..8].ToUpperInvariant();
|
|
return $"{prefix}-{suffix}";
|
|
}
|
|
|
|
private static SortOptions ParseSortOptions(string sort)
|
|
{
|
|
var descending = sort.StartsWith('-');
|
|
var field = descending ? sort[1..] : sort;
|
|
return new SortOptions(field, descending);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Order Processing Handler
|
|
|
|
```csharp
|
|
// ReferenceService/Handlers/OrderHandler.cs
|
|
namespace StellaOps.Router.Examples.ReferenceService.Handlers;
|
|
|
|
[StellaEndpoint(ServiceName = "orders", Version = "v1")]
|
|
public class OrderHandler : EndpointHandler
|
|
{
|
|
private readonly IOrderService _orderService;
|
|
private readonly IProductCatalog _catalog;
|
|
private readonly INotificationService _notifications;
|
|
private readonly ILogger<OrderHandler> _logger;
|
|
|
|
public OrderHandler(
|
|
IOrderService orderService,
|
|
IProductCatalog catalog,
|
|
INotificationService notifications,
|
|
ILogger<OrderHandler> logger)
|
|
{
|
|
_orderService = orderService;
|
|
_catalog = catalog;
|
|
_notifications = notifications;
|
|
_logger = logger;
|
|
}
|
|
|
|
[StellaRoute("POST", "/orders")]
|
|
[StellaAuth(RequiredClaims = ["order:create"])]
|
|
[StellaRateLimit(RequestsPerMinute = 20, Key = "user_id")]
|
|
public async Task<StellaResponse> CreateOrder(
|
|
StellaRequestContext context,
|
|
[FromBody] CreateOrderRequest request)
|
|
{
|
|
var userId = context.GetClaim<Guid>("user_id");
|
|
|
|
// Validate products and calculate total
|
|
var lineItems = new List<OrderLineItem>();
|
|
decimal total = 0;
|
|
|
|
foreach (var item in request.Items)
|
|
{
|
|
var product = await _catalog.GetByIdAsync(item.ProductId);
|
|
if (product == null)
|
|
{
|
|
return BadRequest(new ErrorResponse("INVALID_PRODUCT",
|
|
$"Product {item.ProductId} not found"));
|
|
}
|
|
|
|
if (product.Inventory < item.Quantity)
|
|
{
|
|
return BadRequest(new ErrorResponse("INSUFFICIENT_INVENTORY",
|
|
$"Product {item.ProductId} has only {product.Inventory} units available"));
|
|
}
|
|
|
|
lineItems.Add(new OrderLineItem
|
|
{
|
|
ProductId = product.Id,
|
|
ProductName = product.Name,
|
|
UnitPrice = product.Price,
|
|
Quantity = item.Quantity,
|
|
Subtotal = product.Price * item.Quantity
|
|
});
|
|
|
|
total += product.Price * item.Quantity;
|
|
}
|
|
|
|
// Create order
|
|
var order = new Order
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
UserId = userId,
|
|
Items = lineItems,
|
|
Subtotal = total,
|
|
Tax = total * 0.08m, // 8% tax
|
|
Total = total * 1.08m,
|
|
Status = OrderStatus.Pending,
|
|
ShippingAddress = request.ShippingAddress,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
// Reserve inventory
|
|
foreach (var item in request.Items)
|
|
{
|
|
await _catalog.ReserveInventoryAsync(item.ProductId, item.Quantity, order.Id);
|
|
}
|
|
|
|
await _orderService.CreateAsync(order);
|
|
|
|
// Send notification (fire-and-forget)
|
|
_ = _notifications.SendOrderConfirmationAsync(userId, order);
|
|
|
|
_logger.LogInformation("Order created: {OrderId} for user {UserId}, total: {Total}",
|
|
order.Id, userId, order.Total);
|
|
|
|
return Created($"/orders/{order.Id}", order.ToDto());
|
|
}
|
|
|
|
[StellaRoute("GET", "/orders")]
|
|
[StellaAuth(RequiredClaims = ["order:list"])]
|
|
public async Task<StellaResponse> ListOrders(
|
|
StellaRequestContext context,
|
|
[FromQuery("status")] string? status = null,
|
|
[FromQuery("page")] int page = 1,
|
|
[FromQuery("size")] int pageSize = 20)
|
|
{
|
|
var userId = context.GetClaim<Guid>("user_id");
|
|
var isAdmin = context.HasClaim("order:admin");
|
|
|
|
// Non-admins can only see their own orders
|
|
Guid? filterUserId = isAdmin ? null : userId;
|
|
OrderStatus? filterStatus = status != null ? Enum.Parse<OrderStatus>(status, true) : null;
|
|
|
|
var (orders, total) = await _orderService.ListAsync(filterUserId, filterStatus, page, pageSize);
|
|
|
|
return Ok(new OrderListResponse
|
|
{
|
|
Orders = orders.Select(o => o.ToSummaryDto()).ToList(),
|
|
Page = page,
|
|
PageSize = pageSize,
|
|
TotalCount = total
|
|
});
|
|
}
|
|
|
|
[StellaRoute("GET", "/orders/{id}")]
|
|
[StellaAuth(RequiredClaims = ["order:read"])]
|
|
public async Task<StellaResponse> GetOrder(
|
|
StellaRequestContext context,
|
|
[FromPath("id")] Guid id)
|
|
{
|
|
var order = await _orderService.GetByIdAsync(id);
|
|
if (order == null)
|
|
{
|
|
return NotFound(new ErrorResponse("ORDER_NOT_FOUND", $"Order {id} not found"));
|
|
}
|
|
|
|
// Check authorization
|
|
var userId = context.GetClaim<Guid>("user_id");
|
|
var isAdmin = context.HasClaim("order:admin");
|
|
|
|
if (order.UserId != userId && !isAdmin)
|
|
{
|
|
return Forbidden(new ErrorResponse("FORBIDDEN", "Cannot access this order"));
|
|
}
|
|
|
|
return Ok(order.ToDetailDto());
|
|
}
|
|
|
|
[StellaRoute("POST", "/orders/{id}/cancel")]
|
|
[StellaAuth(RequiredClaims = ["order:cancel"])]
|
|
public async Task<StellaResponse> CancelOrder(
|
|
StellaRequestContext context,
|
|
[FromPath("id")] Guid id,
|
|
[FromBody] CancelOrderRequest request)
|
|
{
|
|
var order = await _orderService.GetByIdAsync(id);
|
|
if (order == null)
|
|
{
|
|
return NotFound(new ErrorResponse("ORDER_NOT_FOUND", $"Order {id} not found"));
|
|
}
|
|
|
|
// Check authorization
|
|
var userId = context.GetClaim<Guid>("user_id");
|
|
var isAdmin = context.HasClaim("order:admin");
|
|
|
|
if (order.UserId != userId && !isAdmin)
|
|
{
|
|
return Forbidden(new ErrorResponse("FORBIDDEN", "Cannot cancel this order"));
|
|
}
|
|
|
|
// Check if cancellable
|
|
if (order.Status is OrderStatus.Shipped or OrderStatus.Delivered or OrderStatus.Cancelled)
|
|
{
|
|
return BadRequest(new ErrorResponse("NOT_CANCELLABLE",
|
|
$"Order with status {order.Status} cannot be cancelled"));
|
|
}
|
|
|
|
// Release inventory
|
|
foreach (var item in order.Items)
|
|
{
|
|
await _catalog.ReleaseInventoryAsync(item.ProductId, item.Quantity, order.Id);
|
|
}
|
|
|
|
order.Status = OrderStatus.Cancelled;
|
|
order.CancelledAt = DateTime.UtcNow;
|
|
order.CancellationReason = request.Reason;
|
|
|
|
await _orderService.UpdateAsync(order);
|
|
|
|
// Send notification
|
|
_ = _notifications.SendOrderCancellationAsync(order.UserId, order);
|
|
|
|
return Ok(order.ToDto());
|
|
}
|
|
}
|
|
```
|
|
|
|
### Admin Handler
|
|
|
|
```csharp
|
|
// ReferenceService/Handlers/AdminHandler.cs
|
|
namespace StellaOps.Router.Examples.ReferenceService.Handlers;
|
|
|
|
[StellaEndpoint(ServiceName = "admin", Version = "v1")]
|
|
[StellaAuth(RequiredClaims = ["admin:access"])]
|
|
public class AdminHandler : EndpointHandler
|
|
{
|
|
private readonly IUserRepository _users;
|
|
private readonly IOrderService _orders;
|
|
private readonly IProductCatalog _products;
|
|
|
|
public AdminHandler(
|
|
IUserRepository users,
|
|
IOrderService orders,
|
|
IProductCatalog products)
|
|
{
|
|
_users = users;
|
|
_orders = orders;
|
|
_products = products;
|
|
}
|
|
|
|
[StellaRoute("GET", "/admin/stats")]
|
|
[StellaCache(Duration = 60)]
|
|
public async Task<StellaResponse> GetStats(StellaRequestContext context)
|
|
{
|
|
var userCount = await _users.CountAsync();
|
|
var orderStats = await _orders.GetStatsAsync();
|
|
var productStats = await _products.GetStatsAsync();
|
|
|
|
return Ok(new AdminStatsResponse
|
|
{
|
|
Users = new UserStats
|
|
{
|
|
Total = userCount,
|
|
ActiveLast30Days = await _users.CountActiveAsync(TimeSpan.FromDays(30))
|
|
},
|
|
Orders = orderStats,
|
|
Products = productStats,
|
|
GeneratedAt = DateTime.UtcNow
|
|
});
|
|
}
|
|
|
|
[StellaRoute("POST", "/admin/users/{id}/impersonate")]
|
|
[StellaAuth(RequiredClaims = ["admin:impersonate"])]
|
|
public async Task<StellaResponse> ImpersonateUser(
|
|
StellaRequestContext context,
|
|
[FromPath("id")] Guid id)
|
|
{
|
|
var user = await _users.GetByIdAsync(id);
|
|
if (user == null)
|
|
{
|
|
return NotFound(new ErrorResponse("USER_NOT_FOUND", $"User {id} not found"));
|
|
}
|
|
|
|
var adminId = context.GetClaim<Guid>("user_id");
|
|
|
|
// Generate impersonation token (normally would call Authority)
|
|
var impersonationClaims = new Dictionary<string, object>
|
|
{
|
|
["user_id"] = id,
|
|
["impersonated_by"] = adminId,
|
|
["impersonation_started"] = DateTime.UtcNow.ToString("O")
|
|
};
|
|
|
|
return Ok(new ImpersonationResponse
|
|
{
|
|
UserId = id,
|
|
ImpersonatedBy = adminId,
|
|
Message = "Impersonation token generated - integrate with Authority for actual token"
|
|
});
|
|
}
|
|
|
|
[StellaRoute("GET", "/admin/audit-log")]
|
|
[StellaAuth(RequiredClaims = ["admin:audit"])]
|
|
public async Task<StellaResponse> GetAuditLog(
|
|
StellaRequestContext context,
|
|
[FromQuery("startDate")] DateTime? startDate = null,
|
|
[FromQuery("endDate")] DateTime? endDate = null,
|
|
[FromQuery("action")] string? action = null,
|
|
[FromQuery("userId")] Guid? userId = null,
|
|
[FromQuery("page")] int page = 1,
|
|
[FromQuery("size")] int pageSize = 50)
|
|
{
|
|
// This would integrate with actual audit logging system
|
|
return Ok(new AuditLogResponse
|
|
{
|
|
Entries = new List<AuditEntry>(),
|
|
Page = page,
|
|
PageSize = pageSize,
|
|
TotalCount = 0
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
## Migration Templates
|
|
|
|
### Basic Migration Template
|
|
|
|
```csharp
|
|
// MigrationTemplates/BasicMigration/Program.cs
|
|
// Minimal migration from ASP.NET Core controller to Stella Router
|
|
|
|
/*
|
|
* BEFORE: Traditional ASP.NET Core Controller
|
|
*
|
|
* [ApiController]
|
|
* [Route("api/[controller]")]
|
|
* public class UsersController : ControllerBase
|
|
* {
|
|
* [HttpGet("{id}")]
|
|
* public async Task<IActionResult> GetUser(Guid id)
|
|
* {
|
|
* var user = await _repository.GetByIdAsync(id);
|
|
* return user == null ? NotFound() : Ok(user);
|
|
* }
|
|
* }
|
|
*/
|
|
|
|
// AFTER: Stella Router Handler
|
|
using StellaOps.Router.Microservice;
|
|
|
|
var builder = StellaMicroservice.CreateBuilder(args);
|
|
builder.AddHandler<MigratedUserHandler>();
|
|
builder.UseTransport("tcp", o => o.Port = 9100);
|
|
|
|
var host = builder.Build();
|
|
await host.RunAsync();
|
|
|
|
[StellaEndpoint(ServiceName = "users")]
|
|
public class MigratedUserHandler : EndpointHandler
|
|
{
|
|
private readonly IUserRepository _repository;
|
|
|
|
public MigratedUserHandler(IUserRepository repository)
|
|
{
|
|
_repository = repository;
|
|
}
|
|
|
|
// Route pattern: GET /api/users/{id} → GET /users/{id}
|
|
[StellaRoute("GET", "/users/{id}")]
|
|
public async Task<StellaResponse> GetUser(
|
|
StellaRequestContext context,
|
|
[FromPath("id")] Guid id)
|
|
{
|
|
var user = await _repository.GetByIdAsync(id);
|
|
return user == null ? NotFound() : Ok(user);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Dual Mode Migration Template
|
|
|
|
```csharp
|
|
// MigrationTemplates/DualModeMigration/Program.cs
|
|
// Run both old ASP.NET Core endpoints and new Stella endpoints simultaneously
|
|
|
|
using StellaOps.Router.Microservice;
|
|
using StellaOps.Router.Compatibility;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
// Configure both ASP.NET Core and Stella
|
|
builder.Services.AddControllers();
|
|
builder.Services.AddStellaMicroservice(stella =>
|
|
{
|
|
stella.AddHandler<NewFeatureHandler>();
|
|
stella.UseTransport("tcp", o => o.Port = 9100);
|
|
});
|
|
|
|
var app = builder.Build();
|
|
|
|
// Mount old controllers at legacy path
|
|
app.MapControllers(); // /api/v1/users - old implementation
|
|
|
|
// Mount Stella handlers
|
|
app.UseStellaMicroservice(); // /users - new implementation
|
|
|
|
// Compatibility middleware: route based on header or path version
|
|
app.UseMiddleware<VersionRoutingMiddleware>();
|
|
|
|
await app.RunAsync();
|
|
|
|
// Version routing middleware for gradual migration
|
|
public class VersionRoutingMiddleware
|
|
{
|
|
private readonly RequestDelegate _next;
|
|
|
|
public VersionRoutingMiddleware(RequestDelegate next)
|
|
{
|
|
_next = next;
|
|
}
|
|
|
|
public async Task InvokeAsync(HttpContext context)
|
|
{
|
|
// Check for explicit version header
|
|
if (context.Request.Headers.TryGetValue("X-API-Version", out var version))
|
|
{
|
|
if (version == "2")
|
|
{
|
|
// Rewrite path to Stella endpoints
|
|
context.Request.Path = context.Request.Path.Value?
|
|
.Replace("/api/v1/", "/")
|
|
.Replace("/api/", "/");
|
|
}
|
|
}
|
|
|
|
await _next(context);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Gradual Migration Template
|
|
|
|
```csharp
|
|
// MigrationTemplates/GradualMigration/MigrationConfig.cs
|
|
// Configure which endpoints use old vs new implementation
|
|
|
|
namespace StellaOps.Router.Migration;
|
|
|
|
public class MigrationConfig
|
|
{
|
|
public Dictionary<string, EndpointMigrationState> Endpoints { get; set; } = new();
|
|
|
|
public enum EndpointMigrationState
|
|
{
|
|
Legacy, // Use old ASP.NET Core implementation
|
|
Shadow, // Call both, compare results, return legacy
|
|
Canary, // Route X% to new implementation
|
|
Migrated // Use new Stella implementation
|
|
}
|
|
}
|
|
|
|
// MigrationTemplates/GradualMigration/MigrationMiddleware.cs
|
|
public class MigrationMiddleware
|
|
{
|
|
private readonly RequestDelegate _next;
|
|
private readonly MigrationConfig _config;
|
|
private readonly IStellaDispatcher _stellaDispatcher;
|
|
private readonly ILogger<MigrationMiddleware> _logger;
|
|
|
|
public MigrationMiddleware(
|
|
RequestDelegate next,
|
|
MigrationConfig config,
|
|
IStellaDispatcher stellaDispatcher,
|
|
ILogger<MigrationMiddleware> logger)
|
|
{
|
|
_next = next;
|
|
_config = config;
|
|
_stellaDispatcher = stellaDispatcher;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task InvokeAsync(HttpContext context)
|
|
{
|
|
var path = context.Request.Path.Value ?? "";
|
|
var state = GetMigrationState(path);
|
|
|
|
switch (state)
|
|
{
|
|
case MigrationConfig.EndpointMigrationState.Legacy:
|
|
await _next(context);
|
|
break;
|
|
|
|
case MigrationConfig.EndpointMigrationState.Shadow:
|
|
await ExecuteShadowMode(context);
|
|
break;
|
|
|
|
case MigrationConfig.EndpointMigrationState.Canary:
|
|
await ExecuteCanaryMode(context);
|
|
break;
|
|
|
|
case MigrationConfig.EndpointMigrationState.Migrated:
|
|
await ExecuteMigratedMode(context);
|
|
break;
|
|
}
|
|
}
|
|
|
|
private async Task ExecuteShadowMode(HttpContext context)
|
|
{
|
|
// Execute both implementations
|
|
var legacyTask = ExecuteLegacyAsync(context);
|
|
var stellaTask = ExecuteStellaAsync(context);
|
|
|
|
await Task.WhenAll(legacyTask, stellaTask);
|
|
|
|
var legacyResult = await legacyTask;
|
|
var stellaResult = await stellaTask;
|
|
|
|
// Compare results for monitoring
|
|
if (!ResultsMatch(legacyResult, stellaResult))
|
|
{
|
|
_logger.LogWarning(
|
|
"Shadow mode mismatch for {Path}: Legacy={LegacyStatus}, Stella={StellaStatus}",
|
|
context.Request.Path,
|
|
legacyResult.StatusCode,
|
|
stellaResult.StatusCode);
|
|
}
|
|
|
|
// Return legacy result
|
|
await WriteResponse(context, legacyResult);
|
|
}
|
|
|
|
private async Task ExecuteCanaryMode(HttpContext context)
|
|
{
|
|
var path = context.Request.Path.Value ?? "";
|
|
var canaryPercentage = GetCanaryPercentage(path);
|
|
|
|
// Determine which implementation to use based on request hash
|
|
var requestHash = ComputeRequestHash(context);
|
|
var useStella = (requestHash % 100) < canaryPercentage;
|
|
|
|
if (useStella)
|
|
{
|
|
await ExecuteMigratedMode(context);
|
|
}
|
|
else
|
|
{
|
|
await _next(context);
|
|
}
|
|
}
|
|
|
|
private async Task ExecuteMigratedMode(HttpContext context)
|
|
{
|
|
var stellaResult = await ExecuteStellaAsync(context);
|
|
await WriteResponse(context, stellaResult);
|
|
}
|
|
|
|
private MigrationConfig.EndpointMigrationState GetMigrationState(string path)
|
|
{
|
|
foreach (var (pattern, state) in _config.Endpoints)
|
|
{
|
|
if (PathMatchesPattern(path, pattern))
|
|
{
|
|
return state;
|
|
}
|
|
}
|
|
return MigrationConfig.EndpointMigrationState.Legacy;
|
|
}
|
|
|
|
private async Task<MigrationResult> ExecuteLegacyAsync(HttpContext context)
|
|
{
|
|
// Capture legacy response
|
|
var originalBody = context.Response.Body;
|
|
using var memoryStream = new MemoryStream();
|
|
context.Response.Body = memoryStream;
|
|
|
|
try
|
|
{
|
|
await _next(context);
|
|
|
|
memoryStream.Position = 0;
|
|
var body = await new StreamReader(memoryStream).ReadToEndAsync();
|
|
|
|
return new MigrationResult
|
|
{
|
|
StatusCode = context.Response.StatusCode,
|
|
Body = body,
|
|
Headers = context.Response.Headers.ToDictionary(h => h.Key, h => h.Value.ToString())
|
|
};
|
|
}
|
|
finally
|
|
{
|
|
context.Response.Body = originalBody;
|
|
}
|
|
}
|
|
|
|
private async Task<MigrationResult> ExecuteStellaAsync(HttpContext context)
|
|
{
|
|
var request = await ConvertToStellaRequest(context);
|
|
var response = await _stellaDispatcher.DispatchAsync(request);
|
|
|
|
return new MigrationResult
|
|
{
|
|
StatusCode = response.StatusCode,
|
|
Body = response.GetBodyAsString(),
|
|
Headers = response.Headers.ToDictionary()
|
|
};
|
|
}
|
|
|
|
private static bool PathMatchesPattern(string path, string pattern)
|
|
{
|
|
// Simple glob matching
|
|
var regex = "^" + Regex.Escape(pattern)
|
|
.Replace("\\*\\*", ".*")
|
|
.Replace("\\*", "[^/]*") + "$";
|
|
return Regex.IsMatch(path, regex);
|
|
}
|
|
|
|
private static bool ResultsMatch(MigrationResult legacy, MigrationResult stella)
|
|
{
|
|
return legacy.StatusCode == stella.StatusCode;
|
|
// Could add body comparison with normalization
|
|
}
|
|
|
|
private static int ComputeRequestHash(HttpContext context)
|
|
{
|
|
var key = context.Request.Path + context.Request.QueryString;
|
|
return Math.Abs(key.GetHashCode()) % 100;
|
|
}
|
|
|
|
private int GetCanaryPercentage(string path) => 10; // Default 10%
|
|
|
|
private static async Task WriteResponse(HttpContext context, MigrationResult result)
|
|
{
|
|
context.Response.StatusCode = result.StatusCode;
|
|
foreach (var (key, value) in result.Headers)
|
|
{
|
|
context.Response.Headers[key] = value;
|
|
}
|
|
await context.Response.WriteAsync(result.Body);
|
|
}
|
|
|
|
private static async Task<StellaRequest> ConvertToStellaRequest(HttpContext context)
|
|
{
|
|
using var reader = new StreamReader(context.Request.Body);
|
|
var body = await reader.ReadToEndAsync();
|
|
|
|
return new StellaRequest
|
|
{
|
|
Method = context.Request.Method,
|
|
Path = context.Request.Path.Value ?? "/",
|
|
Headers = context.Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString()),
|
|
Body = Encoding.UTF8.GetBytes(body)
|
|
};
|
|
}
|
|
}
|
|
|
|
public class MigrationResult
|
|
{
|
|
public int StatusCode { get; set; }
|
|
public string Body { get; set; } = "";
|
|
public Dictionary<string, string> Headers { get; set; } = new();
|
|
}
|
|
```
|
|
|
|
## Code Scaffolding Tool
|
|
|
|
### CLI Scaffolding Generator
|
|
|
|
```csharp
|
|
// Scaffolding/Generator/StellaScaffold.cs
|
|
namespace StellaOps.Router.Scaffolding;
|
|
|
|
public class StellaScaffold
|
|
{
|
|
private readonly ITemplateEngine _templateEngine;
|
|
|
|
public StellaScaffold(ITemplateEngine templateEngine)
|
|
{
|
|
_templateEngine = templateEngine;
|
|
}
|
|
|
|
public async Task GenerateServiceAsync(ServiceScaffoldOptions options)
|
|
{
|
|
var context = new ScaffoldContext
|
|
{
|
|
ServiceName = options.ServiceName,
|
|
Namespace = options.Namespace ?? $"StellaOps.{options.ServiceName}",
|
|
OutputPath = options.OutputPath,
|
|
Features = options.Features
|
|
};
|
|
|
|
// Generate project structure
|
|
await GenerateProjectFileAsync(context);
|
|
await GenerateProgramAsync(context);
|
|
|
|
// Generate handlers
|
|
foreach (var handler in options.Handlers)
|
|
{
|
|
await GenerateHandlerAsync(context, handler);
|
|
}
|
|
|
|
// Generate models
|
|
foreach (var model in options.Models)
|
|
{
|
|
await GenerateModelAsync(context, model);
|
|
}
|
|
|
|
// Generate configuration
|
|
await GenerateConfigurationAsync(context);
|
|
|
|
// Generate tests if requested
|
|
if (options.Features.Contains("tests"))
|
|
{
|
|
await GenerateTestsAsync(context, options);
|
|
}
|
|
}
|
|
|
|
private async Task GenerateProjectFileAsync(ScaffoldContext context)
|
|
{
|
|
var template = await _templateEngine.LoadAsync("project.csproj.scriban");
|
|
var content = await template.RenderAsync(context);
|
|
|
|
var path = Path.Combine(context.OutputPath, $"{context.ServiceName}.csproj");
|
|
await File.WriteAllTextAsync(path, content);
|
|
}
|
|
|
|
private async Task GenerateProgramAsync(ScaffoldContext context)
|
|
{
|
|
var template = await _templateEngine.LoadAsync("Program.cs.scriban");
|
|
var content = await template.RenderAsync(context);
|
|
|
|
var path = Path.Combine(context.OutputPath, "Program.cs");
|
|
await File.WriteAllTextAsync(path, content);
|
|
}
|
|
|
|
private async Task GenerateHandlerAsync(ScaffoldContext context, HandlerDefinition handler)
|
|
{
|
|
var template = await _templateEngine.LoadAsync("Handler.cs.scriban");
|
|
var handlerContext = new HandlerScaffoldContext
|
|
{
|
|
Base = context,
|
|
Handler = handler
|
|
};
|
|
|
|
var content = await template.RenderAsync(handlerContext);
|
|
|
|
var dir = Path.Combine(context.OutputPath, "Handlers");
|
|
Directory.CreateDirectory(dir);
|
|
|
|
var path = Path.Combine(dir, $"{handler.Name}Handler.cs");
|
|
await File.WriteAllTextAsync(path, content);
|
|
}
|
|
|
|
private async Task GenerateModelAsync(ScaffoldContext context, ModelDefinition model)
|
|
{
|
|
var template = await _templateEngine.LoadAsync("Model.cs.scriban");
|
|
var modelContext = new ModelScaffoldContext
|
|
{
|
|
Base = context,
|
|
Model = model
|
|
};
|
|
|
|
var content = await template.RenderAsync(modelContext);
|
|
|
|
var dir = Path.Combine(context.OutputPath, "Models");
|
|
Directory.CreateDirectory(dir);
|
|
|
|
var path = Path.Combine(dir, $"{model.Name}.cs");
|
|
await File.WriteAllTextAsync(path, content);
|
|
}
|
|
|
|
private async Task GenerateConfigurationAsync(ScaffoldContext context)
|
|
{
|
|
// Generate appsettings.json
|
|
var appSettings = new
|
|
{
|
|
Stella = new
|
|
{
|
|
Transport = new
|
|
{
|
|
Type = "tcp",
|
|
Host = "0.0.0.0",
|
|
Port = 9100
|
|
},
|
|
DualExposure = new
|
|
{
|
|
Enabled = true,
|
|
Port = 8080
|
|
}
|
|
},
|
|
Logging = new
|
|
{
|
|
LogLevel = new { Default = "Information" }
|
|
}
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(appSettings, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true
|
|
});
|
|
|
|
var path = Path.Combine(context.OutputPath, "appsettings.json");
|
|
await File.WriteAllTextAsync(path, json);
|
|
}
|
|
|
|
private async Task GenerateTestsAsync(ScaffoldContext context, ServiceScaffoldOptions options)
|
|
{
|
|
var testContext = new TestScaffoldContext
|
|
{
|
|
Base = context,
|
|
Handlers = options.Handlers
|
|
};
|
|
|
|
var testDir = Path.Combine(context.OutputPath, "..", $"{context.ServiceName}.Tests");
|
|
Directory.CreateDirectory(testDir);
|
|
|
|
// Generate test project
|
|
var testProjectTemplate = await _templateEngine.LoadAsync("test-project.csproj.scriban");
|
|
var testProjectContent = await testProjectTemplate.RenderAsync(testContext);
|
|
await File.WriteAllTextAsync(
|
|
Path.Combine(testDir, $"{context.ServiceName}.Tests.csproj"),
|
|
testProjectContent);
|
|
|
|
// Generate test fixtures
|
|
var fixtureTemplate = await _templateEngine.LoadAsync("TestFixture.cs.scriban");
|
|
var fixtureContent = await fixtureTemplate.RenderAsync(testContext);
|
|
await File.WriteAllTextAsync(
|
|
Path.Combine(testDir, "TestFixture.cs"),
|
|
fixtureContent);
|
|
|
|
// Generate handler tests
|
|
foreach (var handler in options.Handlers)
|
|
{
|
|
var handlerTestTemplate = await _templateEngine.LoadAsync("HandlerTests.cs.scriban");
|
|
var handlerTestContext = new { Base = testContext, Handler = handler };
|
|
var handlerTestContent = await handlerTestTemplate.RenderAsync(handlerTestContext);
|
|
await File.WriteAllTextAsync(
|
|
Path.Combine(testDir, $"{handler.Name}HandlerTests.cs"),
|
|
handlerTestContent);
|
|
}
|
|
}
|
|
}
|
|
|
|
public class ServiceScaffoldOptions
|
|
{
|
|
public string ServiceName { get; set; } = "";
|
|
public string? Namespace { get; set; }
|
|
public string OutputPath { get; set; } = "";
|
|
public List<string> Features { get; set; } = new();
|
|
public List<HandlerDefinition> Handlers { get; set; } = new();
|
|
public List<ModelDefinition> Models { get; set; } = new();
|
|
}
|
|
|
|
public class HandlerDefinition
|
|
{
|
|
public string Name { get; set; } = "";
|
|
public string ResourceName { get; set; } = "";
|
|
public List<EndpointDefinition> Endpoints { get; set; } = new();
|
|
}
|
|
|
|
public class EndpointDefinition
|
|
{
|
|
public string Method { get; set; } = "GET";
|
|
public string Path { get; set; } = "";
|
|
public string Name { get; set; } = "";
|
|
public List<string> RequiredClaims { get; set; } = new();
|
|
public int? RateLimit { get; set; }
|
|
}
|
|
|
|
public class ModelDefinition
|
|
{
|
|
public string Name { get; set; } = "";
|
|
public List<PropertyDefinition> Properties { get; set; } = new();
|
|
}
|
|
|
|
public class PropertyDefinition
|
|
{
|
|
public string Name { get; set; } = "";
|
|
public string Type { get; set; } = "string";
|
|
public bool Required { get; set; }
|
|
}
|
|
```
|
|
|
|
### Scriban Templates
|
|
|
|
```
|
|
{{# Templates/Handler.cs.scriban }}
|
|
// Auto-generated by StellaScaffold
|
|
using StellaOps.Router.Microservice;
|
|
|
|
namespace {{ base.namespace }}.Handlers;
|
|
|
|
[StellaEndpoint(ServiceName = "{{ handler.resource_name }}")]
|
|
public class {{ handler.name }}Handler : EndpointHandler
|
|
{
|
|
private readonly ILogger<{{ handler.name }}Handler> _logger;
|
|
|
|
public {{ handler.name }}Handler(ILogger<{{ handler.name }}Handler> logger)
|
|
{
|
|
_logger = logger;
|
|
}
|
|
|
|
{{~ for endpoint in handler.endpoints ~}}
|
|
[StellaRoute("{{ endpoint.method }}", "{{ endpoint.path }}")]
|
|
{{~ if endpoint.required_claims.size > 0 ~}}
|
|
[StellaAuth(RequiredClaims = [{{ endpoint.required_claims | array.join '", "' | prepend '"' | append '"' }}])]
|
|
{{~ end ~}}
|
|
{{~ if endpoint.rate_limit ~}}
|
|
[StellaRateLimit(RequestsPerMinute = {{ endpoint.rate_limit }})]
|
|
{{~ end ~}}
|
|
public async Task<StellaResponse> {{ endpoint.name }}(StellaRequestContext context)
|
|
{
|
|
_logger.LogInformation("{{ endpoint.name }} called");
|
|
|
|
// TODO: Implement {{ endpoint.name }}
|
|
throw new NotImplementedException();
|
|
}
|
|
|
|
{{~ end ~}}
|
|
}
|
|
```
|
|
|
|
## CLI Commands
|
|
|
|
```bash
|
|
# Generate a new microservice
|
|
stella-scaffold new MyService \
|
|
--namespace StellaOps.MyService \
|
|
--handler User:users \
|
|
--handler Product:products \
|
|
--features tests,docker
|
|
|
|
# Generate a single handler
|
|
stella-scaffold handler Order \
|
|
--resource orders \
|
|
--endpoint "GET /orders" \
|
|
--endpoint "POST /orders" \
|
|
--endpoint "GET /orders/{id}"
|
|
|
|
# Generate migration shim
|
|
stella-scaffold migrate \
|
|
--source Controllers/UsersController.cs \
|
|
--output Handlers/UserHandler.cs
|
|
```
|
|
|
|
## YAML Configuration
|
|
|
|
```yaml
|
|
# config/scaffold-config.yaml
|
|
scaffolding:
|
|
defaultNamespace: "StellaOps.Services"
|
|
defaultFeatures:
|
|
- tests
|
|
- docker
|
|
- healthchecks
|
|
|
|
templates:
|
|
path: "./templates"
|
|
customTemplates:
|
|
- name: "enterprise-handler"
|
|
file: "custom/EnterpriseHandler.cs.scriban"
|
|
|
|
conventions:
|
|
handlerSuffix: "Handler"
|
|
modelSuffix: ""
|
|
testSuffix: "Tests"
|
|
|
|
migration:
|
|
defaultMode: "gradual"
|
|
shadowModeEnabled: true
|
|
canaryPercentage: 10
|
|
|
|
pathMappings:
|
|
"/api/v1/": "/"
|
|
"/api/": "/"
|
|
|
|
endpoints:
|
|
"/users/**": "shadow"
|
|
"/products/**": "canary"
|
|
"/orders/**": "legacy"
|
|
```
|
|
|
|
## Testing
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task ReferenceService_HandlesCompleteWorkflow()
|
|
{
|
|
// Arrange
|
|
using var fixture = new ReferenceServiceFixture();
|
|
await fixture.StartAsync();
|
|
|
|
var client = fixture.CreateClient();
|
|
|
|
// Create user
|
|
var createUserResponse = await client.PostAsync("/users", new CreateUserRequest
|
|
{
|
|
Email = "test@example.com",
|
|
Name = "Test User"
|
|
});
|
|
|
|
Assert.Equal(201, createUserResponse.StatusCode);
|
|
var user = await createUserResponse.ReadAsAsync<UserDto>();
|
|
|
|
// Create product
|
|
var createProductResponse = await client.PostAsync("/products", new CreateProductRequest
|
|
{
|
|
Name = "Test Product",
|
|
Price = 99.99m,
|
|
Category = "TEST",
|
|
InitialInventory = 100
|
|
});
|
|
|
|
Assert.Equal(201, createProductResponse.StatusCode);
|
|
var product = await createProductResponse.ReadAsAsync<ProductDto>();
|
|
|
|
// Create order
|
|
var createOrderResponse = await client.PostAsync("/orders", new CreateOrderRequest
|
|
{
|
|
Items = new[]
|
|
{
|
|
new OrderItemRequest { ProductId = product.Id, Quantity = 2 }
|
|
},
|
|
ShippingAddress = new AddressDto { Street = "123 Main St", City = "Test City" }
|
|
});
|
|
|
|
Assert.Equal(201, createOrderResponse.StatusCode);
|
|
var order = await createOrderResponse.ReadAsAsync<OrderDto>();
|
|
|
|
Assert.Equal(user.Id, order.UserId);
|
|
Assert.Equal(199.98m * 1.08m, order.Total, 2); // 2 * 99.99 * 1.08 tax
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MigrationMiddleware_ShadowMode_ExecutesBothImplementations()
|
|
{
|
|
// Arrange
|
|
var config = new MigrationConfig
|
|
{
|
|
Endpoints = new Dictionary<string, MigrationConfig.EndpointMigrationState>
|
|
{
|
|
["/users/**"] = MigrationConfig.EndpointMigrationState.Shadow
|
|
}
|
|
};
|
|
|
|
using var fixture = new MigrationTestFixture(config);
|
|
await fixture.StartAsync();
|
|
|
|
// Act
|
|
var response = await fixture.Client.GetAsync("/users/123");
|
|
|
|
// Assert
|
|
Assert.Equal(200, response.StatusCode);
|
|
Assert.True(fixture.LegacyWasCalled);
|
|
Assert.True(fixture.StellaWasCalled);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Scaffolding_GeneratesValidService()
|
|
{
|
|
// Arrange
|
|
var options = new ServiceScaffoldOptions
|
|
{
|
|
ServiceName = "TestService",
|
|
Namespace = "Test.Service",
|
|
OutputPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()),
|
|
Features = new List<string> { "tests" },
|
|
Handlers = new List<HandlerDefinition>
|
|
{
|
|
new()
|
|
{
|
|
Name = "User",
|
|
ResourceName = "users",
|
|
Endpoints = new List<EndpointDefinition>
|
|
{
|
|
new() { Method = "GET", Path = "/users", Name = "ListUsers" },
|
|
new() { Method = "GET", Path = "/users/{id}", Name = "GetUser" }
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
var scaffold = new StellaScaffold(new ScribanTemplateEngine());
|
|
|
|
try
|
|
{
|
|
// Act
|
|
await scaffold.GenerateServiceAsync(options);
|
|
|
|
// Assert
|
|
Assert.True(File.Exists(Path.Combine(options.OutputPath, "TestService.csproj")));
|
|
Assert.True(File.Exists(Path.Combine(options.OutputPath, "Program.cs")));
|
|
Assert.True(File.Exists(Path.Combine(options.OutputPath, "Handlers", "UserHandler.cs")));
|
|
|
|
// Verify generated code compiles (optional)
|
|
var compilation = await CompileGeneratedCodeAsync(options.OutputPath);
|
|
Assert.Empty(compilation.Errors);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(options.OutputPath, recursive: true);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Deliverables
|
|
|
|
| Artifact | Path |
|
|
|----------|------|
|
|
| Reference Service | `src/Examples/StellaOps.Router.Examples/ReferenceService/` |
|
|
| Migration Templates | `src/Examples/StellaOps.Router.Examples/MigrationTemplates/` |
|
|
| Scaffolding Tool | `src/Examples/StellaOps.Router.Examples/Scaffolding/` |
|
|
| Scriban Templates | `src/Examples/StellaOps.Router.Examples/Scaffolding/Templates/` |
|
|
| Tests | `src/Examples/StellaOps.Router.Examples.Tests/` |
|
|
|
|
## Next Step
|
|
|
|
[Step 28: Agent Process Guidelines →](28-Step.md)
|