Adds SlpModularCms.Api.Slave for local master/slave dev testing (Unit 1)

Relocates ModuleOrchestrator, ServiceCollectionExtensions, and
ApiPrefixConvention from SlpModularCms.Api into SlpModularCms.Core.Hosting
so a new Master-less SlpModularCms.Api.Slave host project (ports 5285/7222)
can share the same bootstrap code without duplicating it. This lets a
developer run a master instance and a slave instance side by side locally
to test the master/slave connection, without touching the existing
master/slave protocol itself.

Relocates the two orchestrator/convention test files from
Modules.Identity.Tests to Core.Tests, dropping an incidental
ProjectReference to SlpModularCms.Api that existed only for those tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 02:07:35 +02:00
co-authored by Claude Sonnet 5
parent 8072025e0b
commit 274946dbff
36 changed files with 1380 additions and 14 deletions
@@ -1,146 +0,0 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using SlpModularCms.Core.Availability;
using SlpModularCms.Core.Data;
using SlpModularCms.Core.Exceptions;
using SlpModularCms.Core.Identity.Authorization;
using SlpModularCms.Core.Identity.Entities;
using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Core.Identity.Services;
using SlpModularCms.Api.Infrastructure;
using System.Text;
using System.Threading.RateLimiting;
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Api.Extensions;
[ExcludeFromCodeCoverage]
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddCoreInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
// 1. Database
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));
// 2. Identity
services.AddIdentityCore<ApplicationUser>(options =>
{
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = true;
options.Password.RequiredLength = 8;
})
.AddRoles<ApplicationRole>()
.AddRoleManager<RoleManager<ApplicationRole>>()
.AddEntityFrameworkStores<ApplicationDbContext>();
// 3. Auth & Identity Services
var jwtSettings = configuration.GetSection("JwtSettings").Get<JwtSettings>()
?? throw new InvalidOperationException("JwtSettings not found in configuration.");
services.Configure<JwtSettings>(configuration.GetSection("JwtSettings"));
services.Configure<AvailabilityOptions>(configuration.GetSection("Availability"));
services.AddScoped<IAuthService, AuthService>();
services.AddScoped<IInvitationService, InvitationService>();
services.AddScoped<ISetupService, SetupService>();
// 4. Authentication
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtSettings.Issuer,
ValidAudience = jwtSettings.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Secret)),
ClockSkew = TimeSpan.Zero
};
});
// 5. Authorization
services.AddAuthorization(options =>
{
options.AddPolicy("OwnerOnly", policy => policy.Requirements.Add(new HierarchicalRoleRequirement("Owner")));
options.AddPolicy("AdminOnly", policy => policy.Requirements.Add(new HierarchicalRoleRequirement("Administrator")));
options.AddPolicy("UserOnly", policy => policy.Requirements.Add(new HierarchicalRoleRequirement("User")));
});
services.AddSingleton<IAuthorizationHandler, HierarchicalRoleHandler>();
// 6. Exception Handling
services.AddExceptionHandler<GlobalExceptionHandler>();
services.AddProblemDetails();
// 7. Versioning
services.AddApiVersioning(options =>
{
options.ReportApiVersions = true;
});
// 8. Native .NET OpenAPI
services.AddOpenApi();
return services;
}
public static IServiceCollection AddCmsCors(this IServiceCollection services, IConfiguration configuration)
{
var allowedOrigins = configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
services.AddCors(options =>
{
options.AddDefaultPolicy(builder =>
{
builder.WithOrigins(allowedOrigins)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
return services;
}
public static IServiceCollection AddCmsRateLimiting(this IServiceCollection services, IConfiguration configuration)
{
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("login", opt =>
{
var settings = configuration.GetSection("RateLimiting:Login");
opt.PermitLimit = settings.GetValue<int>("PermitLimit", 5);
opt.Window = TimeSpan.FromSeconds(settings.GetValue<int>("WindowSeconds", 60));
opt.QueueLimit = 0;
});
options.AddSlidingWindowLimiter("refresh", opt =>
{
var settings = configuration.GetSection("RateLimiting:Refresh");
opt.PermitLimit = settings.GetValue<int>("PermitLimit", 20);
opt.Window = TimeSpan.FromSeconds(settings.GetValue<int>("WindowSeconds", 60));
opt.SegmentsPerWindow = 4;
opt.QueueLimit = 0;
});
});
return services;
}
}
@@ -1,35 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
namespace SlpModularCms.Api.Infrastructure;
/// <summary>
/// Conventie die automatisch de /api/v1/ prefix toevoegt aan alle controllers.
/// </summary>
public class ApiPrefixConvention : IApplicationModelConvention
{
private readonly AttributeRouteModel _routePrefix;
public ApiPrefixConvention(string prefix)
{
_routePrefix = new AttributeRouteModel(new RouteAttribute(prefix));
}
public void Apply(ApplicationModel application)
{
foreach (var controller in application.Controllers)
{
foreach (var selector in controller.Selectors)
{
if (selector.AttributeRouteModel != null)
{
selector.AttributeRouteModel.Template = _routePrefix.Template + "/" + selector.AttributeRouteModel.Template;
}
else
{
selector.AttributeRouteModel = _routePrefix;
}
}
}
}
}
@@ -1,107 +0,0 @@
using System.Reflection;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using SlpModularCms.Core.Modules;
namespace SlpModularCms.Api.Infrastructure;
/// <summary>
/// Orkestrator die modules ontdekt en hun lifecycle beheert.
/// </summary>
public class ModuleOrchestrator
{
private readonly List<IModule> _modules = new();
private readonly ILogger<ModuleOrchestrator> _logger;
public ModuleOrchestrator(ILogger<ModuleOrchestrator> logger)
{
_logger = logger;
}
public void DiscoverModules()
{
_logger.LogInformation("Start module discovery...");
// Forceer het laden van module assemblies van disk
var path = AppDomain.CurrentDomain.BaseDirectory;
var moduleFiles = Directory.GetFiles(path, "SlpModularCms.Modules.*.dll");
foreach (var file in moduleFiles)
{
try
{
var assemblyName = AssemblyName.GetAssemblyName(file);
if (AppDomain.CurrentDomain.GetAssemblies().All(a => a.FullName != assemblyName.FullName))
{
Assembly.Load(assemblyName);
_logger.LogDebug("Assembly geladen: {AssemblyName}", assemblyName.Name);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Kon assembly niet laden van disk: {FilePath}", file);
}
}
var assemblies = AppDomain.CurrentDomain.GetAssemblies()
.Where(a => a.FullName != null && a.FullName.StartsWith("SlpModularCms.Modules"))
.ToList();
foreach (var assembly in assemblies)
{
var moduleTypes = assembly.GetTypes()
.Where(t => typeof(IModule).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract);
foreach (var type in moduleTypes)
{
try
{
if (Activator.CreateInstance(type) is IModule module)
{
_modules.Add(module);
_logger.LogInformation("Module ontdekt: {ModuleName} v{Version}", module.Name, module.Version);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Fout bij het instantiëren van module type {TypeName}", type.FullName);
}
}
}
_logger.LogInformation("{Count} modules succesvol geladen.", _modules.Count);
}
public void RegisterModuleServices(IServiceCollection services)
{
foreach (var module in _modules)
{
try
{
module.RegisterServices(services);
_logger.LogInformation("Services geregistreerd voor module: {ModuleName}", module.Name);
}
catch (Exception ex)
{
_logger.LogError(ex, "Fout bij het registreren van services voor module {ModuleName}", module.Name);
}
}
}
public void UseModules(IApplicationBuilder app)
{
foreach (var module in _modules)
{
try
{
module.UseModule(app);
_logger.LogInformation("Module geactiveerd in pipeline: {ModuleName}", module.Name);
}
catch (Exception ex)
{
_logger.LogError(ex, "Fout bij het activeren van module {ModuleName} in de pipeline", module.Name);
}
}
}
}
+1 -2
View File
@@ -1,5 +1,4 @@
using SlpModularCms.Api.Extensions;
using SlpModularCms.Api.Infrastructure;
using SlpModularCms.Core.Hosting;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);