1
0
mirror of https://github.com/bitwarden/server synced 2025-12-14 15:23:42 +00:00

Add queries, rename seed to scene

This commit is contained in:
Hinton
2025-10-18 15:55:48 -04:00
parent fd41332e4c
commit 1daf9ad892
13 changed files with 298 additions and 187 deletions

15
util/Seeder/IQuery.cs Normal file
View File

@@ -0,0 +1,15 @@
namespace Bit.Seeder;
public interface IQuery
{
Type GetRequestType();
object Execute(object request);
}
public interface IQuery<TRequest> : IQuery where TRequest : class
{
object Execute(TRequest request);
Type IQuery.GetRequestType() => typeof(TRequest);
object IQuery.Execute(object request) => Execute((TRequest)request);
}

View File

@@ -3,13 +3,13 @@ namespace Bit.Seeder;
public interface IScene
{
Type GetRequestType();
RecipeResult Seed(object request);
SceneResult Seed(object request);
}
public interface IScene<TRequest> : IScene where TRequest : class
{
RecipeResult Seed(TRequest request);
SceneResult Seed(TRequest request);
Type IScene.GetRequestType() => typeof(TRequest);
RecipeResult IScene.Seed(object request) => Seed((TRequest)request);
SceneResult IScene.Seed(object request) => Seed((TRequest)request);
}

View File

