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:
15
util/Seeder/IQuery.cs
Normal file
15
util/Seeder/IQuery.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
11
util/SeederApi/Models/Request/SeedRequestModel.cs
Normal file
11
util/SeederApi/Models/Request/SeedRequestModel.cs
Normal 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; }
|
||||
}
|
||||
7
util/SeederApi/Models/Response/SeedResponseModel.cs
Normal file
7
util/SeederApi/Models/Response/SeedResponseModel.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Bit.SeederApi.Models.Response;
|
||||
|
||||
public class SeedResponseModel
|
||||
{
|
||||
public Guid? SeedId { get; set; }
|
||||
public object? Result { get; set; }
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user