1
0
mirror of https://github.com/bitwarden/server synced 2025-12-31 23:53:17 +00:00

Refactor to CQRS

This commit is contained in:
Hinton
2025-12-19 15:15:31 +01:00
parent d9ddd34fbd
commit 44b107d4eb
18 changed files with 341 additions and 267 deletions

View File

@@ -0,0 +1,36 @@
using Bit.SeederApi.Commands.Interfaces;
namespace Bit.SeederApi.Commands;
public class DestroyBatchScenesCommand(
ILogger<DestroyBatchScenesCommand> logger,
IDestroySceneCommand destroySceneCommand) : IDestroyBatchScenesCommand
{
public async Task DestroyAsync(IEnumerable<string> playIds)
{
var exceptions = new List<Exception>();
var deleteTasks = playIds.Select(async playId =>
{
try
{
await destroySceneCommand.DestroyAsync(playId);
}
catch (Exception ex)
{
lock (exceptions)
{
exceptions.Add(ex);
}
logger.LogError(ex, "Error deleting seeded data: {PlayId}", playId);
}
});
await Task.WhenAll(deleteTasks);
if (exceptions.Count > 0)
{
throw new AggregateException("One or more errors occurred while deleting seeded data", exceptions);
}
}
}

View File

@@ -0,0 +1,57 @@
using Bit.Core.Repositories;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.SeederApi.Commands.Interfaces;
using Bit.SeederApi.Services;
namespace Bit.SeederApi.Commands;
public class DestroySceneCommand(
DatabaseContext databaseContext,
ILogger<DestroySceneCommand> logger,
IUserRepository userRepository,
IPlayDataRepository playDataRepository,
IOrganizationRepository organizationRepository) : IDestroySceneCommand
{
public async Task<object?> DestroyAsync(string playId)
{
// Note, delete cascade will remove PlayData entries
var playData = await playDataRepository.GetByPlayIdAsync(playId);
var userIds = playData.Select(pd => pd.UserId).Distinct().ToList();
var organizationIds = playData.Select(pd => pd.OrganizationId).Distinct().ToList();
// Delete Users before Organizations to respect foreign key constraints
if (userIds.Count > 0)
{
var users = databaseContext.Users.Where(u => userIds.Contains(u.Id));
await userRepository.DeleteManyAsync(users);
}
if (organizationIds.Count > 0)
{
var organizations = databaseContext.Organizations.Where(o => organizationIds.Contains(o.Id));
var aggregateException = new AggregateException();
foreach (var org in organizations)
{
try
{
await organizationRepository.DeleteAsync(org);
}
catch (Exception ex)
{
aggregateException = new AggregateException(aggregateException, ex);
}
}
if (aggregateException.InnerExceptions.Count > 0)
{
throw new SceneExecutionException(
$"One or more errors occurred while deleting organizations for seed ID {playId}",
aggregateException);
}
}
logger.LogInformation("Successfully destroyed seeded data with ID {PlayId}", playId);
return new { PlayId = playId };
}
}

View File

@@ -0,0 +1,14 @@
namespace Bit.SeederApi.Commands.Interfaces;
/// <summary>
/// Command for destroying multiple scenes in parallel.
/// </summary>
public interface IDestroyBatchScenesCommand
{
/// <summary>
/// Destroys multiple scenes by their play IDs in parallel.
/// </summary>
/// <param name="playIds">The list of play IDs to destroy</param>
/// <exception cref="AggregateException">Thrown when one or more scenes fail to destroy</exception>
Task DestroyAsync(IEnumerable<string> playIds);
}

View File

@@ -0,0 +1,15 @@
namespace Bit.SeederApi.Commands.Interfaces;
/// <summary>
/// Command for destroying data created by a single scene.
/// </summary>
public interface IDestroySceneCommand
{
/// <summary>
/// Destroys data created by a scene using the seeded data ID.
/// </summary>
/// <param name="playId">The ID of the seeded data to destroy</param>
/// <returns>The result of the destroy operation</returns>
/// <exception cref="Services.SceneExecutionException">Thrown when there's an error destroying the seeded data</exception>
Task<object?> DestroyAsync(string playId);
}