@@ -1,18 +1,26 @@
using Bit.Core.Auth.Enums;
using System.ComponentModel.DataAnnotations;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Tokens;
using Bit.Infrastructure.EntityFramework.Repositories;
namespace Bit.Seeder.Recipes;
namespace Bit.Seeder.Queries;
public class EmergencyAccessInviteRecipe(
public class EmergencyAccessInviteQuery(
DatabaseContext db,
IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> dataProtectorTokenizer)
: IQuery<EmergencyAccessInviteQuery.Request>
{
public RecipeResult Seed(string email)
public class Request
{
[Required]
public required string Email { get; set; }
}
public object Execute(Request request)
{
var invites = db.EmergencyAccesses
.Where(ea => ea.Email == email).ToList().Select(ea =>
.Where(ea => ea.Email == request.Email).ToList().Select(ea =>
{
var token = dataProtectorTokenizer.Protect(
new EmergencyAccessInviteTokenable(ea, hoursTillExpiration: 1)
@@ -20,9 +28,6 @@ public class EmergencyAccessInviteRecipe(
return $"/accept-emergency?id={ea.Id}&name=Dummy&email={ea.Email}&token={token}";
});
return new RecipeResult
{
Result = invites,
};
return invites;
}
}

View File

@@ -7,9 +7,8 @@ namespace Bit.Seeder.Recipes;
public class OrganizationWithUsersRecipe(DatabaseContext db)
{
public RecipeResult Seed(string name, int users, string domain)
public Guid Seed(string name, int users, string domain)
{
var mangleId = Guid.NewGuid();
var organization = OrganizationSeeder.CreateEnterprise(name, domain, users);
var user = UserSeeder.CreateUserNoMangle($"admin@{domain}");
var orgUser = organization.CreateOrganizationUser(user);
@@ -33,14 +32,6 @@ public class OrganizationWithUsersRecipe(DatabaseContext db)
db.BulkCopy(additionalUsers);
db.BulkCopy(additionalOrgUsers);
return new RecipeResult
{
Result = organization.Id,
TrackedEntities = new Dictionary<string, List<Guid>>
{
["Organization"] = [organization.Id],
["User"] = [user.Id, .. additionalUsers.Select(u => u.Id)]
}
};
return organization.Id;
}
}

View File

@@ -1,6 +1,6 @@
namespace Bit.Seeder;
public class RecipeResult
public class SceneResult
{
public required object Result { get; init; }
public Dictionary<string, List<Guid>> TrackedEntities { get; init; } = new();

View File

@@ -15,14 +15,14 @@ public class SingleUserScene(DatabaseContext db, UserSeeder userSeeder) : IScene
public bool Premium { get; set; } = false;
}
public RecipeResult Seed(Request request)
public SceneResult Seed(Request request)
{
var user = userSeeder.CreateUser(request.Email, request.EmailVerified, request.Premium);
db.Add(user);
db.SaveChanges();
return new RecipeResult
return new SceneResult
{
Result = userSeeder.GetMangleMap(user, new UserData
{

View File

@@ -1,21 +1,40 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Bit.SeederApi.Models.Requests;
using Bit.SeederApi.Models.Response;
using Bit.SeederApi.Services;
using Microsoft.AspNetCore.Mvc;
namespace Bit.SeederApi.Controllers;
public class SeedRequestModel
namespace Bit.SeederApi.Controllers
{
[Required]
public required string Template { get; set; }
public JsonElement? Arguments { get; set; }
}
[Route("")]
public class SeedController(ILogger<SeedController> logger, IRecipeService recipeService)
[Route("")]
public class SeedController(ILogger<SeedController> logger, IRecipeService recipeService)
: Controller
{
{
[HttpPost("/query")]
public IActionResult Query([FromBody] SeedRequestModel request)
{
logger.LogInformation("Executing query: {Query}", request.Template);
try
{
var result = recipeService.ExecuteQuery(request.Template, request.Arguments);
return Json(new { Result = result });
}
catch (RecipeNotFoundException ex)
{
return NotFound(new { Error = ex.Message });
}
catch (RecipeExecutionException ex)
{
logger.LogError(ex, "Error executing query: {Query}", request.Template);
return BadRequest(new
{
Error = ex.Message,
Details = ex.InnerException?.Message
});
}
}
[HttpPost("/seed")]
public IActionResult Seed([FromBody] SeedRequestModel request)
{
@@ -25,12 +44,10 @@ public class SeedController(ILogger<SeedController> logger, IRecipeService recip
{
var (result, seedId) = recipeService.ExecuteRecipe(request.Template, request.Arguments);
return Ok(new
return Json(new SeedResponseModel
{
Message = "Seed completed successfully",
request.Template,
SeedId = seedId,
Result = result,
SeedId = seedId
});
}
catch (RecipeNotFoundException ex)
@@ -46,15 +63,6 @@ public class SeedController(ILogger<SeedController> logger, IRecipeService recip
Details = ex.InnerException?.Message
});
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected error seeding with template: {Template}", request.Template);
return StatusCode(500, new
{
Error = "An unexpected error occurred while seeding",
Details = ex.Message
});
}
}
[HttpDelete("/seed/batch")]
@@ -103,11 +111,7 @@ public class SeedController(ILogger<SeedController> logger, IRecipeService recip
{
var result = await recipeService.DestroyRecipe(seedId);
return Ok(new
{
Message = "Delete completed successfully",
Result = result
});
return Json(result);
}
catch (RecipeExecutionException ex)
{
@@ -118,15 +122,6 @@ public class SeedController(ILogger<SeedController> logger, IRecipeService recip
Details = ex.InnerException?.Message
});
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected error deleting seeded data: {SeedId}", seedId);
return StatusCode(500, new
{
Error = "An unexpected error occurred while deleting",
Details = ex.Message
});
}
}
@@ -164,9 +159,7 @@ public class SeedController(ILogger<SeedController> logger, IRecipeService recip
Details = aggregateException.InnerExceptions.Select(e => e.Message).ToList()
});
}
return Ok(new
{
Message = "All seeded data deleted successfully"
});
return NoContent();
}
}
}

View File

@@ -21,7 +21,28 @@ public static class ServiceCollectionExtensions
foreach (var sceneType in sceneTypes)
{
services.TryAddScoped(sceneType);
services.TryAddKeyedScoped(typeof(IScene), sceneType.Name, (sp, key) => sp.GetRequiredService(sceneType));
services.TryAddKeyedScoped(typeof(IScene), sceneType.Name, (sp, _) => sp.GetRequiredService(sceneType));
}
return services;
}
/// <summary>
/// Dynamically registers all query types that implement IQuery<TRequest> from the Seeder assembly.
/// Queries are registered as keyed scoped services using their class name as the key.
/// </summary>
public static IServiceCollection AddQueries(this IServiceCollection services)
{
var seederAssembly = Assembly.Load("Seeder");
var queryTypes = seederAssembly.GetTypes()
.Where(t => t is { IsClass: true, IsAbstract: false } &&
t.GetInterfaces().Any(i => i.IsGenericType &&
i.GetGenericTypeDefinition().Name == "IQuery`1"));
foreach (var queryType in queryTypes)
{
services.TryAddScoped(queryType);
services.TryAddKeyedScoped(typeof(IQuery), queryType.Name, (sp, _) => sp.GetRequiredService(queryType));
}
return services;

View File

@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
namespace Bit.SeederApi.Models.Requests;
public class SeedRequestModel
{
[Required]
public required string Template { get; set; }
public JsonElement? Arguments { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace Bit.SeederApi.Models.Response;
public class SeedResponseModel
{
public Guid? SeedId { get; set; }
public object? Result { get; set; }
}

View File

@@ -22,6 +22,7 @@ builder.Services.AddScoped<Bit.Seeder.Factories.UserSeeder>();
builder.Services.AddScoped<IRecipeService, RecipeService>();
builder.Services.AddScoped<MangleId>(_ => new MangleId());
builder.Services.AddScenes();
builder.Services.AddQueries();
var app = builder.Build();

View File

@@ -23,4 +23,15 @@ public interface IRecipeService
/// <exception cref="RecipeExecutionException">Thrown when there's an error destroying the seeded data</exception>
Task<object?> DestroyRecipe(Guid seedId);
List<SeededData> GetAllSeededData();
/// <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="RecipeNotFoundException">Thrown when the query is not found</exception>
/// <exception cref="RecipeExecutionException">Thrown when there's an error executing the query</exception>
object ExecuteQuery(string queryName, JsonElement? arguments);
}

View File

@@ -29,21 +29,16 @@ public class RecipeService(
{
var result = ExecuteRecipeMethod(templateName, arguments, "Seed");
if (result is not RecipeResult recipeResult)
if (result.TrackedEntities.Count == 0)
{
return (Result: result, SeedId: null);
}
if (recipeResult.TrackedEntities.Count == 0)
{
return (Result: recipeResult.Result, SeedId: null);
return (Result: result.Result, SeedId: null);
}
var seededData = new SeededData
{
Id = Guid.NewGuid(),
RecipeName = templateName,
Data = JsonSerializer.Serialize(recipeResult.TrackedEntities),
Data = JsonSerializer.Serialize(result.TrackedEntities),
CreationDate = DateTime.UtcNow
};
@@ -53,7 +48,68 @@ public class RecipeService(
logger.LogInformation("Saved seeded data with ID {SeedId} for scene {RecipeName}",
seededData.Id, templateName);
return (Result: recipeResult.Result, SeedId: seededData.Id);
return (Result: result.Result, SeedId: seededData.Id);
}
public object ExecuteQuery(string queryName, JsonElement? arguments)
{
try
{
var query = serviceProvider.GetKeyedService<IQuery>(queryName)
?? throw new RecipeNotFoundException(queryName);
var requestType = query.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 RecipeExecutionException(
$"Arguments are required for query '{queryName}'");
}
}
catch
{
throw new RecipeExecutionException(
$"Arguments are required for query '{queryName}'");
}
}
else
{
try
{
requestModel = JsonSerializer.Deserialize(arguments.Value.GetRawText(), requestType, _jsonOptions);
if (requestModel == null)
{
throw new RecipeExecutionException(
$"Failed to deserialize request model for query '{queryName}'");
}
}
catch (JsonException ex)
{
throw new RecipeExecutionException(
$"Failed to deserialize request model for query '{queryName}': {ex.Message}", ex);
}
}
var result = query.Execute(requestModel);
logger.LogInformation("Successfully executed query: {QueryName}", queryName);
return result;
}
catch (Exception ex) when (ex is not RecipeNotFoundException and not RecipeExecutionException)
{
logger.LogError(ex, "Unexpected error executing query: {QueryName}", queryName);
throw new RecipeExecutionException(
$"An unexpected error occurred while executing query '{queryName}'",
ex.InnerException ?? ex);
}
}
public async Task<object?> DestroyRecipe(Guid seedId)
@@ -110,7 +166,7 @@ public class RecipeService(
return new { SeedId = seedId, RecipeName = seededData.RecipeName };
}
private RecipeResult? ExecuteRecipeMethod(string templateName, JsonElement? arguments, string methodName)
private SceneResult ExecuteRecipeMethod(string templateName, JsonElement? arguments, string methodName)
{
try
{