View File

@@ -1,11 +1,12 @@
using Bit.SeederApi.Models.Request;
using Bit.SeederApi.Execution;
using Bit.SeederApi.Models.Request;
using Bit.SeederApi.Services;
using Microsoft.AspNetCore.Mvc;
namespace Bit.SeederApi.Controllers;
[Route("query")]
public class QueryController(ILogger<QueryController> logger, IQueryService queryService) : Controller
public class QueryController(ILogger<QueryController> logger, IQueryExecutor queryExecutor) : Controller
{
[HttpPost]
public IActionResult Query([FromBody] QueryRequestModel request)
@@ -14,7 +15,7 @@ public class QueryController(ILogger<QueryController> logger, IQueryService quer
try
{
var result = queryService.ExecuteQuery(request.Template, request.Arguments);
var result = queryExecutor.Execute(request.Template, request.Arguments);
return Json(result);
}

View File

@@ -1,11 +1,19 @@
using Bit.SeederApi.Models.Request;
using Bit.SeederApi.Commands.Interfaces;
using Bit.SeederApi.Execution;
using Bit.SeederApi.Models.Request;
using Bit.SeederApi.Queries.Interfaces;
using Bit.SeederApi.Services;
using Microsoft.AspNetCore.Mvc;
namespace Bit.SeederApi.Controllers;
[Route("seed")]
public class SeedController(ILogger<SeedController> logger, ISceneService sceneService) : Controller
public class SeedController(
ILogger<SeedController> logger,
ISceneExecutor sceneExecutor,
IDestroySceneCommand destroySceneCommand,
IDestroyBatchScenesCommand destroyBatchScenesCommand,
IGetAllPlayIdsQuery getAllPlayIdsQuery) : Controller
{
[HttpPost]
public async Task<IActionResult> SeedAsync([FromBody] SeedRequestModel request)
@@ -14,7 +22,7 @@ public class SeedController(ILogger<SeedController> logger, ISceneService sceneS
try
{
var response = await sceneService.ExecuteSceneAsync(request.Template, request.Arguments);
var response = await sceneExecutor.ExecuteAsync(request.Template, request.Arguments);
return Json(response);
}
@@ -36,7 +44,7 @@ public class SeedController(ILogger<SeedController> logger, ISceneService sceneS
try
{
await sceneService.DestroyScenesAsync(playIds);
await destroyBatchScenesCommand.DestroyAsync(playIds);
return Ok(new { Message = "Batch delete completed successfully" });
}
catch (AggregateException ex)
@@ -56,7 +64,7 @@ public class SeedController(ILogger<SeedController> logger, ISceneService sceneS
try
{
var result = await sceneService.DestroySceneAsync(playId);
var result = await destroySceneCommand.DestroyAsync(playId);
return Json(result);
}
@@ -73,11 +81,11 @@ public class SeedController(ILogger<SeedController> logger, ISceneService sceneS
{
logger.LogInformation("Deleting all seeded data");
var playIds = sceneService.GetAllPlayIds();
var playIds = getAllPlayIdsQuery.GetAllPlayIds();
try
{
await sceneService.DestroyScenesAsync(playIds);
await destroyBatchScenesCommand.DestroyAsync(playIds);
return NoContent();
}
catch (AggregateException ex)

View File

@@ -0,0 +1,22 @@
using System.Text.Json;
namespace Bit.SeederApi.Execution;
/// <summary>
/// Executor for dynamically resolving and executing queries by name.
/// This is an infrastructure component that orchestrates query execution,
/// not a domain-level query.
/// </summary>
public interface IQueryExecutor
{
/// <summary>
/// Executes a query with the given query name and arguments.
/// Queries are read-only and do not track entities or create seed IDs.
/// </summary>
/// <param name="queryName">The name of the query (e.g., "EmergencyAccessInviteQuery")</param>
/// <param name="arguments">Optional JSON arguments to pass to the query's Execute method</param>
/// <returns>The result of the query execution</returns>
/// <exception cref="Services.QueryNotFoundException">Thrown when the query is not found</exception>
/// <exception cref="Services.QueryExecutionException">Thrown when there's an error executing the query</exception>
object Execute(string queryName, JsonElement? arguments);
}

View File

@@ -0,0 +1,22 @@
using System.Text.Json;
using Bit.SeederApi.Models.Response;
namespace Bit.SeederApi.Execution;
/// <summary>
/// Executor for dynamically resolving and executing scenes by template name.
/// This is an infrastructure component that orchestrates scene execution,
/// not a domain-level command.
/// </summary>
public interface ISceneExecutor
{
/// <summary>
/// Executes a scene with the given template name and arguments.
/// </summary>
/// <param name="templateName">The name of the scene template (e.g., "SingleUserScene")</param>
/// <param name="arguments">Optional JSON arguments to pass to the scene's Seed method</param>
/// <returns>A scene response model containing the result and mangle map</returns>
/// <exception cref="Services.SceneNotFoundException">Thrown when the scene template is not found</exception>
/// <exception cref="Services.SceneExecutionException">Thrown when there's an error executing the scene</exception>
Task<SceneResponseModel> ExecuteAsync(string templateName, JsonElement? arguments);
}

View File

@@ -0,0 +1,19 @@
using System.Text.Json;
namespace Bit.SeederApi.Execution;
/// <summary>
/// Provides shared JSON serialization configuration for executors.
/// </summary>
internal static class JsonConfiguration
{
/// <summary>
/// Standard JSON serializer options used for deserializing scene and query request models.
/// Uses case-insensitive property matching and camelCase naming policy.
/// </summary>
internal static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
}

View File

@@ -1,20 +1,15 @@
using System.Text.Json;
using Bit.Seeder;
using Bit.SeederApi.Services;
namespace Bit.SeederApi.Services;
namespace Bit.SeederApi.Execution;
public class QueryService(
ILogger<QueryService> logger,
IServiceProvider serviceProvider)
: IQueryService
public class QueryExecutor(
ILogger<QueryExecutor> logger,
IServiceProvider serviceProvider) : IQueryExecutor
{
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public object ExecuteQuery(string queryName, JsonElement? arguments)
public object Execute(string queryName, JsonElement? arguments)
{
try
{
@@ -22,7 +17,6 @@ public class QueryService(
?? throw new QueryNotFoundException(queryName);
var requestType = query.GetRequestType();
var requestModel = DeserializeRequestModel(queryName, requestType, arguments);
var result = query.Execute(requestModel);
@@ -47,7 +41,7 @@ public class QueryService(
try
{
var requestModel = JsonSerializer.Deserialize(arguments.Value.GetRawText(), requestType, _jsonOptions);
var requestModel = JsonSerializer.Deserialize(arguments.Value.GetRawText(), requestType, JsonConfiguration.Options);
if (requestModel == null)
{
throw new QueryExecutionException(

View File

@@ -0,0 +1,78 @@
using System.Text.Json;
using Bit.Seeder;
using Bit.SeederApi.Models.Response;
using Bit.SeederApi.Services;
namespace Bit.SeederApi.Execution;
public class SceneExecutor(
ILogger<SceneExecutor> logger,
IServiceProvider serviceProvider) : ISceneExecutor
{
public async Task<SceneResponseModel> ExecuteAsync(string templateName, JsonElement? arguments)
{
try
{
var scene = serviceProvider.GetKeyedService<IScene>(templateName)
?? throw new SceneNotFoundException(templateName);
var requestType = scene.GetRequestType();
var requestModel = DeserializeRequestModel(templateName, requestType, arguments);
var result = await scene.SeedAsync(requestModel);
logger.LogInformation("Successfully executed scene: {TemplateName}", templateName);
return SceneResponseModel.FromSceneResult(result);
}
catch (Exception ex) when (ex is not SceneNotFoundException and not SceneExecutionException)
{
logger.LogError(ex, "Unexpected error executing scene: {TemplateName}", templateName);
throw new SceneExecutionException(
$"An unexpected error occurred while executing scene '{templateName}'",
ex.InnerException ?? ex);
}
}
private object DeserializeRequestModel(string templateName, Type requestType, JsonElement? arguments)
{
if (arguments == null)
{
return CreateDefaultRequestModel(templateName, requestType);
}
try
{
var requestModel = JsonSerializer.Deserialize(arguments.Value.GetRawText(), requestType, JsonConfiguration.Options);
if (requestModel == null)
{
throw new SceneExecutionException(
$"Failed to deserialize request model for scene '{templateName}'");
}
return requestModel;
}
catch (JsonException ex)
{
throw new SceneExecutionException(
$"Failed to deserialize request model for scene '{templateName}': {ex.Message}", ex);
}
}
private object CreateDefaultRequestModel(string templateName, Type requestType)
{
try
{
var requestModel = Activator.CreateInstance(requestType);
if (requestModel == null)
{
throw new SceneExecutionException(
$"Arguments are required for scene '{templateName}'");
}
return requestModel;
}
catch
{
throw new SceneExecutionException(
$"Arguments are required for scene '{templateName}'");
}
}
}

View File

@@ -1,11 +1,32 @@
using System.Reflection;
using Bit.Seeder;
using Bit.SeederApi.Commands;
using Bit.SeederApi.Commands.Interfaces;
using Bit.SeederApi.Execution;
using Bit.SeederApi.Queries;
using Bit.SeederApi.Queries.Interfaces;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Bit.SeederApi.Extensions;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers SeederApi executors, commands, and queries.
/// </summary>
public static IServiceCollection AddSeederApiServices(this IServiceCollection services)
{
services.AddScoped<ISceneExecutor, SceneExecutor>();
services.AddScoped<IQueryExecutor, QueryExecutor>();
services.AddScoped<IDestroySceneCommand, DestroySceneCommand>();
services.AddScoped<IDestroyBatchScenesCommand, DestroyBatchScenesCommand>();
services.AddScoped<IGetAllPlayIdsQuery, GetAllPlayIdsQuery>();
return services;
}
/// <summary>
/// Dynamically registers all scene types that implement IScene&lt;TRequest&gt; from the Seeder assembly.
/// Scenes are registered as keyed scoped services using their class name as the key.

View File

@@ -0,0 +1,15 @@
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.SeederApi.Queries.Interfaces;
namespace Bit.SeederApi.Queries;
public class GetAllPlayIdsQuery(DatabaseContext databaseContext) : IGetAllPlayIdsQuery
{
public List<string> GetAllPlayIds()
{
return databaseContext.PlayData
.Select(pd => pd.PlayId)
.Distinct()
.ToList();
}
}

View File

@@ -0,0 +1,13 @@
namespace Bit.SeederApi.Queries.Interfaces;
/// <summary>
/// Query for retrieving all play IDs for currently tracked seeded data.
/// </summary>
public interface IGetAllPlayIdsQuery
{
/// <summary>
/// Retrieves all play IDs for currently tracked seeded data.
/// </summary>
/// <returns>A list of play IDs representing active seeded data that can be destroyed.</returns>
List<string> GetAllPlayIds();
}

View File

@@ -1,24 +0,0 @@
using System.Text.Json;
namespace Bit.SeederApi.Services;
/// <summary>
/// Service for executing query operations.
/// </summary>
/// <remarks>
/// The query service provides a mechanism to execute read-only query operations by name with optional JSON arguments.
/// Queries retrieve existing data from the system without modifying state or tracking entities.
/// </remarks>
public interface IQueryService
{
/// <summary>
/// Executes a query with the given query name and arguments.
/// Queries are read-only and do not track entities or create seed IDs.
/// </summary>
/// <param name="queryName">The name of the query (e.g., "EmergencyAccessInviteQuery")</param>
/// <param name="arguments">Optional JSON arguments to pass to the query's Execute method</param>
/// <returns>The result of the query execution</returns>
/// <exception cref="SceneNotFoundException">Thrown when the query is not found</exception>
/// <exception cref="SceneExecutionException">Thrown when there's an error executing the query</exception>
object ExecuteQuery(string queryName, JsonElement? arguments);
}

View File

@@ -1,46 +0,0 @@
using System.Text.Json;
using Bit.SeederApi.Models.Response;
namespace Bit.SeederApi.Services;
/// <summary>
/// Service for executing and managing scene operations.
/// </summary>
/// <remarks>
/// The scene service provides a mechanism to execute scene operations by name with optional JSON arguments.
/// Scenes create and configure test data, track entities for cleanup, and support destruction of seeded data.
/// Each scene execution can be assigned a play ID for tracking and subsequent cleanup operations.
/// </remarks>
public interface ISceneService
{
/// <summary>
/// Executes a scene with the given template name and arguments.
/// </summary>
/// <param name="templateName">The name of the scene template (e.g., "SingleUserScene")</param>
/// <param name="arguments">Optional JSON arguments to pass to the scene's Seed method</param>
/// <returns>A tuple containing the result and optional seed ID for tracked entities</returns>
/// <exception cref="SceneNotFoundException">Thrown when the scene template is not found</exception>
/// <exception cref="SceneExecutionException">Thrown when there's an error executing the scene</exception>
Task<SceneResponseModel> ExecuteSceneAsync(string templateName, JsonElement? arguments);
/// <summary>
/// Destroys data created by a scene using the seeded data ID.
/// </summary>
/// <param name="playId">The ID of the seeded data to destroy</param>
/// <returns>The result of the destroy operation</returns>
/// <exception cref="SceneExecutionException">Thrown when there's an error destroying the seeded data</exception>
Task<object?> DestroySceneAsync(string playId);
/// <summary>
/// Retrieves all play IDs for currently tracked seeded data.
/// </summary>
/// <returns>A list of play IDs representing active seeded data that can be destroyed.</returns>
List<string> GetAllPlayIds();
/// <summary>
/// Destroys multiple scenes by their play IDs.
/// </summary>
/// <param name="playIds">The list of play IDs to destroy</param>
/// <exception cref="AggregateException">Thrown when one or more scenes fail to destroy</exception>
Task DestroyScenesAsync(IEnumerable<string> playIds);
}

View File

@@ -1,171 +0,0 @@
using System.Text.Json;
using Bit.Core.Repositories;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Seeder;
using Bit.SeederApi.Models.Response;
namespace Bit.SeederApi.Services;
public class SceneService(
DatabaseContext databaseContext,
ILogger<SceneService> logger,
IServiceProvider serviceProvider,
IUserRepository userRepository,
IPlayDataRepository playDataRepository,
IOrganizationRepository organizationRepository)
: ISceneService
{
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public List<string> GetAllPlayIds()
{
return databaseContext.PlayData
.Select(pd => pd.PlayId)
.Distinct()
.ToList();
}
public async Task<SceneResponseModel> ExecuteSceneAsync(string templateName, JsonElement? arguments)
{
var result = await ExecuteSceneMethodAsync(templateName, arguments, "Seed");
return SceneResponseModel.FromSceneResult(result);
}
public async Task<object?> DestroySceneAsync(string playId)
{
// Note, delete cascade will remove PlayData entries
var playData = await playDataRepository.GetByPlayIdAsync(playId);
var userIds = playData.Select(pd => pd.UserId).Distinct().ToList();
var organizationIds = playData.Select(pd => pd.OrganizationId).Distinct().ToList();
// Delete Users before Organizations to respect foreign key constraints
if (userIds.Count > 0)
{
var users = databaseContext.Users.Where(u => userIds.Contains(u.Id));
await userRepository.DeleteManyAsync(users);
}
if (organizationIds.Count > 0)
{
var organizations = databaseContext.Organizations.Where(o => organizationIds.Contains(o.Id));
var aggregateException = new AggregateException();
foreach (var org in organizations)
{
try
{
await organizationRepository.DeleteAsync(org);
}
catch (Exception ex)
{
aggregateException = new AggregateException(aggregateException, ex);
}
}
if (aggregateException.InnerExceptions.Count > 0)
{
throw new SceneExecutionException(
$"One or more errors occurred while deleting organizations for seed ID {playId}",
aggregateException);
}
}
logger.LogInformation("Successfully destroyed seeded data with ID {PlayId}",
playId);
return new { PlayId = playId };
}
public async Task DestroyScenesAsync(IEnumerable<string> playIds)
{
var exceptions = new List<Exception>();
var deleteTasks = playIds.Select(async playId =>
{
try
{
await DestroySceneAsync(playId);
}
catch (Exception ex)
{
lock (exceptions)
{
exceptions.Add(ex);
}
logger.LogError(ex, "Error deleting seeded data: {PlayId}", playId);
}
});
await Task.WhenAll(deleteTasks);
if (exceptions.Count > 0)
{
throw new AggregateException("One or more errors occurred while deleting seeded data", exceptions);
}
}
private async Task<SceneResult<object?>> ExecuteSceneMethodAsync(string templateName, JsonElement? arguments, string methodName)
{
try
{
var scene = serviceProvider.GetKeyedService<IScene>(templateName)
?? throw new SceneNotFoundException(templateName);
var requestType = scene.GetRequestType();
// Deserialize the arguments into the request model
object? requestModel;
if (arguments == null)
{
// Try to create an instance with default values
try
{
requestModel = Activator.CreateInstance(requestType);
if (requestModel == null)
{
throw new SceneExecutionException(
$"Arguments are required for scene '{templateName}'");
}
}
catch
{
throw new SceneExecutionException(
$"Arguments are required for scene '{templateName}'");
}
}
else
{
try
{
requestModel = JsonSerializer.Deserialize(arguments.Value.GetRawText(), requestType, _jsonOptions);
if (requestModel == null)
{
throw new SceneExecutionException(
$"Failed to deserialize request model for scene '{templateName}'");
}
}
catch (JsonException ex)
{
throw new SceneExecutionException(
$"Failed to deserialize request model for scene '{templateName}': {ex.Message}", ex);
}
}
var result = await scene.SeedAsync(requestModel);
logger.LogInformation("Successfully executed {MethodName} on scene: {TemplateName}", methodName, templateName);
return result;
}
catch (Exception ex) when (ex is not SceneNotFoundException and not SceneExecutionException)
{
logger.LogError(ex, "Unexpected error executing {MethodName} on scene: {TemplateName}", methodName, templateName);
throw new SceneExecutionException(
$"An unexpected error occurred while executing {methodName} on scene '{templateName}'",
ex.InnerException ?? ex);
}
}
}

View File

@@ -3,7 +3,6 @@ using Bit.Core.Settings;
using Bit.Seeder;
using Bit.Seeder.Factories;
using Bit.SeederApi.Extensions;
using Bit.SeederApi.Services;
using Bit.SharedWeb.Utilities;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -39,8 +38,9 @@ public class Startup
services.AddSingleton<RustSDK.RustSdkService>();
services.AddScoped<UserSeeder>();
services.AddScoped<ISceneService, SceneService>();
services.AddScoped<IQueryService, QueryService>();
services.AddSeederApiServices();
services.AddScoped<MangleId>(_ => new MangleId());
services.AddScenes();
services.AddQueries();