From 9e274d288696368a953d01fa0a1b2a2f3cfdb8cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:43:02 +0000 Subject: [PATCH 01/38] Initial plan From 205766e8c1a1412aadb3c0f746d1c30c05c42c3a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:54:58 +0000 Subject: [PATCH 02/38] feat: replace todo app with FlagdSchemaAbstraction port in wasm-tryout Co-authored-by: DanielHabenicht <13590797+DanielHabenicht@users.noreply.github.com> --- wasm-tryout/Backend/EnvironmentEntry.cs | 25 + wasm-tryout/Backend/FlagEntry.cs | 40 + .../Backend/{TodoItem.cs => FlagFile.cs} | 14 +- wasm-tryout/Backend/FlagdController.cs | 137 ++++ wasm-tryout/Backend/FlagdDbContext.cs | 52 ++ wasm-tryout/Backend/FlagdJsonContext.cs | 36 + wasm-tryout/Backend/FlagdService.cs | 575 ++++++++++++++ wasm-tryout/Backend/TodoDbContext.cs | 33 - wasm-tryout/Backend/TodoJsonContext.cs | 12 - wasm-tryout/Backend/TodoService.cs | 193 ----- wasm-tryout/index.html | 736 ++++++++++++------ 11 files changed, 1354 insertions(+), 499 deletions(-) create mode 100644 wasm-tryout/Backend/EnvironmentEntry.cs create mode 100644 wasm-tryout/Backend/FlagEntry.cs rename wasm-tryout/Backend/{TodoItem.cs => FlagFile.cs} (64%) create mode 100644 wasm-tryout/Backend/FlagdController.cs create mode 100644 wasm-tryout/Backend/FlagdDbContext.cs create mode 100644 wasm-tryout/Backend/FlagdJsonContext.cs create mode 100644 wasm-tryout/Backend/FlagdService.cs delete mode 100644 wasm-tryout/Backend/TodoDbContext.cs delete mode 100644 wasm-tryout/Backend/TodoJsonContext.cs delete mode 100644 wasm-tryout/Backend/TodoService.cs diff --git a/wasm-tryout/Backend/EnvironmentEntry.cs b/wasm-tryout/Backend/EnvironmentEntry.cs new file mode 100644 index 0000000..681ce09 --- /dev/null +++ b/wasm-tryout/Backend/EnvironmentEntry.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Backend; + +[Table("environment_entries")] +public class EnvironmentEntry +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + [Column("id")] + public long Id { get; set; } + + [Column("file_id")] + public long FileId { get; set; } + + /// Lowercase environment name, e.g. "production" + [Required] + [Column("name")] + public string Name { get; set; } = string.Empty; + + /// JSON array of alias strings + [Column("aliases_json")] + public string AliasesJson { get; set; } = "[]"; +} diff --git a/wasm-tryout/Backend/FlagEntry.cs b/wasm-tryout/Backend/FlagEntry.cs new file mode 100644 index 0000000..02f2de3 --- /dev/null +++ b/wasm-tryout/Backend/FlagEntry.cs @@ -0,0 +1,40 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Backend; + +[Table("flag_entries")] +public class FlagEntry +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + [Column("id")] + public long Id { get; set; } + + [Column("file_id")] + public long FileId { get; set; } + + [Required] + [Column("flag_key")] + public string FlagKey { get; set; } = string.Empty; + + /// Flag type: boolean | string | number | object + [Column("type")] + public string Type { get; set; } = "boolean"; + + /// Flag state: ENABLED | DISABLED + [Column("state")] + public string State { get; set; } = "ENABLED"; + + /// Raw JSON text of the flag's default value + [Column("value_json")] + public string? ValueJson { get; set; } + + /// Raw JSON text of the flag's metadata object + [Column("metadata_json")] + public string? MetadataJson { get; set; } + + /// Raw JSON text of the flag's targeting rule + [Column("targeting_json")] + public string? TargetingJson { get; set; } +} diff --git a/wasm-tryout/Backend/TodoItem.cs b/wasm-tryout/Backend/FlagFile.cs similarity index 64% rename from wasm-tryout/Backend/TodoItem.cs rename to wasm-tryout/Backend/FlagFile.cs index da47d89..9a07d33 100644 --- a/wasm-tryout/Backend/TodoItem.cs +++ b/wasm-tryout/Backend/FlagFile.cs @@ -3,8 +3,8 @@ namespace Backend; -[Table("todos")] -public class TodoItem +[Table("flag_files")] +public class FlagFile { [Key] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] @@ -12,12 +12,12 @@ public class TodoItem public long Id { get; set; } [Required] - [Column("title")] - public string Title { get; set; } = string.Empty; - - [Column("completed")] - public bool Completed { get; set; } + [Column("name")] + public string Name { get; set; } = string.Empty; [Column("created_at")] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [Column("metadata_json")] + public string? MetadataJson { get; set; } } diff --git a/wasm-tryout/Backend/FlagdController.cs b/wasm-tryout/Backend/FlagdController.cs new file mode 100644 index 0000000..a4ce30a --- /dev/null +++ b/wasm-tryout/Backend/FlagdController.cs @@ -0,0 +1,137 @@ +using System.Text.Json; +using Bootsharp; + +namespace Backend; + +/// +/// REST-style controller that routes incoming requests to methods. +/// +/// Can be called from JavaScript via WASM: +/// Backend.FlagdController.handleRequest('GET', '/api/files', null) +/// or used to simulate a REST API from any other entry-point. +/// +/// Route table: +/// +/// Method + PathAction +/// GET /api/filesList all files +/// POST /api/filesCreate file – body: {"name":"…"} +/// PUT /api/files/{id}Rename file – body: {"name":"…"} +/// DELETE /api/files/{id}Delete file and all its data +/// GET /api/files/{id}/flagsList flags +/// POST /api/files/{id}/flagsUpsert flag – body: FlagEntryDto JSON +/// DELETE /api/files/{id}/flags/{key}Delete flag +/// GET /api/files/{id}/environmentsList environments +/// POST /api/files/{id}/environmentsUpsert environment – body: EnvironmentEntryDto JSON +/// DELETE /api/files/{id}/environments/{name}Delete environment +/// GET /api/files/{id}/schemaExport schema as JSON +/// POST /api/files/{id}/schemaImport schema – body: FlagdSchema JSON +/// +/// +public static class FlagdController +{ + [JSInvokable] + public static string HandleRequest(string method, string path, string? body) + { + try + { + var segments = path.Trim('/').Split('/'); + + // Must start with /api/files + if (segments.Length < 2 || segments[0] != "api" || segments[1] != "files") + return Error($"Route not found: {method} {path}"); + + // /api/files + if (segments.Length == 2) + { + return method.ToUpperInvariant() switch + { + "GET" => FlagdService.GetFiles(), + "POST" => FlagdService.CreateFile(ExtractString(body, "name")), + _ => Error("Method not allowed"), + }; + } + + if (!long.TryParse(segments[2], out var fileId)) + return Error("Invalid file ID"); + + // /api/files/{id} + if (segments.Length == 3) + { + return method.ToUpperInvariant() switch + { + "PUT" => FlagdService.RenameFile(fileId, ExtractString(body, "name")), + "DELETE" => FlagdService.DeleteFile(fileId), + _ => Error("Method not allowed"), + }; + } + + var resource = segments[3]; + + // /api/files/{id}/flags[/{key}] + if (resource == "flags") + { + if (segments.Length == 4) + { + return method.ToUpperInvariant() switch + { + "GET" => FlagdService.GetFlags(fileId), + "POST" or "PUT" => FlagdService.UpsertFlag(fileId, body ?? "{}"), + _ => Error("Method not allowed"), + }; + } + + if (segments.Length == 5 && method.Equals("DELETE", StringComparison.OrdinalIgnoreCase)) + return FlagdService.DeleteFlag(fileId, Uri.UnescapeDataString(segments[4])); + } + + // /api/files/{id}/environments[/{name}] + if (resource == "environments") + { + if (segments.Length == 4) + { + return method.ToUpperInvariant() switch + { + "GET" => FlagdService.GetEnvironments(fileId), + "POST" or "PUT" => FlagdService.UpsertEnvironment(fileId, body ?? "{}"), + _ => Error("Method not allowed"), + }; + } + + if (segments.Length == 5 && method.Equals("DELETE", StringComparison.OrdinalIgnoreCase)) + return FlagdService.DeleteEnvironment(fileId, Uri.UnescapeDataString(segments[4])); + } + + // /api/files/{id}/schema + if (resource == "schema" && segments.Length == 4) + { + return method.ToUpperInvariant() switch + { + "GET" => FlagdService.ExportSchema(fileId), + "POST" => FlagdService.ImportSchema(fileId, body ?? "{}"), + _ => Error("Method not allowed"), + }; + } + + return Error($"Route not found: {method} {path}"); + } + catch (Exception ex) + { + return Error(ex.Message); + } + } + + private static string Error(string message) => $"Error: {message}"; + + private static string ExtractString(string? json, string key) + { + if (string.IsNullOrEmpty(json)) return string.Empty; + try + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty(key, out var prop)) + return prop.GetString() ?? string.Empty; + } + catch { /* ignore */ } + return string.Empty; + } +} diff --git a/wasm-tryout/Backend/FlagdDbContext.cs b/wasm-tryout/Backend/FlagdDbContext.cs new file mode 100644 index 0000000..52960e8 --- /dev/null +++ b/wasm-tryout/Backend/FlagdDbContext.cs @@ -0,0 +1,52 @@ +using Microsoft.EntityFrameworkCore; + +namespace Backend; + +public class FlagdDbContext : DbContext +{ + private readonly string _connectionString; + + public DbSet FlagFiles => Set(); + public DbSet FlagEntries => Set(); + public DbSet EnvironmentEntries => Set(); + + public FlagdDbContext(string connectionString) + { + _connectionString = connectionString; + } + + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + options.UseSqlite(_connectionString); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("flag_files"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.Name).IsRequired(); + entity.Property(e => e.CreatedAt).HasDefaultValueSql("datetime('now')"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("flag_entries"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.FlagKey).IsRequired(); + entity.HasIndex(e => new { e.FileId, e.FlagKey }).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("environment_entries"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.Name).IsRequired(); + entity.HasIndex(e => new { e.FileId, e.Name }).IsUnique(); + }); + } +} diff --git a/wasm-tryout/Backend/FlagdJsonContext.cs b/wasm-tryout/Backend/FlagdJsonContext.cs new file mode 100644 index 0000000..3758513 --- /dev/null +++ b/wasm-tryout/Backend/FlagdJsonContext.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; + +namespace Backend; + +/// DTO for a flag file (schema document). +public record FlagFileDto(long Id, string Name, DateTime CreatedAt, string? MetadataJson = null); + +/// +/// DTO for a single flag entry. +/// ValueJson, MetadataJson, and TargetingJson carry raw JSON text +/// so that arbitrary flag values and targeting rules survive the WASM boundary without +/// further reflection-based serialisation. +/// +public record FlagEntryDto( + string Key, + string Type, + string State, + string? ValueJson = null, + string? MetadataJson = null, + string? TargetingJson = null, + string? PreviousKey = null); + +/// DTO for an environment definition. +public record EnvironmentEntryDto(string Name, string DisplayName, string[] Aliases); + +[JsonSerializable(typeof(string[]))] +[JsonSerializable(typeof(FlagFileDto))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(FlagEntryDto))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(EnvironmentEntryDto))] +[JsonSerializable(typeof(List))] +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +public partial class FlagdJsonContext : JsonSerializerContext; diff --git a/wasm-tryout/Backend/FlagdService.cs b/wasm-tryout/Backend/FlagdService.cs new file mode 100644 index 0000000..6a808db --- /dev/null +++ b/wasm-tryout/Backend/FlagdService.cs @@ -0,0 +1,575 @@ +using System.Text; +using System.Text.Json; +using Bootsharp; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Backend; + +/// +/// SQLite-backed flagd schema service exposed to JS via Bootsharp. +/// +/// This is a C# port of the TypeScript FlagdSchemaAbstraction class that adds: +/// +/// Multi-file support – each flag configuration is stored as a row. +/// Persistent storage – flags and environments live in an EF-Core / SQLite in-memory DB. +/// [JSInvokable] surface – every public method is callable from JavaScript via WASM. +/// +/// +public static class FlagdService +{ + private const string EnvironmentEvaluatorPrefix = "is"; + private const string EnvironmentVarName = "environment"; + private const string BooleanOnVariant = "on"; + private const string DefaultVariant = "default"; + + private static SqliteConnection? _keepAliveConnection; + private static readonly FlagdJsonContext JsonCtx = FlagdJsonContext.Default; + private const string ConnectionString = "Data Source=FlagdDb;Mode=Memory;Cache=Shared"; + + private static FlagdDbContext CreateContext() => new(ConnectionString); + + // ─── Database lifecycle ──────────────────────────────────────────────── + + [JSInvokable] + public static string InitDatabase() + { + try + { + _keepAliveConnection = new SqliteConnection(ConnectionString); + _keepAliveConnection.Open(); + using var db = CreateContext(); + db.Database.EnsureCreated(); + return "Database initialized successfully."; + } + catch (Exception ex) + { + return $"Error: {ex.Message}"; + } + } + + /// Restore the database from raw SQLite bytes previously exported via . + [JSInvokable] + public static string ImportDatabase(byte[] data) + { + try + { + _keepAliveConnection = new SqliteConnection(ConnectionString); + _keepAliveConnection.Open(); + using (var db = CreateContext()) + { + db.Database.EnsureCreated(); + } + + var tempPath = Path.GetTempFileName(); + File.WriteAllBytes(tempPath, data); + using var source = new SqliteConnection($"Data Source={tempPath}"); + source.Open(); + source.BackupDatabase(_keepAliveConnection); + source.Close(); + try { File.Delete(tempPath); } catch { /* best effort */ } + + return "Database imported successfully."; + } + catch (Exception ex) + { + return $"Error: {ex.Message}"; + } + } + + /// Export the in-memory database as raw bytes for IndexedDB persistence. + [JSInvokable] + public static byte[] ExportDatabase() + { + if (_keepAliveConnection is null) return []; + + var tempPath = Path.GetTempFileName(); + try + { + using var dest = new SqliteConnection($"Data Source={tempPath}"); + dest.Open(); + _keepAliveConnection.BackupDatabase(dest); + dest.Close(); + return File.ReadAllBytes(tempPath); + } + finally + { + try { File.Delete(tempPath); } catch { /* best effort */ } + } + } + + // ─── File management ────────────────────────────────────────────────── + + [JSInvokable] + public static string CreateFile(string name) + { + try + { + using var db = CreateContext(); + var file = new FlagFile { Name = name }; + db.FlagFiles.Add(file); + db.SaveChanges(); + return JsonSerializer.Serialize(ToDto(file), JsonCtx.FlagFileDto); + } + catch (Exception ex) { return $"Error: {ex.Message}"; } + } + + [JSInvokable] + public static string GetFiles() + { + try + { + using var db = CreateContext(); + var dtos = db.FlagFiles.OrderBy(f => f.Id).ToList().Select(ToDto).ToList(); + return JsonSerializer.Serialize(dtos, JsonCtx.ListFlagFileDto); + } + catch (Exception ex) { return $"Error: {ex.Message}"; } + } + + [JSInvokable] + public static string RenameFile(long id, string name) + { + try + { + using var db = CreateContext(); + var file = db.FlagFiles.Find(id); + if (file is null) return $"Error: File {id} not found"; + file.Name = name; + db.SaveChanges(); + return JsonSerializer.Serialize(ToDto(file), JsonCtx.FlagFileDto); + } + catch (Exception ex) { return $"Error: {ex.Message}"; } + } + + [JSInvokable] + public static string DeleteFile(long id) + { + try + { + using var db = CreateContext(); + var file = db.FlagFiles.Find(id); + if (file is null) return $"Error: File {id} not found"; + db.FlagEntries.RemoveRange(db.FlagEntries.Where(f => f.FileId == id)); + db.EnvironmentEntries.RemoveRange(db.EnvironmentEntries.Where(e => e.FileId == id)); + db.FlagFiles.Remove(file); + db.SaveChanges(); + return $"Deleted file {id}"; + } + catch (Exception ex) { return $"Error: {ex.Message}"; } + } + + // ─── Flag management ────────────────────────────────────────────────── + + [JSInvokable] + public static string GetFlags(long fileId) + { + try + { + using var db = CreateContext(); + var dtos = db.FlagEntries + .Where(f => f.FileId == fileId) + .OrderBy(f => f.FlagKey) + .ToList() + .Select(ToDto) + .ToList(); + return JsonSerializer.Serialize(dtos, JsonCtx.ListFlagEntryDto); + } + catch (Exception ex) { return $"Error: {ex.Message}"; } + } + + /// + /// Create or update a flag. Pass a JSON-serialised . + /// Supports renaming via the optional previousKey field inside the DTO. + /// + [JSInvokable] + public static string UpsertFlag(long fileId, string flagJson) + { + try + { + var dto = JsonSerializer.Deserialize(flagJson, JsonCtx.FlagEntryDto); + if (dto is null) return "Error: Invalid flag JSON"; + + using var db = CreateContext(); + + // Handle rename: remove the old entry when the key changes + if (!string.IsNullOrEmpty(dto.PreviousKey) && dto.PreviousKey != dto.Key) + { + var old = db.FlagEntries.FirstOrDefault(f => f.FileId == fileId && f.FlagKey == dto.PreviousKey); + if (old is not null) db.FlagEntries.Remove(old); + } + + var existing = db.FlagEntries.FirstOrDefault(f => f.FileId == fileId && f.FlagKey == dto.Key); + if (existing is null) + { + existing = new FlagEntry { FileId = fileId, FlagKey = dto.Key }; + db.FlagEntries.Add(existing); + } + + existing.Type = dto.Type; + existing.State = dto.State; + existing.ValueJson = dto.ValueJson; + existing.MetadataJson = dto.MetadataJson; + existing.TargetingJson = dto.TargetingJson; + db.SaveChanges(); + + return JsonSerializer.Serialize(ToDto(existing), JsonCtx.FlagEntryDto); + } + catch (Exception ex) { return $"Error: {ex.Message}"; } + } + + [JSInvokable] + public static string DeleteFlag(long fileId, string flagKey) + { + try + { + using var db = CreateContext(); + var entry = db.FlagEntries.FirstOrDefault(f => f.FileId == fileId && f.FlagKey == flagKey); + if (entry is null) return $"Error: Flag '{flagKey}' not found in file {fileId}"; + db.FlagEntries.Remove(entry); + db.SaveChanges(); + return $"Deleted flag '{flagKey}'"; + } + catch (Exception ex) { return $"Error: {ex.Message}"; } + } + + // ─── Environment management ─────────────────────────────────────────── + + [JSInvokable] + public static string GetEnvironments(long fileId) + { + try + { + using var db = CreateContext(); + var dtos = db.EnvironmentEntries + .Where(e => e.FileId == fileId) + .OrderBy(e => e.Name) + .ToList() + .Select(ToDto) + .ToList(); + return JsonSerializer.Serialize(dtos, JsonCtx.ListEnvironmentEntryDto); + } + catch (Exception ex) { return $"Error: {ex.Message}"; } + } + + /// + /// Create or update an environment. Pass a JSON-serialised . + /// The displayName is title-cased; the key stored in the DB is its lower-case form. + /// + [JSInvokable] + public static string UpsertEnvironment(long fileId, string envJson) + { + try + { + var dto = JsonSerializer.Deserialize(envJson, JsonCtx.EnvironmentEntryDto); + if (dto is null) return "Error: Invalid environment JSON"; + + if (string.IsNullOrWhiteSpace(dto.DisplayName)) + return "Error: Environment display name is required"; + var nameLower = dto.DisplayName.ToLowerInvariant(); + using var db = CreateContext(); + + var existing = db.EnvironmentEntries.FirstOrDefault(e => e.FileId == fileId && e.Name == nameLower); + if (existing is null) + { + existing = new EnvironmentEntry { FileId = fileId, Name = nameLower }; + db.EnvironmentEntries.Add(existing); + } + + existing.AliasesJson = JsonSerializer.Serialize(dto.Aliases, JsonCtx.StringArray); + db.SaveChanges(); + + return JsonSerializer.Serialize(ToDto(existing), JsonCtx.EnvironmentEntryDto); + } + catch (Exception ex) { return $"Error: {ex.Message}"; } + } + + [JSInvokable] + public static string DeleteEnvironment(long fileId, string name) + { + try + { + var nameLower = name.ToLowerInvariant(); + using var db = CreateContext(); + var entry = db.EnvironmentEntries.FirstOrDefault(e => e.FileId == fileId && e.Name == nameLower); + if (entry is null) return $"Error: Environment '{name}' not found in file {fileId}"; + db.EnvironmentEntries.Remove(entry); + db.SaveChanges(); + return $"Deleted environment '{name}'"; + } + catch (Exception ex) { return $"Error: {ex.Message}"; } + } + + // ─── Schema import / export ─────────────────────────────────────────── + + /// + /// Parse a FlagdSchema JSON string into the database for the given file. + /// Mirrors FlagdSchemaAbstraction.fromSchema() from TypeScript. + /// Existing flags and environments for the file are replaced. + /// + [JSInvokable] + public static string ImportSchema(long fileId, string schemaJson) + { + try + { + using var doc = JsonDocument.Parse(schemaJson); + var root = doc.RootElement; + + using var db = CreateContext(); + + // Verify the target file exists + if (!db.FlagFiles.Any(f => f.Id == fileId)) + return $"Error: File {fileId} not found"; + + // Clear existing data for this file + db.EnvironmentEntries.RemoveRange(db.EnvironmentEntries.Where(e => e.FileId == fileId)); + db.FlagEntries.RemoveRange(db.FlagEntries.Where(f => f.FileId == fileId)); + db.SaveChanges(); + + // Update file-level metadata if present + if (root.TryGetProperty("metadata", out var schemaMeta)) + { + var file = db.FlagFiles.Find(fileId); + if (file is not null) + { + file.MetadataJson = schemaMeta.GetRawText(); + } + } + + // Parse $evaluators → environments (pattern: "isXxx") + if (root.TryGetProperty("$evaluators", out var evaluators)) + { + foreach (var prop in evaluators.EnumerateObject()) + { + var key = prop.Name; + if (!key.StartsWith(EnvironmentEvaluatorPrefix) || key.Length <= EnvironmentEvaluatorPrefix.Length) + continue; + + if (!prop.Value.TryGetProperty("in", out var inOp) || + inOp.ValueKind != JsonValueKind.Array || + inOp.GetArrayLength() != 2) + continue; + + var varPart = inOp[0]; + var aliasesPart = inOp[1]; + + if (!varPart.TryGetProperty("var", out var varValue) || + varValue.GetString() != EnvironmentVarName || + aliasesPart.ValueKind != JsonValueKind.Array) + continue; + + var envName = key[EnvironmentEvaluatorPrefix.Length..].ToLowerInvariant(); + var aliases = aliasesPart.EnumerateArray() + .Select(a => a.GetString() ?? string.Empty) + .Where(a => !string.IsNullOrEmpty(a)) + .ToArray(); + + db.EnvironmentEntries.Add(new EnvironmentEntry + { + FileId = fileId, + Name = envName, + AliasesJson = JsonSerializer.Serialize(aliases, JsonCtx.StringArray), + }); + } + } + + // Parse flags + if (root.TryGetProperty("flags", out var flags)) + { + foreach (var flagProp in flags.EnumerateObject()) + { + var flagKey = flagProp.Name; + var flagDef = flagProp.Value; + + var state = flagDef.TryGetProperty("state", out var stateProp) + ? stateProp.GetString() ?? "ENABLED" + : "ENABLED"; + + // Infer flag type and default value from variants + defaultVariant + var flagType = "object"; + string? valueJson = null; + + if (flagDef.TryGetProperty("variants", out var variants) && + flagDef.TryGetProperty("defaultVariant", out var defaultVariant)) + { + var defaultVariantKey = defaultVariant.GetString(); + if (!string.IsNullOrEmpty(defaultVariantKey) && + variants.TryGetProperty(defaultVariantKey, out var defaultValue)) + { + flagType = defaultValue.ValueKind switch + { + JsonValueKind.True or JsonValueKind.False => "boolean", + JsonValueKind.Number => "number", + JsonValueKind.String => "string", + _ => "object", + }; + valueJson = defaultValue.GetRawText(); + } + } + + string? metadataJson = flagDef.TryGetProperty("metadata", out var meta) + ? meta.GetRawText() : null; + + string? targetingJson = flagDef.TryGetProperty("targeting", out var targeting) + ? targeting.GetRawText() : null; + + db.FlagEntries.Add(new FlagEntry + { + FileId = fileId, + FlagKey = flagKey, + Type = flagType, + State = state, + ValueJson = valueJson, + MetadataJson = metadataJson, + TargetingJson = targetingJson, + }); + } + } + + db.SaveChanges(); + return "Schema imported successfully."; + } + catch (Exception ex) { return $"Error: {ex.Message}"; } + } + + /// + /// Reconstruct and return a FlagdSchema JSON string from the database state. + /// Mirrors FlagdSchemaAbstraction.exportSchema() from TypeScript. + /// + [JSInvokable] + public static string ExportSchema(long fileId) + { + try + { + using var db = CreateContext(); + + var file = db.FlagFiles.Find(fileId); + if (file is null) return $"Error: File {fileId} not found"; + + var environments = db.EnvironmentEntries + .Where(e => e.FileId == fileId) + .OrderBy(e => e.Name) + .ToList(); + + var flagEntries = db.FlagEntries + .Where(f => f.FileId == fileId) + .OrderBy(f => f.FlagKey) + .ToList(); + + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); + + writer.WriteStartObject(); + writer.WriteString("$schema", "https://flagd.dev/schema/v0/flags.json"); + + // flags + writer.WritePropertyName("flags"); + writer.WriteStartObject(); + foreach (var flag in flagEntries) + { + writer.WritePropertyName(flag.FlagKey); + writer.WriteStartObject(); + + writer.WriteString("state", flag.State); + + var variantKey = flag.Type == "boolean" ? BooleanOnVariant : DefaultVariant; + writer.WritePropertyName("variants"); + writer.WriteStartObject(); + writer.WritePropertyName(variantKey); + WriteRawOrNull(writer, flag.ValueJson); + writer.WriteEndObject(); + + writer.WriteString("defaultVariant", variantKey); + + if (!string.IsNullOrEmpty(flag.TargetingJson)) + { + writer.WritePropertyName("targeting"); + WriteRaw(writer, flag.TargetingJson); + } + + if (!string.IsNullOrEmpty(flag.MetadataJson)) + { + writer.WritePropertyName("metadata"); + WriteRaw(writer, flag.MetadataJson); + } + + writer.WriteEndObject(); + } + writer.WriteEndObject(); // flags + + // $evaluators (from environments) + if (environments.Count > 0) + { + writer.WritePropertyName("$evaluators"); + writer.WriteStartObject(); + foreach (var env in environments) + { + if (string.IsNullOrEmpty(env.Name)) continue; + var refKey = EnvironmentEvaluatorPrefix + + char.ToUpperInvariant(env.Name[0]) + + env.Name[1..]; + var aliases = JsonSerializer.Deserialize(env.AliasesJson, JsonCtx.StringArray) ?? []; + + writer.WritePropertyName(refKey); + writer.WriteStartObject(); + writer.WritePropertyName("in"); + writer.WriteStartArray(); + // { "var": "environment" } + writer.WriteStartObject(); + writer.WriteString("var", EnvironmentVarName); + writer.WriteEndObject(); + // aliases array + writer.WriteStartArray(); + foreach (var alias in aliases) writer.WriteStringValue(alias); + writer.WriteEndArray(); + writer.WriteEndArray(); + writer.WriteEndObject(); + } + writer.WriteEndObject(); // $evaluators + } + + // file-level metadata + if (!string.IsNullOrEmpty(file.MetadataJson)) + { + writer.WritePropertyName("metadata"); + WriteRaw(writer, file.MetadataJson); + } + + writer.WriteEndObject(); // root + writer.Flush(); + + return Encoding.UTF8.GetString(stream.ToArray()); + } + catch (Exception ex) { return $"Error: {ex.Message}"; } + } + + // ─── Private helpers ────────────────────────────────────────────────── + + private static FlagFileDto ToDto(FlagFile f) => + new(f.Id, f.Name, f.CreatedAt, f.MetadataJson); + + private static FlagEntryDto ToDto(FlagEntry e) => + new(e.FlagKey, e.Type, e.State, e.ValueJson, e.MetadataJson, e.TargetingJson); + + private static EnvironmentEntryDto ToDto(EnvironmentEntry e) + { + var aliases = JsonSerializer.Deserialize(e.AliasesJson, FlagdJsonContext.Default.StringArray) ?? []; + var displayName = string.IsNullOrEmpty(e.Name) + ? e.Name + : char.ToUpperInvariant(e.Name[0]) + e.Name[1..]; + return new EnvironmentEntryDto(e.Name, displayName, aliases); + } + + private static void WriteRaw(Utf8JsonWriter writer, string rawJson) + { + using var doc = JsonDocument.Parse(rawJson); + doc.RootElement.WriteTo(writer); + } + + private static void WriteRawOrNull(Utf8JsonWriter writer, string? rawJson) + { + if (string.IsNullOrEmpty(rawJson)) + writer.WriteNullValue(); + else + WriteRaw(writer, rawJson); + } +} diff --git a/wasm-tryout/Backend/TodoDbContext.cs b/wasm-tryout/Backend/TodoDbContext.cs deleted file mode 100644 index a8fa987..0000000 --- a/wasm-tryout/Backend/TodoDbContext.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace Backend; - -public class TodoDbContext : DbContext -{ - private readonly string _connectionString; - - public DbSet Todos => Set(); - - public TodoDbContext(string connectionString) - { - _connectionString = connectionString; - } - - protected override void OnConfiguring(DbContextOptionsBuilder options) - { - options.UseSqlite(_connectionString); - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.ToTable("todos"); - entity.HasKey(e => e.Id); - entity.Property(e => e.Id).ValueGeneratedOnAdd(); - entity.Property(e => e.Title).IsRequired(); - entity.Property(e => e.Completed).HasDefaultValue(false); - entity.Property(e => e.CreatedAt).HasDefaultValueSql("datetime('now')"); - }); - } -} diff --git a/wasm-tryout/Backend/TodoJsonContext.cs b/wasm-tryout/Backend/TodoJsonContext.cs deleted file mode 100644 index 94cb3c2..0000000 --- a/wasm-tryout/Backend/TodoJsonContext.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Backend; - -[JsonSerializable(typeof(TodoItem))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(int))] -[JsonSourceGenerationOptions( - PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull -)] -public partial class TodoJsonContext : JsonSerializerContext; diff --git a/wasm-tryout/Backend/TodoService.cs b/wasm-tryout/Backend/TodoService.cs deleted file mode 100644 index 0eb579b..0000000 --- a/wasm-tryout/Backend/TodoService.cs +++ /dev/null @@ -1,193 +0,0 @@ -using System.Text.Json; -using Bootsharp; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; - -namespace Backend; - -/// -/// SQLite-backed todo service using EF Core DbContext, exposed to JS via Bootsharp. -/// The database runs entirely in-memory in the WASM runtime. -/// Raw bytes are synced to IndexedDB from the JS side via Export/ImportDatabase. -/// -public static class TodoService -{ - private static SqliteConnection? _keepAliveConnection; - // Use source-generated JSON context for trimming compatibility - private static readonly TodoJsonContext JsonCtx = TodoJsonContext.Default; - - /// - /// The in-memory connection string shared by all DbContext instances. - /// Using a named in-memory DB + a keep-alive connection so EF can - /// open/close connections freely without losing the data. - /// - private const string ConnectionString = "Data Source=TodoDb;Mode=Memory;Cache=Shared"; - - private static TodoDbContext CreateContext() => new(ConnectionString); - - [JSInvokable] - public static string InitDatabase() - { - try - { - // Keep one connection open so the in-memory DB survives DbContext disposal - _keepAliveConnection = new SqliteConnection(ConnectionString); - _keepAliveConnection.Open(); - - using var db = CreateContext(); - db.Database.EnsureCreated(); - - return "Database initialized successfully (EF Core)."; - } - catch (Exception ex) - { - return $"Error: {ex.Message}"; - } - } - - /// - /// Import a previously exported database from IndexedDB bytes. - /// Restores data into the shared in-memory database via SQLite backup API. - /// - [JSInvokable] - public static string ImportDatabase(byte[] data) - { - try - { - // Open the keep-alive connection first - _keepAliveConnection = new SqliteConnection(ConnectionString); - _keepAliveConnection.Open(); - - // Ensure schema exists - using (var db = CreateContext()) - { - db.Database.EnsureCreated(); - } - - // Restore from bytes via temp file + SQLite backup API - var tempPath = Path.GetTempFileName(); - File.WriteAllBytes(tempPath, data); - - using var source = new SqliteConnection($"Data Source={tempPath}"); - source.Open(); - source.BackupDatabase(_keepAliveConnection); - source.Close(); - - try { File.Delete(tempPath); } catch { /* best effort */ } - - return "Database imported successfully (EF Core)."; - } - catch (Exception ex) - { - return $"Error: {ex.Message}"; - } - } - - /// - /// Export the in-memory database as raw bytes for IndexedDB persistence. - /// - [JSInvokable] - public static byte[] ExportDatabase() - { - if (_keepAliveConnection is null) return Array.Empty(); - - var tempPath = Path.GetTempFileName(); - try - { - using var dest = new SqliteConnection($"Data Source={tempPath}"); - dest.Open(); - _keepAliveConnection.BackupDatabase(dest); - dest.Close(); - return File.ReadAllBytes(tempPath); - } - finally - { - try { File.Delete(tempPath); } catch { /* best effort */ } - } - } - - [JSInvokable] - public static string AddTodo(string title) - { - try - { - using var db = CreateContext(); - var todo = new TodoItem { Title = title }; - db.Todos.Add(todo); - db.SaveChanges(); - return JsonSerializer.Serialize(todo, JsonCtx.TodoItem); - } - catch (Exception ex) - { - return $"Error: {ex.Message}"; - } - } - - [JSInvokable] - public static string GetAllTodos() - { - try - { - using var db = CreateContext(); - var todos = db.Todos.OrderBy(t => t.Id).ToList(); - return JsonSerializer.Serialize(todos, JsonCtx.ListTodoItem); - } - catch (Exception ex) - { - return $"Error: {ex.Message}"; - } - } - - [JSInvokable] - public static string ToggleTodo(int id) - { - try - { - using var db = CreateContext(); - var todo = db.Todos.Find((long)id); - if (todo is null) return $"Todo {id} not found"; - - todo.Completed = !todo.Completed; - db.SaveChanges(); - return $"Toggled todo {id}"; - } - catch (Exception ex) - { - return $"Error: {ex.Message}"; - } - } - - [JSInvokable] - public static string DeleteTodo(int id) - { - try - { - using var db = CreateContext(); - var todo = db.Todos.Find((long)id); - if (todo is null) return $"Todo {id} not found"; - - db.Todos.Remove(todo); - db.SaveChanges(); - return $"Deleted todo {id}"; - } - catch (Exception ex) - { - return $"Error: {ex.Message}"; - } - } - - [JSInvokable] - public static string GetTodoCount() - { - try - { - using var db = CreateContext(); - var count = db.Todos.Count(); - return $"{{\"count\":{count}}}"; - } - catch (Exception ex) - { - return $"Error: {ex.Message}"; - } - } -} diff --git a/wasm-tryout/index.html b/wasm-tryout/index.html index 6e0aaa4..d703a09 100644 --- a/wasm-tryout/index.html +++ b/wasm-tryout/index.html @@ -3,227 +3,310 @@ - Bootsharp + SQLite WASM — Todo App + Flagd Schema Manager — WASM -

🗄️ SQLite WASM Todo

-

- .NET (Bootsharp) + SQLite running in-browser via WebAssembly.
- Data persists in IndexedDB across page reloads. -

- -
⏳ Booting .NET runtime…
- -
- - +
+

🏳️ Flagd Schema Manager — WASM

+ ⏳ Booting .NET runtime… +
+ +
+ +
+

Flag Files

+
+
+ + +
+
+ + +
+
← Select or create a flag file
+ + +
-
-
    - -
    - - +
    + +
    -
    From 744086ea8b429193790c12bee1304e6d41988d1b Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Fri, 6 Mar 2026 19:42:50 +0000 Subject: [PATCH 03/38] move projects --- .gitignore | 3 + {wasm-tryout => src}/Backend/Backend.csproj | 0 .../Backend/EnvironmentEntry.cs | 0 {wasm-tryout => src}/Backend/FlagEntry.cs | 0 {wasm-tryout => src}/Backend/FlagFile.cs | 0 .../Backend/FlagdController.cs | 0 .../Backend/FlagdDbContext.cs | 0 .../Backend/FlagdJsonContext.cs | 0 {wasm-tryout => src}/Backend/FlagdService.cs | 0 {wasm-tryout => src}/Backend/LinkerConfig.xml | 0 {wasm-tryout => src}/Backend/Program.cs | 0 {wasm-tryout => src}/Backend/TrimmerRoots.cs | 0 src/config.rs | 76 ---- src/error.rs | 53 --- src/handlers/api/flags.rs | 250 ----------- src/handlers/api/mod.rs | 6 - src/handlers/health.rs | 32 -- src/handlers/mod.rs | 5 - {wasm-tryout => src}/index.html | 0 src/main.rs | 107 ----- src/middleware/mod.rs | 1 - src/openapi_doc.rs | 31 -- src/storage/azure.rs | 384 ----------------- src/storage/azurite_auth.rs | 104 ----- src/storage/local.rs | 125 ------ src/storage/mod.rs | 48 --- wasm-tryout/.gitignore | 2 - wasm-tryout/Backend/package-lock.json | 405 ------------------ wasm-tryout/Backend/package.json | 16 - 29 files changed, 3 insertions(+), 1645 deletions(-) rename {wasm-tryout => src}/Backend/Backend.csproj (100%) rename {wasm-tryout => src}/Backend/EnvironmentEntry.cs (100%) rename {wasm-tryout => src}/Backend/FlagEntry.cs (100%) rename {wasm-tryout => src}/Backend/FlagFile.cs (100%) rename {wasm-tryout => src}/Backend/FlagdController.cs (100%) rename {wasm-tryout => src}/Backend/FlagdDbContext.cs (100%) rename {wasm-tryout => src}/Backend/FlagdJsonContext.cs (100%) rename {wasm-tryout => src}/Backend/FlagdService.cs (100%) rename {wasm-tryout => src}/Backend/LinkerConfig.xml (100%) rename {wasm-tryout => src}/Backend/Program.cs (100%) rename {wasm-tryout => src}/Backend/TrimmerRoots.cs (100%) delete mode 100644 src/config.rs delete mode 100644 src/error.rs delete mode 100644 src/handlers/api/flags.rs delete mode 100644 src/handlers/api/mod.rs delete mode 100644 src/handlers/health.rs delete mode 100644 src/handlers/mod.rs rename {wasm-tryout => src}/index.html (100%) delete mode 100644 src/main.rs delete mode 100644 src/middleware/mod.rs delete mode 100644 src/openapi_doc.rs delete mode 100644 src/storage/azure.rs delete mode 100644 src/storage/azurite_auth.rs delete mode 100644 src/storage/local.rs delete mode 100644 src/storage/mod.rs delete mode 100644 wasm-tryout/.gitignore delete mode 100644 wasm-tryout/Backend/package-lock.json delete mode 100644 wasm-tryout/Backend/package.json diff --git a/.gitignore b/.gitignore index 79c5c4a..bb3ae0d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ Cargo.lock .env .env.local +bin +obj + public/* !public/.gitkeep diff --git a/wasm-tryout/Backend/Backend.csproj b/src/Backend/Backend.csproj similarity index 100% rename from wasm-tryout/Backend/Backend.csproj rename to src/Backend/Backend.csproj diff --git a/wasm-tryout/Backend/EnvironmentEntry.cs b/src/Backend/EnvironmentEntry.cs similarity index 100% rename from wasm-tryout/Backend/EnvironmentEntry.cs rename to src/Backend/EnvironmentEntry.cs diff --git a/wasm-tryout/Backend/FlagEntry.cs b/src/Backend/FlagEntry.cs similarity index 100% rename from wasm-tryout/Backend/FlagEntry.cs rename to src/Backend/FlagEntry.cs diff --git a/wasm-tryout/Backend/FlagFile.cs b/src/Backend/FlagFile.cs similarity index 100% rename from wasm-tryout/Backend/FlagFile.cs rename to src/Backend/FlagFile.cs diff --git a/wasm-tryout/Backend/FlagdController.cs b/src/Backend/FlagdController.cs similarity index 100% rename from wasm-tryout/Backend/FlagdController.cs rename to src/Backend/FlagdController.cs diff --git a/wasm-tryout/Backend/FlagdDbContext.cs b/src/Backend/FlagdDbContext.cs similarity index 100% rename from wasm-tryout/Backend/FlagdDbContext.cs rename to src/Backend/FlagdDbContext.cs diff --git a/wasm-tryout/Backend/FlagdJsonContext.cs b/src/Backend/FlagdJsonContext.cs similarity index 100% rename from wasm-tryout/Backend/FlagdJsonContext.cs rename to src/Backend/FlagdJsonContext.cs diff --git a/wasm-tryout/Backend/FlagdService.cs b/src/Backend/FlagdService.cs similarity index 100% rename from wasm-tryout/Backend/FlagdService.cs rename to src/Backend/FlagdService.cs diff --git a/wasm-tryout/Backend/LinkerConfig.xml b/src/Backend/LinkerConfig.xml similarity index 100% rename from wasm-tryout/Backend/LinkerConfig.xml rename to src/Backend/LinkerConfig.xml diff --git a/wasm-tryout/Backend/Program.cs b/src/Backend/Program.cs similarity index 100% rename from wasm-tryout/Backend/Program.cs rename to src/Backend/Program.cs diff --git a/wasm-tryout/Backend/TrimmerRoots.cs b/src/Backend/TrimmerRoots.cs similarity index 100% rename from wasm-tryout/Backend/TrimmerRoots.cs rename to src/Backend/TrimmerRoots.cs diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index 2123d2b..0000000 --- a/src/config.rs +++ /dev/null @@ -1,76 +0,0 @@ -use clap::Parser; -use std::env; - -/// Command-line arguments for the flagd-ui server -#[derive(Parser, Debug, Clone)] -#[command(name = "flagd-ui")] -#[command(about = "A web UI for managing feature flags in OpenFeature flagd service")] -pub struct CliArgs { - /// Storage URI for feature flags - /// - /// Supported formats: - /// - Local filesystem: file:///path/to/flags or /path/to/flags or ./flags - /// - Azure Blob Storage: azblob://my-container/myblob.json - /// (service endpoint and auth come from Azure environment variables) - #[arg(long, env = "STORAGE_URI")] - pub storage_uri: Option, - - /// HTTP server port - #[arg(long, env = "SERVER_PORT", default_value = "3000")] - pub port: u16, - - /// Directory for static files - #[arg(long, env = "STATIC_DIR", default_value = "./public")] - pub static_dir: String, - - /// Path to the flagd JSON schema file - #[arg( - long, - env = "FLAGD_SCHEMA_FILE", - default_value = "./schema/flagd-schema.json" - )] - pub schema_file_path: String, -} - -/// Server configuration loaded from environment variables and CLI arguments -#[derive(Debug, Clone)] -pub struct ServerConfig { - /// HTTP server port - pub port: u16, - /// Directory for static files - pub static_dir: String, - /// Storage URI for feature flags (supports file://, local paths, or azblob://container/blob) - pub storage_uri: String, - /// Path to the flagd JSON schema file - pub schema_file_path: String, -} - -impl ServerConfig { - /// Load configuration from CLI arguments and environment variables - pub fn from_cli() -> Self { - let args = CliArgs::parse(); - - let storage_uri = args - .storage_uri - .or_else(|| env::var("FLAGS_DIR").ok()) - .unwrap_or_else(|| "./flags".to_string()); - - Self { - port: args.port, - static_dir: args.static_dir, - storage_uri, - schema_file_path: args.schema_file_path, - } - } -} - -impl Default for ServerConfig { - fn default() -> Self { - Self { - port: 3000, - static_dir: "./public".to_string(), - storage_uri: "./flags".to_string(), - schema_file_path: "./schema/flagd-schema.json".to_string(), - } - } -} diff --git a/src/error.rs b/src/error.rs deleted file mode 100644 index 9b1ac0d..0000000 --- a/src/error.rs +++ /dev/null @@ -1,53 +0,0 @@ -use axum::{ - http::StatusCode, - response::{IntoResponse, Response}, - Json, -}; -use serde::Serialize; - -/// Custom error types for the application -#[derive(Debug)] -pub enum AppError { - NotFound(String), - BadRequest(String), - InternalServerError(String), -} - -/// JSON error response structure -#[derive(Serialize)] -struct ErrorResponse { - error: String, - status: u16, -} - -impl IntoResponse for AppError { - fn into_response(self) -> Response { - let (status, message) = match self { - AppError::NotFound(msg) => { - tracing::warn!(error = %msg, "Request failed with not found error"); - (StatusCode::NOT_FOUND, "Not found".to_string()) - } - AppError::BadRequest(msg) => { - tracing::warn!(error = %msg, "Request failed with bad request error"); - (StatusCode::BAD_REQUEST, msg) - } - AppError::InternalServerError(msg) => { - tracing::error!(error = %msg, "Request failed with internal server error"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "Internal server error".to_string(), - ) - } - }; - - let body = Json(ErrorResponse { - error: message, - status: status.as_u16(), - }); - - (status, body).into_response() - } -} - -/// Result type alias for convenience -pub type AppResult = Result; diff --git a/src/handlers/api/flags.rs b/src/handlers/api/flags.rs deleted file mode 100644 index c44a35f..0000000 --- a/src/handlers/api/flags.rs +++ /dev/null @@ -1,250 +0,0 @@ -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::Serialize; -use std::{fs, path::PathBuf, sync::Arc}; -use utoipa::ToSchema; - -use crate::{ - config::ServerConfig, - error::{AppError, AppResult}, - storage::{create_storage_backend, StorageBackend}, -}; - -struct LocalSchemaRetriever { - base_dir: PathBuf, -} - -impl jsonschema::Retrieve for LocalSchemaRetriever { - fn retrieve( - &self, - uri: &jsonschema::Uri<&str>, - ) -> Result> { - let uri_str = uri.as_str(); - - let candidate = if uri_str.starts_with("json-schema:///") { - let rel = uri_str.trim_start_matches("json-schema:///"); - self.base_dir.join(rel) - } else if uri.scheme().as_str() == "file" { - PathBuf::from(uri.path().as_str()) - } else if uri.scheme().as_str() == "https" - && uri.path().as_str().ends_with("targeting.json") - { - self.base_dir.join("targeting.json") - } else { - return Err(format!("Unsupported schema URI: {uri_str}").into()); - }; - - let content = fs::read_to_string(&candidate)?; - Ok(serde_json::from_str(&content)?) - } -} - -/// Application state containing configuration -#[derive(Clone)] -pub struct AppState { - pub config: Arc, - pub schema: Arc, - pub storage: Arc, -} - -/// Response for listing all flag definition files -#[derive(Debug, Serialize, ToSchema)] -pub struct ListFlagsResponse { - /// List of flag definition file names - #[schema(example = json!(["demo", "production"]))] - pub files: Vec, -} - -/// Initialize the application state with schema validation -pub async fn init_app_state(config: ServerConfig) -> AppResult { - let schema_dir = PathBuf::from(&config.schema_file_path) - .parent() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| PathBuf::from(".")); - - // Load the schema from the local file - let schema_content = fs::read_to_string(&config.schema_file_path) - .map_err(|e| AppError::InternalServerError(format!("Failed to read schema file: {}", e)))?; - - let schema_json: serde_json::Value = serde_json::from_str(&schema_content) - .map_err(|e| AppError::InternalServerError(format!("Failed to parse schema: {}", e)))?; - - // Compile the JSON schema into a validator - let schema = jsonschema::options() - .with_retriever(LocalSchemaRetriever { - base_dir: schema_dir, - }) - .build(&schema_json) - .map_err(|e| AppError::InternalServerError(format!("Invalid schema: {}", e)))?; - - // Create storage backend based on URI - let storage = create_storage_backend(&config.storage_uri)?; - - Ok(AppState { - config: Arc::new(config), - schema: Arc::new(schema), - storage, - }) -} - -/// Validate flag definition against the schema -fn validate_flags( - schema: &jsonschema::Validator, - complete_doc: &serde_json::Value, -) -> AppResult<()> { - schema - .validate(complete_doc) - .map_err(|error| AppError::BadRequest(format!("Schema validation failed: {}", error))) -} - -/// List all flag definition files -#[utoipa::path( - get, - path = "/api/flags", - responses( - (status = 200, description = "List of all flag definition files", body = ListFlagsResponse), - (status = 500, description = "Internal server error") - ), - tag = "flags" -)] -pub async fn list_flags(State(state): State) -> AppResult { - let files = state.storage.list_flags().await?; - Ok(Json(ListFlagsResponse { files })) -} - -/// Get a specific flag definition file -#[utoipa::path( - get, - path = "/api/flags/{name}", - params( - ("name" = String, Path, description = "Name of the flag definition file") - ), - responses( - (status = 200, description = "Flag definition file content", body = Object), - (status = 404, description = "Flag definition not found"), - (status = 400, description = "Invalid filename"), - (status = 500, description = "Internal server error") - ), - tag = "flags" -)] -pub async fn get_flag( - State(state): State, - Path(name): Path, -) -> AppResult { - let json = state.storage.read_flag(&name).await?; - Ok(Json(json)) -} - -/// Create a new flag definition file -#[utoipa::path( - post, - path = "/api/flags/{name}", - params( - ("name" = String, Path, description = "Name of the flag definition file to create") - ), - request_body = Object, - responses( - (status = 201, description = "Flag definition file created successfully", body = Object), - (status = 400, description = "Invalid request or validation failed"), - (status = 500, description = "Internal server error") - ), - tag = "flags" -)] -pub async fn create_flag( - State(state): State, - Path(name): Path, - Json(payload): Json, -) -> AppResult { - // Check if file already exists - if state.storage.flag_exists(&name).await? { - return Err(AppError::BadRequest(format!( - "Flag definition '{}' already exists", - name - ))); - } - - if !payload.is_object() { - return Err(AppError::BadRequest( - "Request body must be a JSON object".to_string(), - )); - } - - // Validate the full document against the schema - validate_flags(&state.schema, &payload)?; - - // Write the file - state.storage.write_flag(&name, &payload).await?; - - Ok((StatusCode::CREATED, Json(payload))) -} - -/// Update an existing flag definition file -#[utoipa::path( - put, - path = "/api/flags/{name}", - params( - ("name" = String, Path, description = "Name of the flag definition file to update") - ), - request_body = Object, - responses( - (status = 200, description = "Flag definition file updated successfully", body = Object), - (status = 404, description = "Flag definition not found"), - (status = 400, description = "Invalid request or validation failed"), - (status = 500, description = "Internal server error") - ), - tag = "flags" -)] -pub async fn update_flag( - State(state): State, - Path(name): Path, - Json(payload): Json, -) -> AppResult { - // Check if file exists - if !state.storage.flag_exists(&name).await? { - return Err(AppError::NotFound(format!( - "Flag definition '{}' not found", - name - ))); - } - - if !payload.is_object() { - return Err(AppError::BadRequest( - "Request body must be a JSON object".to_string(), - )); - } - - // Validate the full document against the schema - validate_flags(&state.schema, &payload)?; - - // Write the file - state.storage.write_flag(&name, &payload).await?; - - Ok(Json(payload)) -} - -/// Delete a flag definition file -#[utoipa::path( - delete, - path = "/api/flags/{name}", - params( - ("name" = String, Path, description = "Name of the flag definition file to delete") - ), - responses( - (status = 204, description = "Flag definition file deleted successfully"), - (status = 404, description = "Flag definition not found"), - (status = 400, description = "Invalid filename"), - (status = 500, description = "Internal server error") - ), - tag = "flags" -)] -pub async fn delete_flag( - State(state): State, - Path(name): Path, -) -> AppResult { - state.storage.delete_flag(&name).await?; - Ok(StatusCode::NO_CONTENT) -} diff --git a/src/handlers/api/mod.rs b/src/handlers/api/mod.rs deleted file mode 100644 index f845f92..0000000 --- a/src/handlers/api/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod flags; - -pub use flags::{ - create_flag, delete_flag, get_flag, init_app_state, list_flags, update_flag, AppState, - ListFlagsResponse, -}; diff --git a/src/handlers/health.rs b/src/handlers/health.rs deleted file mode 100644 index a230ac2..0000000 --- a/src/handlers/health.rs +++ /dev/null @@ -1,32 +0,0 @@ -use axum::{http::StatusCode, response::IntoResponse, Json}; -use serde::Serialize; - -#[derive(Serialize)] -struct HealthResponse { - status: String, - message: String, -} - -/// GET /health - Basic liveness check -pub async fn health_check() -> impl IntoResponse { - ( - StatusCode::OK, - Json(HealthResponse { - status: "ok".to_string(), - message: "Server is running".to_string(), - }), - ) -} - -/// GET /ready - Readiness check -pub async fn readiness_check() -> impl IntoResponse { - // For now, just return OK. In the future, this can check database connections, - // external services, etc. - ( - StatusCode::OK, - Json(HealthResponse { - status: "ready".to_string(), - message: "Server is ready to accept requests".to_string(), - }), - ) -} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs deleted file mode 100644 index 059df04..0000000 --- a/src/handlers/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod api; -pub mod health; - -pub use api::{create_flag, delete_flag, get_flag, init_app_state, list_flags, update_flag}; -pub use health::{health_check, readiness_check}; diff --git a/wasm-tryout/index.html b/src/index.html similarity index 100% rename from wasm-tryout/index.html rename to src/index.html diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index f0c633f..0000000 --- a/src/main.rs +++ /dev/null @@ -1,107 +0,0 @@ -mod config; -mod error; -mod handlers; -mod middleware; -mod openapi_doc; -mod storage; - -use axum::{routing::get, Router}; -use tower_http::{ - compression::CompressionLayer, - cors::CorsLayer, - services::{ServeDir, ServeFile}, - trace::TraceLayer, -}; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -use utoipa::OpenApi; -use utoipa_swagger_ui::{Config as SwaggerConfig, SwaggerUi}; - -use config::ServerConfig; -use handlers::{ - create_flag, delete_flag, get_flag, health_check, init_app_state, list_flags, readiness_check, - update_flag, -}; -use openapi_doc::ApiDoc; - -#[tokio::main] -async fn main() { - // Initialize tracing for structured logging - tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), - ) - .with(tracing_subscriber::fmt::layer()) - .init(); - - // Load configuration - let config = ServerConfig::from_cli(); - let addr = format!("0.0.0.0:{}", config.port); - - tracing::info!("Starting server with config: {:?}", config); - - // Initialize application state with schema validation - let app_state = init_app_state(config.clone()) - .await - .expect("Failed to initialize application state"); - - tracing::info!( - "Schema validation initialized from: {}", - config.schema_file_path - ); - - // Build the application router - let app = create_router(&config, app_state); - - // Create TCP listener - let listener = tokio::net::TcpListener::bind(&addr) - .await - .expect("Failed to bind to address"); - - tracing::info!("Server listening on {}", addr); - - // Start the server - axum::serve(listener, app) - .await - .expect("Server failed to start"); -} - -/// Create the Axum router with all routes and middleware -fn create_router(config: &ServerConfig, app_state: handlers::api::AppState) -> Router { - // API routes - prefix all with /api - let api_routes = Router::new() - // Flag management endpoints - .route("/flags", get(list_flags)) - .route( - "/flags/:name", - get(get_flag) - .post(create_flag) - .put(update_flag) - .delete(delete_flag), - ) - .with_state(app_state); - - // Main application router - Router::new() - // Health check endpoints - .route("/health", get(health_check)) - .route("/ready", get(readiness_check)) - // Mount API routes under /api prefix - .nest("/api", api_routes) - // Swagger UI for interactive API documentation - .merge( - SwaggerUi::new("/swagger-ui") - .url("/api/openapi.json", ApiDoc::openapi()) - .config(SwaggerConfig::default().try_it_out_enabled(true)), - ) - // Serve static files from the public directory - // This will also fallback to index.html for SPA routing - .nest_service( - "/", - ServeDir::new(&config.static_dir) - .not_found_service(ServeFile::new(format!("{}/index.html", &config.static_dir))), - ) - // Add middleware stack - .layer(CorsLayer::permissive()) - .layer(CompressionLayer::new()) - .layer(TraceLayer::new_for_http()) -} diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs deleted file mode 100644 index 4de69b2..0000000 --- a/src/middleware/mod.rs +++ /dev/null @@ -1 +0,0 @@ -// Placeholder for future custom middleware diff --git a/src/openapi_doc.rs b/src/openapi_doc.rs deleted file mode 100644 index f765ab0..0000000 --- a/src/openapi_doc.rs +++ /dev/null @@ -1,31 +0,0 @@ -use utoipa::OpenApi; - -/// OpenAPI documentation -#[derive(OpenApi)] -#[openapi( - paths( - crate::handlers::api::flags::list_flags, - crate::handlers::api::flags::get_flag, - crate::handlers::api::flags::create_flag, - crate::handlers::api::flags::update_flag, - crate::handlers::api::flags::delete_flag, - ), - components( - schemas( - crate::handlers::api::ListFlagsResponse, - ) - ), - tags( - (name = "flags", description = "Feature Flag Definition Management API") - ), - info( - title = "Flagd UI API", - version = "0.1.0", - description = "API for managing feature flag definition files compatible with flagd", - license( - name = "MIT", - identifier = "MIT", - ) - ) -)] -pub struct ApiDoc; diff --git a/src/storage/azure.rs b/src/storage/azure.rs deleted file mode 100644 index cc3dca1..0000000 --- a/src/storage/azure.rs +++ /dev/null @@ -1,384 +0,0 @@ -use super::StorageBackend; -use crate::error::{AppError, AppResult}; -use async_trait::async_trait; -use azure_core::credentials::TokenCredential; -use azure_core::http::RequestContent; -use azure_storage_blob::clients::BlobContainerClient; -use azure_storage_blob::BlobServiceClient; -use std::collections::HashMap; -use std::sync::Arc; - -fn internal_error(message: impl Into) -> AppError { - AppError::InternalServerError(message.into()) -} - -fn not_found_error(message: impl Into) -> AppError { - AppError::NotFound(message.into()) -} - -fn sanitize_service_url_for_logs(service_url: &str) -> String { - service_url - .split('?') - .next() - .unwrap_or(service_url) - .to_string() -} - -/// Azure Blob Storage backend -pub struct AzureStorage { - container_client: Arc, - configured_blob_name: String, -} - -#[derive(Default)] -struct ServiceUrlOptions { - account_name: String, - storage_domain: String, - protocol: String, - is_local_emulator: bool, - sas_token: String, -} - -impl AzureStorage { - /// Create a new Azure Blob Storage backend from a URI - /// - /// Supported format: - /// - azblob://my-container/myblob.json - pub fn new(uri: &str) -> AppResult { - let (container_name, configured_blob_name) = Self::parse_uri(uri)?; - let blob_service = Self::create_blob_service_client()?; - let container_client = blob_service.blob_container_client(&container_name); - - Ok(Self { - container_client: Arc::new(container_client), - configured_blob_name, - }) - } - - /// Parse URI format - /// - /// Supported format: - /// - azblob://my-container/myblob.json - fn parse_uri(uri: &str) -> AppResult<(String, String)> { - // Remove azblob:// prefix - let path = uri - .strip_prefix("azblob://") - .ok_or_else(|| internal_error("URI must start with azblob://".to_string()))?; - - let parts: Vec<&str> = path.split('/').collect(); - if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { - return Err(internal_error( - "Invalid Azure Blob URI format. Expected: azblob://my-container/myblob.json" - .to_string(), - )); - } - - let container_name = parts[0]; - let starts_valid = container_name - .chars() - .next() - .map(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit()) - .unwrap_or(false); - let chars_valid = container_name - .chars() - .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-'); - let has_consecutive_hyphens = container_name.contains("--"); - - if !starts_valid || !chars_valid || has_consecutive_hyphens { - return Err(internal_error( - "The container name must start with a letter or number, use only lowercase letters, numbers, and hyphens, and avoid consecutive hyphens." - .to_string(), - )); - } - - Ok((parts[0].to_string(), parts[1].to_string())) - } - - fn parse_connection_string(connection_string: &str) -> HashMap { - connection_string - .split(';') - .filter_map(|part| { - let mut key_val = part.splitn(2, '='); - let key = key_val.next()?.trim(); - let value = key_val.next()?.trim(); - - if key.is_empty() { - return None; - } - - Some((key.to_string(), value.to_string())) - }) - .collect() - } - - fn is_true_env(name: &str) -> bool { - std::env::var(name) - .ok() - .map(|value| { - matches!( - value.to_ascii_lowercase().as_str(), - "1" | "true" | "yes" | "on" - ) - }) - .unwrap_or(false) - } - - fn connection_string_from_env() -> Option { - std::env::var("AZURE_STORAGE_CONNECTION_STRING") - .ok() - .filter(|value| !value.trim().is_empty()) - .or_else(|| { - std::env::var("AZURE_STORAGEBLOB_CONNECTIONSTRING") - .ok() - .filter(|value| !value.trim().is_empty()) - }) - } - - fn resolve_service_url_and_credential() -> AppResult<(String, Option>)> - { - let connection_string = Self::connection_string_from_env(); - let mut connection_string_values = HashMap::new(); - if let Some(value) = &connection_string { - connection_string_values = Self::parse_connection_string(value); - } - - let mut service_opts = ServiceUrlOptions { - account_name: std::env::var("AZURE_STORAGE_ACCOUNT").unwrap_or_default(), - storage_domain: std::env::var("AZURE_STORAGE_DOMAIN").unwrap_or_default(), - protocol: std::env::var("AZURE_STORAGE_PROTOCOL").unwrap_or_default(), - is_local_emulator: Self::is_true_env("AZURE_STORAGE_IS_LOCAL_EMULATOR"), - sas_token: std::env::var("AZURE_STORAGE_SAS_TOKEN").unwrap_or_default(), - }; - - if service_opts.account_name.is_empty() { - if let Some(account_name) = connection_string_values.get("AccountName") { - service_opts.account_name = account_name.clone(); - } - } - - if service_opts.protocol.is_empty() { - if let Some(protocol) = connection_string_values.get("DefaultEndpointsProtocol") { - service_opts.protocol = protocol.clone(); - } - } - - if service_opts.storage_domain.is_empty() { - if let Some(suffix) = connection_string_values.get("EndpointSuffix") { - service_opts.storage_domain = format!("blob.{}", suffix); - } - } - - if service_opts.sas_token.is_empty() { - if let Some(sas) = connection_string_values.get("SharedAccessSignature") { - service_opts.sas_token = sas.clone(); - } - } - - let explicit_blob_endpoint = connection_string_values.get("BlobEndpoint").cloned(); - - if std::env::var("AZURE_STORAGE_KEY").is_ok() { - return Err(internal_error( - "AZURE_STORAGE_KEY shared-key auth is not supported by the current Rust Azure Blob SDK integration. Use AZURE_STORAGE_SAS_TOKEN, a connection string with SharedAccessSignature, or Entra ID credentials." - .to_string(), - )); - } - - if connection_string_values.contains_key("AccountKey") && service_opts.sas_token.is_empty() - { - return Err(internal_error( - "Connection strings using AccountKey are not supported by the current Rust Azure Blob SDK integration. Use SharedAccessSignature in the connection string, AZURE_STORAGE_SAS_TOKEN, or Entra ID credentials." - .to_string(), - )); - } - - let protocol = if service_opts.protocol.is_empty() { - "https".to_string() - } else { - let value = service_opts.protocol.to_ascii_lowercase(); - if value != "http" && value != "https" { - return Err(internal_error(format!( - "Invalid AZURE_STORAGE_PROTOCOL '{}'. Expected 'http' or 'https'.", - service_opts.protocol - ))); - } - value - }; - - let storage_domain = if service_opts.storage_domain.is_empty() { - "blob.core.windows.net".to_string() - } else { - service_opts.storage_domain.clone() - }; - - if service_opts.account_name.is_empty() { - return Err(internal_error( - "AZURE_STORAGE_ACCOUNT is required to construct the Azure Blob service URL." - .to_string(), - )); - } - - let service_url = if let Some(blob_endpoint) = explicit_blob_endpoint { - blob_endpoint.trim_end_matches('/').to_string() - } else if service_opts.is_local_emulator { - format!( - "{}://{}/{}", - protocol, storage_domain, service_opts.account_name - ) - } else { - format!( - "{}://{}.{}", - protocol, service_opts.account_name, storage_domain - ) - }; - - if service_opts.is_local_emulator { - tracing::info!( - credential_mode = "local_azurite_token", - service_url = %sanitize_service_url_for_logs(&service_url), - "Azure Blob auth mode selected" - ); - #[cfg(feature = "azurite-local-auth")] - return Ok(( - service_url, - Some(super::azurite_auth::build_azurite_token_credential()?), - )); - #[cfg(not(feature = "azurite-local-auth"))] - return Err(internal_error( - "Azurite local auth feature is not enabled.".to_string(), - )); - } - - tracing::info!( - credential_mode = "default_azure_identity", - service_url = %sanitize_service_url_for_logs(&service_url), - "Azure Blob auth mode selected" - ); - Ok((service_url, None)) - } - - fn create_blob_service_client() -> AppResult { - let (service_url, credential) = Self::resolve_service_url_and_credential()?; - - let blob_service = BlobServiceClient::new(&service_url, credential, None).map_err(|e| { - internal_error(format!( - "Failed to create blob service client: {}. \ - Configure via AZURE_STORAGE_ACCOUNT (plus optional AZURE_STORAGE_DOMAIN/AZURE_STORAGE_PROTOCOL), \ - AZURE_STORAGE_CONNECTION_STRING/AZURE_STORAGEBLOB_CONNECTIONSTRING, or AZURE_STORAGE_SAS_TOKEN.", - e - )) - })?; - - Ok(blob_service) - } - - /// Validate blob name to prevent path traversal - fn validate_blob_name(&self, name: &str) -> AppResult { - if name != self.configured_blob_name { - return Err(not_found_error(format!( - "Flag definition '{}' not found", - name - ))); - } - - Ok(self.configured_blob_name.clone()) - } - - /// Helper to check if an error is a 404 - fn is_not_found_error(e: &dyn std::error::Error) -> bool { - // Check if error message contains "404" or "NotFound" - let err_str = format!("{:?}", e); - err_str.contains("404") || err_str.contains("NotFound") || err_str.contains("BlobNotFound") - } -} - -#[async_trait] -impl StorageBackend for AzureStorage { - async fn list_flags(&self) -> AppResult> { - let blob_client = self - .container_client - .blob_client(&self.configured_blob_name); - - match blob_client.exists().await { - Ok(true) => Ok(vec![self.configured_blob_name.clone()]), - Ok(false) => Ok(vec![]), - Err(e) => { - if Self::is_not_found_error(&e) { - tracing::warn!("Container or blob not found, returning empty list"); - Ok(vec![]) - } else { - Err(internal_error(format!("Failed to list blobs: {}", e))) - } - } - } - } - - async fn read_flag(&self, name: &str) -> AppResult { - let blob_name = self.validate_blob_name(name)?; - let blob_client = self.container_client.blob_client(&blob_name); - - // Download the blob - let response = blob_client.download(None).await.map_err(|e| { - if Self::is_not_found_error(&e) { - not_found_error(format!("Flag definition '{}' not found", name)) - } else { - internal_error(format!("Failed to read blob: {}", e)) - } - })?; - - let (_, _, body) = response.deconstruct(); - let bytes = body - .collect() - .await - .map_err(|e| internal_error(format!("Failed to read blob content: {}", e)))?; - - serde_json::from_slice(&bytes) - .map_err(|e| internal_error(format!("Failed to parse JSON: {}", e))) - } - - async fn write_flag(&self, name: &str, content: &serde_json::Value) -> AppResult<()> { - let blob_name = self.validate_blob_name(name)?; - let blob_client = self.container_client.blob_client(&blob_name); - - let json_bytes = serde_json::to_vec_pretty(content) - .map_err(|e| internal_error(format!("Failed to serialize JSON: {}", e)))?; - - let content_length = json_bytes.len() as u64; - let request_content = RequestContent::from(json_bytes); - - // Upload the blob - blob_client - .upload(request_content, true, content_length, None) - .await - .map_err(|e| internal_error(format!("Failed to write blob: {}", e)))?; - - Ok(()) - } - - async fn delete_flag(&self, name: &str) -> AppResult<()> { - let blob_name = self.validate_blob_name(name)?; - let blob_client = self.container_client.blob_client(&blob_name); - - blob_client.delete(None).await.map_err(|e| { - if Self::is_not_found_error(&e) { - not_found_error(format!("Flag definition '{}' not found", name)) - } else { - internal_error(format!("Failed to delete blob: {}", e)) - } - })?; - - Ok(()) - } - - async fn flag_exists(&self, name: &str) -> AppResult { - let blob_name = self.validate_blob_name(name)?; - let blob_client = self.container_client.blob_client(&blob_name); - - match blob_client.exists().await { - Ok(exists) => Ok(exists), - Err(e) => Err(internal_error(format!( - "Failed to check if blob exists: {}", - e - ))), - } - } -} diff --git a/src/storage/azurite_auth.rs b/src/storage/azurite_auth.rs deleted file mode 100644 index 53a2bc5..0000000 --- a/src/storage/azurite_auth.rs +++ /dev/null @@ -1,104 +0,0 @@ -use crate::error::{AppError, AppResult}; -use async_trait::async_trait; -use azure_core::credentials::{AccessToken, Secret, TokenCredential, TokenRequestOptions}; -use std::sync::Arc; - -/// A static token credential for Azurite local development. -/// -/// generates a JWT signed with the Azurite server key and returns it -/// as a static bearer token. Azurite accepts OAuth tokens without -/// validating the signature (see ). -#[derive(Clone)] -struct AzuriteTokenCredential { - token: String, -} - -impl std::fmt::Debug for AzuriteTokenCredential { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("AzuriteTokenCredential") - } -} - -#[async_trait] -impl TokenCredential for AzuriteTokenCredential { - async fn get_token( - &self, - _scopes: &[&str], - _options: Option>, - ) -> azure_core::Result { - let expires_on = azure_core::time::OffsetDateTime::from_unix_timestamp(9_999_999_999) - .expect("valid timestamp"); - Ok(AccessToken { - token: Secret::new(self.token.clone()), - expires_on, - }) - } -} - -/// Generate a JWT token for Azurite authentication. -/// -/// - Reads the RSA private key from `AZURITE_KEY_PATH` env var -/// (defaults to `services/azurite/certs/server-key.pem`) -/// - Signs a JWT with RS256 containing Azure-compatible claims -fn generate_azurite_token() -> AppResult { - let key_path = std::env::var("AZURITE_KEY_PATH") - .unwrap_or_else(|_| "services/azurite/certs/server-key.pem".to_string()); - - let private_key_pem = std::fs::read_to_string(&key_path).map_err(|e| { - AppError::BadRequest(format!( - "Failed to read Azurite private key from '{}': {}. \ - Generate certs with: ./services/azurite/generate-certs.sh", - key_path, e - )) - })?; - - let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_key_pem.as_bytes()) - .map_err(|e| AppError::BadRequest(format!("Failed to parse RSA private key: {}", e)))?; - - let claims = serde_json::json!({ - "aud": "https://storage.azure.com", - "iss": "https://sts.windows.net/", - "iat": 0, - "nbf": 0, - "exp": 9_999_999_999_u64, - "acr": "1", - "aio": "", - "altsecid": "1:live.com:foo", - "amr": ["pwd"], - "appid": "foo", - "appidacr": "0", - "email": "foo@foo.com", - "family_name": "foo", - "given_name": "foo", - "groups": ["foo"], - "idp": "live.com", - "idtyp": "user", - "ipaddr": "127.0.0.1", - "name": "foo", - "oid": "23657296-5cd5-45b0-a809-d972a7f4dfe1", - "puid": "", - "rh": "foo", - "scp": "user_impersonation", - "sub": "", - "tid": "dd0d0df1-06c3-436c-8034-4b9a153097ce", - "unique_name": "live.com#foo@foo.com", - "uti": "", - "ver": "1.0", - "xms_idrel": "16 5" - }); - - let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256); - header.typ = Some("JWT".to_string()); - header.kid = Some("foo".to_string()); - - let token = jsonwebtoken::encode(&header, &claims, &encoding_key) - .map_err(|e| AppError::BadRequest(format!("Failed to encode JWT for Azurite: {}", e)))?; - - tracing::info!("Generated Azurite OAuth token for local development"); - Ok(token) -} - -pub fn build_azurite_token_credential() -> AppResult> { - let token = generate_azurite_token()?; - Ok(Arc::new(AzuriteTokenCredential { token })) -} diff --git a/src/storage/local.rs b/src/storage/local.rs deleted file mode 100644 index 5f7df03..0000000 --- a/src/storage/local.rs +++ /dev/null @@ -1,125 +0,0 @@ -use async_trait::async_trait; -use std::fs; -use std::path::PathBuf; - -use super::StorageBackend; -use crate::error::{AppError, AppResult}; - -/// Local file system storage backend -pub struct LocalStorage { - base_path: PathBuf, -} - -impl LocalStorage { - /// Create a new local storage backend - pub fn new(path: &str) -> Self { - Self { - base_path: PathBuf::from(path), - } - } - - /// Get the file path for a flag definition - fn get_file_path(&self, name: &str) -> AppResult { - // Validate filename to prevent path traversal attacks - if name.contains("..") || name.contains('/') || name.contains('\\') { - return Err(AppError::BadRequest( - "Invalid filename: cannot contain path separators or '..'".to_string(), - )); - } - - if name.is_empty() { - return Err(AppError::BadRequest("Filename cannot be empty".to_string())); - } - - Ok(self.base_path.join(name)) - } -} - -#[async_trait] -impl StorageBackend for LocalStorage { - async fn list_flags(&self) -> AppResult> { - // Create directory if it doesn't exist - if !self.base_path.exists() { - fs::create_dir_all(&self.base_path).map_err(|e| { - AppError::InternalServerError(format!("Failed to create flags directory: {}", e)) - })?; - } - - let entries = fs::read_dir(&self.base_path).map_err(|e| { - AppError::InternalServerError(format!("Failed to read flags directory: {}", e)) - })?; - - let mut files = Vec::new(); - for entry in entries { - let entry = entry.map_err(|e| { - AppError::InternalServerError(format!("Failed to read directory entry: {}", e)) - })?; - let path = entry.path(); - - if path.is_file() { - if let Some(filename) = path.file_name() { - if let Some(name_str) = filename.to_str() { - files.push(name_str.to_string()); - } - } - } - } - - files.sort(); - Ok(files) - } - - async fn read_flag(&self, name: &str) -> AppResult { - let file_path = self.get_file_path(name)?; - - if !file_path.exists() { - return Err(AppError::NotFound(format!( - "Flag definition '{}' not found", - name - ))); - } - - let content = fs::read_to_string(&file_path) - .map_err(|e| AppError::InternalServerError(format!("Failed to read file: {}", e)))?; - - serde_json::from_str(&content) - .map_err(|e| AppError::InternalServerError(format!("Failed to parse JSON: {}", e))) - } - - async fn write_flag(&self, name: &str, content: &serde_json::Value) -> AppResult<()> { - let file_path = self.get_file_path(name)?; - - // Create the directory if it doesn't exist - if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent).map_err(|e| { - AppError::InternalServerError(format!("Failed to create directory: {}", e)) - })?; - } - - let json_string = serde_json::to_string_pretty(content).map_err(|e| { - AppError::InternalServerError(format!("Failed to serialize JSON: {}", e)) - })?; - - fs::write(&file_path, json_string) - .map_err(|e| AppError::InternalServerError(format!("Failed to write file: {}", e))) - } - - async fn delete_flag(&self, name: &str) -> AppResult<()> { - let file_path = self.get_file_path(name)?; - - if !file_path.exists() { - return Err(AppError::NotFound(format!( - "Flag definition '{}' not found", - name - ))); - } - - fs::remove_file(&file_path) - .map_err(|e| AppError::InternalServerError(format!("Failed to delete file: {}", e))) - } - - async fn flag_exists(&self, name: &str) -> AppResult { - let file_path = self.get_file_path(name)?; - Ok(file_path.exists()) - } -} diff --git a/src/storage/mod.rs b/src/storage/mod.rs deleted file mode 100644 index eea07f5..0000000 --- a/src/storage/mod.rs +++ /dev/null @@ -1,48 +0,0 @@ -mod azure; -#[cfg(feature = "azurite-local-auth")] -mod azurite_auth; -mod local; - -pub use azure::AzureStorage; -pub use local::LocalStorage; - -use crate::error::AppResult; -use async_trait::async_trait; -use std::sync::Arc; - -/// Trait defining the interface for flag storage backends -#[async_trait] -pub trait StorageBackend: Send + Sync { - /// List all flag definition files - /// Returns a vector of flag file names - async fn list_flags(&self) -> AppResult>; - - /// Read a flag definition file by name - /// Returns the JSON content of the file - async fn read_flag(&self, name: &str) -> AppResult; - - /// Write a flag definition file - /// Creates or updates the file with the given content - async fn write_flag(&self, name: &str, content: &serde_json::Value) -> AppResult<()>; - - /// Delete a flag definition file - async fn delete_flag(&self, name: &str) -> AppResult<()>; - - /// Check if a flag definition file exists - async fn flag_exists(&self, name: &str) -> AppResult; -} - -/// Factory function to create a storage backend based on URI -pub fn create_storage_backend(uri: &str) -> AppResult> { - if uri.starts_with("azblob://") { - // Azure Blob Storage URI - Ok(Arc::new(AzureStorage::new(uri)?)) - } else if uri.starts_with("file://") { - // Local file system - let path = uri.trim_start_matches("file://"); - Ok(Arc::new(LocalStorage::new(path))) - } else { - // Default to local file system for relative or absolute paths - Ok(Arc::new(LocalStorage::new(uri))) - } -} diff --git a/wasm-tryout/.gitignore b/wasm-tryout/.gitignore deleted file mode 100644 index 8d4a6c0..0000000 --- a/wasm-tryout/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -bin -obj \ No newline at end of file diff --git a/wasm-tryout/Backend/package-lock.json b/wasm-tryout/Backend/package-lock.json deleted file mode 100644 index 442452d..0000000 --- a/wasm-tryout/Backend/package-lock.json +++ /dev/null @@ -1,405 +0,0 @@ -{ - "name": "backend", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "backend", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "rollup": "^4.57.1" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", - "fsevents": "~2.3.2" - } - } - } -} diff --git a/wasm-tryout/Backend/package.json b/wasm-tryout/Backend/package.json deleted file mode 100644 index 743cab4..0000000 --- a/wasm-tryout/Backend/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "backend", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "keywords": [], - "author": "", - "license": "ISC", - "type": "commonjs", - "dependencies": { - "rollup": "^4.57.1" - } -} From 2d1bd6d0b9ec9347ae0b862bf0a5ed9fa15fcc7c Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Fri, 6 Mar 2026 20:03:38 +0000 Subject: [PATCH 04/38] rename and check build --- .vscode/settings.json | 5 + src/Backend/FlagdController.cs | 137 ----- src/Backend/FlagdService.cs | 575 ------------------ .../OpenFeatureManager.Api.csproj | 13 + src/OpenFeatureManager.Api/Program.cs | 92 +++ src/OpenFeatureManager.Api/appsettings.json | 11 + src/OpenFeatureManager.Api/flagd.db | Bin 0 -> 28672 bytes .../LinkerConfig.xml | 3 +- .../OpenFeatureManager.Wasm.csproj} | 7 +- .../Program.cs | 3 +- .../TrimmerRoots.cs | 2 +- src/OpenFeatureManager.Wasm/WasmBindings.cs | 54 ++ src/OpenFeatureManager.slnx | 5 + src/OpenFeatureManager/DatabaseManager.cs | 73 +++ .../EnvironmentEntry.cs | 2 +- .../FlagEntry.cs | 2 +- .../FlagFile.cs | 2 +- .../FlagdDbContext.cs | 11 +- .../FlagdJsonContext.cs | 4 +- src/OpenFeatureManager/FlagdService.cs | 422 +++++++++++++ .../OpenFeatureManager.csproj | 14 + 21 files changed, 712 insertions(+), 725 deletions(-) create mode 100644 .vscode/settings.json delete mode 100644 src/Backend/FlagdController.cs delete mode 100644 src/Backend/FlagdService.cs create mode 100644 src/OpenFeatureManager.Api/OpenFeatureManager.Api.csproj create mode 100644 src/OpenFeatureManager.Api/Program.cs create mode 100644 src/OpenFeatureManager.Api/appsettings.json create mode 100644 src/OpenFeatureManager.Api/flagd.db rename src/{Backend => OpenFeatureManager.Wasm}/LinkerConfig.xml (76%) rename src/{Backend/Backend.csproj => OpenFeatureManager.Wasm/OpenFeatureManager.Wasm.csproj} (77%) rename src/{Backend => OpenFeatureManager.Wasm}/Program.cs (91%) rename src/{Backend => OpenFeatureManager.Wasm}/TrimmerRoots.cs (96%) create mode 100644 src/OpenFeatureManager.Wasm/WasmBindings.cs create mode 100644 src/OpenFeatureManager.slnx create mode 100644 src/OpenFeatureManager/DatabaseManager.cs rename src/{Backend => OpenFeatureManager}/EnvironmentEntry.cs (95%) rename src/{Backend => OpenFeatureManager}/FlagEntry.cs (97%) rename src/{Backend => OpenFeatureManager}/FlagFile.cs (94%) rename src/{Backend => OpenFeatureManager}/FlagdDbContext.cs (77%) rename src/{Backend => OpenFeatureManager}/FlagdJsonContext.cs (91%) create mode 100644 src/OpenFeatureManager/FlagdService.cs create mode 100644 src/OpenFeatureManager/OpenFeatureManager.csproj diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..80ad961 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "chat.tools.terminal.autoApprove": { + "dotnet build": true + } +} diff --git a/src/Backend/FlagdController.cs b/src/Backend/FlagdController.cs deleted file mode 100644 index a4ce30a..0000000 --- a/src/Backend/FlagdController.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System.Text.Json; -using Bootsharp; - -namespace Backend; - -/// -/// REST-style controller that routes incoming requests to methods. -/// -/// Can be called from JavaScript via WASM: -/// Backend.FlagdController.handleRequest('GET', '/api/files', null) -/// or used to simulate a REST API from any other entry-point. -/// -/// Route table: -/// -/// Method + PathAction -/// GET /api/filesList all files -/// POST /api/filesCreate file – body: {"name":"…"} -/// PUT /api/files/{id}Rename file – body: {"name":"…"} -/// DELETE /api/files/{id}Delete file and all its data -/// GET /api/files/{id}/flagsList flags -/// POST /api/files/{id}/flagsUpsert flag – body: FlagEntryDto JSON -/// DELETE /api/files/{id}/flags/{key}Delete flag -/// GET /api/files/{id}/environmentsList environments -/// POST /api/files/{id}/environmentsUpsert environment – body: EnvironmentEntryDto JSON -/// DELETE /api/files/{id}/environments/{name}Delete environment -/// GET /api/files/{id}/schemaExport schema as JSON -/// POST /api/files/{id}/schemaImport schema – body: FlagdSchema JSON -/// -/// -public static class FlagdController -{ - [JSInvokable] - public static string HandleRequest(string method, string path, string? body) - { - try - { - var segments = path.Trim('/').Split('/'); - - // Must start with /api/files - if (segments.Length < 2 || segments[0] != "api" || segments[1] != "files") - return Error($"Route not found: {method} {path}"); - - // /api/files - if (segments.Length == 2) - { - return method.ToUpperInvariant() switch - { - "GET" => FlagdService.GetFiles(), - "POST" => FlagdService.CreateFile(ExtractString(body, "name")), - _ => Error("Method not allowed"), - }; - } - - if (!long.TryParse(segments[2], out var fileId)) - return Error("Invalid file ID"); - - // /api/files/{id} - if (segments.Length == 3) - { - return method.ToUpperInvariant() switch - { - "PUT" => FlagdService.RenameFile(fileId, ExtractString(body, "name")), - "DELETE" => FlagdService.DeleteFile(fileId), - _ => Error("Method not allowed"), - }; - } - - var resource = segments[3]; - - // /api/files/{id}/flags[/{key}] - if (resource == "flags") - { - if (segments.Length == 4) - { - return method.ToUpperInvariant() switch - { - "GET" => FlagdService.GetFlags(fileId), - "POST" or "PUT" => FlagdService.UpsertFlag(fileId, body ?? "{}"), - _ => Error("Method not allowed"), - }; - } - - if (segments.Length == 5 && method.Equals("DELETE", StringComparison.OrdinalIgnoreCase)) - return FlagdService.DeleteFlag(fileId, Uri.UnescapeDataString(segments[4])); - } - - // /api/files/{id}/environments[/{name}] - if (resource == "environments") - { - if (segments.Length == 4) - { - return method.ToUpperInvariant() switch - { - "GET" => FlagdService.GetEnvironments(fileId), - "POST" or "PUT" => FlagdService.UpsertEnvironment(fileId, body ?? "{}"), - _ => Error("Method not allowed"), - }; - } - - if (segments.Length == 5 && method.Equals("DELETE", StringComparison.OrdinalIgnoreCase)) - return FlagdService.DeleteEnvironment(fileId, Uri.UnescapeDataString(segments[4])); - } - - // /api/files/{id}/schema - if (resource == "schema" && segments.Length == 4) - { - return method.ToUpperInvariant() switch - { - "GET" => FlagdService.ExportSchema(fileId), - "POST" => FlagdService.ImportSchema(fileId, body ?? "{}"), - _ => Error("Method not allowed"), - }; - } - - return Error($"Route not found: {method} {path}"); - } - catch (Exception ex) - { - return Error(ex.Message); - } - } - - private static string Error(string message) => $"Error: {message}"; - - private static string ExtractString(string? json, string key) - { - if (string.IsNullOrEmpty(json)) return string.Empty; - try - { - using var doc = JsonDocument.Parse(json); - if (doc.RootElement.TryGetProperty(key, out var prop)) - return prop.GetString() ?? string.Empty; - } - catch { /* ignore */ } - return string.Empty; - } -} diff --git a/src/Backend/FlagdService.cs b/src/Backend/FlagdService.cs deleted file mode 100644 index 6a808db..0000000 --- a/src/Backend/FlagdService.cs +++ /dev/null @@ -1,575 +0,0 @@ -using System.Text; -using System.Text.Json; -using Bootsharp; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; - -namespace Backend; - -/// -/// SQLite-backed flagd schema service exposed to JS via Bootsharp. -/// -/// This is a C# port of the TypeScript FlagdSchemaAbstraction class that adds: -/// -/// Multi-file support – each flag configuration is stored as a row. -/// Persistent storage – flags and environments live in an EF-Core / SQLite in-memory DB. -/// [JSInvokable] surface – every public method is callable from JavaScript via WASM. -/// -/// -public static class FlagdService -{ - private const string EnvironmentEvaluatorPrefix = "is"; - private const string EnvironmentVarName = "environment"; - private const string BooleanOnVariant = "on"; - private const string DefaultVariant = "default"; - - private static SqliteConnection? _keepAliveConnection; - private static readonly FlagdJsonContext JsonCtx = FlagdJsonContext.Default; - private const string ConnectionString = "Data Source=FlagdDb;Mode=Memory;Cache=Shared"; - - private static FlagdDbContext CreateContext() => new(ConnectionString); - - // ─── Database lifecycle ──────────────────────────────────────────────── - - [JSInvokable] - public static string InitDatabase() - { - try - { - _keepAliveConnection = new SqliteConnection(ConnectionString); - _keepAliveConnection.Open(); - using var db = CreateContext(); - db.Database.EnsureCreated(); - return "Database initialized successfully."; - } - catch (Exception ex) - { - return $"Error: {ex.Message}"; - } - } - - /// Restore the database from raw SQLite bytes previously exported via . - [JSInvokable] - public static string ImportDatabase(byte[] data) - { - try - { - _keepAliveConnection = new SqliteConnection(ConnectionString); - _keepAliveConnection.Open(); - using (var db = CreateContext()) - { - db.Database.EnsureCreated(); - } - - var tempPath = Path.GetTempFileName(); - File.WriteAllBytes(tempPath, data); - using var source = new SqliteConnection($"Data Source={tempPath}"); - source.Open(); - source.BackupDatabase(_keepAliveConnection); - source.Close(); - try { File.Delete(tempPath); } catch { /* best effort */ } - - return "Database imported successfully."; - } - catch (Exception ex) - { - return $"Error: {ex.Message}"; - } - } - - /// Export the in-memory database as raw bytes for IndexedDB persistence. - [JSInvokable] - public static byte[] ExportDatabase() - { - if (_keepAliveConnection is null) return []; - - var tempPath = Path.GetTempFileName(); - try - { - using var dest = new SqliteConnection($"Data Source={tempPath}"); - dest.Open(); - _keepAliveConnection.BackupDatabase(dest); - dest.Close(); - return File.ReadAllBytes(tempPath); - } - finally - { - try { File.Delete(tempPath); } catch { /* best effort */ } - } - } - - // ─── File management ────────────────────────────────────────────────── - - [JSInvokable] - public static string CreateFile(string name) - { - try - { - using var db = CreateContext(); - var file = new FlagFile { Name = name }; - db.FlagFiles.Add(file); - db.SaveChanges(); - return JsonSerializer.Serialize(ToDto(file), JsonCtx.FlagFileDto); - } - catch (Exception ex) { return $"Error: {ex.Message}"; } - } - - [JSInvokable] - public static string GetFiles() - { - try - { - using var db = CreateContext(); - var dtos = db.FlagFiles.OrderBy(f => f.Id).ToList().Select(ToDto).ToList(); - return JsonSerializer.Serialize(dtos, JsonCtx.ListFlagFileDto); - } - catch (Exception ex) { return $"Error: {ex.Message}"; } - } - - [JSInvokable] - public static string RenameFile(long id, string name) - { - try - { - using var db = CreateContext(); - var file = db.FlagFiles.Find(id); - if (file is null) return $"Error: File {id} not found"; - file.Name = name; - db.SaveChanges(); - return JsonSerializer.Serialize(ToDto(file), JsonCtx.FlagFileDto); - } - catch (Exception ex) { return $"Error: {ex.Message}"; } - } - - [JSInvokable] - public static string DeleteFile(long id) - { - try - { - using var db = CreateContext(); - var file = db.FlagFiles.Find(id); - if (file is null) return $"Error: File {id} not found"; - db.FlagEntries.RemoveRange(db.FlagEntries.Where(f => f.FileId == id)); - db.EnvironmentEntries.RemoveRange(db.EnvironmentEntries.Where(e => e.FileId == id)); - db.FlagFiles.Remove(file); - db.SaveChanges(); - return $"Deleted file {id}"; - } - catch (Exception ex) { return $"Error: {ex.Message}"; } - } - - // ─── Flag management ────────────────────────────────────────────────── - - [JSInvokable] - public static string GetFlags(long fileId) - { - try - { - using var db = CreateContext(); - var dtos = db.FlagEntries - .Where(f => f.FileId == fileId) - .OrderBy(f => f.FlagKey) - .ToList() - .Select(ToDto) - .ToList(); - return JsonSerializer.Serialize(dtos, JsonCtx.ListFlagEntryDto); - } - catch (Exception ex) { return $"Error: {ex.Message}"; } - } - - /// - /// Create or update a flag. Pass a JSON-serialised . - /// Supports renaming via the optional previousKey field inside the DTO. - /// - [JSInvokable] - public static string UpsertFlag(long fileId, string flagJson) - { - try - { - var dto = JsonSerializer.Deserialize(flagJson, JsonCtx.FlagEntryDto); - if (dto is null) return "Error: Invalid flag JSON"; - - using var db = CreateContext(); - - // Handle rename: remove the old entry when the key changes - if (!string.IsNullOrEmpty(dto.PreviousKey) && dto.PreviousKey != dto.Key) - { - var old = db.FlagEntries.FirstOrDefault(f => f.FileId == fileId && f.FlagKey == dto.PreviousKey); - if (old is not null) db.FlagEntries.Remove(old); - } - - var existing = db.FlagEntries.FirstOrDefault(f => f.FileId == fileId && f.FlagKey == dto.Key); - if (existing is null) - { - existing = new FlagEntry { FileId = fileId, FlagKey = dto.Key }; - db.FlagEntries.Add(existing); - } - - existing.Type = dto.Type; - existing.State = dto.State; - existing.ValueJson = dto.ValueJson; - existing.MetadataJson = dto.MetadataJson; - existing.TargetingJson = dto.TargetingJson; - db.SaveChanges(); - - return JsonSerializer.Serialize(ToDto(existing), JsonCtx.FlagEntryDto); - } - catch (Exception ex) { return $"Error: {ex.Message}"; } - } - - [JSInvokable] - public static string DeleteFlag(long fileId, string flagKey) - { - try - { - using var db = CreateContext(); - var entry = db.FlagEntries.FirstOrDefault(f => f.FileId == fileId && f.FlagKey == flagKey); - if (entry is null) return $"Error: Flag '{flagKey}' not found in file {fileId}"; - db.FlagEntries.Remove(entry); - db.SaveChanges(); - return $"Deleted flag '{flagKey}'"; - } - catch (Exception ex) { return $"Error: {ex.Message}"; } - } - - // ─── Environment management ─────────────────────────────────────────── - - [JSInvokable] - public static string GetEnvironments(long fileId) - { - try - { - using var db = CreateContext(); - var dtos = db.EnvironmentEntries - .Where(e => e.FileId == fileId) - .OrderBy(e => e.Name) - .ToList() - .Select(ToDto) - .ToList(); - return JsonSerializer.Serialize(dtos, JsonCtx.ListEnvironmentEntryDto); - } - catch (Exception ex) { return $"Error: {ex.Message}"; } - } - - /// - /// Create or update an environment. Pass a JSON-serialised . - /// The displayName is title-cased; the key stored in the DB is its lower-case form. - /// - [JSInvokable] - public static string UpsertEnvironment(long fileId, string envJson) - { - try - { - var dto = JsonSerializer.Deserialize(envJson, JsonCtx.EnvironmentEntryDto); - if (dto is null) return "Error: Invalid environment JSON"; - - if (string.IsNullOrWhiteSpace(dto.DisplayName)) - return "Error: Environment display name is required"; - var nameLower = dto.DisplayName.ToLowerInvariant(); - using var db = CreateContext(); - - var existing = db.EnvironmentEntries.FirstOrDefault(e => e.FileId == fileId && e.Name == nameLower); - if (existing is null) - { - existing = new EnvironmentEntry { FileId = fileId, Name = nameLower }; - db.EnvironmentEntries.Add(existing); - } - - existing.AliasesJson = JsonSerializer.Serialize(dto.Aliases, JsonCtx.StringArray); - db.SaveChanges(); - - return JsonSerializer.Serialize(ToDto(existing), JsonCtx.EnvironmentEntryDto); - } - catch (Exception ex) { return $"Error: {ex.Message}"; } - } - - [JSInvokable] - public static string DeleteEnvironment(long fileId, string name) - { - try - { - var nameLower = name.ToLowerInvariant(); - using var db = CreateContext(); - var entry = db.EnvironmentEntries.FirstOrDefault(e => e.FileId == fileId && e.Name == nameLower); - if (entry is null) return $"Error: Environment '{name}' not found in file {fileId}"; - db.EnvironmentEntries.Remove(entry); - db.SaveChanges(); - return $"Deleted environment '{name}'"; - } - catch (Exception ex) { return $"Error: {ex.Message}"; } - } - - // ─── Schema import / export ─────────────────────────────────────────── - - /// - /// Parse a FlagdSchema JSON string into the database for the given file. - /// Mirrors FlagdSchemaAbstraction.fromSchema() from TypeScript. - /// Existing flags and environments for the file are replaced. - /// - [JSInvokable] - public static string ImportSchema(long fileId, string schemaJson) - { - try - { - using var doc = JsonDocument.Parse(schemaJson); - var root = doc.RootElement; - - using var db = CreateContext(); - - // Verify the target file exists - if (!db.FlagFiles.Any(f => f.Id == fileId)) - return $"Error: File {fileId} not found"; - - // Clear existing data for this file - db.EnvironmentEntries.RemoveRange(db.EnvironmentEntries.Where(e => e.FileId == fileId)); - db.FlagEntries.RemoveRange(db.FlagEntries.Where(f => f.FileId == fileId)); - db.SaveChanges(); - - // Update file-level metadata if present - if (root.TryGetProperty("metadata", out var schemaMeta)) - { - var file = db.FlagFiles.Find(fileId); - if (file is not null) - { - file.MetadataJson = schemaMeta.GetRawText(); - } - } - - // Parse $evaluators → environments (pattern: "isXxx") - if (root.TryGetProperty("$evaluators", out var evaluators)) - { - foreach (var prop in evaluators.EnumerateObject()) - { - var key = prop.Name; - if (!key.StartsWith(EnvironmentEvaluatorPrefix) || key.Length <= EnvironmentEvaluatorPrefix.Length) - continue; - - if (!prop.Value.TryGetProperty("in", out var inOp) || - inOp.ValueKind != JsonValueKind.Array || - inOp.GetArrayLength() != 2) - continue; - - var varPart = inOp[0]; - var aliasesPart = inOp[1]; - - if (!varPart.TryGetProperty("var", out var varValue) || - varValue.GetString() != EnvironmentVarName || - aliasesPart.ValueKind != JsonValueKind.Array) - continue; - - var envName = key[EnvironmentEvaluatorPrefix.Length..].ToLowerInvariant(); - var aliases = aliasesPart.EnumerateArray() - .Select(a => a.GetString() ?? string.Empty) - .Where(a => !string.IsNullOrEmpty(a)) - .ToArray(); - - db.EnvironmentEntries.Add(new EnvironmentEntry - { - FileId = fileId, - Name = envName, - AliasesJson = JsonSerializer.Serialize(aliases, JsonCtx.StringArray), - }); - } - } - - // Parse flags - if (root.TryGetProperty("flags", out var flags)) - { - foreach (var flagProp in flags.EnumerateObject()) - { - var flagKey = flagProp.Name; - var flagDef = flagProp.Value; - - var state = flagDef.TryGetProperty("state", out var stateProp) - ? stateProp.GetString() ?? "ENABLED" - : "ENABLED"; - - // Infer flag type and default value from variants + defaultVariant - var flagType = "object"; - string? valueJson = null; - - if (flagDef.TryGetProperty("variants", out var variants) && - flagDef.TryGetProperty("defaultVariant", out var defaultVariant)) - { - var defaultVariantKey = defaultVariant.GetString(); - if (!string.IsNullOrEmpty(defaultVariantKey) && - variants.TryGetProperty(defaultVariantKey, out var defaultValue)) - { - flagType = defaultValue.ValueKind switch - { - JsonValueKind.True or JsonValueKind.False => "boolean", - JsonValueKind.Number => "number", - JsonValueKind.String => "string", - _ => "object", - }; - valueJson = defaultValue.GetRawText(); - } - } - - string? metadataJson = flagDef.TryGetProperty("metadata", out var meta) - ? meta.GetRawText() : null; - - string? targetingJson = flagDef.TryGetProperty("targeting", out var targeting) - ? targeting.GetRawText() : null; - - db.FlagEntries.Add(new FlagEntry - { - FileId = fileId, - FlagKey = flagKey, - Type = flagType, - State = state, - ValueJson = valueJson, - MetadataJson = metadataJson, - TargetingJson = targetingJson, - }); - } - } - - db.SaveChanges(); - return "Schema imported successfully."; - } - catch (Exception ex) { return $"Error: {ex.Message}"; } - } - - /// - /// Reconstruct and return a FlagdSchema JSON string from the database state. - /// Mirrors FlagdSchemaAbstraction.exportSchema() from TypeScript. - /// - [JSInvokable] - public static string ExportSchema(long fileId) - { - try - { - using var db = CreateContext(); - - var file = db.FlagFiles.Find(fileId); - if (file is null) return $"Error: File {fileId} not found"; - - var environments = db.EnvironmentEntries - .Where(e => e.FileId == fileId) - .OrderBy(e => e.Name) - .ToList(); - - var flagEntries = db.FlagEntries - .Where(f => f.FileId == fileId) - .OrderBy(f => f.FlagKey) - .ToList(); - - using var stream = new MemoryStream(); - using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); - - writer.WriteStartObject(); - writer.WriteString("$schema", "https://flagd.dev/schema/v0/flags.json"); - - // flags - writer.WritePropertyName("flags"); - writer.WriteStartObject(); - foreach (var flag in flagEntries) - { - writer.WritePropertyName(flag.FlagKey); - writer.WriteStartObject(); - - writer.WriteString("state", flag.State); - - var variantKey = flag.Type == "boolean" ? BooleanOnVariant : DefaultVariant; - writer.WritePropertyName("variants"); - writer.WriteStartObject(); - writer.WritePropertyName(variantKey); - WriteRawOrNull(writer, flag.ValueJson); - writer.WriteEndObject(); - - writer.WriteString("defaultVariant", variantKey); - - if (!string.IsNullOrEmpty(flag.TargetingJson)) - { - writer.WritePropertyName("targeting"); - WriteRaw(writer, flag.TargetingJson); - } - - if (!string.IsNullOrEmpty(flag.MetadataJson)) - { - writer.WritePropertyName("metadata"); - WriteRaw(writer, flag.MetadataJson); - } - - writer.WriteEndObject(); - } - writer.WriteEndObject(); // flags - - // $evaluators (from environments) - if (environments.Count > 0) - { - writer.WritePropertyName("$evaluators"); - writer.WriteStartObject(); - foreach (var env in environments) - { - if (string.IsNullOrEmpty(env.Name)) continue; - var refKey = EnvironmentEvaluatorPrefix + - char.ToUpperInvariant(env.Name[0]) + - env.Name[1..]; - var aliases = JsonSerializer.Deserialize(env.AliasesJson, JsonCtx.StringArray) ?? []; - - writer.WritePropertyName(refKey); - writer.WriteStartObject(); - writer.WritePropertyName("in"); - writer.WriteStartArray(); - // { "var": "environment" } - writer.WriteStartObject(); - writer.WriteString("var", EnvironmentVarName); - writer.WriteEndObject(); - // aliases array - writer.WriteStartArray(); - foreach (var alias in aliases) writer.WriteStringValue(alias); - writer.WriteEndArray(); - writer.WriteEndArray(); - writer.WriteEndObject(); - } - writer.WriteEndObject(); // $evaluators - } - - // file-level metadata - if (!string.IsNullOrEmpty(file.MetadataJson)) - { - writer.WritePropertyName("metadata"); - WriteRaw(writer, file.MetadataJson); - } - - writer.WriteEndObject(); // root - writer.Flush(); - - return Encoding.UTF8.GetString(stream.ToArray()); - } - catch (Exception ex) { return $"Error: {ex.Message}"; } - } - - // ─── Private helpers ────────────────────────────────────────────────── - - private static FlagFileDto ToDto(FlagFile f) => - new(f.Id, f.Name, f.CreatedAt, f.MetadataJson); - - private static FlagEntryDto ToDto(FlagEntry e) => - new(e.FlagKey, e.Type, e.State, e.ValueJson, e.MetadataJson, e.TargetingJson); - - private static EnvironmentEntryDto ToDto(EnvironmentEntry e) - { - var aliases = JsonSerializer.Deserialize(e.AliasesJson, FlagdJsonContext.Default.StringArray) ?? []; - var displayName = string.IsNullOrEmpty(e.Name) - ? e.Name - : char.ToUpperInvariant(e.Name[0]) + e.Name[1..]; - return new EnvironmentEntryDto(e.Name, displayName, aliases); - } - - private static void WriteRaw(Utf8JsonWriter writer, string rawJson) - { - using var doc = JsonDocument.Parse(rawJson); - doc.RootElement.WriteTo(writer); - } - - private static void WriteRawOrNull(Utf8JsonWriter writer, string? rawJson) - { - if (string.IsNullOrEmpty(rawJson)) - writer.WriteNullValue(); - else - WriteRaw(writer, rawJson); - } -} diff --git a/src/OpenFeatureManager.Api/OpenFeatureManager.Api.csproj b/src/OpenFeatureManager.Api/OpenFeatureManager.Api.csproj new file mode 100644 index 0000000..65e3db1 --- /dev/null +++ b/src/OpenFeatureManager.Api/OpenFeatureManager.Api.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/src/OpenFeatureManager.Api/Program.cs b/src/OpenFeatureManager.Api/Program.cs new file mode 100644 index 0000000..6fa8b14 --- /dev/null +++ b/src/OpenFeatureManager.Api/Program.cs @@ -0,0 +1,92 @@ +using OpenFeatureManager; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +var connectionString = builder.Configuration.GetConnectionString("Flagd") + ?? "Data Source=flagd.db"; + +builder.Services.AddDbContext(options => + options.UseSqlite(connectionString)); + +builder.Services.AddScoped(sp => + new FlagdService(() => sp.GetRequiredService())); + +var app = builder.Build(); + +// Ensure database is created on startup +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); +} + +// ─── File endpoints ─────────────────────────────────────────────────── + +app.MapGet("/api/files", (FlagdService svc) => Results.Ok(svc.GetFiles())); + +app.MapPost("/api/files", (CreateFileRequest req, FlagdService svc) => + Results.Created($"/api/files/{req.Name}", svc.CreateFile(req.Name))); + +app.MapPut("/api/files/{id}", (long id, RenameFileRequest req, FlagdService svc) => + Results.Ok(svc.RenameFile(id, req.Name))); + +app.MapDelete("/api/files/{id}", (long id, FlagdService svc) => +{ + svc.DeleteFile(id); + return Results.NoContent(); +}); + +// ─── Flag endpoints ─────────────────────────────────────────────────── + +app.MapGet("/api/files/{id}/flags", (long id, FlagdService svc) => + Results.Ok(svc.GetFlags(id))); + +app.MapPost("/api/files/{id}/flags", (long id, FlagEntryDto dto, FlagdService svc) => + Results.Ok(svc.UpsertFlag(id, dto))); + +app.MapPut("/api/files/{id}/flags", (long id, FlagEntryDto dto, FlagdService svc) => + Results.Ok(svc.UpsertFlag(id, dto))); + +app.MapDelete("/api/files/{id}/flags/{key}", (long id, string key, FlagdService svc) => +{ + svc.DeleteFlag(id, Uri.UnescapeDataString(key)); + return Results.NoContent(); +}); + +// ─── Environment endpoints ──────────────────────────────────────────── + +app.MapGet("/api/files/{id}/environments", (long id, FlagdService svc) => + Results.Ok(svc.GetEnvironments(id))); + +app.MapPost("/api/files/{id}/environments", (long id, EnvironmentEntryDto dto, FlagdService svc) => + Results.Ok(svc.UpsertEnvironment(id, dto))); + +app.MapPut("/api/files/{id}/environments", (long id, EnvironmentEntryDto dto, FlagdService svc) => + Results.Ok(svc.UpsertEnvironment(id, dto))); + +app.MapDelete("/api/files/{id}/environments/{name}", (long id, string name, FlagdService svc) => +{ + svc.DeleteEnvironment(id, Uri.UnescapeDataString(name)); + return Results.NoContent(); +}); + +// ─── Schema endpoints ───────────────────────────────────────────────── + +app.MapGet("/api/files/{id}/schema", (long id, FlagdService svc) => + Results.Content(svc.ExportSchema(id), "application/json")); + +app.MapPost("/api/files/{id}/schema", async (long id, HttpRequest request, FlagdService svc) => +{ + using var reader = new StreamReader(request.Body); + var body = await reader.ReadToEndAsync(); + svc.ImportSchema(id, body); + return Results.Ok("Schema imported successfully."); +}); + +app.Run(); + +// ─── Request DTOs ───────────────────────────────────────────────────── + +record CreateFileRequest(string Name); +record RenameFileRequest(string Name); diff --git a/src/OpenFeatureManager.Api/appsettings.json b/src/OpenFeatureManager.Api/appsettings.json new file mode 100644 index 0000000..ac6e408 --- /dev/null +++ b/src/OpenFeatureManager.Api/appsettings.json @@ -0,0 +1,11 @@ +{ + "ConnectionStrings": { + "Flagd": "Data Source=flagd.db" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/OpenFeatureManager.Api/flagd.db b/src/OpenFeatureManager.Api/flagd.db new file mode 100644 index 0000000000000000000000000000000000000000..47edf7c78f1e2aa3a3077d252d95bf65884e2e27 GIT binary patch literal 28672 zcmeI(QES>z6bJCcs$p)Hy@kEq2Be0{USzPxrENAujaDP*Q^@pMvT0mR+O3aTHrSWg zHyrFE>=>`D+K7~~m+%jWNzT0|_x|otxQOkGniq05oJ_}V$jZ`7DV37mF(ye;ie3u6 zR!4?T(yI%4m)FjBou;JMKlD6Bv$@~W;h)1_`L{3LJpV}-I3NH42tWV=5P$##An?Bh zX0Nh}S}(}6y5|r0w@TL;j@(O!`{C5%fiv_*-0=p>o1gjZdQdrQnTBn$j#atnn5<%* zn_Z^u%b>A_#kBQ28Y^nzIa*1C=uw(J(=&?NF3YptYHZxU@um}hOv8ILMBg3rn0R*A zC}Y~*Pj``PInbC^TjPz|>T@Zrs7FWgT{Uz+j(9Y`#iW9Vvy2#tZJgFjG0&(8CCo88 zG;g30qiudLTg+S3-1awkma_)niq&$Bd5Ms95xfx@p;^ zWxnM!Y_m(LL^=`aPq`cNf#Zf6+laGs^S#lj*{nFA2BA0R#bbZ+^;p+M&oK{O3c1dw zVB)Vj(>Qbbynmpmg@XM3*#m*7qxI!Z$1sRyPlvegxQ}90V2^yow4xC%s&11*M8n%J zR1X_aA`*ne6d$^AN7sCtX^-OSdG7R*@cql}?m4}g%Bm+PaxXFtu12&~bpn2M&HX;# zx;)sB-U=2M#ZjW3`aCZyYPl@m8BxxdijTRK+Q_ZNw0P2D#`gc$HQinQ?#Ock`g&Wh zVetXUMSFAF^y7d41Rwwb2tWV=5P$##AOHafKp?>ai{Iw4|4;A&V`>n900bZa0SG_< z0uX=z1Rwwb62Sf+WdH#PKmY;|fB*y_009U<00Iz5z5w?B$zNm45CRZ@00bZa0SG_< z0uX=z1R#)G5a9p+Ceq6M)3PxSI)au9$31Rwwb2tWV= N5P$##AOL|#;4fW+pG*J% literal 0 HcmV?d00001 diff --git a/src/Backend/LinkerConfig.xml b/src/OpenFeatureManager.Wasm/LinkerConfig.xml similarity index 76% rename from src/Backend/LinkerConfig.xml rename to src/OpenFeatureManager.Wasm/LinkerConfig.xml index efc6ed5..3776c05 100644 --- a/src/Backend/LinkerConfig.xml +++ b/src/OpenFeatureManager.Wasm/LinkerConfig.xml @@ -4,5 +4,6 @@ - + + diff --git a/src/Backend/Backend.csproj b/src/OpenFeatureManager.Wasm/OpenFeatureManager.Wasm.csproj similarity index 77% rename from src/Backend/Backend.csproj rename to src/OpenFeatureManager.Wasm/OpenFeatureManager.Wasm.csproj index 9e963f6..37ecd77 100644 --- a/src/Backend/Backend.csproj +++ b/src/OpenFeatureManager.Wasm/OpenFeatureManager.Wasm.csproj @@ -7,6 +7,7 @@ enable Exe true + $(NoWarn);WASM0001 @@ -15,8 +16,10 @@ - - + + + + diff --git a/src/Backend/Program.cs b/src/OpenFeatureManager.Wasm/Program.cs similarity index 91% rename from src/Backend/Program.cs rename to src/OpenFeatureManager.Wasm/Program.cs index 50d02e8..66bc1d5 100644 --- a/src/Backend/Program.cs +++ b/src/OpenFeatureManager.Wasm/Program.cs @@ -1,5 +1,5 @@ using Bootsharp; -using Backend; +using OpenFeatureManager.Wasm; public static partial class Program { @@ -13,3 +13,4 @@ public static void Main() [JSEvent] public static partial void OnReady(string message); } + diff --git a/src/Backend/TrimmerRoots.cs b/src/OpenFeatureManager.Wasm/TrimmerRoots.cs similarity index 96% rename from src/Backend/TrimmerRoots.cs rename to src/OpenFeatureManager.Wasm/TrimmerRoots.cs index 7cb0066..ae526f1 100644 --- a/src/Backend/TrimmerRoots.cs +++ b/src/OpenFeatureManager.Wasm/TrimmerRoots.cs @@ -1,7 +1,7 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.EntityFrameworkCore.ChangeTracking; -namespace Backend; +namespace OpenFeatureManager.Wasm; /// /// Prevents the trimmer from removing types that EF Core needs via reflection. diff --git a/src/OpenFeatureManager.Wasm/WasmBindings.cs b/src/OpenFeatureManager.Wasm/WasmBindings.cs new file mode 100644 index 0000000..d402c51 --- /dev/null +++ b/src/OpenFeatureManager.Wasm/WasmBindings.cs @@ -0,0 +1,54 @@ +using Bootsharp; +using OpenFeatureManager; + +namespace OpenFeatureManager.Wasm; + +/// +/// WASM bindings for database lifecycle operations. +/// Exposes InitDatabase, ImportDatabase, and ExportDatabase to JavaScript via Bootsharp. +/// All domain logic lives in . +/// +public static class WasmBindings +{ + private const string ConnectionString = "Data Source=FlagdDb;Mode=Memory;Cache=Shared"; + private static readonly DatabaseManager DbManager = new(ConnectionString); + + /// The shared service instance, available after or . + public static FlagdService? Service { get; private set; } + + [JSInvokable] + public static string InitDatabase() + { + try + { + DbManager.InitializeDatabase(); + Service = new FlagdService(() => new FlagdDbContext(ConnectionString)); + return "Database initialized successfully."; + } + catch (Exception ex) + { + return $"Error: {ex.Message}"; + } + } + + [JSInvokable] + public static string ImportDatabase(byte[] data) + { + try + { + DbManager.ImportDatabase(data); + Service = new FlagdService(() => new FlagdDbContext(ConnectionString)); + return "Database imported successfully."; + } + catch (Exception ex) + { + return $"Error: {ex.Message}"; + } + } + + [JSInvokable] + public static byte[] ExportDatabase() + { + return DbManager.ExportDatabase(); + } +} diff --git a/src/OpenFeatureManager.slnx b/src/OpenFeatureManager.slnx new file mode 100644 index 0000000..bdff023 --- /dev/null +++ b/src/OpenFeatureManager.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/src/OpenFeatureManager/DatabaseManager.cs b/src/OpenFeatureManager/DatabaseManager.cs new file mode 100644 index 0000000..85d961f --- /dev/null +++ b/src/OpenFeatureManager/DatabaseManager.cs @@ -0,0 +1,73 @@ +using Microsoft.Data.Sqlite; + +namespace OpenFeatureManager; + +/// +/// Manages SQLite in-memory database lifecycle: initialization, export, and import. +/// Used by the WASM project for IndexedDB persistence and available to any host +/// that needs raw database backup/restore. +/// +public class DatabaseManager +{ + private readonly string _connectionString; + private SqliteConnection? _keepAliveConnection; + + public DatabaseManager(string connectionString) + { + _connectionString = connectionString; + } + + /// Gets the keep-alive connection (null until is called). + public SqliteConnection? KeepAliveConnection => _keepAliveConnection; + + /// + /// Open a keep-alive connection and ensure the schema is created. + /// Required for in-memory SQLite databases to prevent the DB from being destroyed. + /// + public void InitializeDatabase() + { + _keepAliveConnection = new SqliteConnection(_connectionString); + _keepAliveConnection.Open(); + using var db = new FlagdDbContext(_connectionString); + db.Database.EnsureCreated(); + } + + /// Restore the database from raw SQLite bytes previously exported via . + public void ImportDatabase(byte[] data) + { + _keepAliveConnection = new SqliteConnection(_connectionString); + _keepAliveConnection.Open(); + using (var db = new FlagdDbContext(_connectionString)) + { + db.Database.EnsureCreated(); + } + + var tempPath = Path.GetTempFileName(); + File.WriteAllBytes(tempPath, data); + using var source = new SqliteConnection($"Data Source={tempPath}"); + source.Open(); + source.BackupDatabase(_keepAliveConnection); + source.Close(); + try { File.Delete(tempPath); } catch { /* best effort */ } + } + + /// Export the in-memory database as raw bytes for persistence (e.g. IndexedDB). + public byte[] ExportDatabase() + { + if (_keepAliveConnection is null) return []; + + var tempPath = Path.GetTempFileName(); + try + { + using var dest = new SqliteConnection($"Data Source={tempPath}"); + dest.Open(); + _keepAliveConnection.BackupDatabase(dest); + dest.Close(); + return File.ReadAllBytes(tempPath); + } + finally + { + try { File.Delete(tempPath); } catch { /* best effort */ } + } + } +} diff --git a/src/Backend/EnvironmentEntry.cs b/src/OpenFeatureManager/EnvironmentEntry.cs similarity index 95% rename from src/Backend/EnvironmentEntry.cs rename to src/OpenFeatureManager/EnvironmentEntry.cs index 681ce09..b07ed30 100644 --- a/src/Backend/EnvironmentEntry.cs +++ b/src/OpenFeatureManager/EnvironmentEntry.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace Backend; +namespace OpenFeatureManager; [Table("environment_entries")] public class EnvironmentEntry diff --git a/src/Backend/FlagEntry.cs b/src/OpenFeatureManager/FlagEntry.cs similarity index 97% rename from src/Backend/FlagEntry.cs rename to src/OpenFeatureManager/FlagEntry.cs index 02f2de3..1291586 100644 --- a/src/Backend/FlagEntry.cs +++ b/src/OpenFeatureManager/FlagEntry.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace Backend; +namespace OpenFeatureManager; [Table("flag_entries")] public class FlagEntry diff --git a/src/Backend/FlagFile.cs b/src/OpenFeatureManager/FlagFile.cs similarity index 94% rename from src/Backend/FlagFile.cs rename to src/OpenFeatureManager/FlagFile.cs index 9a07d33..225454c 100644 --- a/src/Backend/FlagFile.cs +++ b/src/OpenFeatureManager/FlagFile.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace Backend; +namespace OpenFeatureManager; [Table("flag_files")] public class FlagFile diff --git a/src/Backend/FlagdDbContext.cs b/src/OpenFeatureManager/FlagdDbContext.cs similarity index 77% rename from src/Backend/FlagdDbContext.cs rename to src/OpenFeatureManager/FlagdDbContext.cs index 52960e8..c5b58bf 100644 --- a/src/Backend/FlagdDbContext.cs +++ b/src/OpenFeatureManager/FlagdDbContext.cs @@ -1,23 +1,28 @@ using Microsoft.EntityFrameworkCore; -namespace Backend; +namespace OpenFeatureManager; public class FlagdDbContext : DbContext { - private readonly string _connectionString; + private readonly string? _connectionString; public DbSet FlagFiles => Set(); public DbSet FlagEntries => Set(); public DbSet EnvironmentEntries => Set(); + /// Constructor for connection-string based configuration (WASM, standalone). public FlagdDbContext(string connectionString) { _connectionString = connectionString; } + /// Constructor for DI-based configuration (ASP.NET Core). + public FlagdDbContext(DbContextOptions options) : base(options) { } + protected override void OnConfiguring(DbContextOptionsBuilder options) { - options.UseSqlite(_connectionString); + if (!options.IsConfigured && _connectionString is not null) + options.UseSqlite(_connectionString); } protected override void OnModelCreating(ModelBuilder modelBuilder) diff --git a/src/Backend/FlagdJsonContext.cs b/src/OpenFeatureManager/FlagdJsonContext.cs similarity index 91% rename from src/Backend/FlagdJsonContext.cs rename to src/OpenFeatureManager/FlagdJsonContext.cs index 3758513..73898aa 100644 --- a/src/Backend/FlagdJsonContext.cs +++ b/src/OpenFeatureManager/FlagdJsonContext.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Backend; +namespace OpenFeatureManager; /// DTO for a flag file (schema document). public record FlagFileDto(long Id, string Name, DateTime CreatedAt, string? MetadataJson = null); @@ -8,7 +8,7 @@ public record FlagFileDto(long Id, string Name, DateTime CreatedAt, string? Meta /// /// DTO for a single flag entry. /// ValueJson, MetadataJson, and TargetingJson carry raw JSON text -/// so that arbitrary flag values and targeting rules survive the WASM boundary without +/// so that arbitrary flag values and targeting rules survive serialisation boundaries without /// further reflection-based serialisation. /// public record FlagEntryDto( diff --git a/src/OpenFeatureManager/FlagdService.cs b/src/OpenFeatureManager/FlagdService.cs new file mode 100644 index 0000000..ef8de2b --- /dev/null +++ b/src/OpenFeatureManager/FlagdService.cs @@ -0,0 +1,422 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace OpenFeatureManager; + +/// +/// SQLite-backed flagd schema service. +/// +/// Provides multi-file flag/environment management and FlagdSchema JSON import/export. +/// Instantiate with a factory so that callers +/// (WASM, REST API, tests) can supply their own DB configuration. +/// +public class FlagdService +{ + private const string EnvironmentEvaluatorPrefix = "is"; + private const string EnvironmentVarName = "environment"; + private const string BooleanOnVariant = "on"; + private const string DefaultVariant = "default"; + + private static readonly FlagdJsonContext JsonCtx = FlagdJsonContext.Default; + private readonly Func _contextFactory; + + public FlagdService(Func contextFactory) + { + _contextFactory = contextFactory; + } + + // ─── File management ────────────────────────────────────────────────── + + public FlagFileDto CreateFile(string name) + { + using var db = _contextFactory(); + var file = new FlagFile { Name = name }; + db.FlagFiles.Add(file); + db.SaveChanges(); + return ToDto(file); + } + + public List GetFiles() + { + using var db = _contextFactory(); + return db.FlagFiles.OrderBy(f => f.Id).ToList().Select(ToDto).ToList(); + } + + public FlagFileDto RenameFile(long id, string name) + { + using var db = _contextFactory(); + var file = db.FlagFiles.Find(id) ?? throw new KeyNotFoundException($"File {id} not found"); + file.Name = name; + db.SaveChanges(); + return ToDto(file); + } + + public void DeleteFile(long id) + { + using var db = _contextFactory(); + var file = db.FlagFiles.Find(id) ?? throw new KeyNotFoundException($"File {id} not found"); + db.FlagEntries.RemoveRange(db.FlagEntries.Where(f => f.FileId == id)); + db.EnvironmentEntries.RemoveRange(db.EnvironmentEntries.Where(e => e.FileId == id)); + db.FlagFiles.Remove(file); + db.SaveChanges(); + } + + // ─── Flag management ────────────────────────────────────────────────── + + public List GetFlags(long fileId) + { + using var db = _contextFactory(); + return db.FlagEntries + .Where(f => f.FileId == fileId) + .OrderBy(f => f.FlagKey) + .ToList() + .Select(ToDto) + .ToList(); + } + + /// + /// Create or update a flag from a . + /// Supports renaming via the optional PreviousKey field. + /// + public FlagEntryDto UpsertFlag(long fileId, FlagEntryDto dto) + { + using var db = _contextFactory(); + + // Handle rename: remove the old entry when the key changes + if (!string.IsNullOrEmpty(dto.PreviousKey) && dto.PreviousKey != dto.Key) + { + var old = db.FlagEntries.FirstOrDefault(f => f.FileId == fileId && f.FlagKey == dto.PreviousKey); + if (old is not null) db.FlagEntries.Remove(old); + } + + var existing = db.FlagEntries.FirstOrDefault(f => f.FileId == fileId && f.FlagKey == dto.Key); + if (existing is null) + { + existing = new FlagEntry { FileId = fileId, FlagKey = dto.Key }; + db.FlagEntries.Add(existing); + } + + existing.Type = dto.Type; + existing.State = dto.State; + existing.ValueJson = dto.ValueJson; + existing.MetadataJson = dto.MetadataJson; + existing.TargetingJson = dto.TargetingJson; + db.SaveChanges(); + + return ToDto(existing); + } + + public void DeleteFlag(long fileId, string flagKey) + { + using var db = _contextFactory(); + var entry = db.FlagEntries.FirstOrDefault(f => f.FileId == fileId && f.FlagKey == flagKey) + ?? throw new KeyNotFoundException($"Flag '{flagKey}' not found in file {fileId}"); + db.FlagEntries.Remove(entry); + db.SaveChanges(); + } + + // ─── Environment management ─────────────────────────────────────────── + + public List GetEnvironments(long fileId) + { + using var db = _contextFactory(); + return db.EnvironmentEntries + .Where(e => e.FileId == fileId) + .OrderBy(e => e.Name) + .ToList() + .Select(ToDto) + .ToList(); + } + + /// + /// Create or update an environment from a . + /// The DisplayName is title-cased; the key stored in the DB is its lower-case form. + /// + public EnvironmentEntryDto UpsertEnvironment(long fileId, EnvironmentEntryDto dto) + { + if (string.IsNullOrWhiteSpace(dto.DisplayName)) + throw new ArgumentException("Environment display name is required"); + + var nameLower = dto.DisplayName.ToLowerInvariant(); + using var db = _contextFactory(); + + var existing = db.EnvironmentEntries.FirstOrDefault(e => e.FileId == fileId && e.Name == nameLower); + if (existing is null) + { + existing = new EnvironmentEntry { FileId = fileId, Name = nameLower }; + db.EnvironmentEntries.Add(existing); + } + + existing.AliasesJson = JsonSerializer.Serialize(dto.Aliases, JsonCtx.StringArray); + db.SaveChanges(); + + return ToDto(existing); + } + + public void DeleteEnvironment(long fileId, string name) + { + var nameLower = name.ToLowerInvariant(); + using var db = _contextFactory(); + var entry = db.EnvironmentEntries.FirstOrDefault(e => e.FileId == fileId && e.Name == nameLower) + ?? throw new KeyNotFoundException($"Environment '{name}' not found in file {fileId}"); + db.EnvironmentEntries.Remove(entry); + db.SaveChanges(); + } + + // ─── Schema import / export ─────────────────────────────────────────── + + /// + /// Parse a FlagdSchema JSON string into the database for the given file. + /// Existing flags and environments for the file are replaced. + /// + public void ImportSchema(long fileId, string schemaJson) + { + using var doc = JsonDocument.Parse(schemaJson); + var root = doc.RootElement; + + using var db = _contextFactory(); + + if (!db.FlagFiles.Any(f => f.Id == fileId)) + throw new KeyNotFoundException($"File {fileId} not found"); + + // Clear existing data for this file + db.EnvironmentEntries.RemoveRange(db.EnvironmentEntries.Where(e => e.FileId == fileId)); + db.FlagEntries.RemoveRange(db.FlagEntries.Where(f => f.FileId == fileId)); + db.SaveChanges(); + + // Update file-level metadata if present + if (root.TryGetProperty("metadata", out var schemaMeta)) + { + var file = db.FlagFiles.Find(fileId); + if (file is not null) + file.MetadataJson = schemaMeta.GetRawText(); + } + + // Parse $evaluators → environments (pattern: "isXxx") + if (root.TryGetProperty("$evaluators", out var evaluators)) + { + foreach (var prop in evaluators.EnumerateObject()) + { + var key = prop.Name; + if (!key.StartsWith(EnvironmentEvaluatorPrefix) || key.Length <= EnvironmentEvaluatorPrefix.Length) + continue; + + if (!prop.Value.TryGetProperty("in", out var inOp) || + inOp.ValueKind != JsonValueKind.Array || + inOp.GetArrayLength() != 2) + continue; + + var varPart = inOp[0]; + var aliasesPart = inOp[1]; + + if (!varPart.TryGetProperty("var", out var varValue) || + varValue.GetString() != EnvironmentVarName || + aliasesPart.ValueKind != JsonValueKind.Array) + continue; + + var envName = key[EnvironmentEvaluatorPrefix.Length..].ToLowerInvariant(); + var aliases = aliasesPart.EnumerateArray() + .Select(a => a.GetString() ?? string.Empty) + .Where(a => !string.IsNullOrEmpty(a)) + .ToArray(); + + db.EnvironmentEntries.Add(new EnvironmentEntry + { + FileId = fileId, + Name = envName, + AliasesJson = JsonSerializer.Serialize(aliases, JsonCtx.StringArray), + }); + } + } + + // Parse flags + if (root.TryGetProperty("flags", out var flags)) + { + foreach (var flagProp in flags.EnumerateObject()) + { + var flagKey = flagProp.Name; + var flagDef = flagProp.Value; + + var state = flagDef.TryGetProperty("state", out var stateProp) + ? stateProp.GetString() ?? "ENABLED" + : "ENABLED"; + + var flagType = "object"; + string? valueJson = null; + + if (flagDef.TryGetProperty("variants", out var variants) && + flagDef.TryGetProperty("defaultVariant", out var defaultVariant)) + { + var defaultVariantKey = defaultVariant.GetString(); + if (!string.IsNullOrEmpty(defaultVariantKey) && + variants.TryGetProperty(defaultVariantKey, out var defaultValue)) + { + flagType = defaultValue.ValueKind switch + { + JsonValueKind.True or JsonValueKind.False => "boolean", + JsonValueKind.Number => "number", + JsonValueKind.String => "string", + _ => "object", + }; + valueJson = defaultValue.GetRawText(); + } + } + + string? metadataJson = flagDef.TryGetProperty("metadata", out var meta) + ? meta.GetRawText() : null; + + string? targetingJson = flagDef.TryGetProperty("targeting", out var targeting) + ? targeting.GetRawText() : null; + + db.FlagEntries.Add(new FlagEntry + { + FileId = fileId, + FlagKey = flagKey, + Type = flagType, + State = state, + ValueJson = valueJson, + MetadataJson = metadataJson, + TargetingJson = targetingJson, + }); + } + } + + db.SaveChanges(); + } + + /// + /// Reconstruct and return a FlagdSchema JSON string from the database state. + /// + public string ExportSchema(long fileId) + { + using var db = _contextFactory(); + + var file = db.FlagFiles.Find(fileId) + ?? throw new KeyNotFoundException($"File {fileId} not found"); + + var environments = db.EnvironmentEntries + .Where(e => e.FileId == fileId) + .OrderBy(e => e.Name) + .ToList(); + + var flagEntries = db.FlagEntries + .Where(f => f.FileId == fileId) + .OrderBy(f => f.FlagKey) + .ToList(); + + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); + + writer.WriteStartObject(); + writer.WriteString("$schema", "https://flagd.dev/schema/v0/flags.json"); + + // flags + writer.WritePropertyName("flags"); + writer.WriteStartObject(); + foreach (var flag in flagEntries) + { + writer.WritePropertyName(flag.FlagKey); + writer.WriteStartObject(); + + writer.WriteString("state", flag.State); + + var variantKey = flag.Type == "boolean" ? BooleanOnVariant : DefaultVariant; + writer.WritePropertyName("variants"); + writer.WriteStartObject(); + writer.WritePropertyName(variantKey); + WriteRawOrNull(writer, flag.ValueJson); + writer.WriteEndObject(); + + writer.WriteString("defaultVariant", variantKey); + + if (!string.IsNullOrEmpty(flag.TargetingJson)) + { + writer.WritePropertyName("targeting"); + WriteRaw(writer, flag.TargetingJson); + } + + if (!string.IsNullOrEmpty(flag.MetadataJson)) + { + writer.WritePropertyName("metadata"); + WriteRaw(writer, flag.MetadataJson); + } + + writer.WriteEndObject(); + } + writer.WriteEndObject(); // flags + + // $evaluators (from environments) + if (environments.Count > 0) + { + writer.WritePropertyName("$evaluators"); + writer.WriteStartObject(); + foreach (var env in environments) + { + if (string.IsNullOrEmpty(env.Name)) continue; + var refKey = EnvironmentEvaluatorPrefix + + char.ToUpperInvariant(env.Name[0]) + + env.Name[1..]; + var aliases = JsonSerializer.Deserialize(env.AliasesJson, JsonCtx.StringArray) ?? []; + + writer.WritePropertyName(refKey); + writer.WriteStartObject(); + writer.WritePropertyName("in"); + writer.WriteStartArray(); + writer.WriteStartObject(); + writer.WriteString("var", EnvironmentVarName); + writer.WriteEndObject(); + writer.WriteStartArray(); + foreach (var alias in aliases) writer.WriteStringValue(alias); + writer.WriteEndArray(); + writer.WriteEndArray(); + writer.WriteEndObject(); + } + writer.WriteEndObject(); // $evaluators + } + + // file-level metadata + if (!string.IsNullOrEmpty(file.MetadataJson)) + { + writer.WritePropertyName("metadata"); + WriteRaw(writer, file.MetadataJson); + } + + writer.WriteEndObject(); // root + writer.Flush(); + + return Encoding.UTF8.GetString(stream.ToArray()); + } + + // ─── Private helpers ────────────────────────────────────────────────── + + private static FlagFileDto ToDto(FlagFile f) => + new(f.Id, f.Name, f.CreatedAt, f.MetadataJson); + + private static FlagEntryDto ToDto(FlagEntry e) => + new(e.FlagKey, e.Type, e.State, e.ValueJson, e.MetadataJson, e.TargetingJson); + + private static EnvironmentEntryDto ToDto(EnvironmentEntry e) + { + var aliases = JsonSerializer.Deserialize(e.AliasesJson, FlagdJsonContext.Default.StringArray) ?? []; + var displayName = string.IsNullOrEmpty(e.Name) + ? e.Name + : char.ToUpperInvariant(e.Name[0]) + e.Name[1..]; + return new EnvironmentEntryDto(e.Name, displayName, aliases); + } + + private static void WriteRaw(Utf8JsonWriter writer, string rawJson) + { + using var doc = JsonDocument.Parse(rawJson); + doc.RootElement.WriteTo(writer); + } + + private static void WriteRawOrNull(Utf8JsonWriter writer, string? rawJson) + { + if (string.IsNullOrEmpty(rawJson)) + writer.WriteNullValue(); + else + WriteRaw(writer, rawJson); + } +} diff --git a/src/OpenFeatureManager/OpenFeatureManager.csproj b/src/OpenFeatureManager/OpenFeatureManager.csproj new file mode 100644 index 0000000..6ee21f8 --- /dev/null +++ b/src/OpenFeatureManager/OpenFeatureManager.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + + + + + + + + From 97d8d926dde398fcb96226efa7c4cf587815506e Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Fri, 6 Mar 2026 20:09:45 +0000 Subject: [PATCH 05/38] new structure --- src/OpenFeatureManager.Api/Program.cs | 4 +++- src/OpenFeatureManager.Wasm/WasmBindings.cs | 3 ++- src/OpenFeatureManager/{ => Data}/DatabaseManager.cs | 2 +- src/OpenFeatureManager/{ => Data}/FlagdDbContext.cs | 3 ++- src/OpenFeatureManager/{ => Entities}/EnvironmentEntry.cs | 2 +- src/OpenFeatureManager/{ => Entities}/FlagEntry.cs | 2 +- src/OpenFeatureManager/{ => Entities}/FlagFile.cs | 2 +- src/OpenFeatureManager/{ => Models}/FlagdJsonContext.cs | 2 +- src/OpenFeatureManager/{ => Services}/FlagdService.cs | 5 ++++- 9 files changed, 16 insertions(+), 9 deletions(-) rename src/OpenFeatureManager/{ => Data}/DatabaseManager.cs (98%) rename src/OpenFeatureManager/{ => Data}/FlagdDbContext.cs (96%) rename src/OpenFeatureManager/{ => Entities}/EnvironmentEntry.cs (94%) rename src/OpenFeatureManager/{ => Entities}/FlagEntry.cs (96%) rename src/OpenFeatureManager/{ => Entities}/FlagFile.cs (93%) rename src/OpenFeatureManager/{ => Models}/FlagdJsonContext.cs (97%) rename src/OpenFeatureManager/{ => Services}/FlagdService.cs (99%) diff --git a/src/OpenFeatureManager.Api/Program.cs b/src/OpenFeatureManager.Api/Program.cs index 6fa8b14..703188b 100644 --- a/src/OpenFeatureManager.Api/Program.cs +++ b/src/OpenFeatureManager.Api/Program.cs @@ -1,5 +1,7 @@ -using OpenFeatureManager; using Microsoft.EntityFrameworkCore; +using OpenFeatureManager.Data; +using OpenFeatureManager.Models; +using OpenFeatureManager.Services; var builder = WebApplication.CreateBuilder(args); diff --git a/src/OpenFeatureManager.Wasm/WasmBindings.cs b/src/OpenFeatureManager.Wasm/WasmBindings.cs index d402c51..4029064 100644 --- a/src/OpenFeatureManager.Wasm/WasmBindings.cs +++ b/src/OpenFeatureManager.Wasm/WasmBindings.cs @@ -1,5 +1,6 @@ using Bootsharp; -using OpenFeatureManager; +using OpenFeatureManager.Data; +using OpenFeatureManager.Services; namespace OpenFeatureManager.Wasm; diff --git a/src/OpenFeatureManager/DatabaseManager.cs b/src/OpenFeatureManager/Data/DatabaseManager.cs similarity index 98% rename from src/OpenFeatureManager/DatabaseManager.cs rename to src/OpenFeatureManager/Data/DatabaseManager.cs index 85d961f..d4da2f5 100644 --- a/src/OpenFeatureManager/DatabaseManager.cs +++ b/src/OpenFeatureManager/Data/DatabaseManager.cs @@ -1,6 +1,6 @@ using Microsoft.Data.Sqlite; -namespace OpenFeatureManager; +namespace OpenFeatureManager.Data; /// /// Manages SQLite in-memory database lifecycle: initialization, export, and import. diff --git a/src/OpenFeatureManager/FlagdDbContext.cs b/src/OpenFeatureManager/Data/FlagdDbContext.cs similarity index 96% rename from src/OpenFeatureManager/FlagdDbContext.cs rename to src/OpenFeatureManager/Data/FlagdDbContext.cs index c5b58bf..0ff90f7 100644 --- a/src/OpenFeatureManager/FlagdDbContext.cs +++ b/src/OpenFeatureManager/Data/FlagdDbContext.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; +using OpenFeatureManager.Entities; -namespace OpenFeatureManager; +namespace OpenFeatureManager.Data; public class FlagdDbContext : DbContext { diff --git a/src/OpenFeatureManager/EnvironmentEntry.cs b/src/OpenFeatureManager/Entities/EnvironmentEntry.cs similarity index 94% rename from src/OpenFeatureManager/EnvironmentEntry.cs rename to src/OpenFeatureManager/Entities/EnvironmentEntry.cs index b07ed30..76c7191 100644 --- a/src/OpenFeatureManager/EnvironmentEntry.cs +++ b/src/OpenFeatureManager/Entities/EnvironmentEntry.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace OpenFeatureManager; +namespace OpenFeatureManager.Entities; [Table("environment_entries")] public class EnvironmentEntry diff --git a/src/OpenFeatureManager/FlagEntry.cs b/src/OpenFeatureManager/Entities/FlagEntry.cs similarity index 96% rename from src/OpenFeatureManager/FlagEntry.cs rename to src/OpenFeatureManager/Entities/FlagEntry.cs index 1291586..d3c7928 100644 --- a/src/OpenFeatureManager/FlagEntry.cs +++ b/src/OpenFeatureManager/Entities/FlagEntry.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace OpenFeatureManager; +namespace OpenFeatureManager.Entities; [Table("flag_entries")] public class FlagEntry diff --git a/src/OpenFeatureManager/FlagFile.cs b/src/OpenFeatureManager/Entities/FlagFile.cs similarity index 93% rename from src/OpenFeatureManager/FlagFile.cs rename to src/OpenFeatureManager/Entities/FlagFile.cs index 225454c..2667404 100644 --- a/src/OpenFeatureManager/FlagFile.cs +++ b/src/OpenFeatureManager/Entities/FlagFile.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace OpenFeatureManager; +namespace OpenFeatureManager.Entities; [Table("flag_files")] public class FlagFile diff --git a/src/OpenFeatureManager/FlagdJsonContext.cs b/src/OpenFeatureManager/Models/FlagdJsonContext.cs similarity index 97% rename from src/OpenFeatureManager/FlagdJsonContext.cs rename to src/OpenFeatureManager/Models/FlagdJsonContext.cs index 73898aa..c2747d1 100644 --- a/src/OpenFeatureManager/FlagdJsonContext.cs +++ b/src/OpenFeatureManager/Models/FlagdJsonContext.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace OpenFeatureManager; +namespace OpenFeatureManager.Models; /// DTO for a flag file (schema document). public record FlagFileDto(long Id, string Name, DateTime CreatedAt, string? MetadataJson = null); diff --git a/src/OpenFeatureManager/FlagdService.cs b/src/OpenFeatureManager/Services/FlagdService.cs similarity index 99% rename from src/OpenFeatureManager/FlagdService.cs rename to src/OpenFeatureManager/Services/FlagdService.cs index ef8de2b..5bcac11 100644 --- a/src/OpenFeatureManager/FlagdService.cs +++ b/src/OpenFeatureManager/Services/FlagdService.cs @@ -2,8 +2,11 @@ using System.Text.Json; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using OpenFeatureManager.Data; +using OpenFeatureManager.Entities; +using OpenFeatureManager.Models; -namespace OpenFeatureManager; +namespace OpenFeatureManager.Services; /// /// SQLite-backed flagd schema service. From 099edbbda2c500dcc90aa1fee1205a712cb5f207 Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Fri, 6 Mar 2026 20:27:55 +0000 Subject: [PATCH 06/38] add json schema generation --- .../OpenFeatureManager.SchemaGenerator.csproj | 14 + .../Program.cs | 98 ++++ src/OpenFeatureManager.slnx | 1 + .../Generated/FlagdSchema.g.cs | 550 ++++++++++++++++++ .../OpenFeatureManager.csproj | 11 + 5 files changed, 674 insertions(+) create mode 100644 src/OpenFeatureManager.SchemaGenerator/OpenFeatureManager.SchemaGenerator.csproj create mode 100644 src/OpenFeatureManager.SchemaGenerator/Program.cs create mode 100644 src/OpenFeatureManager/Generated/FlagdSchema.g.cs diff --git a/src/OpenFeatureManager.SchemaGenerator/OpenFeatureManager.SchemaGenerator.csproj b/src/OpenFeatureManager.SchemaGenerator/OpenFeatureManager.SchemaGenerator.csproj new file mode 100644 index 0000000..51593f8 --- /dev/null +++ b/src/OpenFeatureManager.SchemaGenerator/OpenFeatureManager.SchemaGenerator.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/src/OpenFeatureManager.SchemaGenerator/Program.cs b/src/OpenFeatureManager.SchemaGenerator/Program.cs new file mode 100644 index 0000000..43da0d5 --- /dev/null +++ b/src/OpenFeatureManager.SchemaGenerator/Program.cs @@ -0,0 +1,98 @@ +using NJsonSchema; +using NJsonSchema.CodeGeneration; +using NJsonSchema.CodeGeneration.CSharp; + +if (args.Length < 2) +{ + Console.Error.WriteLine("Usage: OpenFeatureManager.SchemaGenerator "); + return 1; +} + +var schemaPath = Path.GetFullPath(args[0]); +var outputPath = Path.GetFullPath(args[1]); + +if (!File.Exists(schemaPath)) +{ + Console.Error.WriteLine($"Schema file not found: {schemaPath}"); + return 1; +} + +var schema = await JsonSchema.FromFileAsync(schemaPath); + +var settings = new CSharpGeneratorSettings +{ + Namespace = "OpenFeatureManager.Generated", + GenerateDataAnnotations = false, + GenerateJsonMethods = false, + ClassStyle = CSharpClassStyle.Poco, + JsonLibrary = CSharpJsonLibrary.SystemTextJson, + GenerateOptionalPropertiesAsNullable = true, + GenerateNullableReferenceTypes = true, + PropertyNameGenerator = new OperatorSafePropertyNameGenerator(), +}; + +var generator = new CSharpGenerator(schema, settings); +var code = generator.GenerateFile(); + +// Post-process: NJsonSchema cannot always resolve complex oneOf/anyOf union types. +// Replace missing "Anonymous4" (the "args" union) with System.Text.Json.JsonElement. +code = code.Replace("Anonymous4", "System.Text.Json.JsonElement"); + +// Post-process: fix duplicate enum member names for semver operators "~" and "^". +code = code.Replace( + """ + [System.Runtime.Serialization.EnumMember(Value = @"~")] + _ = 6, + + + [System.Runtime.Serialization.EnumMember(Value = @"^")] + _ = 7, + """, + """ + [System.Runtime.Serialization.EnumMember(Value = @"~")] + MatchMinor = 6, + + + [System.Runtime.Serialization.EnumMember(Value = @"^")] + MatchMajor = 7, + """); + +Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); +await File.WriteAllTextAsync(outputPath, code); + +Console.WriteLine($"Generated schema classes to: {outputPath}"); +return 0; + +/// +/// Converts JSONLogic operator property names (==, !=, <, etc.) into valid C# identifiers. +/// +sealed class OperatorSafePropertyNameGenerator : IPropertyNameGenerator +{ + private static readonly Dictionary OperatorMap = new() + { + ["=="] = "Equal", + ["==="] = "StrictEqual", + ["!="] = "NotEqual", + ["!=="] = "StrictNotEqual", + ["<"] = "LessThan", + ["<="] = "LessThanOrEqual", + [">"] = "GreaterThan", + [">="] = "GreaterThanOrEqual", + ["%"] = "Modulo", + ["/"] = "Divide", + ["*"] = "Multiply", + ["+"] = "Add", + ["-"] = "Subtract", + ["!"] = "Not", + ["!!"] = "DoubleNot", + }; + + public string Generate(JsonSchemaProperty property) + { + var name = property.Name; + if (OperatorMap.TryGetValue(name, out var mapped)) + return mapped; + return ConversionUtilities.ConvertToUpperCamelCase(name.Replace("$", string.Empty) + .Replace("@", string.Empty), firstCharacterMustBeAlpha: true); + } +} diff --git a/src/OpenFeatureManager.slnx b/src/OpenFeatureManager.slnx index bdff023..0caf93d 100644 --- a/src/OpenFeatureManager.slnx +++ b/src/OpenFeatureManager.slnx @@ -1,5 +1,6 @@ + diff --git a/src/OpenFeatureManager/Generated/FlagdSchema.g.cs b/src/OpenFeatureManager/Generated/FlagdSchema.g.cs new file mode 100644 index 0000000..aa8c9b5 --- /dev/null +++ b/src/OpenFeatureManager/Generated/FlagdSchema.g.cs @@ -0,0 +1,550 @@ +//---------------------- +// +// Generated using the NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0) (http://NJsonSchema.org) +// +//---------------------- + + +#nullable enable + + +namespace OpenFeatureManager.Generated +{ + #pragma warning disable // Disable all warnings + + /// + /// Defines flags for use in providers (not flagd), including typed variants and rules. + /// + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class Anonymous : Anonymous2 + { + + [System.Text.Json.Serialization.JsonPropertyName("flags")] + public Flags Flags { get; set; } = new Flags(); + + } + + /// + /// Defines flags for use in flagd providers, including typed variants and rules. + /// + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class Anonymous2 + { + + /// + /// Reusable targeting rules that can be referenced with "$ref": "myRule" in multiple flags. + /// + [System.Text.Json.Serialization.JsonPropertyName("$evaluators")] + public System.Collections.Generic.IDictionary? Evaluators { get; set; } = default!; + + /// + /// Metadata about the flag set, with keys of type string, and values of type boolean, string, or number. + /// + [System.Text.Json.Serialization.JsonPropertyName("metadata")] + public Metadata? Metadata { get; set; } = default!; + + private System.Collections.Generic.IDictionary? _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + /// + /// Top-level flags object. All flags are defined here. + /// + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class Flags : System.Collections.Generic.Dictionary + { + + } + + /// + /// When returned from rules, booleans are converted to strings ("true"/"false"), and used to as keys to retrieve the associated value from the "variants" object. Be sure that the returned string is present as a key in the variants! + /// + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class Primitive + { + + private System.Collections.Generic.IDictionary? _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + /// + /// Retrieve data from the provided data object. + /// + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class VarRule + { + + [System.Text.Json.Serialization.JsonPropertyName("var")] + public Var? Var { get; set; } = default!; + + } + + /// + /// Takes an array of data keys to search for (same format as var). Returns an array of any keys that are missing from the data object, or an empty array. + /// + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class MissingRule + { + + [System.Text.Json.Serialization.JsonPropertyName("missing")] + public System.Collections.Generic.ICollection? Missing { get; set; } = default!; + + } + + /// + /// Takes a minimum number of data keys that are required, and an array of keys to search for (same format as var or missing). Returns an empty array if the minimum is met, or an array of the missing keys otherwise. + /// + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class MissingSomeRule + { + + [System.Text.Json.Serialization.JsonPropertyName("missing_some")] + public System.Tuple>? Missing_some { get; set; } = default!; + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class BinaryOrTernaryOp : System.Collections.ObjectModel.Collection + { + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class BinaryOrTernaryRule + { + + /// + /// Get a portion of a string. Give a positive start position to return everything beginning at that index. Give a negative start position to work backwards from the end of the string, then return everything. Give a positive length to express how many characters to return. + /// + [System.Text.Json.Serialization.JsonPropertyName("substr")] + public BinaryOrTernaryOp? Substr { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("<")] + public BinaryOrTernaryOp? LessThan { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("<=")] + public BinaryOrTernaryOp? LessThanOrEqual { get; set; } = default!; + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class BinaryOp : System.Collections.ObjectModel.Collection + { + + } + + /// + /// Any primitive JSONLogic operation with 2 operands. + /// + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class BinaryRule + { + + /// + /// The if statement takes 1 or more arguments: a condition ("if"), what to do if its true ("then", optional, defaults to returning true), and what to do if its false ("else", optional, defaults to returning false). Note that the else condition can be used as an else-if statement by adding additional arguments. + /// + [System.Text.Json.Serialization.JsonPropertyName("if")] + public VariadicOp? If { get; set; } = default!; + + /// + /// Tests equality, with type coercion. Requires two arguments. + /// + [System.Text.Json.Serialization.JsonPropertyName("==")] + public BinaryOp? Equal { get; set; } = default!; + + /// + /// Tests strict equality. Requires two arguments. + /// + [System.Text.Json.Serialization.JsonPropertyName("===")] + public BinaryOp? StrictEqual { get; set; } = default!; + + /// + /// Tests not-equal, with type coercion. + /// + [System.Text.Json.Serialization.JsonPropertyName("!=")] + public BinaryOp? NotEqual { get; set; } = default!; + + /// + /// Tests strict not-equal. + /// + [System.Text.Json.Serialization.JsonPropertyName("!==")] + public BinaryOp? StrictNotEqual { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName(">")] + public BinaryOp? GreaterThan { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName(">=")] + public BinaryOp? GreaterThanOrEqual { get; set; } = default!; + + /// + /// Finds the remainder after the first argument is divided by the second argument. + /// + [System.Text.Json.Serialization.JsonPropertyName("%")] + public BinaryOp? Modulo { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("/")] + public BinaryOp? Divide { get; set; } = default!; + + /// + /// Perform an action on every member of an array. Note, that inside the logic being used to map, var operations are relative to the array element being worked on. + /// + [System.Text.Json.Serialization.JsonPropertyName("map")] + public BinaryOp? Map { get; set; } = default!; + + /// + /// Keep only elements of the array that pass a test. Note, that inside the logic being used to filter, var operations are relative to the array element being worked on. + /// + [System.Text.Json.Serialization.JsonPropertyName("filter")] + public BinaryOp? Filter { get; set; } = default!; + + /// + /// Perform a test on each member of that array, returning true if all pass. Inside the test code, var operations are relative to the array element being tested. + /// + [System.Text.Json.Serialization.JsonPropertyName("all")] + public BinaryOp? All { get; set; } = default!; + + /// + /// Perform a test on each member of that array, returning true if none pass. Inside the test code, var operations are relative to the array element being tested. + /// + [System.Text.Json.Serialization.JsonPropertyName("none")] + public BinaryOp? None { get; set; } = default!; + + /// + /// Perform a test on each member of that array, returning true if some pass. Inside the test code, var operations are relative to the array element being tested. + /// + [System.Text.Json.Serialization.JsonPropertyName("some")] + public BinaryOp? Some { get; set; } = default!; + + /// + /// If the second argument is an array, tests that the first argument is a member of the array. + /// + [System.Text.Json.Serialization.JsonPropertyName("in")] + public BinaryOp? In { get; set; } = default!; + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class ReduceRule + { + + /// + /// Combine all the elements in an array into a single value, like adding up a list of numbers. Note, that inside the logic being used to reduce, var operations only have access to an object with a "current" and a "accumulator". + /// + [System.Text.Json.Serialization.JsonPropertyName("reduce")] + public System.Collections.Generic.ICollection? Reduce { get; set; } = default!; + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class AssociativeOp : System.Collections.ObjectModel.Collection + { + + } + + /// + /// Operation applicable to 2 or more parameters. + /// + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class AssociativeRule + { + + /// + /// Multiplication; associative, will accept and unlimited amount of arguments. + /// + [System.Text.Json.Serialization.JsonPropertyName("*")] + public AssociativeOp? Multiply { get; set; } = default!; + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class UnaryOp + { + + private System.Collections.Generic.IDictionary? _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + /// + /// Any primitive JSONLogic operation with 1 operands. + /// + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class UnaryRule + { + + /// + /// Logical negation (“not”). Takes just one argument. + /// + [System.Text.Json.Serialization.JsonPropertyName("!")] + public UnaryOp? Not { get; set; } = default!; + + /// + /// Double negation, or 'cast to a boolean'. Takes a single argument. + /// + [System.Text.Json.Serialization.JsonPropertyName("!!")] + public UnaryOp? DoubleNot { get; set; } = default!; + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class VariadicOp : System.Collections.ObjectModel.Collection + { + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class VariadicRule + { + + /// + /// Simple boolean test, with 1 or more arguments. At a more sophisticated level, "or" returns the first truthy argument, or the last argument. + /// + [System.Text.Json.Serialization.JsonPropertyName("or")] + public VariadicOp? Or { get; set; } = default!; + + /// + /// Simple boolean test, with 1 or more arguments. At a more sophisticated level, "and" returns the first falsy argument, or the last argument. + /// + [System.Text.Json.Serialization.JsonPropertyName("and")] + public VariadicOp? And { get; set; } = default!; + + /// + /// Addition; associative, will accept and unlimited amount of arguments. + /// + [System.Text.Json.Serialization.JsonPropertyName("+")] + public VariadicOp? Add { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("-")] + public VariadicOp? Subtract { get; set; } = default!; + + /// + /// Return the maximum from a list of values. + /// + [System.Text.Json.Serialization.JsonPropertyName("max")] + public VariadicOp? Max { get; set; } = default!; + + /// + /// Return the minimum from a list of values. + /// + [System.Text.Json.Serialization.JsonPropertyName("min")] + public VariadicOp? Min { get; set; } = default!; + + /// + /// Takes one or more arrays, and merges them into one array. If arguments aren't arrays, they get cast to arrays. + /// + [System.Text.Json.Serialization.JsonPropertyName("merge")] + public VariadicOp? Merge { get; set; } = default!; + + /// + /// Concatenate all the supplied arguments. Note that this is not a join or implode operation, there is no “glue” string. + /// + [System.Text.Json.Serialization.JsonPropertyName("cat")] + public VariadicOp? Cat { get; set; } = default!; + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class StringCompareArg + { + + private System.Collections.Generic.IDictionary? _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class StringCompareArgs : System.Collections.ObjectModel.Collection + { + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class StringCompareRule + { + + /// + /// The string attribute starts with the specified string value. + /// + [System.Text.Json.Serialization.JsonPropertyName("starts_with")] + public StringCompareArgs? Starts_with { get; set; } = default!; + + /// + /// The string attribute ends with the specified string value. + /// + [System.Text.Json.Serialization.JsonPropertyName("ends_with")] + public StringCompareArgs? Ends_with { get; set; } = default!; + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class RuleSemVer + { + + /// + /// Attribute matches a semantic version condition. Accepts "npm-style" range specifiers: "=", "!=", ">", "<", ">=", "<=", "~" (match minor version), "^" (match major version). + /// + [System.Text.Json.Serialization.JsonPropertyName("sem_ver")] + public System.Tuple? Sem_ver { get; set; } = default!; + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class FractionalShorthandOp : System.Collections.ObjectModel.Collection> + { + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class FractionalRule + { + + /// + /// Deterministic, pseudorandom fractional distribution. + /// + [System.Text.Json.Serialization.JsonPropertyName("fractional")] + public System.Tuple, System.Tuple>? Fractional { get; set; } = default!; + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class Reference : System.Collections.Generic.Dictionary + { + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class AnyRule + { + + private System.Collections.Generic.IDictionary? _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + /// + /// Defines targeting logic for flagd; a extension of JSONLogic, including purpose-built feature-flagging operations. Note that this schema applies to top-level objects; no additional properties are supported, including "$schema", which means built-in JSON-schema support is not possible in editors. Please use flags.json (which imports this schema) for a rich editor experience. + /// + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class Json + { + + private System.Collections.Generic.IDictionary? _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class Metadata : System.Collections.Generic.Dictionary + { + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class Anonymous3 + { + + private System.Collections.Generic.IDictionary? _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public partial class Var + { + + private System.Collections.Generic.IDictionary? _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + /// + /// Range specifiers: "=", "!=", ">", "<", ">=", "<=", "~" (match minor version), "^" (match major version). + /// + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] + public enum Anonymous5 + { + + [System.Runtime.Serialization.EnumMember(Value = @"=")] + Eq = 0, + + + [System.Runtime.Serialization.EnumMember(Value = @"!=")] + Ne = 1, + + + [System.Runtime.Serialization.EnumMember(Value = @">")] + Gt = 2, + + + [System.Runtime.Serialization.EnumMember(Value = @"<")] + Lt = 3, + + + [System.Runtime.Serialization.EnumMember(Value = @">=")] + Ge = 4, + + + [System.Runtime.Serialization.EnumMember(Value = @"<=")] + Le = 5, + + + [System.Runtime.Serialization.EnumMember(Value = @"~")] + MatchMinor = 6, + + + [System.Runtime.Serialization.EnumMember(Value = @"^")] + MatchMajor = 7, + + + } +} \ No newline at end of file diff --git a/src/OpenFeatureManager/OpenFeatureManager.csproj b/src/OpenFeatureManager/OpenFeatureManager.csproj index 6ee21f8..293e968 100644 --- a/src/OpenFeatureManager/OpenFeatureManager.csproj +++ b/src/OpenFeatureManager/OpenFeatureManager.csproj @@ -4,6 +4,9 @@ net10.0 enable enable + $(MSBuildThisFileDirectory)..\..\schema\flagd-schema.json + $(MSBuildThisFileDirectory)Generated\FlagdSchema.g.cs + $(MSBuildThisFileDirectory)..\OpenFeatureManager.SchemaGenerator\OpenFeatureManager.SchemaGenerator.csproj @@ -11,4 +14,12 @@ + + + + + From 52142829429aec15913ea63e29b50012461b14fa Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Fri, 6 Mar 2026 20:47:51 +0000 Subject: [PATCH 07/38] remove db --- .gitignore | 5 +++++ src/OpenFeatureManager.Api/flagd.db | Bin 28672 -> 0 bytes 2 files changed, 5 insertions(+) delete mode 100644 src/OpenFeatureManager.Api/flagd.db diff --git a/.gitignore b/.gitignore index bb3ae0d..5e4a0fd 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,8 @@ services/azurite/azurite-store/ services/azurite/azurite-logs/ services/azurite/certs/* !services/azurite/certs/.gitkeep + +# sqlite +*.db +*.db-shm +*.db-wal \ No newline at end of file diff --git a/src/OpenFeatureManager.Api/flagd.db b/src/OpenFeatureManager.Api/flagd.db deleted file mode 100644 index 47edf7c78f1e2aa3a3077d252d95bf65884e2e27..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28672 zcmeI(QES>z6bJCcs$p)Hy@kEq2Be0{USzPxrENAujaDP*Q^@pMvT0mR+O3aTHrSWg zHyrFE>=>`D+K7~~m+%jWNzT0|_x|otxQOkGniq05oJ_}V$jZ`7DV37mF(ye;ie3u6 zR!4?T(yI%4m)FjBou;JMKlD6Bv$@~W;h)1_`L{3LJpV}-I3NH42tWV=5P$##An?Bh zX0Nh}S}(}6y5|r0w@TL;j@(O!`{C5%fiv_*-0=p>o1gjZdQdrQnTBn$j#atnn5<%* zn_Z^u%b>A_#kBQ28Y^nzIa*1C=uw(J(=&?NF3YptYHZxU@um}hOv8ILMBg3rn0R*A zC}Y~*Pj``PInbC^TjPz|>T@Zrs7FWgT{Uz+j(9Y`#iW9Vvy2#tZJgFjG0&(8CCo88 zG;g30qiudLTg+S3-1awkma_)niq&$Bd5Ms95xfx@p;^ zWxnM!Y_m(LL^=`aPq`cNf#Zf6+laGs^S#lj*{nFA2BA0R#bbZ+^;p+M&oK{O3c1dw zVB)Vj(>Qbbynmpmg@XM3*#m*7qxI!Z$1sRyPlvegxQ}90V2^yow4xC%s&11*M8n%J zR1X_aA`*ne6d$^AN7sCtX^-OSdG7R*@cql}?m4}g%Bm+PaxXFtu12&~bpn2M&HX;# zx;)sB-U=2M#ZjW3`aCZyYPl@m8BxxdijTRK+Q_ZNw0P2D#`gc$HQinQ?#Ock`g&Wh zVetXUMSFAF^y7d41Rwwb2tWV=5P$##AOHafKp?>ai{Iw4|4;A&V`>n900bZa0SG_< z0uX=z1Rwwb62Sf+WdH#PKmY;|fB*y_009U<00Iz5z5w?B$zNm45CRZ@00bZa0SG_< z0uX=z1R#)G5a9p+Ceq6M)3PxSI)au9$31Rwwb2tWV= N5P$##AOL|#;4fW+pG*J% From 3ad954566e36876f13add4f50df38790486a47c6 Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Fri, 6 Mar 2026 20:57:36 +0000 Subject: [PATCH 08/38] use json schema generator --- src/OpenFeatureManager.Api/Program.cs | 24 +++- .../Program.cs | 16 ++- .../Generated/FlagdSchema.g.cs | 14 +-- .../Models/FlagDefinitionExtensions.cs | 27 ++++ .../OpenFeatureManager.csproj | 8 +- .../Services/FlagdService.cs | 119 +++++++++--------- .../Services/SchemaValidator.cs | 58 +++++++++ 7 files changed, 192 insertions(+), 74 deletions(-) create mode 100644 src/OpenFeatureManager/Models/FlagDefinitionExtensions.cs create mode 100644 src/OpenFeatureManager/Services/SchemaValidator.cs diff --git a/src/OpenFeatureManager.Api/Program.cs b/src/OpenFeatureManager.Api/Program.cs index 703188b..93b6804 100644 --- a/src/OpenFeatureManager.Api/Program.cs +++ b/src/OpenFeatureManager.Api/Program.cs @@ -8,11 +8,18 @@ var connectionString = builder.Configuration.GetConnectionString("Flagd") ?? "Data Source=flagd.db"; +var schemaPath = builder.Configuration["FlagdSchemaFile"] + ?? FindSchemaFile(); + +SchemaValidator? validator = null; +if (schemaPath != null && File.Exists(schemaPath)) + validator = SchemaValidator.CreateAsync(Path.GetFullPath(schemaPath)).GetAwaiter().GetResult(); + builder.Services.AddDbContext(options => options.UseSqlite(connectionString)); builder.Services.AddScoped(sp => - new FlagdService(() => sp.GetRequiredService())); + new FlagdService(() => sp.GetRequiredService(), validator)); var app = builder.Build(); @@ -88,6 +95,21 @@ app.Run(); +// ─── Local helpers ──────────────────────────────────────────────────── + +static string? FindSchemaFile() +{ + var dir = new DirectoryInfo(Directory.GetCurrentDirectory()); + while (dir != null) + { + var candidate = Path.Combine(dir.FullName, "schema", "flagd-schema.json"); + if (File.Exists(candidate)) + return candidate; + dir = dir.Parent; + } + return null; +} + // ─── Request DTOs ───────────────────────────────────────────────────── record CreateFileRequest(string Name); diff --git a/src/OpenFeatureManager.SchemaGenerator/Program.cs b/src/OpenFeatureManager.SchemaGenerator/Program.cs index 43da0d5..f928efa 100644 --- a/src/OpenFeatureManager.SchemaGenerator/Program.cs +++ b/src/OpenFeatureManager.SchemaGenerator/Program.cs @@ -1,4 +1,5 @@ -using NJsonSchema; +using System.Text.RegularExpressions; +using NJsonSchema; using NJsonSchema.CodeGeneration; using NJsonSchema.CodeGeneration.CSharp; @@ -22,7 +23,7 @@ var settings = new CSharpGeneratorSettings { Namespace = "OpenFeatureManager.Generated", - GenerateDataAnnotations = false, + GenerateDataAnnotations = true, GenerateJsonMethods = false, ClassStyle = CSharpClassStyle.Poco, JsonLibrary = CSharpJsonLibrary.SystemTextJson, @@ -57,6 +58,17 @@ MatchMajor = 7, """); +// Post-process: rename anonymous types to meaningful names. +code = Regex.Replace(code, @"\bAnonymous3\b", "FlagDefinition"); +code = Regex.Replace(code, @"\bAnonymous5\b", "SemVerOperator"); +code = Regex.Replace(code, @"\bAnonymous2\b", "BaseConfig"); +code = Regex.Replace(code, @"\bAnonymous\b", "ProviderConfig"); + +// Post-process: fix Metadata type (schema allows string|number|boolean, not just number). +code = code.Replace( + "public partial class Metadata : System.Collections.Generic.Dictionary", + "public partial class Metadata : System.Collections.Generic.Dictionary"); + Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); await File.WriteAllTextAsync(outputPath, code); diff --git a/src/OpenFeatureManager/Generated/FlagdSchema.g.cs b/src/OpenFeatureManager/Generated/FlagdSchema.g.cs index aa8c9b5..bf76843 100644 --- a/src/OpenFeatureManager/Generated/FlagdSchema.g.cs +++ b/src/OpenFeatureManager/Generated/FlagdSchema.g.cs @@ -16,7 +16,7 @@ namespace OpenFeatureManager.Generated /// Defines flags for use in providers (not flagd), including typed variants and rules. /// [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class Anonymous : Anonymous2 + public partial class ProviderConfig : BaseConfig { [System.Text.Json.Serialization.JsonPropertyName("flags")] @@ -28,7 +28,7 @@ public partial class Anonymous : Anonymous2 /// Defines flags for use in flagd providers, including typed variants and rules. /// [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class Anonymous2 + public partial class BaseConfig { /// @@ -58,7 +58,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// Top-level flags object. All flags are defined here. /// [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class Flags : System.Collections.Generic.Dictionary + public partial class Flags : System.Collections.Generic.Dictionary { } @@ -410,7 +410,7 @@ public partial class RuleSemVer /// Attribute matches a semantic version condition. Accepts "npm-style" range specifiers: "=", "!=", ">", "<", ">=", "<=", "~" (match minor version), "^" (match major version). /// [System.Text.Json.Serialization.JsonPropertyName("sem_ver")] - public System.Tuple? Sem_ver { get; set; } = default!; + public System.Tuple? Sem_ver { get; set; } = default!; } @@ -472,13 +472,13 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class Metadata : System.Collections.Generic.Dictionary + public partial class Metadata : System.Collections.Generic.Dictionary { } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class Anonymous3 + public partial class FlagDefinition { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -511,7 +511,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// Range specifiers: "=", "!=", ">", "<", ">=", "<=", "~" (match minor version), "^" (match major version). /// [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public enum Anonymous5 + public enum SemVerOperator { [System.Runtime.Serialization.EnumMember(Value = @"=")] diff --git a/src/OpenFeatureManager/Models/FlagDefinitionExtensions.cs b/src/OpenFeatureManager/Models/FlagDefinitionExtensions.cs new file mode 100644 index 0000000..943c01c --- /dev/null +++ b/src/OpenFeatureManager/Models/FlagDefinitionExtensions.cs @@ -0,0 +1,27 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenFeatureManager.Generated; + +/// +/// Adds typed flag properties to the generated class. +/// The base generated class only has JsonExtensionData; these explicit properties +/// are deserialized by System.Text.Json before unknown keys fall through to AdditionalProperties. +/// +public partial class FlagDefinition +{ + [JsonPropertyName("state")] + public string? State { get; set; } + + [JsonPropertyName("variants")] + public Dictionary? Variants { get; set; } + + [JsonPropertyName("defaultVariant")] + public string? DefaultVariant { get; set; } + + [JsonPropertyName("targeting")] + public JsonElement? Targeting { get; set; } + + [JsonPropertyName("metadata")] + public JsonElement? FlagMetadata { get; set; } +} diff --git a/src/OpenFeatureManager/OpenFeatureManager.csproj b/src/OpenFeatureManager/OpenFeatureManager.csproj index 293e968..52fa565 100644 --- a/src/OpenFeatureManager/OpenFeatureManager.csproj +++ b/src/OpenFeatureManager/OpenFeatureManager.csproj @@ -12,14 +12,12 @@ + - - + + diff --git a/src/OpenFeatureManager/Services/FlagdService.cs b/src/OpenFeatureManager/Services/FlagdService.cs index 5bcac11..ebb8bfe 100644 --- a/src/OpenFeatureManager/Services/FlagdService.cs +++ b/src/OpenFeatureManager/Services/FlagdService.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using OpenFeatureManager.Data; using OpenFeatureManager.Entities; +using OpenFeatureManager.Generated; using OpenFeatureManager.Models; namespace OpenFeatureManager.Services; @@ -24,10 +25,12 @@ public class FlagdService private static readonly FlagdJsonContext JsonCtx = FlagdJsonContext.Default; private readonly Func _contextFactory; + private readonly SchemaValidator? _validator; - public FlagdService(Func contextFactory) + public FlagdService(Func contextFactory, SchemaValidator? validator = null) { _contextFactory = contextFactory; + _validator = validator; } // ─── File management ────────────────────────────────────────────────── @@ -172,12 +175,16 @@ public void DeleteEnvironment(long fileId, string name) /// /// Parse a FlagdSchema JSON string into the database for the given file. + /// Validates the JSON against the flagd schema (if a validator is configured), + /// then deserialises into generated classes. /// Existing flags and environments for the file are replaced. /// public void ImportSchema(long fileId, string schemaJson) { - using var doc = JsonDocument.Parse(schemaJson); - var root = doc.RootElement; + _validator?.ValidateOrThrow(schemaJson); + + var config = JsonSerializer.Deserialize(schemaJson) + ?? throw new ArgumentException("Failed to deserialize schema JSON"); using var db = _contextFactory(); @@ -190,29 +197,30 @@ public void ImportSchema(long fileId, string schemaJson) db.SaveChanges(); // Update file-level metadata if present - if (root.TryGetProperty("metadata", out var schemaMeta)) + if (config.Metadata is { Count: > 0 }) { var file = db.FlagFiles.Find(fileId); if (file is not null) - file.MetadataJson = schemaMeta.GetRawText(); + file.MetadataJson = JsonSerializer.Serialize(config.Metadata); } // Parse $evaluators → environments (pattern: "isXxx") - if (root.TryGetProperty("$evaluators", out var evaluators)) + if (config.Evaluators != null) { - foreach (var prop in evaluators.EnumerateObject()) + foreach (var (key, evalValue) in config.Evaluators) { - var key = prop.Name; + if (evalValue?.AdditionalProperties is null) continue; if (!key.StartsWith(EnvironmentEvaluatorPrefix) || key.Length <= EnvironmentEvaluatorPrefix.Length) continue; - if (!prop.Value.TryGetProperty("in", out var inOp) || - inOp.ValueKind != JsonValueKind.Array || - inOp.GetArrayLength() != 2) + if (!evalValue.AdditionalProperties.TryGetValue("in", out var inObj) + || inObj is not JsonElement inElem + || inElem.ValueKind != JsonValueKind.Array + || inElem.GetArrayLength() != 2) continue; - var varPart = inOp[0]; - var aliasesPart = inOp[1]; + var varPart = inElem[0]; + var aliasesPart = inElem[1]; if (!varPart.TryGetProperty("var", out var varValue) || varValue.GetString() != EnvironmentVarName || @@ -234,56 +242,44 @@ public void ImportSchema(long fileId, string schemaJson) } } - // Parse flags - if (root.TryGetProperty("flags", out var flags)) + // Parse flags using generated FlagDefinition typed properties + foreach (var (flagKey, flagDef) in config.Flags) { - foreach (var flagProp in flags.EnumerateObject()) - { - var flagKey = flagProp.Name; - var flagDef = flagProp.Value; + if (flagDef is null) continue; - var state = flagDef.TryGetProperty("state", out var stateProp) - ? stateProp.GetString() ?? "ENABLED" - : "ENABLED"; + var state = flagDef.State ?? "ENABLED"; + var flagType = "object"; + string? valueJson = null; - var flagType = "object"; - string? valueJson = null; - - if (flagDef.TryGetProperty("variants", out var variants) && - flagDef.TryGetProperty("defaultVariant", out var defaultVariant)) - { - var defaultVariantKey = defaultVariant.GetString(); - if (!string.IsNullOrEmpty(defaultVariantKey) && - variants.TryGetProperty(defaultVariantKey, out var defaultValue)) - { - flagType = defaultValue.ValueKind switch - { - JsonValueKind.True or JsonValueKind.False => "boolean", - JsonValueKind.Number => "number", - JsonValueKind.String => "string", - _ => "object", - }; - valueJson = defaultValue.GetRawText(); - } - } - - string? metadataJson = flagDef.TryGetProperty("metadata", out var meta) - ? meta.GetRawText() : null; - - string? targetingJson = flagDef.TryGetProperty("targeting", out var targeting) - ? targeting.GetRawText() : null; - - db.FlagEntries.Add(new FlagEntry + if (flagDef.Variants != null && !string.IsNullOrEmpty(flagDef.DefaultVariant) + && flagDef.Variants.TryGetValue(flagDef.DefaultVariant, out var defaultValue)) + { + flagType = defaultValue.ValueKind switch { - FileId = fileId, - FlagKey = flagKey, - Type = flagType, - State = state, - ValueJson = valueJson, - MetadataJson = metadataJson, - TargetingJson = targetingJson, - }); + JsonValueKind.True or JsonValueKind.False => "boolean", + JsonValueKind.Number => "number", + JsonValueKind.String => "string", + _ => "object", + }; + valueJson = defaultValue.GetRawText(); } + + string? metadataJson = flagDef.FlagMetadata.HasValue + ? flagDef.FlagMetadata.Value.GetRawText() : null; + + string? targetingJson = flagDef.Targeting.HasValue + ? flagDef.Targeting.Value.GetRawText() : null; + + db.FlagEntries.Add(new FlagEntry + { + FileId = fileId, + FlagKey = flagKey, + Type = flagType, + State = state, + ValueJson = valueJson, + MetadataJson = metadataJson, + TargetingJson = targetingJson, + }); } db.SaveChanges(); @@ -291,6 +287,7 @@ public void ImportSchema(long fileId, string schemaJson) /// /// Reconstruct and return a FlagdSchema JSON string from the database state. + /// Validates the output against the flagd schema (if a validator is configured). /// public string ExportSchema(long fileId) { @@ -389,7 +386,11 @@ public string ExportSchema(long fileId) writer.WriteEndObject(); // root writer.Flush(); - return Encoding.UTF8.GetString(stream.ToArray()); + var json = Encoding.UTF8.GetString(stream.ToArray()); + + _validator?.ValidateOrThrow(json); + + return json; } // ─── Private helpers ────────────────────────────────────────────────── diff --git a/src/OpenFeatureManager/Services/SchemaValidator.cs b/src/OpenFeatureManager/Services/SchemaValidator.cs new file mode 100644 index 0000000..b7ac3f8 --- /dev/null +++ b/src/OpenFeatureManager/Services/SchemaValidator.cs @@ -0,0 +1,58 @@ +namespace OpenFeatureManager.Services; + +/// +/// Validates JSON strings against the flagd JSON Schema using NJsonSchema. +/// Create via with the path to flagd-schema.json. +/// The schema's relative $ref to targeting.json is resolved automatically +/// when both files are in the same directory. +/// +public class SchemaValidator +{ + private readonly NJsonSchema.JsonSchema _schema; + + private SchemaValidator(NJsonSchema.JsonSchema schema) + { + _schema = schema; + } + + /// + /// Load the flagd JSON Schema from disk. The file's directory is used to + /// resolve sibling $ref references (e.g. targeting.json). + /// + public static async Task CreateAsync(string schemaFilePath) + { + var schema = await NJsonSchema.JsonSchema.FromFileAsync(schemaFilePath); + return new SchemaValidator(schema); + } + + /// + /// Validate the given JSON string against the schema. + /// Throws on failure. + /// + public void ValidateOrThrow(string json) + { + var errors = _schema.Validate(json); + if (errors.Count > 0) + { + var details = errors + .Select(e => $"{e.Path}: {e.Kind}") + .ToList(); + throw new SchemaValidationException(details); + } + } +} + +/// +/// Thrown when a flagd schema JSON document fails validation. +/// +public class SchemaValidationException : Exception +{ + public IReadOnlyList Errors { get; } + + public SchemaValidationException(IReadOnlyList errors) + : base($"Schema validation failed with {errors.Count} error(s):{Environment.NewLine}" + + string.Join(Environment.NewLine, errors.Select(e => $" {e}"))) + { + Errors = errors; + } +} From 2eae5b7b6f001334c1e65eafc66e9e212678e3da Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Fri, 6 Mar 2026 22:47:04 +0000 Subject: [PATCH 09/38] remove schema --- .../OpenFeatureManager.SchemaGenerator.csproj | 14 - .../Program.cs | 110 ---- src/OpenFeatureManager.slnx | 1 - .../Generated/FlagdSchema.g.cs | 550 ------------------ .../Models/FlagDefinitionExtensions.cs | 27 - .../OpenFeatureManager.csproj | 8 - .../Services/FlagdService.cs | 2 +- 7 files changed, 1 insertion(+), 711 deletions(-) delete mode 100644 src/OpenFeatureManager.SchemaGenerator/OpenFeatureManager.SchemaGenerator.csproj delete mode 100644 src/OpenFeatureManager.SchemaGenerator/Program.cs delete mode 100644 src/OpenFeatureManager/Generated/FlagdSchema.g.cs delete mode 100644 src/OpenFeatureManager/Models/FlagDefinitionExtensions.cs diff --git a/src/OpenFeatureManager.SchemaGenerator/OpenFeatureManager.SchemaGenerator.csproj b/src/OpenFeatureManager.SchemaGenerator/OpenFeatureManager.SchemaGenerator.csproj deleted file mode 100644 index 51593f8..0000000 --- a/src/OpenFeatureManager.SchemaGenerator/OpenFeatureManager.SchemaGenerator.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - Exe - net10.0 - enable - enable - - - - - - - diff --git a/src/OpenFeatureManager.SchemaGenerator/Program.cs b/src/OpenFeatureManager.SchemaGenerator/Program.cs deleted file mode 100644 index f928efa..0000000 --- a/src/OpenFeatureManager.SchemaGenerator/Program.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System.Text.RegularExpressions; -using NJsonSchema; -using NJsonSchema.CodeGeneration; -using NJsonSchema.CodeGeneration.CSharp; - -if (args.Length < 2) -{ - Console.Error.WriteLine("Usage: OpenFeatureManager.SchemaGenerator "); - return 1; -} - -var schemaPath = Path.GetFullPath(args[0]); -var outputPath = Path.GetFullPath(args[1]); - -if (!File.Exists(schemaPath)) -{ - Console.Error.WriteLine($"Schema file not found: {schemaPath}"); - return 1; -} - -var schema = await JsonSchema.FromFileAsync(schemaPath); - -var settings = new CSharpGeneratorSettings -{ - Namespace = "OpenFeatureManager.Generated", - GenerateDataAnnotations = true, - GenerateJsonMethods = false, - ClassStyle = CSharpClassStyle.Poco, - JsonLibrary = CSharpJsonLibrary.SystemTextJson, - GenerateOptionalPropertiesAsNullable = true, - GenerateNullableReferenceTypes = true, - PropertyNameGenerator = new OperatorSafePropertyNameGenerator(), -}; - -var generator = new CSharpGenerator(schema, settings); -var code = generator.GenerateFile(); - -// Post-process: NJsonSchema cannot always resolve complex oneOf/anyOf union types. -// Replace missing "Anonymous4" (the "args" union) with System.Text.Json.JsonElement. -code = code.Replace("Anonymous4", "System.Text.Json.JsonElement"); - -// Post-process: fix duplicate enum member names for semver operators "~" and "^". -code = code.Replace( - """ - [System.Runtime.Serialization.EnumMember(Value = @"~")] - _ = 6, - - - [System.Runtime.Serialization.EnumMember(Value = @"^")] - _ = 7, - """, - """ - [System.Runtime.Serialization.EnumMember(Value = @"~")] - MatchMinor = 6, - - - [System.Runtime.Serialization.EnumMember(Value = @"^")] - MatchMajor = 7, - """); - -// Post-process: rename anonymous types to meaningful names. -code = Regex.Replace(code, @"\bAnonymous3\b", "FlagDefinition"); -code = Regex.Replace(code, @"\bAnonymous5\b", "SemVerOperator"); -code = Regex.Replace(code, @"\bAnonymous2\b", "BaseConfig"); -code = Regex.Replace(code, @"\bAnonymous\b", "ProviderConfig"); - -// Post-process: fix Metadata type (schema allows string|number|boolean, not just number). -code = code.Replace( - "public partial class Metadata : System.Collections.Generic.Dictionary", - "public partial class Metadata : System.Collections.Generic.Dictionary"); - -Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); -await File.WriteAllTextAsync(outputPath, code); - -Console.WriteLine($"Generated schema classes to: {outputPath}"); -return 0; - -/// -/// Converts JSONLogic operator property names (==, !=, <, etc.) into valid C# identifiers. -/// -sealed class OperatorSafePropertyNameGenerator : IPropertyNameGenerator -{ - private static readonly Dictionary OperatorMap = new() - { - ["=="] = "Equal", - ["==="] = "StrictEqual", - ["!="] = "NotEqual", - ["!=="] = "StrictNotEqual", - ["<"] = "LessThan", - ["<="] = "LessThanOrEqual", - [">"] = "GreaterThan", - [">="] = "GreaterThanOrEqual", - ["%"] = "Modulo", - ["/"] = "Divide", - ["*"] = "Multiply", - ["+"] = "Add", - ["-"] = "Subtract", - ["!"] = "Not", - ["!!"] = "DoubleNot", - }; - - public string Generate(JsonSchemaProperty property) - { - var name = property.Name; - if (OperatorMap.TryGetValue(name, out var mapped)) - return mapped; - return ConversionUtilities.ConvertToUpperCamelCase(name.Replace("$", string.Empty) - .Replace("@", string.Empty), firstCharacterMustBeAlpha: true); - } -} diff --git a/src/OpenFeatureManager.slnx b/src/OpenFeatureManager.slnx index 0caf93d..bdff023 100644 --- a/src/OpenFeatureManager.slnx +++ b/src/OpenFeatureManager.slnx @@ -1,6 +1,5 @@ - diff --git a/src/OpenFeatureManager/Generated/FlagdSchema.g.cs b/src/OpenFeatureManager/Generated/FlagdSchema.g.cs deleted file mode 100644 index bf76843..0000000 --- a/src/OpenFeatureManager/Generated/FlagdSchema.g.cs +++ /dev/null @@ -1,550 +0,0 @@ -//---------------------- -// -// Generated using the NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0) (http://NJsonSchema.org) -// -//---------------------- - - -#nullable enable - - -namespace OpenFeatureManager.Generated -{ - #pragma warning disable // Disable all warnings - - /// - /// Defines flags for use in providers (not flagd), including typed variants and rules. - /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class ProviderConfig : BaseConfig - { - - [System.Text.Json.Serialization.JsonPropertyName("flags")] - public Flags Flags { get; set; } = new Flags(); - - } - - /// - /// Defines flags for use in flagd providers, including typed variants and rules. - /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class BaseConfig - { - - /// - /// Reusable targeting rules that can be referenced with "$ref": "myRule" in multiple flags. - /// - [System.Text.Json.Serialization.JsonPropertyName("$evaluators")] - public System.Collections.Generic.IDictionary? Evaluators { get; set; } = default!; - - /// - /// Metadata about the flag set, with keys of type string, and values of type boolean, string, or number. - /// - [System.Text.Json.Serialization.JsonPropertyName("metadata")] - public Metadata? Metadata { get; set; } = default!; - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - /// - /// Top-level flags object. All flags are defined here. - /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class Flags : System.Collections.Generic.Dictionary - { - - } - - /// - /// When returned from rules, booleans are converted to strings ("true"/"false"), and used to as keys to retrieve the associated value from the "variants" object. Be sure that the returned string is present as a key in the variants! - /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class Primitive - { - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - /// - /// Retrieve data from the provided data object. - /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class VarRule - { - - [System.Text.Json.Serialization.JsonPropertyName("var")] - public Var? Var { get; set; } = default!; - - } - - /// - /// Takes an array of data keys to search for (same format as var). Returns an array of any keys that are missing from the data object, or an empty array. - /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class MissingRule - { - - [System.Text.Json.Serialization.JsonPropertyName("missing")] - public System.Collections.Generic.ICollection? Missing { get; set; } = default!; - - } - - /// - /// Takes a minimum number of data keys that are required, and an array of keys to search for (same format as var or missing). Returns an empty array if the minimum is met, or an array of the missing keys otherwise. - /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class MissingSomeRule - { - - [System.Text.Json.Serialization.JsonPropertyName("missing_some")] - public System.Tuple>? Missing_some { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class BinaryOrTernaryOp : System.Collections.ObjectModel.Collection - { - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class BinaryOrTernaryRule - { - - /// - /// Get a portion of a string. Give a positive start position to return everything beginning at that index. Give a negative start position to work backwards from the end of the string, then return everything. Give a positive length to express how many characters to return. - /// - [System.Text.Json.Serialization.JsonPropertyName("substr")] - public BinaryOrTernaryOp? Substr { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("<")] - public BinaryOrTernaryOp? LessThan { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("<=")] - public BinaryOrTernaryOp? LessThanOrEqual { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class BinaryOp : System.Collections.ObjectModel.Collection - { - - } - - /// - /// Any primitive JSONLogic operation with 2 operands. - /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class BinaryRule - { - - /// - /// The if statement takes 1 or more arguments: a condition ("if"), what to do if its true ("then", optional, defaults to returning true), and what to do if its false ("else", optional, defaults to returning false). Note that the else condition can be used as an else-if statement by adding additional arguments. - /// - [System.Text.Json.Serialization.JsonPropertyName("if")] - public VariadicOp? If { get; set; } = default!; - - /// - /// Tests equality, with type coercion. Requires two arguments. - /// - [System.Text.Json.Serialization.JsonPropertyName("==")] - public BinaryOp? Equal { get; set; } = default!; - - /// - /// Tests strict equality. Requires two arguments. - /// - [System.Text.Json.Serialization.JsonPropertyName("===")] - public BinaryOp? StrictEqual { get; set; } = default!; - - /// - /// Tests not-equal, with type coercion. - /// - [System.Text.Json.Serialization.JsonPropertyName("!=")] - public BinaryOp? NotEqual { get; set; } = default!; - - /// - /// Tests strict not-equal. - /// - [System.Text.Json.Serialization.JsonPropertyName("!==")] - public BinaryOp? StrictNotEqual { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName(">")] - public BinaryOp? GreaterThan { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName(">=")] - public BinaryOp? GreaterThanOrEqual { get; set; } = default!; - - /// - /// Finds the remainder after the first argument is divided by the second argument. - /// - [System.Text.Json.Serialization.JsonPropertyName("%")] - public BinaryOp? Modulo { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("/")] - public BinaryOp? Divide { get; set; } = default!; - - /// - /// Perform an action on every member of an array. Note, that inside the logic being used to map, var operations are relative to the array element being worked on. - /// - [System.Text.Json.Serialization.JsonPropertyName("map")] - public BinaryOp? Map { get; set; } = default!; - - /// - /// Keep only elements of the array that pass a test. Note, that inside the logic being used to filter, var operations are relative to the array element being worked on. - /// - [System.Text.Json.Serialization.JsonPropertyName("filter")] - public BinaryOp? Filter { get; set; } = default!; - - /// - /// Perform a test on each member of that array, returning true if all pass. Inside the test code, var operations are relative to the array element being tested. - /// - [System.Text.Json.Serialization.JsonPropertyName("all")] - public BinaryOp? All { get; set; } = default!; - - /// - /// Perform a test on each member of that array, returning true if none pass. Inside the test code, var operations are relative to the array element being tested. - /// - [System.Text.Json.Serialization.JsonPropertyName("none")] - public BinaryOp? None { get; set; } = default!; - - /// - /// Perform a test on each member of that array, returning true if some pass. Inside the test code, var operations are relative to the array element being tested. - /// - [System.Text.Json.Serialization.JsonPropertyName("some")] - public BinaryOp? Some { get; set; } = default!; - - /// - /// If the second argument is an array, tests that the first argument is a member of the array. - /// - [System.Text.Json.Serialization.JsonPropertyName("in")] - public BinaryOp? In { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class ReduceRule - { - - /// - /// Combine all the elements in an array into a single value, like adding up a list of numbers. Note, that inside the logic being used to reduce, var operations only have access to an object with a "current" and a "accumulator". - /// - [System.Text.Json.Serialization.JsonPropertyName("reduce")] - public System.Collections.Generic.ICollection? Reduce { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class AssociativeOp : System.Collections.ObjectModel.Collection - { - - } - - /// - /// Operation applicable to 2 or more parameters. - /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class AssociativeRule - { - - /// - /// Multiplication; associative, will accept and unlimited amount of arguments. - /// - [System.Text.Json.Serialization.JsonPropertyName("*")] - public AssociativeOp? Multiply { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class UnaryOp - { - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - /// - /// Any primitive JSONLogic operation with 1 operands. - /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class UnaryRule - { - - /// - /// Logical negation (“not”). Takes just one argument. - /// - [System.Text.Json.Serialization.JsonPropertyName("!")] - public UnaryOp? Not { get; set; } = default!; - - /// - /// Double negation, or 'cast to a boolean'. Takes a single argument. - /// - [System.Text.Json.Serialization.JsonPropertyName("!!")] - public UnaryOp? DoubleNot { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class VariadicOp : System.Collections.ObjectModel.Collection - { - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class VariadicRule - { - - /// - /// Simple boolean test, with 1 or more arguments. At a more sophisticated level, "or" returns the first truthy argument, or the last argument. - /// - [System.Text.Json.Serialization.JsonPropertyName("or")] - public VariadicOp? Or { get; set; } = default!; - - /// - /// Simple boolean test, with 1 or more arguments. At a more sophisticated level, "and" returns the first falsy argument, or the last argument. - /// - [System.Text.Json.Serialization.JsonPropertyName("and")] - public VariadicOp? And { get; set; } = default!; - - /// - /// Addition; associative, will accept and unlimited amount of arguments. - /// - [System.Text.Json.Serialization.JsonPropertyName("+")] - public VariadicOp? Add { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("-")] - public VariadicOp? Subtract { get; set; } = default!; - - /// - /// Return the maximum from a list of values. - /// - [System.Text.Json.Serialization.JsonPropertyName("max")] - public VariadicOp? Max { get; set; } = default!; - - /// - /// Return the minimum from a list of values. - /// - [System.Text.Json.Serialization.JsonPropertyName("min")] - public VariadicOp? Min { get; set; } = default!; - - /// - /// Takes one or more arrays, and merges them into one array. If arguments aren't arrays, they get cast to arrays. - /// - [System.Text.Json.Serialization.JsonPropertyName("merge")] - public VariadicOp? Merge { get; set; } = default!; - - /// - /// Concatenate all the supplied arguments. Note that this is not a join or implode operation, there is no “glue” string. - /// - [System.Text.Json.Serialization.JsonPropertyName("cat")] - public VariadicOp? Cat { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class StringCompareArg - { - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class StringCompareArgs : System.Collections.ObjectModel.Collection - { - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class StringCompareRule - { - - /// - /// The string attribute starts with the specified string value. - /// - [System.Text.Json.Serialization.JsonPropertyName("starts_with")] - public StringCompareArgs? Starts_with { get; set; } = default!; - - /// - /// The string attribute ends with the specified string value. - /// - [System.Text.Json.Serialization.JsonPropertyName("ends_with")] - public StringCompareArgs? Ends_with { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class RuleSemVer - { - - /// - /// Attribute matches a semantic version condition. Accepts "npm-style" range specifiers: "=", "!=", ">", "<", ">=", "<=", "~" (match minor version), "^" (match major version). - /// - [System.Text.Json.Serialization.JsonPropertyName("sem_ver")] - public System.Tuple? Sem_ver { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class FractionalShorthandOp : System.Collections.ObjectModel.Collection> - { - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class FractionalRule - { - - /// - /// Deterministic, pseudorandom fractional distribution. - /// - [System.Text.Json.Serialization.JsonPropertyName("fractional")] - public System.Tuple, System.Tuple>? Fractional { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class Reference : System.Collections.Generic.Dictionary - { - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class AnyRule - { - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - /// - /// Defines targeting logic for flagd; a extension of JSONLogic, including purpose-built feature-flagging operations. Note that this schema applies to top-level objects; no additional properties are supported, including "$schema", which means built-in JSON-schema support is not possible in editors. Please use flags.json (which imports this schema) for a rich editor experience. - /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class Json - { - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class Metadata : System.Collections.Generic.Dictionary - { - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class FlagDefinition - { - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public partial class Var - { - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - /// - /// Range specifiers: "=", "!=", ">", "<", ">=", "<=", "~" (match minor version), "^" (match major version). - /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "11.5.2.0 (Newtonsoft.Json v13.0.0.0)")] - public enum SemVerOperator - { - - [System.Runtime.Serialization.EnumMember(Value = @"=")] - Eq = 0, - - - [System.Runtime.Serialization.EnumMember(Value = @"!=")] - Ne = 1, - - - [System.Runtime.Serialization.EnumMember(Value = @">")] - Gt = 2, - - - [System.Runtime.Serialization.EnumMember(Value = @"<")] - Lt = 3, - - - [System.Runtime.Serialization.EnumMember(Value = @">=")] - Ge = 4, - - - [System.Runtime.Serialization.EnumMember(Value = @"<=")] - Le = 5, - - - [System.Runtime.Serialization.EnumMember(Value = @"~")] - MatchMinor = 6, - - - [System.Runtime.Serialization.EnumMember(Value = @"^")] - MatchMajor = 7, - - - } -} \ No newline at end of file diff --git a/src/OpenFeatureManager/Models/FlagDefinitionExtensions.cs b/src/OpenFeatureManager/Models/FlagDefinitionExtensions.cs deleted file mode 100644 index 943c01c..0000000 --- a/src/OpenFeatureManager/Models/FlagDefinitionExtensions.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace OpenFeatureManager.Generated; - -/// -/// Adds typed flag properties to the generated class. -/// The base generated class only has JsonExtensionData; these explicit properties -/// are deserialized by System.Text.Json before unknown keys fall through to AdditionalProperties. -/// -public partial class FlagDefinition -{ - [JsonPropertyName("state")] - public string? State { get; set; } - - [JsonPropertyName("variants")] - public Dictionary? Variants { get; set; } - - [JsonPropertyName("defaultVariant")] - public string? DefaultVariant { get; set; } - - [JsonPropertyName("targeting")] - public JsonElement? Targeting { get; set; } - - [JsonPropertyName("metadata")] - public JsonElement? FlagMetadata { get; set; } -} diff --git a/src/OpenFeatureManager/OpenFeatureManager.csproj b/src/OpenFeatureManager/OpenFeatureManager.csproj index 52fa565..7a1b9b4 100644 --- a/src/OpenFeatureManager/OpenFeatureManager.csproj +++ b/src/OpenFeatureManager/OpenFeatureManager.csproj @@ -4,9 +4,6 @@ net10.0 enable enable - $(MSBuildThisFileDirectory)..\..\schema\flagd-schema.json - $(MSBuildThisFileDirectory)Generated\FlagdSchema.g.cs - $(MSBuildThisFileDirectory)..\OpenFeatureManager.SchemaGenerator\OpenFeatureManager.SchemaGenerator.csproj @@ -15,9 +12,4 @@ - - - - - diff --git a/src/OpenFeatureManager/Services/FlagdService.cs b/src/OpenFeatureManager/Services/FlagdService.cs index ebb8bfe..cd239e7 100644 --- a/src/OpenFeatureManager/Services/FlagdService.cs +++ b/src/OpenFeatureManager/Services/FlagdService.cs @@ -247,7 +247,7 @@ public void ImportSchema(long fileId, string schemaJson) { if (flagDef is null) continue; - var state = flagDef.State ?? "ENABLED"; + var state = flagDef.State.ToString(); var flagType = "object"; string? valueJson = null; From f678340df990629c4ebf68b03f8c5e04a2756904 Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Fri, 6 Mar 2026 23:09:49 +0000 Subject: [PATCH 10/38] fix import/export and add tests --- src/OpenFeatureManager.Api/Program.cs | 9 +- .../FlagdSchemaServiceTests.cs | 666 ++++++++++++++++++ .../OpenFeatureManager.Tests.csproj | 26 + src/OpenFeatureManager.slnx | 1 + .../Services/FlagdSchemaService.cs | 250 +++++++ .../Services/FlagdService.cs | 277 +------- 6 files changed, 977 insertions(+), 252 deletions(-) create mode 100644 src/OpenFeatureManager.Tests/FlagdSchemaServiceTests.cs create mode 100644 src/OpenFeatureManager.Tests/OpenFeatureManager.Tests.csproj create mode 100644 src/OpenFeatureManager/Services/FlagdSchemaService.cs diff --git a/src/OpenFeatureManager.Api/Program.cs b/src/OpenFeatureManager.Api/Program.cs index 93b6804..494433e 100644 --- a/src/OpenFeatureManager.Api/Program.cs +++ b/src/OpenFeatureManager.Api/Program.cs @@ -19,7 +19,10 @@ options.UseSqlite(connectionString)); builder.Services.AddScoped(sp => - new FlagdService(() => sp.GetRequiredService(), validator)); + new FlagdService(() => sp.GetRequiredService())); + +builder.Services.AddScoped(sp => + new FlagdSchemaService(sp.GetRequiredService(), validator)); var app = builder.Build(); @@ -82,10 +85,10 @@ // ─── Schema endpoints ───────────────────────────────────────────────── -app.MapGet("/api/files/{id}/schema", (long id, FlagdService svc) => +app.MapGet("/api/files/{id}/schema", (long id, FlagdSchemaService svc) => Results.Content(svc.ExportSchema(id), "application/json")); -app.MapPost("/api/files/{id}/schema", async (long id, HttpRequest request, FlagdService svc) => +app.MapPost("/api/files/{id}/schema", async (long id, HttpRequest request, FlagdSchemaService svc) => { using var reader = new StreamReader(request.Body); var body = await reader.ReadToEndAsync(); diff --git a/src/OpenFeatureManager.Tests/FlagdSchemaServiceTests.cs b/src/OpenFeatureManager.Tests/FlagdSchemaServiceTests.cs new file mode 100644 index 0000000..cb68eb6 --- /dev/null +++ b/src/OpenFeatureManager.Tests/FlagdSchemaServiceTests.cs @@ -0,0 +1,666 @@ +using System.Text.Json; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using OpenFeatureManager.Data; +using OpenFeatureManager.Models; +using OpenFeatureManager.Services; + +namespace OpenFeatureManager.Tests; + +public class FlagdSchemaServiceTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly FlagdService _flagdService; + private readonly FlagdSchemaService _schemaService; + private readonly SchemaValidator _validator; + + public FlagdSchemaServiceTests() + { + // Shared in-memory SQLite connection (stays open for the test lifetime) + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + // Ensure schema exists + using (var ctx = new FlagdDbContext(options)) + ctx.Database.EnsureCreated(); + + _flagdService = new FlagdService(() => new FlagdDbContext(options)); + + var schemaPath = FindSchemaFile(); + _validator = SchemaValidator.CreateAsync(schemaPath).GetAwaiter().GetResult(); + + _schemaService = new FlagdSchemaService(_flagdService, _validator); + } + + public void Dispose() + { + _connection.Dispose(); + GC.SuppressFinalize(this); + } + + // ─── Import tests ───────────────────────────────────────────────────── + + [Fact] + public void ImportSchema_BooleanFlag_StoresCorrectly() + { + var file = _flagdService.CreateFile("test"); + var json = """ + { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "my-flag": { + "state": "ENABLED", + "variants": { "on": true, "off": false }, + "defaultVariant": "on" + } + } + } + """; + + _schemaService.ImportSchema(file.Id, json); + + var flags = _flagdService.GetFlags(file.Id); + Assert.Single(flags); + var flag = flags[0]; + Assert.Equal("my-flag", flag.Key); + Assert.Equal("boolean", flag.Type); + Assert.Equal("ENABLED", flag.State); + Assert.Equal("true", flag.ValueJson); + } + + [Fact] + public void ImportSchema_StringFlag_StoresCorrectly() + { + var file = _flagdService.CreateFile("test"); + var json = """ + { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "color": { + "state": "DISABLED", + "variants": { "red": "#FF0000", "blue": "#0000FF" }, + "defaultVariant": "red" + } + } + } + """; + + _schemaService.ImportSchema(file.Id, json); + + var flags = _flagdService.GetFlags(file.Id); + Assert.Single(flags); + var flag = flags[0]; + Assert.Equal("color", flag.Key); + Assert.Equal("string", flag.Type); + Assert.Equal("DISABLED", flag.State); + Assert.Equal("\"#FF0000\"", flag.ValueJson); + } + + [Fact] + public void ImportSchema_NumberFlag_StoresCorrectly() + { + var file = _flagdService.CreateFile("test"); + var json = """ + { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "rate-limit": { + "state": "ENABLED", + "variants": { "default": 100 }, + "defaultVariant": "default" + } + } + } + """; + + _schemaService.ImportSchema(file.Id, json); + + var flags = _flagdService.GetFlags(file.Id); + Assert.Single(flags); + Assert.Equal("number", flags[0].Type); + Assert.Equal("100", flags[0].ValueJson); + } + + [Fact] + public void ImportSchema_ObjectFlag_StoresCorrectly() + { + var file = _flagdService.CreateFile("test"); + var json = """ + { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "config": { + "state": "ENABLED", + "variants": { "default": { "key": "value" } }, + "defaultVariant": "default" + } + } + } + """; + + _schemaService.ImportSchema(file.Id, json); + + var flags = _flagdService.GetFlags(file.Id); + Assert.Single(flags); + Assert.Equal("object", flags[0].Type); + // ValueJson should be a valid JSON object + using var doc = JsonDocument.Parse(flags[0].ValueJson!); + Assert.Equal("value", doc.RootElement.GetProperty("key").GetString()); + } + + [Fact] + public void ImportSchema_WithTargeting_StoresTargetingJson() + { + var file = _flagdService.CreateFile("test"); + var json = """ + { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "feature": { + "state": "ENABLED", + "variants": { "on": true, "off": false }, + "defaultVariant": "on", + "targeting": { + "if": [ true, "on", "off" ] + } + } + } + } + """; + + _schemaService.ImportSchema(file.Id, json); + + var flags = _flagdService.GetFlags(file.Id); + Assert.NotNull(flags[0].TargetingJson); + using var doc = JsonDocument.Parse(flags[0].TargetingJson!); + Assert.True(doc.RootElement.TryGetProperty("if", out _)); + } + + [Fact] + public void ImportSchema_WithFlagMetadata_StoresMetadataJson() + { + var file = _flagdService.CreateFile("test"); + var json = """ + { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "feature": { + "state": "ENABLED", + "variants": { "on": true, "off": false }, + "defaultVariant": "on", + "metadata": { "owner": "team-a" } + } + } + } + """; + + _schemaService.ImportSchema(file.Id, json); + + var flags = _flagdService.GetFlags(file.Id); + Assert.NotNull(flags[0].MetadataJson); + using var doc = JsonDocument.Parse(flags[0].MetadataJson!); + Assert.Equal("team-a", doc.RootElement.GetProperty("owner").GetString()); + } + + [Fact] + public void ImportSchema_WithEvaluators_StoresEnvironments() + { + var file = _flagdService.CreateFile("test"); + var json = """ + { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "feature": { + "state": "ENABLED", + "variants": { "on": true, "off": false }, + "defaultVariant": "on" + } + }, + "$evaluators": { + "isProduction": { + "in": [ { "var": "environment" }, [ "prod", "production" ] ] + }, + "isStaging": { + "in": [ { "var": "environment" }, [ "staging" ] ] + } + } + } + """; + + _schemaService.ImportSchema(file.Id, json); + + var envs = _flagdService.GetEnvironments(file.Id); + Assert.Equal(2, envs.Count); + + var prod = envs.First(e => e.Name == "production"); + Assert.Equal("Production", prod.DisplayName); + Assert.Equal(["prod", "production"], prod.Aliases); + + var staging = envs.First(e => e.Name == "staging"); + Assert.Equal("Staging", staging.DisplayName); + Assert.Equal(["staging"], staging.Aliases); + } + + [Fact] + public void ImportSchema_WithFileMetadata_StoresOnFile() + { + var file = _flagdService.CreateFile("test"); + var json = """ + { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "feature": { + "state": "ENABLED", + "variants": { "on": true, "off": false }, + "defaultVariant": "on" + } + }, + "metadata": { "version": "1.0" } + } + """; + + _schemaService.ImportSchema(file.Id, json); + + var updatedFile = _flagdService.GetFile(file.Id); + Assert.NotNull(updatedFile.MetadataJson); + using var doc = JsonDocument.Parse(updatedFile.MetadataJson!); + Assert.Equal("1.0", doc.RootElement.GetProperty("version").GetString()); + } + + [Fact] + public void ImportSchema_MultipleFlags_StoresAll() + { + var file = _flagdService.CreateFile("test"); + var json = """ + { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "alpha": { + "state": "ENABLED", + "variants": { "on": true, "off": false }, + "defaultVariant": "on" + }, + "beta": { + "state": "DISABLED", + "variants": { "default": "hello" }, + "defaultVariant": "default" + }, + "gamma": { + "state": "ENABLED", + "variants": { "default": 42 }, + "defaultVariant": "default" + } + } + } + """; + + _schemaService.ImportSchema(file.Id, json); + + var flags = _flagdService.GetFlags(file.Id); + Assert.Equal(3, flags.Count); + Assert.Equal("boolean", flags.First(f => f.Key == "alpha").Type); + Assert.Equal("string", flags.First(f => f.Key == "beta").Type); + Assert.Equal("number", flags.First(f => f.Key == "gamma").Type); + } + + [Fact] + public void ImportSchema_ReplacesExistingData() + { + var file = _flagdService.CreateFile("test"); + + // First import + var json1 = """ + { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "old-flag": { + "state": "ENABLED", + "variants": { "on": true, "off": false }, + "defaultVariant": "on" + } + } + } + """; + _schemaService.ImportSchema(file.Id, json1); + Assert.Single(_flagdService.GetFlags(file.Id)); + + // Second import replaces + var json2 = """ + { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "new-flag": { + "state": "DISABLED", + "variants": { "default": "value" }, + "defaultVariant": "default" + } + } + } + """; + _schemaService.ImportSchema(file.Id, json2); + + var flags = _flagdService.GetFlags(file.Id); + Assert.Single(flags); + Assert.Equal("new-flag", flags[0].Key); + } + + [Fact] + public void ImportSchema_NonexistentFile_ThrowsKeyNotFoundException() + { + var json = """ + { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "feature": { + "state": "ENABLED", + "variants": { "on": true, "off": false }, + "defaultVariant": "on" + } + } + } + """; + + Assert.Throws(() => _schemaService.ImportSchema(999, json)); + } + + // ─── Export tests ───────────────────────────────────────────────────── + + [Fact] + public void ExportSchema_BooleanFlag_ProducesValidJson() + { + var file = _flagdService.CreateFile("test"); + _flagdService.UpsertFlag(file.Id, new FlagEntryDto("my-flag", "boolean", "ENABLED", "true")); + + var json = _schemaService.ExportSchema(file.Id); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + Assert.Equal("https://flagd.dev/schema/v0/flags.json", root.GetProperty("$schema").GetString()); + + var flag = root.GetProperty("flags").GetProperty("my-flag"); + Assert.Equal("ENABLED", flag.GetProperty("state").GetString()); + Assert.Equal("on", flag.GetProperty("defaultVariant").GetString()); + Assert.True(flag.GetProperty("variants").GetProperty("on").GetBoolean()); + } + + [Fact] + public void ExportSchema_NonBooleanFlag_UsesDefaultVariantKey() + { + var file = _flagdService.CreateFile("test"); + _flagdService.UpsertFlag(file.Id, new FlagEntryDto("color", "string", "ENABLED", "\"red\"")); + + var json = _schemaService.ExportSchema(file.Id); + + using var doc = JsonDocument.Parse(json); + var flag = doc.RootElement.GetProperty("flags").GetProperty("color"); + Assert.Equal("default", flag.GetProperty("defaultVariant").GetString()); + Assert.Equal("red", flag.GetProperty("variants").GetProperty("default").GetString()); + } + + [Fact] + public void ExportSchema_WithTargeting_IncludesTargetingBlock() + { + var file = _flagdService.CreateFile("test"); + var targeting = """{"if":[true,"on","off"]}"""; + _flagdService.UpsertFlag(file.Id, new FlagEntryDto("feature", "boolean", "ENABLED", "true", TargetingJson: targeting)); + + var json = _schemaService.ExportSchema(file.Id); + + using var doc = JsonDocument.Parse(json); + var flag = doc.RootElement.GetProperty("flags").GetProperty("feature"); + Assert.True(flag.TryGetProperty("targeting", out var t)); + Assert.True(t.TryGetProperty("if", out _)); + } + + [Fact] + public void ExportSchema_WithFlagMetadata_IncludesMetadataBlock() + { + var file = _flagdService.CreateFile("test"); + var metadata = """{"owner":"team-a"}"""; + _flagdService.UpsertFlag(file.Id, new FlagEntryDto("feature", "boolean", "ENABLED", "true", MetadataJson: metadata)); + + var json = _schemaService.ExportSchema(file.Id); + + using var doc = JsonDocument.Parse(json); + var flag = doc.RootElement.GetProperty("flags").GetProperty("feature"); + Assert.True(flag.TryGetProperty("metadata", out var m)); + Assert.Equal("team-a", m.GetProperty("owner").GetString()); + } + + [Fact] + public void ExportSchema_WithEnvironments_IncludesEvaluators() + { + var file = _flagdService.CreateFile("test"); + _flagdService.UpsertFlag(file.Id, new FlagEntryDto("feature", "boolean", "ENABLED", "true")); + _flagdService.UpsertEnvironment(file.Id, new EnvironmentEntryDto("production", "Production", ["prod", "production"])); + + var json = _schemaService.ExportSchema(file.Id); + + using var doc = JsonDocument.Parse(json); + var evaluators = doc.RootElement.GetProperty("$evaluators"); + var prod = evaluators.GetProperty("isProduction"); + var inArray = prod.GetProperty("in"); + Assert.Equal("environment", inArray[0].GetProperty("var").GetString()); + Assert.Equal("prod", inArray[1][0].GetString()); + Assert.Equal("production", inArray[1][1].GetString()); + } + + [Fact] + public void ExportSchema_WithFileMetadata_IncludesTopLevelMetadata() + { + var file = _flagdService.CreateFile("test"); + _flagdService.UpsertFlag(file.Id, new FlagEntryDto("feature", "boolean", "ENABLED", "true")); + _flagdService.UpdateFileMetadata(file.Id, """{"version":"2.0"}"""); + + var json = _schemaService.ExportSchema(file.Id); + + using var doc = JsonDocument.Parse(json); + var meta = doc.RootElement.GetProperty("metadata"); + Assert.Equal("2.0", meta.GetProperty("version").GetString()); + } + + [Fact] + public void ExportSchema_EmptyFile_ProducesMinimalValidJson() + { + var file = _flagdService.CreateFile("test"); + + var json = _schemaService.ExportSchema(file.Id); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + Assert.Equal("https://flagd.dev/schema/v0/flags.json", root.GetProperty("$schema").GetString()); + Assert.Empty(root.GetProperty("flags").EnumerateObject()); + } + + [Fact] + public void ExportSchema_NonexistentFile_ThrowsKeyNotFoundException() + { + Assert.Throws(() => _schemaService.ExportSchema(999)); + } + + // ─── Round-trip tests ───────────────────────────────────────────────── + + [Fact] + public void ImportThenExport_PreservesFlags() + { + var file = _flagdService.CreateFile("test"); + var input = """ + { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "bool-flag": { + "state": "ENABLED", + "variants": { "on": true, "off": false }, + "defaultVariant": "on" + }, + "str-flag": { + "state": "DISABLED", + "variants": { "a": "hello", "b": "world" }, + "defaultVariant": "a" + } + } + } + """; + + _schemaService.ImportSchema(file.Id, input); + var output = _schemaService.ExportSchema(file.Id); + + using var doc = JsonDocument.Parse(output); + var flags = doc.RootElement.GetProperty("flags"); + + // bool flag preserved + var boolFlag = flags.GetProperty("bool-flag"); + Assert.Equal("ENABLED", boolFlag.GetProperty("state").GetString()); + Assert.True(boolFlag.GetProperty("variants").GetProperty("on").GetBoolean()); + + // string flag preserved (import stores only the default variant value) + var strFlag = flags.GetProperty("str-flag"); + Assert.Equal("DISABLED", strFlag.GetProperty("state").GetString()); + } + + [Fact] + public void ImportThenExport_PreservesEnvironments() + { + var file = _flagdService.CreateFile("test"); + var input = """ + { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "feature": { + "state": "ENABLED", + "variants": { "on": true, "off": false }, + "defaultVariant": "on" + } + }, + "$evaluators": { + "isStaging": { + "in": [ { "var": "environment" }, [ "stg", "staging" ] ] + } + } + } + """; + + _schemaService.ImportSchema(file.Id, input); + var output = _schemaService.ExportSchema(file.Id); + + using var doc = JsonDocument.Parse(output); + var evaluators = doc.RootElement.GetProperty("$evaluators"); + var staging = evaluators.GetProperty("isStaging"); + var aliases = staging.GetProperty("in")[1]; + Assert.Equal("stg", aliases[0].GetString()); + Assert.Equal("staging", aliases[1].GetString()); + } + + // ─── Invalid schema tests ───────────────────────────────────────────── + + [Fact] + public void ImportSchema_InvalidJson_Throws() + { + var file = _flagdService.CreateFile("test"); + // NJsonSchema validator uses Newtonsoft internally, so invalid JSON + // throws Newtonsoft.Json.JsonReaderException (via SchemaValidationException + // or directly). We just assert any exception is thrown. + Assert.ThrowsAny(() => _schemaService.ImportSchema(file.Id, "not json at all")); + } + + [Fact] + public void ImportSchema_MissingFlags_ThrowsSchemaValidationException() + { + var file = _flagdService.CreateFile("test"); + // Valid JSON but doesn't conform to flagd schema (missing "flags" property) + var json = """{ "notFlags": {} }"""; + + Assert.Throws(() => _schemaService.ImportSchema(file.Id, json)); + } + + [Fact] + public void ImportSchema_InvalidFlagDefinition_ThrowsSchemaValidationException() + { + var file = _flagdService.CreateFile("test"); + // Flag missing required "variants" and "defaultVariant" + var json = """ + { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "bad-flag": { + "state": "ENABLED" + } + } + } + """; + + Assert.Throws(() => _schemaService.ImportSchema(file.Id, json)); + } + + [Fact] + public void ImportSchema_InvalidState_ThrowsSchemaValidationException() + { + var file = _flagdService.CreateFile("test"); + // State must be "ENABLED" or "DISABLED" + var json = """ + { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "bad": { + "state": "BROKEN", + "variants": { "on": true }, + "defaultVariant": "on" + } + } + } + """; + + Assert.Throws(() => _schemaService.ImportSchema(file.Id, json)); + } + + [Fact] + public void ImportSchema_FlagWithInvalidVariantType_ThrowsSchemaValidationException() + { + var file = _flagdService.CreateFile("test"); + // defaultVariant must be a string, not a number + var json = """ + { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "bad": { + "state": "ENABLED", + "variants": { "on": true }, + "defaultVariant": 42 + } + } + } + """; + + Assert.Throws(() => _schemaService.ImportSchema(file.Id, json)); + } + + [Fact] + public void SchemaValidationException_ContainsErrorDetails() + { + var file = _flagdService.CreateFile("test"); + var json = """{ "notFlags": {} }"""; + + var ex = Assert.Throws(() => _schemaService.ImportSchema(file.Id, json)); + Assert.NotEmpty(ex.Errors); + Assert.Contains("error", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + // ─── Helper ─────────────────────────────────────────────────────────── + + private static string FindSchemaFile() + { + var dir = new DirectoryInfo(Directory.GetCurrentDirectory()); + while (dir != null) + { + var candidate = Path.Combine(dir.FullName, "schema", "flagd-schema.json"); + if (File.Exists(candidate)) + return candidate; + dir = dir.Parent; + } + throw new FileNotFoundException("Could not find schema/flagd-schema.json"); + } +} diff --git a/src/OpenFeatureManager.Tests/OpenFeatureManager.Tests.csproj b/src/OpenFeatureManager.Tests/OpenFeatureManager.Tests.csproj new file mode 100644 index 0000000..a6c0c10 --- /dev/null +++ b/src/OpenFeatureManager.Tests/OpenFeatureManager.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/OpenFeatureManager.slnx b/src/OpenFeatureManager.slnx index bdff023..40e427b 100644 --- a/src/OpenFeatureManager.slnx +++ b/src/OpenFeatureManager.slnx @@ -1,5 +1,6 @@ + diff --git a/src/OpenFeatureManager/Services/FlagdSchemaService.cs b/src/OpenFeatureManager/Services/FlagdSchemaService.cs new file mode 100644 index 0000000..ca928eb --- /dev/null +++ b/src/OpenFeatureManager/Services/FlagdSchemaService.cs @@ -0,0 +1,250 @@ +using System.Text; +using System.Text.Json; +using OpenFeatureManager.Models; + +namespace OpenFeatureManager.Services; + +/// +/// Handles import and export of flagd JSON Schema documents. +/// +/// Converts between the flagd JSON schema format and the normalised DTO model +/// exposed by , using raw +/// parsing so there is no dependency on generated schema types. +/// +public class FlagdSchemaService +{ + private const string EnvironmentEvaluatorPrefix = "is"; + private const string EnvironmentVarName = "environment"; + private const string BooleanOnVariant = "on"; + private const string DefaultVariant = "default"; + + private readonly FlagdService _flagdService; + private readonly SchemaValidator? _validator; + + public FlagdSchemaService(FlagdService flagdService, SchemaValidator? validator = null) + { + _flagdService = flagdService; + _validator = validator; + } + + /// + /// Parse a FlagdSchema JSON string and store it via . + /// Validates the JSON against the flagd schema (if a validator is configured), + /// then parses the JSON document directly. + /// Existing flags and environments for the file are replaced. + /// + public void ImportSchema(long fileId, string schemaJson) + { + _validator?.ValidateOrThrow(schemaJson); + + using var doc = JsonDocument.Parse(schemaJson); + var root = doc.RootElement; + + // Clear existing data for this file + _flagdService.ClearFileData(fileId); + + // Update file-level metadata if present + if (root.TryGetProperty("metadata", out var metadataElem) + && metadataElem.ValueKind == JsonValueKind.Object + && metadataElem.EnumerateObject().Any()) + { + _flagdService.UpdateFileMetadata(fileId, metadataElem.GetRawText()); + } + + // Parse $evaluators → environments (pattern: "isXxx") + if (root.TryGetProperty("$evaluators", out var evaluatorsElem) + && evaluatorsElem.ValueKind == JsonValueKind.Object) + { + foreach (var evalProp in evaluatorsElem.EnumerateObject()) + { + var key = evalProp.Name; + if (!key.StartsWith(EnvironmentEvaluatorPrefix) || key.Length <= EnvironmentEvaluatorPrefix.Length) + continue; + + var evalObj = evalProp.Value; + if (evalObj.ValueKind != JsonValueKind.Object + || !evalObj.TryGetProperty("in", out var inElem) + || inElem.ValueKind != JsonValueKind.Array + || inElem.GetArrayLength() != 2) + continue; + + var varPart = inElem[0]; + var aliasesPart = inElem[1]; + + if (!varPart.TryGetProperty("var", out var varValue) || + varValue.GetString() != EnvironmentVarName || + aliasesPart.ValueKind != JsonValueKind.Array) + continue; + + var envName = key[EnvironmentEvaluatorPrefix.Length..].ToLowerInvariant(); + var displayName = char.ToUpperInvariant(envName[0]) + envName[1..]; + var aliases = aliasesPart.EnumerateArray() + .Select(a => a.GetString() ?? string.Empty) + .Where(a => !string.IsNullOrEmpty(a)) + .ToArray(); + + _flagdService.UpsertEnvironment(fileId, new EnvironmentEntryDto(envName, displayName, aliases)); + } + } + + // Parse flags + if (root.TryGetProperty("flags", out var flagsElem) + && flagsElem.ValueKind == JsonValueKind.Object) + { + foreach (var flagProp in flagsElem.EnumerateObject()) + { + var flagKey = flagProp.Name; + var flagDef = flagProp.Value; + if (flagDef.ValueKind != JsonValueKind.Object) continue; + + var state = flagDef.TryGetProperty("state", out var stateElem) + ? stateElem.GetString() ?? "ENABLED" + : "ENABLED"; + + var flagType = "object"; + string? valueJson = null; + + if (flagDef.TryGetProperty("defaultVariant", out var defaultVariantElem) + && flagDef.TryGetProperty("variants", out var variantsElem) + && variantsElem.ValueKind == JsonValueKind.Object) + { + var defaultVariantName = defaultVariantElem.GetString(); + if (!string.IsNullOrEmpty(defaultVariantName) + && variantsElem.TryGetProperty(defaultVariantName, out var defaultValue)) + { + flagType = defaultValue.ValueKind switch + { + JsonValueKind.True or JsonValueKind.False => "boolean", + JsonValueKind.Number => "number", + JsonValueKind.String => "string", + _ => "object", + }; + valueJson = defaultValue.GetRawText(); + } + } + + string? flagMetadataJson = flagDef.TryGetProperty("metadata", out var flagMetaElem) + && flagMetaElem.ValueKind == JsonValueKind.Object + ? flagMetaElem.GetRawText() : null; + + string? targetingJson = flagDef.TryGetProperty("targeting", out var targetingElem) + && targetingElem.ValueKind == JsonValueKind.Object + ? targetingElem.GetRawText() : null; + + _flagdService.UpsertFlag(fileId, new FlagEntryDto(flagKey, flagType, state, valueJson, flagMetadataJson, targetingJson)); + } + } + } + + /// + /// Reconstruct and return a FlagdSchema JSON string from the stored data. + /// Validates the output against the flagd schema (if a validator is configured). + /// + public string ExportSchema(long fileId) + { + var file = _flagdService.GetFile(fileId); + var flags = _flagdService.GetFlags(fileId); + var environments = _flagdService.GetEnvironments(fileId); + + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); + + writer.WriteStartObject(); + writer.WriteString("$schema", "https://flagd.dev/schema/v0/flags.json"); + + // flags + writer.WritePropertyName("flags"); + writer.WriteStartObject(); + foreach (var flag in flags) + { + writer.WritePropertyName(flag.Key); + writer.WriteStartObject(); + + writer.WriteString("state", flag.State); + + var variantKey = flag.Type == "boolean" ? BooleanOnVariant : DefaultVariant; + writer.WritePropertyName("variants"); + writer.WriteStartObject(); + writer.WritePropertyName(variantKey); + WriteRawOrNull(writer, flag.ValueJson); + writer.WriteEndObject(); + + writer.WriteString("defaultVariant", variantKey); + + if (!string.IsNullOrEmpty(flag.TargetingJson)) + { + writer.WritePropertyName("targeting"); + WriteRaw(writer, flag.TargetingJson); + } + + if (!string.IsNullOrEmpty(flag.MetadataJson)) + { + writer.WritePropertyName("metadata"); + WriteRaw(writer, flag.MetadataJson); + } + + writer.WriteEndObject(); + } + writer.WriteEndObject(); // flags + + // $evaluators (from environments) + if (environments.Count > 0) + { + writer.WritePropertyName("$evaluators"); + writer.WriteStartObject(); + foreach (var env in environments) + { + if (string.IsNullOrEmpty(env.Name)) continue; + var refKey = EnvironmentEvaluatorPrefix + + char.ToUpperInvariant(env.Name[0]) + + env.Name[1..]; + + writer.WritePropertyName(refKey); + writer.WriteStartObject(); + writer.WritePropertyName("in"); + writer.WriteStartArray(); + writer.WriteStartObject(); + writer.WriteString("var", EnvironmentVarName); + writer.WriteEndObject(); + writer.WriteStartArray(); + foreach (var alias in env.Aliases) writer.WriteStringValue(alias); + writer.WriteEndArray(); + writer.WriteEndArray(); + writer.WriteEndObject(); + } + writer.WriteEndObject(); // $evaluators + } + + // file-level metadata + if (!string.IsNullOrEmpty(file.MetadataJson)) + { + writer.WritePropertyName("metadata"); + WriteRaw(writer, file.MetadataJson); + } + + writer.WriteEndObject(); // root + writer.Flush(); + + var json = Encoding.UTF8.GetString(stream.ToArray()); + + _validator?.ValidateOrThrow(json); + + return json; + } + + // ─── Private helpers ────────────────────────────────────────────────── + + private static void WriteRaw(Utf8JsonWriter writer, string rawJson) + { + using var doc = JsonDocument.Parse(rawJson); + doc.RootElement.WriteTo(writer); + } + + private static void WriteRawOrNull(Utf8JsonWriter writer, string? rawJson) + { + if (string.IsNullOrEmpty(rawJson)) + writer.WriteNullValue(); + else + WriteRaw(writer, rawJson); + } +} diff --git a/src/OpenFeatureManager/Services/FlagdService.cs b/src/OpenFeatureManager/Services/FlagdService.cs index cd239e7..3d3ef84 100644 --- a/src/OpenFeatureManager/Services/FlagdService.cs +++ b/src/OpenFeatureManager/Services/FlagdService.cs @@ -1,40 +1,37 @@ -using System.Text; using System.Text.Json; -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using OpenFeatureManager.Data; using OpenFeatureManager.Entities; -using OpenFeatureManager.Generated; using OpenFeatureManager.Models; namespace OpenFeatureManager.Services; /// -/// SQLite-backed flagd schema service. +/// SQLite-backed flagd CRUD service. /// -/// Provides multi-file flag/environment management and FlagdSchema JSON import/export. +/// Provides multi-file flag/environment management. /// Instantiate with a factory so that callers /// (WASM, REST API, tests) can supply their own DB configuration. /// public class FlagdService { - private const string EnvironmentEvaluatorPrefix = "is"; - private const string EnvironmentVarName = "environment"; - private const string BooleanOnVariant = "on"; - private const string DefaultVariant = "default"; - private static readonly FlagdJsonContext JsonCtx = FlagdJsonContext.Default; private readonly Func _contextFactory; - private readonly SchemaValidator? _validator; - public FlagdService(Func contextFactory, SchemaValidator? validator = null) + public FlagdService(Func contextFactory) { _contextFactory = contextFactory; - _validator = validator; } // ─── File management ────────────────────────────────────────────────── + public FlagFileDto GetFile(long id) + { + using var db = _contextFactory(); + var file = db.FlagFiles.Find(id) ?? throw new KeyNotFoundException($"File {id} not found"); + return ToDto(file); + } + public FlagFileDto CreateFile(string name) { using var db = _contextFactory(); @@ -69,6 +66,24 @@ public void DeleteFile(long id) db.SaveChanges(); } + public void ClearFileData(long fileId) + { + using var db = _contextFactory(); + if (!db.FlagFiles.Any(f => f.Id == fileId)) + throw new KeyNotFoundException($"File {fileId} not found"); + db.EnvironmentEntries.RemoveRange(db.EnvironmentEntries.Where(e => e.FileId == fileId)); + db.FlagEntries.RemoveRange(db.FlagEntries.Where(f => f.FileId == fileId)); + db.SaveChanges(); + } + + public void UpdateFileMetadata(long fileId, string? metadataJson) + { + using var db = _contextFactory(); + var file = db.FlagFiles.Find(fileId) ?? throw new KeyNotFoundException($"File {fileId} not found"); + file.MetadataJson = metadataJson; + db.SaveChanges(); + } + // ─── Flag management ────────────────────────────────────────────────── public List GetFlags(long fileId) @@ -171,228 +186,6 @@ public void DeleteEnvironment(long fileId, string name) db.SaveChanges(); } - // ─── Schema import / export ─────────────────────────────────────────── - - /// - /// Parse a FlagdSchema JSON string into the database for the given file. - /// Validates the JSON against the flagd schema (if a validator is configured), - /// then deserialises into generated classes. - /// Existing flags and environments for the file are replaced. - /// - public void ImportSchema(long fileId, string schemaJson) - { - _validator?.ValidateOrThrow(schemaJson); - - var config = JsonSerializer.Deserialize(schemaJson) - ?? throw new ArgumentException("Failed to deserialize schema JSON"); - - using var db = _contextFactory(); - - if (!db.FlagFiles.Any(f => f.Id == fileId)) - throw new KeyNotFoundException($"File {fileId} not found"); - - // Clear existing data for this file - db.EnvironmentEntries.RemoveRange(db.EnvironmentEntries.Where(e => e.FileId == fileId)); - db.FlagEntries.RemoveRange(db.FlagEntries.Where(f => f.FileId == fileId)); - db.SaveChanges(); - - // Update file-level metadata if present - if (config.Metadata is { Count: > 0 }) - { - var file = db.FlagFiles.Find(fileId); - if (file is not null) - file.MetadataJson = JsonSerializer.Serialize(config.Metadata); - } - - // Parse $evaluators → environments (pattern: "isXxx") - if (config.Evaluators != null) - { - foreach (var (key, evalValue) in config.Evaluators) - { - if (evalValue?.AdditionalProperties is null) continue; - if (!key.StartsWith(EnvironmentEvaluatorPrefix) || key.Length <= EnvironmentEvaluatorPrefix.Length) - continue; - - if (!evalValue.AdditionalProperties.TryGetValue("in", out var inObj) - || inObj is not JsonElement inElem - || inElem.ValueKind != JsonValueKind.Array - || inElem.GetArrayLength() != 2) - continue; - - var varPart = inElem[0]; - var aliasesPart = inElem[1]; - - if (!varPart.TryGetProperty("var", out var varValue) || - varValue.GetString() != EnvironmentVarName || - aliasesPart.ValueKind != JsonValueKind.Array) - continue; - - var envName = key[EnvironmentEvaluatorPrefix.Length..].ToLowerInvariant(); - var aliases = aliasesPart.EnumerateArray() - .Select(a => a.GetString() ?? string.Empty) - .Where(a => !string.IsNullOrEmpty(a)) - .ToArray(); - - db.EnvironmentEntries.Add(new EnvironmentEntry - { - FileId = fileId, - Name = envName, - AliasesJson = JsonSerializer.Serialize(aliases, JsonCtx.StringArray), - }); - } - } - - // Parse flags using generated FlagDefinition typed properties - foreach (var (flagKey, flagDef) in config.Flags) - { - if (flagDef is null) continue; - - var state = flagDef.State.ToString(); - var flagType = "object"; - string? valueJson = null; - - if (flagDef.Variants != null && !string.IsNullOrEmpty(flagDef.DefaultVariant) - && flagDef.Variants.TryGetValue(flagDef.DefaultVariant, out var defaultValue)) - { - flagType = defaultValue.ValueKind switch - { - JsonValueKind.True or JsonValueKind.False => "boolean", - JsonValueKind.Number => "number", - JsonValueKind.String => "string", - _ => "object", - }; - valueJson = defaultValue.GetRawText(); - } - - string? metadataJson = flagDef.FlagMetadata.HasValue - ? flagDef.FlagMetadata.Value.GetRawText() : null; - - string? targetingJson = flagDef.Targeting.HasValue - ? flagDef.Targeting.Value.GetRawText() : null; - - db.FlagEntries.Add(new FlagEntry - { - FileId = fileId, - FlagKey = flagKey, - Type = flagType, - State = state, - ValueJson = valueJson, - MetadataJson = metadataJson, - TargetingJson = targetingJson, - }); - } - - db.SaveChanges(); - } - - /// - /// Reconstruct and return a FlagdSchema JSON string from the database state. - /// Validates the output against the flagd schema (if a validator is configured). - /// - public string ExportSchema(long fileId) - { - using var db = _contextFactory(); - - var file = db.FlagFiles.Find(fileId) - ?? throw new KeyNotFoundException($"File {fileId} not found"); - - var environments = db.EnvironmentEntries - .Where(e => e.FileId == fileId) - .OrderBy(e => e.Name) - .ToList(); - - var flagEntries = db.FlagEntries - .Where(f => f.FileId == fileId) - .OrderBy(f => f.FlagKey) - .ToList(); - - using var stream = new MemoryStream(); - using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); - - writer.WriteStartObject(); - writer.WriteString("$schema", "https://flagd.dev/schema/v0/flags.json"); - - // flags - writer.WritePropertyName("flags"); - writer.WriteStartObject(); - foreach (var flag in flagEntries) - { - writer.WritePropertyName(flag.FlagKey); - writer.WriteStartObject(); - - writer.WriteString("state", flag.State); - - var variantKey = flag.Type == "boolean" ? BooleanOnVariant : DefaultVariant; - writer.WritePropertyName("variants"); - writer.WriteStartObject(); - writer.WritePropertyName(variantKey); - WriteRawOrNull(writer, flag.ValueJson); - writer.WriteEndObject(); - - writer.WriteString("defaultVariant", variantKey); - - if (!string.IsNullOrEmpty(flag.TargetingJson)) - { - writer.WritePropertyName("targeting"); - WriteRaw(writer, flag.TargetingJson); - } - - if (!string.IsNullOrEmpty(flag.MetadataJson)) - { - writer.WritePropertyName("metadata"); - WriteRaw(writer, flag.MetadataJson); - } - - writer.WriteEndObject(); - } - writer.WriteEndObject(); // flags - - // $evaluators (from environments) - if (environments.Count > 0) - { - writer.WritePropertyName("$evaluators"); - writer.WriteStartObject(); - foreach (var env in environments) - { - if (string.IsNullOrEmpty(env.Name)) continue; - var refKey = EnvironmentEvaluatorPrefix + - char.ToUpperInvariant(env.Name[0]) + - env.Name[1..]; - var aliases = JsonSerializer.Deserialize(env.AliasesJson, JsonCtx.StringArray) ?? []; - - writer.WritePropertyName(refKey); - writer.WriteStartObject(); - writer.WritePropertyName("in"); - writer.WriteStartArray(); - writer.WriteStartObject(); - writer.WriteString("var", EnvironmentVarName); - writer.WriteEndObject(); - writer.WriteStartArray(); - foreach (var alias in aliases) writer.WriteStringValue(alias); - writer.WriteEndArray(); - writer.WriteEndArray(); - writer.WriteEndObject(); - } - writer.WriteEndObject(); // $evaluators - } - - // file-level metadata - if (!string.IsNullOrEmpty(file.MetadataJson)) - { - writer.WritePropertyName("metadata"); - WriteRaw(writer, file.MetadataJson); - } - - writer.WriteEndObject(); // root - writer.Flush(); - - var json = Encoding.UTF8.GetString(stream.ToArray()); - - _validator?.ValidateOrThrow(json); - - return json; - } - // ─── Private helpers ────────────────────────────────────────────────── private static FlagFileDto ToDto(FlagFile f) => @@ -409,18 +202,4 @@ private static EnvironmentEntryDto ToDto(EnvironmentEntry e) : char.ToUpperInvariant(e.Name[0]) + e.Name[1..]; return new EnvironmentEntryDto(e.Name, displayName, aliases); } - - private static void WriteRaw(Utf8JsonWriter writer, string rawJson) - { - using var doc = JsonDocument.Parse(rawJson); - doc.RootElement.WriteTo(writer); - } - - private static void WriteRawOrNull(Utf8JsonWriter writer, string? rawJson) - { - if (string.IsNullOrEmpty(rawJson)) - writer.WriteNullValue(); - else - WriteRaw(writer, rawJson); - } } From 2a1d5face323fdff6b78d7e1d9a7ece0dd737bfc Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Fri, 6 Mar 2026 23:56:32 +0000 Subject: [PATCH 11/38] connect wasm and api to frontend (just generation) --- .vscode/settings.json | 3 +- .../OpenFeatureManager.Api.csproj | 14 +++ src/OpenFeatureManager.Api/Program.cs | 52 +++++++---- ui/package-lock.json | 6 ++ ui/package.json | 3 +- ui/scripts/setup-api-client.js | 10 +- ui/src/app/services/wasm-backend.service.ts | 93 +++++++++++++++++++ 7 files changed, 158 insertions(+), 23 deletions(-) create mode 100644 ui/src/app/services/wasm-backend.service.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 80ad961..1ab25c5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "chat.tools.terminal.autoApprove": { - "dotnet build": true + "dotnet build": true, + "dotnet publish": true } } diff --git a/src/OpenFeatureManager.Api/OpenFeatureManager.Api.csproj b/src/OpenFeatureManager.Api/OpenFeatureManager.Api.csproj index 65e3db1..33fe078 100644 --- a/src/OpenFeatureManager.Api/OpenFeatureManager.Api.csproj +++ b/src/OpenFeatureManager.Api/OpenFeatureManager.Api.csproj @@ -4,10 +4,24 @@ net10.0 enable enable + $(MSBuildProjectDirectory)/../../ui/ + true + + + + + + + + + + diff --git a/src/OpenFeatureManager.Api/Program.cs b/src/OpenFeatureManager.Api/Program.cs index 494433e..b7be79a 100644 --- a/src/OpenFeatureManager.Api/Program.cs +++ b/src/OpenFeatureManager.Api/Program.cs @@ -24,6 +24,8 @@ builder.Services.AddScoped(sp => new FlagdSchemaService(sp.GetRequiredService(), validator)); +builder.Services.AddOpenApi("openapi"); + var app = builder.Build(); // Ensure database is created on startup @@ -33,68 +35,82 @@ db.Database.EnsureCreated(); } +app.MapOpenApi(); + // ─── File endpoints ─────────────────────────────────────────────────── -app.MapGet("/api/files", (FlagdService svc) => Results.Ok(svc.GetFiles())); +app.MapGet("/api/files", (FlagdService svc) => TypedResults.Ok(svc.GetFiles())) + .WithName("listFiles").WithTags("files"); app.MapPost("/api/files", (CreateFileRequest req, FlagdService svc) => - Results.Created($"/api/files/{req.Name}", svc.CreateFile(req.Name))); +{ + var file = svc.CreateFile(req.Name); + return TypedResults.Created($"/api/files/{file.Id}", file); +}).WithName("createFile").WithTags("files"); app.MapPut("/api/files/{id}", (long id, RenameFileRequest req, FlagdService svc) => - Results.Ok(svc.RenameFile(id, req.Name))); + TypedResults.Ok(svc.RenameFile(id, req.Name))) + .WithName("renameFile").WithTags("files"); app.MapDelete("/api/files/{id}", (long id, FlagdService svc) => { svc.DeleteFile(id); - return Results.NoContent(); -}); + return TypedResults.NoContent(); +}).WithName("deleteFile").WithTags("files"); // ─── Flag endpoints ─────────────────────────────────────────────────── app.MapGet("/api/files/{id}/flags", (long id, FlagdService svc) => - Results.Ok(svc.GetFlags(id))); + TypedResults.Ok(svc.GetFlags(id))) + .WithName("getFlags").WithTags("flags"); app.MapPost("/api/files/{id}/flags", (long id, FlagEntryDto dto, FlagdService svc) => - Results.Ok(svc.UpsertFlag(id, dto))); + TypedResults.Ok(svc.UpsertFlag(id, dto))) + .WithName("createFlag").WithTags("flags"); app.MapPut("/api/files/{id}/flags", (long id, FlagEntryDto dto, FlagdService svc) => - Results.Ok(svc.UpsertFlag(id, dto))); + TypedResults.Ok(svc.UpsertFlag(id, dto))) + .WithName("updateFlag").WithTags("flags"); app.MapDelete("/api/files/{id}/flags/{key}", (long id, string key, FlagdService svc) => { svc.DeleteFlag(id, Uri.UnescapeDataString(key)); - return Results.NoContent(); -}); + return TypedResults.NoContent(); +}).WithName("deleteFlag").WithTags("flags"); // ─── Environment endpoints ──────────────────────────────────────────── app.MapGet("/api/files/{id}/environments", (long id, FlagdService svc) => - Results.Ok(svc.GetEnvironments(id))); + TypedResults.Ok(svc.GetEnvironments(id))) + .WithName("getEnvironments").WithTags("environments"); app.MapPost("/api/files/{id}/environments", (long id, EnvironmentEntryDto dto, FlagdService svc) => - Results.Ok(svc.UpsertEnvironment(id, dto))); + TypedResults.Ok(svc.UpsertEnvironment(id, dto))) + .WithName("createEnvironment").WithTags("environments"); app.MapPut("/api/files/{id}/environments", (long id, EnvironmentEntryDto dto, FlagdService svc) => - Results.Ok(svc.UpsertEnvironment(id, dto))); + TypedResults.Ok(svc.UpsertEnvironment(id, dto))) + .WithName("updateEnvironment").WithTags("environments"); app.MapDelete("/api/files/{id}/environments/{name}", (long id, string name, FlagdService svc) => { svc.DeleteEnvironment(id, Uri.UnescapeDataString(name)); - return Results.NoContent(); -}); + return TypedResults.NoContent(); +}).WithName("deleteEnvironment").WithTags("environments"); // ─── Schema endpoints ───────────────────────────────────────────────── app.MapGet("/api/files/{id}/schema", (long id, FlagdSchemaService svc) => - Results.Content(svc.ExportSchema(id), "application/json")); + TypedResults.Text(svc.ExportSchema(id), "application/json")) + .WithName("exportSchema").WithTags("schema"); app.MapPost("/api/files/{id}/schema", async (long id, HttpRequest request, FlagdSchemaService svc) => { using var reader = new StreamReader(request.Body); var body = await reader.ReadToEndAsync(); svc.ImportSchema(id, body); - return Results.Ok("Schema imported successfully."); -}); + return TypedResults.Ok("Schema imported successfully."); +}).WithName("importSchema").WithTags("schema"); app.Run(); diff --git a/ui/package-lock.json b/ui/package-lock.json index 34d69a7..f6d7f9a 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -25,6 +25,7 @@ "@openfeature/ofrep-web-provider": "^0.3.5", "@openfeature/web-sdk": "^1.7.2", "ajv": "^8.12.0", + "bootsharp": "file:../src/OpenFeatureManager.Wasm/bin/bootsharp", "json-logic-engine": "^5.0.6", "rxjs": "~7.8.0", "tslib": "^2.3.0" @@ -48,6 +49,7 @@ "vitest": "^4.0.8" } }, + "../src/OpenFeatureManager.Wasm/bin/bootsharp": {}, "node_modules/@acemir/cssom": { "version": "0.9.31", "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", @@ -5952,6 +5954,10 @@ "dev": true, "license": "ISC" }, + "node_modules/bootsharp": { + "resolved": "../src/OpenFeatureManager.Wasm/bin/bootsharp", + "link": true + }, "node_modules/brace-expansion": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", diff --git a/ui/package.json b/ui/package.json index 7ed2c7e..ec525f3 100644 --- a/ui/package.json +++ b/ui/package.json @@ -48,6 +48,7 @@ "@openfeature/ofrep-web-provider": "^0.3.5", "@openfeature/web-sdk": "^1.7.2", "ajv": "^8.12.0", + "bootsharp": "file:../src/OpenFeatureManager.Wasm/bin/bootsharp", "json-logic-engine": "^5.0.6", "rxjs": "~7.8.0", "tslib": "^2.3.0" @@ -70,4 +71,4 @@ "typescript-eslint": "8.50.1", "vitest": "^4.0.8" } -} \ No newline at end of file +} diff --git a/ui/scripts/setup-api-client.js b/ui/scripts/setup-api-client.js index a0fad5a..0f9c56e 100644 --- a/ui/scripts/setup-api-client.js +++ b/ui/scripts/setup-api-client.js @@ -27,8 +27,12 @@ if (fs.existsSync(sourceFile)) { // Check if openapi.json exists const openapiPath = path.join(uiRoot, 'openapi.json'); if (!fs.existsSync(openapiPath)) { - console.warn(`⚠ Warning: openapi.json not found. This is expected if the Rust backend hasn't been built yet.`); - console.warn(`⚠ Run 'cargo build' in the root directory to generate the OpenAPI spec, then run 'npm install' again in the ui directory.`); + console.warn( + `⚠ Warning: openapi.json not found. This is expected if the API project hasn't been built yet.`, + ); + console.warn( + `⚠ Run 'dotnet build' in the src directory to generate the OpenAPI spec, then run 'npm install' again in the ui directory.`, + ); process.exit(0); } @@ -41,7 +45,7 @@ const dockerCmd = `docker run --rm -v "${cwd}:/local" openapitools/openapi-gener const result = spawnSync(dockerCmd, { shell: true, - stdio: 'inherit' + stdio: 'inherit', }); process.exit(result.status); diff --git a/ui/src/app/services/wasm-backend.service.ts b/ui/src/app/services/wasm-backend.service.ts new file mode 100644 index 0000000..ea65ef7 --- /dev/null +++ b/ui/src/app/services/wasm-backend.service.ts @@ -0,0 +1,93 @@ +import { Injectable, signal } from '@angular/core'; + +export type WasmBackendStatus = 'not-initialized' | 'booting' | 'ready' | 'error'; + +/** + * Angular service wrapping the Bootsharp-compiled .NET WASM backend. + * + * Call {@link boot} once to initialise the .NET runtime and in-memory database. + * After the returned promise resolves the bindings are available via + * {@link initDatabase}, {@link importDatabase}, and {@link exportDatabase}. + * + * Usage is NOT yet wired into any component — this service only exposes the API. + */ +@Injectable({ providedIn: 'root' }) +export class WasmBackendService { + /** Current lifecycle status of the WASM backend. */ + readonly status = signal('not-initialized'); + + /** Human-readable message from the last lifecycle transition. */ + readonly statusMessage = signal(''); + + // Lazily loaded bootsharp module (ESM dynamic import) + private bootsharpModule: typeof import('bootsharp') | null = null; + + /** + * Boot the .NET WASM runtime. + * Safe to call multiple times — subsequent calls are no-ops once ready. + */ + async boot(): Promise { + if (this.status() === 'ready' || this.status() === 'booting') return; + + this.status.set('booting'); + this.statusMessage.set('Loading .NET WASM runtime…'); + + try { + const bootsharp = await import('bootsharp'); + this.bootsharpModule = bootsharp; + + // Boot the .NET runtime (loads the embedded WASM binaries) + await bootsharp.default.boot(); + + this.status.set('ready'); + this.statusMessage.set('.NET WASM runtime ready.'); + } catch (err) { + this.status.set('error'); + this.statusMessage.set(err instanceof Error ? err.message : String(err)); + throw err; + } + } + + /** + * Initialise a fresh in-memory SQLite database inside the WASM runtime. + * The runtime must be booted first. + */ + initDatabase(): string { + this.ensureReady(); + const { OpenFeatureManager } = this.getBindings(); + return OpenFeatureManager.Wasm.WasmBindings.initDatabase(); + } + + /** + * Import an SQLite database from raw bytes. + * The runtime must be booted first. + */ + importDatabase(data: Uint8Array): string { + this.ensureReady(); + const { OpenFeatureManager } = this.getBindings(); + return OpenFeatureManager.Wasm.WasmBindings.importDatabase(data); + } + + /** + * Export the current in-memory SQLite database as raw bytes. + * The runtime must be booted first. + */ + exportDatabase(): Uint8Array { + this.ensureReady(); + const { OpenFeatureManager } = this.getBindings(); + return OpenFeatureManager.Wasm.WasmBindings.exportDatabase(); + } + + private ensureReady(): void { + if (this.status() !== 'ready') { + throw new Error('WasmBackendService is not ready. Call boot() first.'); + } + } + + private getBindings() { + if (!this.bootsharpModule) { + throw new Error('Bootsharp module not loaded.'); + } + return this.bootsharpModule; + } +} From a7b9ac8a4f7131225a75e1a7ba59c1ae69465760 Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Sat, 7 Mar 2026 00:21:14 +0000 Subject: [PATCH 12/38] add entities --- src/OpenFeatureManager.Api/Program.cs | 22 ++ .../FlagdSchemaServiceTests.cs | 293 +++++++++++----- src/OpenFeatureManager/Data/FlagdDbContext.cs | 69 ++++ .../Entities/BooleanFlagEntry.cs | 13 + .../Entities/EnvironmentAlias.cs | 20 ++ .../Entities/EnvironmentEntry.cs | 6 +- .../Entities/FileMetadataEntry.cs | 29 ++ src/OpenFeatureManager/Entities/FlagEntry.cs | 23 +- src/OpenFeatureManager/Entities/FlagFile.cs | 6 +- .../Entities/FlagMetadataEntry.cs | 29 ++ src/OpenFeatureManager/Entities/FlagState.cs | 7 + .../Entities/NumberFlagEntry.cs | 12 + .../Entities/ObjectFlagEntry.cs | 16 + .../Entities/PerEnvironmentDefinition.cs | 38 +++ .../Entities/StringFlagEntry.cs | 12 + src/OpenFeatureManager/Entities/TimeWindow.cs | 27 ++ .../Models/FlagdJsonContext.cs | 57 +++- .../Services/FlagdSchemaService.cs | 161 ++++++--- .../Services/FlagdService.cs | 316 +++++++++++++++--- ui/package.json | 2 +- ui/src/environments.ts | 2 +- 21 files changed, 961 insertions(+), 199 deletions(-) create mode 100644 src/OpenFeatureManager/Entities/BooleanFlagEntry.cs create mode 100644 src/OpenFeatureManager/Entities/EnvironmentAlias.cs create mode 100644 src/OpenFeatureManager/Entities/FileMetadataEntry.cs create mode 100644 src/OpenFeatureManager/Entities/FlagMetadataEntry.cs create mode 100644 src/OpenFeatureManager/Entities/FlagState.cs create mode 100644 src/OpenFeatureManager/Entities/NumberFlagEntry.cs create mode 100644 src/OpenFeatureManager/Entities/ObjectFlagEntry.cs create mode 100644 src/OpenFeatureManager/Entities/PerEnvironmentDefinition.cs create mode 100644 src/OpenFeatureManager/Entities/StringFlagEntry.cs create mode 100644 src/OpenFeatureManager/Entities/TimeWindow.cs diff --git a/src/OpenFeatureManager.Api/Program.cs b/src/OpenFeatureManager.Api/Program.cs index b7be79a..56be5ab 100644 --- a/src/OpenFeatureManager.Api/Program.cs +++ b/src/OpenFeatureManager.Api/Program.cs @@ -98,6 +98,28 @@ return TypedResults.NoContent(); }).WithName("deleteEnvironment").WithTags("environments"); +// ─── Time window endpoints ──────────────────────────────────────────── + +app.MapGet("/api/files/{id}/timewindows", (long id, FlagdService svc) => + TypedResults.Ok(svc.GetTimeWindows(id))) + .WithName("getTimeWindows").WithTags("timewindows"); + +app.MapPost("/api/files/{id}/timewindows", (long id, TimeWindowDto dto, FlagdService svc) => +{ + var tw = svc.CreateTimeWindow(id, dto); + return TypedResults.Created($"/api/files/{id}/timewindows/{tw.Id}", tw); +}).WithName("createTimeWindow").WithTags("timewindows"); + +app.MapPut("/api/files/{id}/timewindows/{twId}", (long id, long twId, TimeWindowDto dto, FlagdService svc) => + TypedResults.Ok(svc.UpdateTimeWindow(id, twId, dto))) + .WithName("updateTimeWindow").WithTags("timewindows"); + +app.MapDelete("/api/files/{id}/timewindows/{twId}", (long id, long twId, FlagdService svc) => +{ + svc.DeleteTimeWindow(id, twId); + return TypedResults.NoContent(); +}).WithName("deleteTimeWindow").WithTags("timewindows"); + // ─── Schema endpoints ───────────────────────────────────────────────── app.MapGet("/api/files/{id}/schema", (long id, FlagdSchemaService svc) => diff --git a/src/OpenFeatureManager.Tests/FlagdSchemaServiceTests.cs b/src/OpenFeatureManager.Tests/FlagdSchemaServiceTests.cs index cb68eb6..2519865 100644 --- a/src/OpenFeatureManager.Tests/FlagdSchemaServiceTests.cs +++ b/src/OpenFeatureManager.Tests/FlagdSchemaServiceTests.cs @@ -69,7 +69,7 @@ public void ImportSchema_BooleanFlag_StoresCorrectly() Assert.Equal("my-flag", flag.Key); Assert.Equal("boolean", flag.Type); Assert.Equal("ENABLED", flag.State); - Assert.Equal("true", flag.ValueJson); + Assert.Equal(true, flag.BooleanValue); } [Fact] @@ -97,7 +97,7 @@ public void ImportSchema_StringFlag_StoresCorrectly() Assert.Equal("color", flag.Key); Assert.Equal("string", flag.Type); Assert.Equal("DISABLED", flag.State); - Assert.Equal("\"#FF0000\"", flag.ValueJson); + Assert.Equal("#FF0000", flag.StringValue); } [Fact] @@ -122,7 +122,7 @@ public void ImportSchema_NumberFlag_StoresCorrectly() var flags = _flagdService.GetFlags(file.Id); Assert.Single(flags); Assert.Equal("number", flags[0].Type); - Assert.Equal("100", flags[0].ValueJson); + Assert.Equal(100.0, flags[0].NumberValue); } [Fact] @@ -147,41 +147,13 @@ public void ImportSchema_ObjectFlag_StoresCorrectly() var flags = _flagdService.GetFlags(file.Id); Assert.Single(flags); Assert.Equal("object", flags[0].Type); - // ValueJson should be a valid JSON object - using var doc = JsonDocument.Parse(flags[0].ValueJson!); + Assert.NotNull(flags[0].ObjectValue); + using var doc = JsonDocument.Parse(flags[0].ObjectValue!); Assert.Equal("value", doc.RootElement.GetProperty("key").GetString()); } [Fact] - public void ImportSchema_WithTargeting_StoresTargetingJson() - { - var file = _flagdService.CreateFile("test"); - var json = """ - { - "$schema": "https://flagd.dev/schema/v0/flags.json", - "flags": { - "feature": { - "state": "ENABLED", - "variants": { "on": true, "off": false }, - "defaultVariant": "on", - "targeting": { - "if": [ true, "on", "off" ] - } - } - } - } - """; - - _schemaService.ImportSchema(file.Id, json); - - var flags = _flagdService.GetFlags(file.Id); - Assert.NotNull(flags[0].TargetingJson); - using var doc = JsonDocument.Parse(flags[0].TargetingJson!); - Assert.True(doc.RootElement.TryGetProperty("if", out _)); - } - - [Fact] - public void ImportSchema_WithFlagMetadata_StoresMetadataJson() + public void ImportSchema_WithFlagMetadata_StoresMetadata() { var file = _flagdService.CreateFile("test"); var json = """ @@ -201,9 +173,11 @@ public void ImportSchema_WithFlagMetadata_StoresMetadataJson() _schemaService.ImportSchema(file.Id, json); var flags = _flagdService.GetFlags(file.Id); - Assert.NotNull(flags[0].MetadataJson); - using var doc = JsonDocument.Parse(flags[0].MetadataJson!); - Assert.Equal("team-a", doc.RootElement.GetProperty("owner").GetString()); + Assert.NotNull(flags[0].Metadata); + Assert.Single(flags[0].Metadata!); + var entry = flags[0].Metadata![0]; + Assert.Equal("owner", entry.Key); + Assert.Equal("team-a", entry.StringValue); } [Fact] @@ -236,12 +210,10 @@ public void ImportSchema_WithEvaluators_StoresEnvironments() var envs = _flagdService.GetEnvironments(file.Id); Assert.Equal(2, envs.Count); - var prod = envs.First(e => e.Name == "production"); - Assert.Equal("Production", prod.DisplayName); + var prod = envs.First(e => e.Name == "Production"); Assert.Equal(["prod", "production"], prod.Aliases); - var staging = envs.First(e => e.Name == "staging"); - Assert.Equal("Staging", staging.DisplayName); + var staging = envs.First(e => e.Name == "Staging"); Assert.Equal(["staging"], staging.Aliases); } @@ -266,9 +238,10 @@ public void ImportSchema_WithFileMetadata_StoresOnFile() _schemaService.ImportSchema(file.Id, json); var updatedFile = _flagdService.GetFile(file.Id); - Assert.NotNull(updatedFile.MetadataJson); - using var doc = JsonDocument.Parse(updatedFile.MetadataJson!); - Assert.Equal("1.0", doc.RootElement.GetProperty("version").GetString()); + Assert.NotNull(updatedFile.Metadata); + Assert.Single(updatedFile.Metadata!); + Assert.Equal("version", updatedFile.Metadata![0].Key); + Assert.Equal("1.0", updatedFile.Metadata![0].StringValue); } [Fact] @@ -373,7 +346,7 @@ public void ImportSchema_NonexistentFile_ThrowsKeyNotFoundException() public void ExportSchema_BooleanFlag_ProducesValidJson() { var file = _flagdService.CreateFile("test"); - _flagdService.UpsertFlag(file.Id, new FlagEntryDto("my-flag", "boolean", "ENABLED", "true")); + _flagdService.UpsertFlag(file.Id, new FlagEntryDto("my-flag", "boolean", "ENABLED", BooleanValue: true)); var json = _schemaService.ExportSchema(file.Id); @@ -391,7 +364,7 @@ public void ExportSchema_BooleanFlag_ProducesValidJson() public void ExportSchema_NonBooleanFlag_UsesDefaultVariantKey() { var file = _flagdService.CreateFile("test"); - _flagdService.UpsertFlag(file.Id, new FlagEntryDto("color", "string", "ENABLED", "\"red\"")); + _flagdService.UpsertFlag(file.Id, new FlagEntryDto("color", "string", "ENABLED", StringValue: "red")); var json = _schemaService.ExportSchema(file.Id); @@ -401,27 +374,12 @@ public void ExportSchema_NonBooleanFlag_UsesDefaultVariantKey() Assert.Equal("red", flag.GetProperty("variants").GetProperty("default").GetString()); } - [Fact] - public void ExportSchema_WithTargeting_IncludesTargetingBlock() - { - var file = _flagdService.CreateFile("test"); - var targeting = """{"if":[true,"on","off"]}"""; - _flagdService.UpsertFlag(file.Id, new FlagEntryDto("feature", "boolean", "ENABLED", "true", TargetingJson: targeting)); - - var json = _schemaService.ExportSchema(file.Id); - - using var doc = JsonDocument.Parse(json); - var flag = doc.RootElement.GetProperty("flags").GetProperty("feature"); - Assert.True(flag.TryGetProperty("targeting", out var t)); - Assert.True(t.TryGetProperty("if", out _)); - } - [Fact] public void ExportSchema_WithFlagMetadata_IncludesMetadataBlock() { var file = _flagdService.CreateFile("test"); - var metadata = """{"owner":"team-a"}"""; - _flagdService.UpsertFlag(file.Id, new FlagEntryDto("feature", "boolean", "ENABLED", "true", MetadataJson: metadata)); + var metadata = new List { new("owner", StringValue: "team-a") }; + _flagdService.UpsertFlag(file.Id, new FlagEntryDto("feature", "boolean", "ENABLED", BooleanValue: true, Metadata: metadata)); var json = _schemaService.ExportSchema(file.Id); @@ -435,8 +393,8 @@ public void ExportSchema_WithFlagMetadata_IncludesMetadataBlock() public void ExportSchema_WithEnvironments_IncludesEvaluators() { var file = _flagdService.CreateFile("test"); - _flagdService.UpsertFlag(file.Id, new FlagEntryDto("feature", "boolean", "ENABLED", "true")); - _flagdService.UpsertEnvironment(file.Id, new EnvironmentEntryDto("production", "Production", ["prod", "production"])); + _flagdService.UpsertFlag(file.Id, new FlagEntryDto("feature", "boolean", "ENABLED", BooleanValue: true)); + _flagdService.UpsertEnvironment(file.Id, new EnvironmentEntryDto("Production", ["prod", "production"])); var json = _schemaService.ExportSchema(file.Id); @@ -453,8 +411,8 @@ public void ExportSchema_WithEnvironments_IncludesEvaluators() public void ExportSchema_WithFileMetadata_IncludesTopLevelMetadata() { var file = _flagdService.CreateFile("test"); - _flagdService.UpsertFlag(file.Id, new FlagEntryDto("feature", "boolean", "ENABLED", "true")); - _flagdService.UpdateFileMetadata(file.Id, """{"version":"2.0"}"""); + _flagdService.UpsertFlag(file.Id, new FlagEntryDto("feature", "boolean", "ENABLED", BooleanValue: true)); + _flagdService.UpdateFileMetadata(file.Id, [new MetadataEntryDto("version", StringValue: "2.0")]); var json = _schemaService.ExportSchema(file.Id); @@ -555,15 +513,135 @@ public void ImportThenExport_PreservesEnvironments() Assert.Equal("staging", aliases[1].GetString()); } + // ─── Typed flag CRUD tests ──────────────────────────────────────────── + + [Fact] + public void UpsertFlag_BooleanFlag_RoundTrips() + { + var file = _flagdService.CreateFile("test"); + _flagdService.UpsertFlag(file.Id, new FlagEntryDto("toggle", "boolean", "ENABLED", BooleanValue: true)); + + var flags = _flagdService.GetFlags(file.Id); + Assert.Single(flags); + Assert.Equal("boolean", flags[0].Type); + Assert.Equal(true, flags[0].BooleanValue); + Assert.Null(flags[0].StringValue); + Assert.Null(flags[0].NumberValue); + Assert.Null(flags[0].ObjectValue); + } + + [Fact] + public void UpsertFlag_StringFlag_RoundTrips() + { + var file = _flagdService.CreateFile("test"); + _flagdService.UpsertFlag(file.Id, new FlagEntryDto("color", "string", "ENABLED", StringValue: "blue")); + + var flags = _flagdService.GetFlags(file.Id); + Assert.Single(flags); + Assert.Equal("string", flags[0].Type); + Assert.Equal("blue", flags[0].StringValue); + } + + [Fact] + public void UpsertFlag_NumberFlag_RoundTrips() + { + var file = _flagdService.CreateFile("test"); + _flagdService.UpsertFlag(file.Id, new FlagEntryDto("rate", "number", "ENABLED", NumberValue: 42.5)); + + var flags = _flagdService.GetFlags(file.Id); + Assert.Single(flags); + Assert.Equal("number", flags[0].Type); + Assert.Equal(42.5, flags[0].NumberValue); + } + + [Fact] + public void UpsertFlag_ObjectFlag_RoundTrips() + { + var file = _flagdService.CreateFile("test"); + _flagdService.UpsertFlag(file.Id, new FlagEntryDto("config", "object", "ENABLED", ObjectValue: """{"a":1}""")); + + var flags = _flagdService.GetFlags(file.Id); + Assert.Single(flags); + Assert.Equal("object", flags[0].Type); + Assert.Equal("""{"a":1}""", flags[0].ObjectValue); + } + + [Fact] + public void UpsertFlag_TypeChange_ReplacesEntity() + { + var file = _flagdService.CreateFile("test"); + _flagdService.UpsertFlag(file.Id, new FlagEntryDto("flag", "boolean", "ENABLED", BooleanValue: true)); + _flagdService.UpsertFlag(file.Id, new FlagEntryDto("flag", "string", "ENABLED", StringValue: "hello")); + + var flags = _flagdService.GetFlags(file.Id); + Assert.Single(flags); + Assert.Equal("string", flags[0].Type); + Assert.Equal("hello", flags[0].StringValue); + } + + [Fact] + public void UpsertFlag_WithMetadata_StoresAndRetrieves() + { + var file = _flagdService.CreateFile("test"); + var metadata = new List + { + new("owner", StringValue: "team-a"), + new("priority", NumberValue: 1.0), + new("active", BooleanValue: true) + }; + _flagdService.UpsertFlag(file.Id, new FlagEntryDto("flag", "boolean", "ENABLED", BooleanValue: true, Metadata: metadata)); + + var flags = _flagdService.GetFlags(file.Id); + Assert.NotNull(flags[0].Metadata); + Assert.Equal(3, flags[0].Metadata!.Count); + Assert.Equal("team-a", flags[0].Metadata!.First(m => m.Key == "owner").StringValue); + Assert.Equal(1.0, flags[0].Metadata!.First(m => m.Key == "priority").NumberValue); + Assert.Equal(true, flags[0].Metadata!.First(m => m.Key == "active").BooleanValue); + } + + [Fact] + public void UpsertFlag_WithPerEnvironmentDefinitions_StoresAndRetrieves() + { + var file = _flagdService.CreateFile("test"); + // Environments must exist before referencing them in per-env definitions + _flagdService.UpsertEnvironment(file.Id, new EnvironmentEntryDto("Production", ["prod"])); + _flagdService.UpsertEnvironment(file.Id, new EnvironmentEntryDto("Staging", ["staging"])); + var perEnv = new Dictionary + { + ["Production"] = new(BooleanValue: false), + ["Staging"] = new(BooleanValue: true) + }; + _flagdService.UpsertFlag(file.Id, new FlagEntryDto("flag", "boolean", "ENABLED", BooleanValue: true, PerEnvironmentDefinitions: perEnv)); + + var flags = _flagdService.GetFlags(file.Id); + Assert.NotNull(flags[0].PerEnvironmentDefinitions); + Assert.Equal(2, flags[0].PerEnvironmentDefinitions!.Count); + Assert.Equal(false, flags[0].PerEnvironmentDefinitions!["Production"].BooleanValue); + Assert.Equal(true, flags[0].PerEnvironmentDefinitions!["Staging"].BooleanValue); + } + + [Fact] + public void UpsertFlag_WithGlobalTimeWindow_StoresAndRetrieves() + { + var file = _flagdService.CreateFile("test"); + var start = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var end = new DateTime(2026, 12, 31, 23, 59, 59, DateTimeKind.Utc); + var tw = _flagdService.CreateTimeWindow(file.Id, new TimeWindowDto(0, "Test Window", start, end)); + var globalTw = new GlobalTimeWindowDto(tw.Id, BooleanValue: false); + _flagdService.UpsertFlag(file.Id, new FlagEntryDto("flag", "boolean", "ENABLED", BooleanValue: true, GlobalTimeWindow: globalTw)); + + var flags = _flagdService.GetFlags(file.Id); + Assert.NotNull(flags[0].GlobalTimeWindow); + Assert.Equal(tw.Id, flags[0].GlobalTimeWindow!.TimeWindowId); + Assert.Equal(false, flags[0].GlobalTimeWindow!.BooleanValue); + } + // ─── Invalid schema tests ───────────────────────────────────────────── [Fact] public void ImportSchema_InvalidJson_Throws() { var file = _flagdService.CreateFile("test"); - // NJsonSchema validator uses Newtonsoft internally, so invalid JSON - // throws Newtonsoft.Json.JsonReaderException (via SchemaValidationException - // or directly). We just assert any exception is thrown. Assert.ThrowsAny(() => _schemaService.ImportSchema(file.Id, "not json at all")); } @@ -571,9 +649,7 @@ public void ImportSchema_InvalidJson_Throws() public void ImportSchema_MissingFlags_ThrowsSchemaValidationException() { var file = _flagdService.CreateFile("test"); - // Valid JSON but doesn't conform to flagd schema (missing "flags" property) var json = """{ "notFlags": {} }"""; - Assert.Throws(() => _schemaService.ImportSchema(file.Id, json)); } @@ -581,7 +657,6 @@ public void ImportSchema_MissingFlags_ThrowsSchemaValidationException() public void ImportSchema_InvalidFlagDefinition_ThrowsSchemaValidationException() { var file = _flagdService.CreateFile("test"); - // Flag missing required "variants" and "defaultVariant" var json = """ { "$schema": "https://flagd.dev/schema/v0/flags.json", @@ -592,7 +667,6 @@ public void ImportSchema_InvalidFlagDefinition_ThrowsSchemaValidationException() } } """; - Assert.Throws(() => _schemaService.ImportSchema(file.Id, json)); } @@ -600,7 +674,6 @@ public void ImportSchema_InvalidFlagDefinition_ThrowsSchemaValidationException() public void ImportSchema_InvalidState_ThrowsSchemaValidationException() { var file = _flagdService.CreateFile("test"); - // State must be "ENABLED" or "DISABLED" var json = """ { "$schema": "https://flagd.dev/schema/v0/flags.json", @@ -613,7 +686,6 @@ public void ImportSchema_InvalidState_ThrowsSchemaValidationException() } } """; - Assert.Throws(() => _schemaService.ImportSchema(file.Id, json)); } @@ -621,7 +693,6 @@ public void ImportSchema_InvalidState_ThrowsSchemaValidationException() public void ImportSchema_FlagWithInvalidVariantType_ThrowsSchemaValidationException() { var file = _flagdService.CreateFile("test"); - // defaultVariant must be a string, not a number var json = """ { "$schema": "https://flagd.dev/schema/v0/flags.json", @@ -634,7 +705,6 @@ public void ImportSchema_FlagWithInvalidVariantType_ThrowsSchemaValidationExcept } } """; - Assert.Throws(() => _schemaService.ImportSchema(file.Id, json)); } @@ -649,6 +719,73 @@ public void SchemaValidationException_ContainsErrorDetails() Assert.Contains("error", ex.Message, StringComparison.OrdinalIgnoreCase); } + // ─── Time window CRUD tests ─────────────────────────────────────────── + + [Fact] + public void CreateTimeWindow_RoundTrips() + { + var file = _flagdService.CreateFile("test"); + var start = new DateTime(2026, 12, 24, 0, 0, 0, DateTimeKind.Utc); + var end = new DateTime(2026, 12, 26, 23, 59, 59, DateTimeKind.Utc); + var tw = _flagdService.CreateTimeWindow(file.Id, new TimeWindowDto(0, "Christmas Time", start, end)); + + Assert.True(tw.Id > 0); + Assert.Equal("Christmas Time", tw.Name); + Assert.Equal(start, tw.StartTime); + Assert.Equal(end, tw.EndTime); + } + + [Fact] + public void GetTimeWindows_ReturnsAll() + { + var file = _flagdService.CreateFile("test"); + _flagdService.CreateTimeWindow(file.Id, new TimeWindowDto(0, "Window A")); + _flagdService.CreateTimeWindow(file.Id, new TimeWindowDto(0, "Window B")); + + var windows = _flagdService.GetTimeWindows(file.Id); + Assert.Equal(2, windows.Count); + } + + [Fact] + public void UpdateTimeWindow_ChangesFields() + { + var file = _flagdService.CreateFile("test"); + var tw = _flagdService.CreateTimeWindow(file.Id, new TimeWindowDto(0, "Old Name")); + var newStart = new DateTime(2027, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var updated = _flagdService.UpdateTimeWindow(file.Id, tw.Id, new TimeWindowDto(tw.Id, "New Name", newStart)); + + Assert.Equal("New Name", updated.Name); + Assert.Equal(newStart, updated.StartTime); + } + + [Fact] + public void DeleteTimeWindow_RemovesEntry() + { + var file = _flagdService.CreateFile("test"); + var tw = _flagdService.CreateTimeWindow(file.Id, new TimeWindowDto(0, "Temporary")); + + _flagdService.DeleteTimeWindow(file.Id, tw.Id); + + Assert.Empty(_flagdService.GetTimeWindows(file.Id)); + } + + [Fact] + public void PerEnvironmentDefinition_WithTimeWindowReference_RoundTrips() + { + var file = _flagdService.CreateFile("test"); + _flagdService.UpsertEnvironment(file.Id, new EnvironmentEntryDto("Production", ["prod"])); + var tw = _flagdService.CreateTimeWindow(file.Id, new TimeWindowDto(0, "Holiday Window")); + var perEnv = new Dictionary + { + ["Production"] = new(BooleanValue: false, TimeWindowId: tw.Id) + }; + _flagdService.UpsertFlag(file.Id, new FlagEntryDto("flag", "boolean", "ENABLED", BooleanValue: true, PerEnvironmentDefinitions: perEnv)); + + var flags = _flagdService.GetFlags(file.Id); + Assert.NotNull(flags[0].PerEnvironmentDefinitions); + Assert.Equal(tw.Id, flags[0].PerEnvironmentDefinitions!["Production"].TimeWindowId); + } + // ─── Helper ─────────────────────────────────────────────────────────── private static string FindSchemaFile() diff --git a/src/OpenFeatureManager/Data/FlagdDbContext.cs b/src/OpenFeatureManager/Data/FlagdDbContext.cs index 0ff90f7..13c674b 100644 --- a/src/OpenFeatureManager/Data/FlagdDbContext.cs +++ b/src/OpenFeatureManager/Data/FlagdDbContext.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using OpenFeatureManager.Entities; namespace OpenFeatureManager.Data; @@ -9,7 +10,16 @@ public class FlagdDbContext : DbContext public DbSet FlagFiles => Set(); public DbSet FlagEntries => Set(); + public DbSet BooleanFlagEntries => Set(); + public DbSet StringFlagEntries => Set(); + public DbSet NumberFlagEntries => Set(); + public DbSet ObjectFlagEntries => Set(); public DbSet EnvironmentEntries => Set(); + public DbSet EnvironmentAliases => Set(); + public DbSet FlagMetadataEntries => Set(); + public DbSet FileMetadataEntries => Set(); + public DbSet PerEnvironmentDefinitions => Set(); + public DbSet TimeWindows => Set(); /// Constructor for connection-string based configuration (WASM, standalone). public FlagdDbContext(string connectionString) @@ -35,6 +45,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.Id).ValueGeneratedOnAdd(); entity.Property(e => e.Name).IsRequired(); entity.Property(e => e.CreatedAt).HasDefaultValueSql("datetime('now')"); + entity.HasMany(e => e.Metadata).WithOne().HasForeignKey(e => e.FileId).OnDelete(DeleteBehavior.Cascade); + entity.HasMany(e => e.Flags).WithOne().HasForeignKey(e => e.FileId).OnDelete(DeleteBehavior.Cascade); + entity.HasMany(e => e.Environments).WithOne().HasForeignKey(e => e.FileId).OnDelete(DeleteBehavior.Cascade); + entity.HasMany(e => e.TimeWindows).WithOne().HasForeignKey(e => e.FileId).OnDelete(DeleteBehavior.Cascade); }); modelBuilder.Entity(entity => @@ -44,6 +58,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.Id).ValueGeneratedOnAdd(); entity.Property(e => e.FlagKey).IsRequired(); entity.HasIndex(e => new { e.FileId, e.FlagKey }).IsUnique(); + entity.Property(e => e.State).HasConversion(new EnumToStringConverter()); + entity.HasDiscriminator("type") + .HasValue("boolean") + .HasValue("string") + .HasValue("number") + .HasValue("object"); + entity.HasMany(e => e.Metadata).WithOne().HasForeignKey(e => e.FlagEntryId).OnDelete(DeleteBehavior.Cascade); + entity.HasMany(e => e.PerEnvironmentDefinitions).WithOne().HasForeignKey(e => e.FlagEntryId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne(e => e.GlobalTimeWindow).WithMany().HasForeignKey(e => e.GlobalTimeWindowId).OnDelete(DeleteBehavior.SetNull); }); modelBuilder.Entity(entity => @@ -53,6 +76,52 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.Id).ValueGeneratedOnAdd(); entity.Property(e => e.Name).IsRequired(); entity.HasIndex(e => new { e.FileId, e.Name }).IsUnique(); + entity.HasMany(e => e.Aliases).WithOne().HasForeignKey(e => e.EnvironmentEntryId).OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("environment_aliases"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.Alias).IsRequired(); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("file_metadata_entries"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.Key).IsRequired(); + entity.HasIndex(e => new { e.FileId, e.Key }).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("flag_metadata_entries"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.Key).IsRequired(); + entity.HasIndex(e => new { e.FlagEntryId, e.Key }).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("per_environment_definitions"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.HasIndex(e => new { e.FlagEntryId, e.EnvironmentEntryId }).IsUnique(); + entity.HasOne(e => e.Environment).WithMany().HasForeignKey(e => e.EnvironmentEntryId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne(e => e.TimeWindow).WithMany().HasForeignKey(e => e.TimeWindowId).OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("time_windows"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.Name).IsRequired(); + entity.HasIndex(e => new { e.FileId, e.Name }).IsUnique(); }); } } diff --git a/src/OpenFeatureManager/Entities/BooleanFlagEntry.cs b/src/OpenFeatureManager/Entities/BooleanFlagEntry.cs new file mode 100644 index 0000000..2714f2d --- /dev/null +++ b/src/OpenFeatureManager/Entities/BooleanFlagEntry.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OpenFeatureManager.Entities; + +public class BooleanFlagEntry : FlagEntry +{ + [Column("boolean_value")] + public bool? Value { get; set; } + + [Column("global_tw_boolean_value")] + public bool? GlobalTimeWindowValue { get; set; } +} diff --git a/src/OpenFeatureManager/Entities/EnvironmentAlias.cs b/src/OpenFeatureManager/Entities/EnvironmentAlias.cs new file mode 100644 index 0000000..98288fd --- /dev/null +++ b/src/OpenFeatureManager/Entities/EnvironmentAlias.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OpenFeatureManager.Entities; + +[Table("environment_aliases")] +public class EnvironmentAlias +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + [Column("id")] + public long Id { get; set; } + + [Column("environment_entry_id")] + public long EnvironmentEntryId { get; set; } + + [Required] + [Column("alias")] + public string Alias { get; set; } = string.Empty; +} diff --git a/src/OpenFeatureManager/Entities/EnvironmentEntry.cs b/src/OpenFeatureManager/Entities/EnvironmentEntry.cs index 76c7191..b8ba269 100644 --- a/src/OpenFeatureManager/Entities/EnvironmentEntry.cs +++ b/src/OpenFeatureManager/Entities/EnvironmentEntry.cs @@ -14,12 +14,10 @@ public class EnvironmentEntry [Column("file_id")] public long FileId { get; set; } - /// Lowercase environment name, e.g. "production" + /// Human-readable environment name, e.g. "Production", "Staging" [Required] [Column("name")] public string Name { get; set; } = string.Empty; - /// JSON array of alias strings - [Column("aliases_json")] - public string AliasesJson { get; set; } = "[]"; + public List Aliases { get; set; } = []; } diff --git a/src/OpenFeatureManager/Entities/FileMetadataEntry.cs b/src/OpenFeatureManager/Entities/FileMetadataEntry.cs new file mode 100644 index 0000000..f0df57b --- /dev/null +++ b/src/OpenFeatureManager/Entities/FileMetadataEntry.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OpenFeatureManager.Entities; + +[Table("file_metadata_entries")] +public class FileMetadataEntry +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + [Column("id")] + public long Id { get; set; } + + [Column("file_id")] + public long FileId { get; set; } + + [Required] + [Column("key")] + public string Key { get; set; } = string.Empty; + + [Column("string_value")] + public string? StringValue { get; set; } + + [Column("number_value")] + public double? NumberValue { get; set; } + + [Column("boolean_value")] + public bool? BooleanValue { get; set; } +} diff --git a/src/OpenFeatureManager/Entities/FlagEntry.cs b/src/OpenFeatureManager/Entities/FlagEntry.cs index d3c7928..b462854 100644 --- a/src/OpenFeatureManager/Entities/FlagEntry.cs +++ b/src/OpenFeatureManager/Entities/FlagEntry.cs @@ -4,7 +4,7 @@ namespace OpenFeatureManager.Entities; [Table("flag_entries")] -public class FlagEntry +public abstract class FlagEntry { [Key] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] @@ -18,23 +18,14 @@ public class FlagEntry [Column("flag_key")] public string FlagKey { get; set; } = string.Empty; - /// Flag type: boolean | string | number | object - [Column("type")] - public string Type { get; set; } = "boolean"; - - /// Flag state: ENABLED | DISABLED [Column("state")] - public string State { get; set; } = "ENABLED"; + public FlagState State { get; set; } = FlagState.ENABLED; - /// Raw JSON text of the flag's default value - [Column("value_json")] - public string? ValueJson { get; set; } + [Column("global_time_window_id")] + public long? GlobalTimeWindowId { get; set; } - /// Raw JSON text of the flag's metadata object - [Column("metadata_json")] - public string? MetadataJson { get; set; } + public TimeWindow? GlobalTimeWindow { get; set; } - /// Raw JSON text of the flag's targeting rule - [Column("targeting_json")] - public string? TargetingJson { get; set; } + public List Metadata { get; set; } = []; + public List PerEnvironmentDefinitions { get; set; } = []; } diff --git a/src/OpenFeatureManager/Entities/FlagFile.cs b/src/OpenFeatureManager/Entities/FlagFile.cs index 2667404..3dd6c5d 100644 --- a/src/OpenFeatureManager/Entities/FlagFile.cs +++ b/src/OpenFeatureManager/Entities/FlagFile.cs @@ -18,6 +18,8 @@ public class FlagFile [Column("created_at")] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - [Column("metadata_json")] - public string? MetadataJson { get; set; } + public List Metadata { get; set; } = []; + public List Flags { get; set; } = []; + public List Environments { get; set; } = []; + public List TimeWindows { get; set; } = []; } diff --git a/src/OpenFeatureManager/Entities/FlagMetadataEntry.cs b/src/OpenFeatureManager/Entities/FlagMetadataEntry.cs new file mode 100644 index 0000000..c47c7fc --- /dev/null +++ b/src/OpenFeatureManager/Entities/FlagMetadataEntry.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OpenFeatureManager.Entities; + +[Table("flag_metadata_entries")] +public class FlagMetadataEntry +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + [Column("id")] + public long Id { get; set; } + + [Column("flag_entry_id")] + public long FlagEntryId { get; set; } + + [Required] + [Column("key")] + public string Key { get; set; } = string.Empty; + + [Column("string_value")] + public string? StringValue { get; set; } + + [Column("number_value")] + public double? NumberValue { get; set; } + + [Column("boolean_value")] + public bool? BooleanValue { get; set; } +} diff --git a/src/OpenFeatureManager/Entities/FlagState.cs b/src/OpenFeatureManager/Entities/FlagState.cs new file mode 100644 index 0000000..c3f4fe7 --- /dev/null +++ b/src/OpenFeatureManager/Entities/FlagState.cs @@ -0,0 +1,7 @@ +namespace OpenFeatureManager.Entities; + +public enum FlagState +{ + ENABLED, + DISABLED +} diff --git a/src/OpenFeatureManager/Entities/NumberFlagEntry.cs b/src/OpenFeatureManager/Entities/NumberFlagEntry.cs new file mode 100644 index 0000000..b674cef --- /dev/null +++ b/src/OpenFeatureManager/Entities/NumberFlagEntry.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace OpenFeatureManager.Entities; + +public class NumberFlagEntry : FlagEntry +{ + [Column("number_value")] + public double? Value { get; set; } + + [Column("global_tw_number_value")] + public double? GlobalTimeWindowValue { get; set; } +} diff --git a/src/OpenFeatureManager/Entities/ObjectFlagEntry.cs b/src/OpenFeatureManager/Entities/ObjectFlagEntry.cs new file mode 100644 index 0000000..36575c6 --- /dev/null +++ b/src/OpenFeatureManager/Entities/ObjectFlagEntry.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace OpenFeatureManager.Entities; + +/// +/// Flag entry with an object value stored as serialized JSON. +/// The type is explicitly known (object), distinguishing this from untyped JSON storage. +/// +public class ObjectFlagEntry : FlagEntry +{ + [Column("object_value")] + public string? ObjectValue { get; set; } + + [Column("global_tw_object_value")] + public string? GlobalTimeWindowObjectValue { get; set; } +} diff --git a/src/OpenFeatureManager/Entities/PerEnvironmentDefinition.cs b/src/OpenFeatureManager/Entities/PerEnvironmentDefinition.cs new file mode 100644 index 0000000..a49b78f --- /dev/null +++ b/src/OpenFeatureManager/Entities/PerEnvironmentDefinition.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OpenFeatureManager.Entities; + +[Table("per_environment_definitions")] +public class PerEnvironmentDefinition +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + [Column("id")] + public long Id { get; set; } + + [Column("flag_entry_id")] + public long FlagEntryId { get; set; } + + [Column("environment_entry_id")] + public long EnvironmentEntryId { get; set; } + + public EnvironmentEntry Environment { get; set; } = null!; + + [Column("boolean_value")] + public bool? BooleanValue { get; set; } + + [Column("string_value")] + public string? StringValue { get; set; } + + [Column("number_value")] + public double? NumberValue { get; set; } + + [Column("object_value")] + public string? ObjectValue { get; set; } + + [Column("time_window_id")] + public long? TimeWindowId { get; set; } + + public TimeWindow? TimeWindow { get; set; } +} diff --git a/src/OpenFeatureManager/Entities/StringFlagEntry.cs b/src/OpenFeatureManager/Entities/StringFlagEntry.cs new file mode 100644 index 0000000..10dbbd8 --- /dev/null +++ b/src/OpenFeatureManager/Entities/StringFlagEntry.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace OpenFeatureManager.Entities; + +public class StringFlagEntry : FlagEntry +{ + [Column("string_value")] + public string? Value { get; set; } + + [Column("global_tw_string_value")] + public string? GlobalTimeWindowValue { get; set; } +} diff --git a/src/OpenFeatureManager/Entities/TimeWindow.cs b/src/OpenFeatureManager/Entities/TimeWindow.cs new file mode 100644 index 0000000..0f8ed82 --- /dev/null +++ b/src/OpenFeatureManager/Entities/TimeWindow.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OpenFeatureManager.Entities; + +[Table("time_windows")] +public class TimeWindow +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + [Column("id")] + public long Id { get; set; } + + [Column("file_id")] + public long FileId { get; set; } + + /// Human-readable name, e.g. "Christmas Time", "Black Friday" + [Required] + [Column("name")] + public string Name { get; set; } = string.Empty; + + [Column("start_time")] + public DateTime? StartTime { get; set; } + + [Column("end_time")] + public DateTime? EndTime { get; set; } +} diff --git a/src/OpenFeatureManager/Models/FlagdJsonContext.cs b/src/OpenFeatureManager/Models/FlagdJsonContext.cs index c2747d1..71654a1 100644 --- a/src/OpenFeatureManager/Models/FlagdJsonContext.cs +++ b/src/OpenFeatureManager/Models/FlagdJsonContext.cs @@ -3,33 +3,72 @@ namespace OpenFeatureManager.Models; /// DTO for a flag file (schema document). -public record FlagFileDto(long Id, string Name, DateTime CreatedAt, string? MetadataJson = null); +public record FlagFileDto( + long Id, + string Name, + DateTime CreatedAt, + List? Metadata = null); + +/// DTO for a metadata key-value entry (supports string, number, boolean). +public record MetadataEntryDto( + string Key, + string? StringValue = null, + double? NumberValue = null, + bool? BooleanValue = null); /// -/// DTO for a single flag entry. -/// ValueJson, MetadataJson, and TargetingJson carry raw JSON text -/// so that arbitrary flag values and targeting rules survive serialisation boundaries without -/// further reflection-based serialisation. +/// DTO for a single flag entry with typed values. +/// Only the value field matching Type is populated. /// public record FlagEntryDto( string Key, string Type, string State, - string? ValueJson = null, - string? MetadataJson = null, - string? TargetingJson = null, + bool? BooleanValue = null, + string? StringValue = null, + double? NumberValue = null, + string? ObjectValue = null, + List? Metadata = null, + Dictionary? PerEnvironmentDefinitions = null, + GlobalTimeWindowDto? GlobalTimeWindow = null, string? PreviousKey = null); +/// DTO for a per-environment value override with optional time window reference. +public record PerEnvironmentDefinitionDto( + bool? BooleanValue = null, + string? StringValue = null, + double? NumberValue = null, + string? ObjectValue = null, + long? TimeWindowId = null); + +/// DTO for a global time-windowed value override referencing a TimeWindow entity. +public record GlobalTimeWindowDto( + long TimeWindowId, + bool? BooleanValue = null, + string? StringValue = null, + double? NumberValue = null, + string? ObjectValue = null); + /// DTO for an environment definition. -public record EnvironmentEntryDto(string Name, string DisplayName, string[] Aliases); +public record EnvironmentEntryDto(string Name, string[] Aliases); + +/// DTO for a reusable time window. +public record TimeWindowDto(long Id, string Name, DateTime? StartTime = null, DateTime? EndTime = null); [JsonSerializable(typeof(string[]))] [JsonSerializable(typeof(FlagFileDto))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(FlagEntryDto))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(MetadataEntryDto))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(PerEnvironmentDefinitionDto))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(GlobalTimeWindowDto))] [JsonSerializable(typeof(EnvironmentEntryDto))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(TimeWindowDto))] +[JsonSerializable(typeof(List))] [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] diff --git a/src/OpenFeatureManager/Services/FlagdSchemaService.cs b/src/OpenFeatureManager/Services/FlagdSchemaService.cs index ca928eb..930c740 100644 --- a/src/OpenFeatureManager/Services/FlagdSchemaService.cs +++ b/src/OpenFeatureManager/Services/FlagdSchemaService.cs @@ -7,9 +7,9 @@ namespace OpenFeatureManager.Services; /// /// Handles import and export of flagd JSON Schema documents. /// -/// Converts between the flagd JSON schema format and the normalised DTO model -/// exposed by , using raw -/// parsing so there is no dependency on generated schema types. +/// Converts between the flagd JSON schema format and the typed DTO model +/// exposed by . Values are stored with their native +/// types rather than as raw JSON strings. /// public class FlagdSchemaService { @@ -48,7 +48,8 @@ public void ImportSchema(long fileId, string schemaJson) && metadataElem.ValueKind == JsonValueKind.Object && metadataElem.EnumerateObject().Any()) { - _flagdService.UpdateFileMetadata(fileId, metadataElem.GetRawText()); + var metadata = ParseMetadata(metadataElem); + _flagdService.UpdateFileMetadata(fileId, metadata); } // Parse $evaluators → environments (pattern: "isXxx") @@ -76,14 +77,13 @@ public void ImportSchema(long fileId, string schemaJson) aliasesPart.ValueKind != JsonValueKind.Array) continue; - var envName = key[EnvironmentEvaluatorPrefix.Length..].ToLowerInvariant(); - var displayName = char.ToUpperInvariant(envName[0]) + envName[1..]; + var envName = key[EnvironmentEvaluatorPrefix.Length..]; // "Production" from "isProduction" var aliases = aliasesPart.EnumerateArray() .Select(a => a.GetString() ?? string.Empty) .Where(a => !string.IsNullOrEmpty(a)) .ToArray(); - _flagdService.UpsertEnvironment(fileId, new EnvironmentEntryDto(envName, displayName, aliases)); + _flagdService.UpsertEnvironment(fileId, new EnvironmentEntryDto(envName, aliases)); } } @@ -102,7 +102,10 @@ public void ImportSchema(long fileId, string schemaJson) : "ENABLED"; var flagType = "object"; - string? valueJson = null; + bool? boolVal = null; + string? strVal = null; + double? numVal = null; + string? objVal = null; if (flagDef.TryGetProperty("defaultVariant", out var defaultVariantElem) && flagDef.TryGetProperty("variants", out var variantsElem) @@ -112,26 +115,43 @@ public void ImportSchema(long fileId, string schemaJson) if (!string.IsNullOrEmpty(defaultVariantName) && variantsElem.TryGetProperty(defaultVariantName, out var defaultValue)) { - flagType = defaultValue.ValueKind switch + switch (defaultValue.ValueKind) { - JsonValueKind.True or JsonValueKind.False => "boolean", - JsonValueKind.Number => "number", - JsonValueKind.String => "string", - _ => "object", - }; - valueJson = defaultValue.GetRawText(); + case JsonValueKind.True: + flagType = "boolean"; + boolVal = true; + break; + case JsonValueKind.False: + flagType = "boolean"; + boolVal = false; + break; + case JsonValueKind.Number: + flagType = "number"; + numVal = defaultValue.GetDouble(); + break; + case JsonValueKind.String: + flagType = "string"; + strVal = defaultValue.GetString(); + break; + default: + flagType = "object"; + objVal = defaultValue.GetRawText(); + break; + } } } - string? flagMetadataJson = flagDef.TryGetProperty("metadata", out var flagMetaElem) - && flagMetaElem.ValueKind == JsonValueKind.Object - ? flagMetaElem.GetRawText() : null; - - string? targetingJson = flagDef.TryGetProperty("targeting", out var targetingElem) - && targetingElem.ValueKind == JsonValueKind.Object - ? targetingElem.GetRawText() : null; + List? flagMetadata = null; + if (flagDef.TryGetProperty("metadata", out var flagMetaElem) + && flagMetaElem.ValueKind == JsonValueKind.Object) + { + flagMetadata = ParseMetadata(flagMetaElem); + } - _flagdService.UpsertFlag(fileId, new FlagEntryDto(flagKey, flagType, state, valueJson, flagMetadataJson, targetingJson)); + _flagdService.UpsertFlag(fileId, new FlagEntryDto( + flagKey, flagType, state, + boolVal, strVal, numVal, objVal, + flagMetadata)); } } } @@ -166,21 +186,15 @@ public string ExportSchema(long fileId) writer.WritePropertyName("variants"); writer.WriteStartObject(); writer.WritePropertyName(variantKey); - WriteRawOrNull(writer, flag.ValueJson); + WriteFlagValue(writer, flag); writer.WriteEndObject(); writer.WriteString("defaultVariant", variantKey); - if (!string.IsNullOrEmpty(flag.TargetingJson)) - { - writer.WritePropertyName("targeting"); - WriteRaw(writer, flag.TargetingJson); - } - - if (!string.IsNullOrEmpty(flag.MetadataJson)) + if (flag.Metadata is { Count: > 0 }) { writer.WritePropertyName("metadata"); - WriteRaw(writer, flag.MetadataJson); + WriteMetadata(writer, flag.Metadata); } writer.WriteEndObject(); @@ -195,9 +209,7 @@ public string ExportSchema(long fileId) foreach (var env in environments) { if (string.IsNullOrEmpty(env.Name)) continue; - var refKey = EnvironmentEvaluatorPrefix + - char.ToUpperInvariant(env.Name[0]) + - env.Name[1..]; + var refKey = EnvironmentEvaluatorPrefix + env.Name; // "is" + "Production" = "isProduction" writer.WritePropertyName(refKey); writer.WriteStartObject(); @@ -216,10 +228,10 @@ public string ExportSchema(long fileId) } // file-level metadata - if (!string.IsNullOrEmpty(file.MetadataJson)) + if (file.Metadata is { Count: > 0 }) { writer.WritePropertyName("metadata"); - WriteRaw(writer, file.MetadataJson); + WriteMetadata(writer, file.Metadata); } writer.WriteEndObject(); // root @@ -234,17 +246,76 @@ public string ExportSchema(long fileId) // ─── Private helpers ────────────────────────────────────────────────── - private static void WriteRaw(Utf8JsonWriter writer, string rawJson) + private static List ParseMetadata(JsonElement metadataObj) { - using var doc = JsonDocument.Parse(rawJson); - doc.RootElement.WriteTo(writer); + var result = new List(); + foreach (var prop in metadataObj.EnumerateObject()) + { + var dto = prop.Value.ValueKind switch + { + JsonValueKind.String => new MetadataEntryDto(prop.Name, StringValue: prop.Value.GetString()), + JsonValueKind.Number => new MetadataEntryDto(prop.Name, NumberValue: prop.Value.GetDouble()), + JsonValueKind.True => new MetadataEntryDto(prop.Name, BooleanValue: true), + JsonValueKind.False => new MetadataEntryDto(prop.Name, BooleanValue: false), + _ => null + }; + if (dto is not null) result.Add(dto); + } + return result; } - private static void WriteRawOrNull(Utf8JsonWriter writer, string? rawJson) + private static void WriteFlagValue(Utf8JsonWriter writer, FlagEntryDto flag) { - if (string.IsNullOrEmpty(rawJson)) - writer.WriteNullValue(); - else - WriteRaw(writer, rawJson); + switch (flag.Type) + { + case "boolean": + if (flag.BooleanValue.HasValue) + writer.WriteBooleanValue(flag.BooleanValue.Value); + else + writer.WriteNullValue(); + break; + case "string": + if (flag.StringValue is not null) + writer.WriteStringValue(flag.StringValue); + else + writer.WriteNullValue(); + break; + case "number": + if (flag.NumberValue.HasValue) + writer.WriteNumberValue(flag.NumberValue.Value); + else + writer.WriteNullValue(); + break; + case "object": + if (!string.IsNullOrEmpty(flag.ObjectValue)) + { + using var doc = JsonDocument.Parse(flag.ObjectValue); + doc.RootElement.WriteTo(writer); + } + else + writer.WriteNullValue(); + break; + default: + writer.WriteNullValue(); + break; + } + } + + private static void WriteMetadata(Utf8JsonWriter writer, List metadata) + { + writer.WriteStartObject(); + foreach (var entry in metadata) + { + writer.WritePropertyName(entry.Key); + if (entry.StringValue is not null) + writer.WriteStringValue(entry.StringValue); + else if (entry.NumberValue.HasValue) + writer.WriteNumberValue(entry.NumberValue.Value); + else if (entry.BooleanValue.HasValue) + writer.WriteBooleanValue(entry.BooleanValue.Value); + else + writer.WriteNullValue(); + } + writer.WriteEndObject(); } } diff --git a/src/OpenFeatureManager/Services/FlagdService.cs b/src/OpenFeatureManager/Services/FlagdService.cs index 3d3ef84..9a23e0d 100644 --- a/src/OpenFeatureManager/Services/FlagdService.cs +++ b/src/OpenFeatureManager/Services/FlagdService.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using Microsoft.EntityFrameworkCore; using OpenFeatureManager.Data; using OpenFeatureManager.Entities; @@ -7,15 +6,13 @@ namespace OpenFeatureManager.Services; /// -/// SQLite-backed flagd CRUD service. +/// SQLite-backed flagd CRUD service with typed flag entities. /// -/// Provides multi-file flag/environment management. -/// Instantiate with a factory so that callers -/// (WASM, REST API, tests) can supply their own DB configuration. +/// Provides multi-file flag/environment management using TPH inheritance +/// for type-safe flag value storage. /// public class FlagdService { - private static readonly FlagdJsonContext JsonCtx = FlagdJsonContext.Default; private readonly Func _contextFactory; public FlagdService(Func contextFactory) @@ -28,7 +25,8 @@ public FlagdService(Func contextFactory) public FlagFileDto GetFile(long id) { using var db = _contextFactory(); - var file = db.FlagFiles.Find(id) ?? throw new KeyNotFoundException($"File {id} not found"); + var file = db.FlagFiles.Include(f => f.Metadata).FirstOrDefault(f => f.Id == id) + ?? throw new KeyNotFoundException($"File {id} not found"); return ToDto(file); } @@ -44,13 +42,14 @@ public FlagFileDto CreateFile(string name) public List GetFiles() { using var db = _contextFactory(); - return db.FlagFiles.OrderBy(f => f.Id).ToList().Select(ToDto).ToList(); + return db.FlagFiles.Include(f => f.Metadata).OrderBy(f => f.Id).ToList().Select(ToDto).ToList(); } public FlagFileDto RenameFile(long id, string name) { using var db = _contextFactory(); - var file = db.FlagFiles.Find(id) ?? throw new KeyNotFoundException($"File {id} not found"); + var file = db.FlagFiles.Include(f => f.Metadata).FirstOrDefault(f => f.Id == id) + ?? throw new KeyNotFoundException($"File {id} not found"); file.Name = name; db.SaveChanges(); return ToDto(file); @@ -60,8 +59,10 @@ public void DeleteFile(long id) { using var db = _contextFactory(); var file = db.FlagFiles.Find(id) ?? throw new KeyNotFoundException($"File {id} not found"); + db.FileMetadataEntries.RemoveRange(db.FileMetadataEntries.Where(m => m.FileId == id)); db.FlagEntries.RemoveRange(db.FlagEntries.Where(f => f.FileId == id)); db.EnvironmentEntries.RemoveRange(db.EnvironmentEntries.Where(e => e.FileId == id)); + db.TimeWindows.RemoveRange(db.TimeWindows.Where(t => t.FileId == id)); db.FlagFiles.Remove(file); db.SaveChanges(); } @@ -71,16 +72,30 @@ public void ClearFileData(long fileId) using var db = _contextFactory(); if (!db.FlagFiles.Any(f => f.Id == fileId)) throw new KeyNotFoundException($"File {fileId} not found"); + db.FileMetadataEntries.RemoveRange(db.FileMetadataEntries.Where(m => m.FileId == fileId)); db.EnvironmentEntries.RemoveRange(db.EnvironmentEntries.Where(e => e.FileId == fileId)); db.FlagEntries.RemoveRange(db.FlagEntries.Where(f => f.FileId == fileId)); + db.TimeWindows.RemoveRange(db.TimeWindows.Where(t => t.FileId == fileId)); db.SaveChanges(); } - public void UpdateFileMetadata(long fileId, string? metadataJson) + public void UpdateFileMetadata(long fileId, List metadata) { using var db = _contextFactory(); var file = db.FlagFiles.Find(fileId) ?? throw new KeyNotFoundException($"File {fileId} not found"); - file.MetadataJson = metadataJson; + + db.FileMetadataEntries.RemoveRange(db.FileMetadataEntries.Where(m => m.FileId == fileId)); + foreach (var entry in metadata) + { + db.FileMetadataEntries.Add(new FileMetadataEntry + { + FileId = fileId, + Key = entry.Key, + StringValue = entry.StringValue, + NumberValue = entry.NumberValue, + BooleanValue = entry.BooleanValue + }); + } db.SaveChanges(); } @@ -89,17 +104,26 @@ public void UpdateFileMetadata(long fileId, string? metadataJson) public List GetFlags(long fileId) { using var db = _contextFactory(); + var envLookup = db.EnvironmentEntries + .Where(e => e.FileId == fileId) + .ToDictionary(e => e.Id, e => e.Name); + return db.FlagEntries .Where(f => f.FileId == fileId) + .Include(f => f.Metadata) + .Include(f => f.PerEnvironmentDefinitions) .OrderBy(f => f.FlagKey) .ToList() - .Select(ToDto) + .Select(f => ToDto(f, envLookup)) .ToList(); } /// /// Create or update a flag from a . /// Supports renaming via the optional PreviousKey field. + /// If the flag type changes, the existing entity is replaced. + /// Per-environment definitions are keyed by environment name; the service + /// resolves names to EnvironmentEntry IDs. /// public FlagEntryDto UpsertFlag(long fileId, FlagEntryDto dto) { @@ -112,21 +136,83 @@ public FlagEntryDto UpsertFlag(long fileId, FlagEntryDto dto) if (old is not null) db.FlagEntries.Remove(old); } - var existing = db.FlagEntries.FirstOrDefault(f => f.FileId == fileId && f.FlagKey == dto.Key); + var existing = db.FlagEntries + .Include(f => f.Metadata) + .Include(f => f.PerEnvironmentDefinitions) + .FirstOrDefault(f => f.FileId == fileId && f.FlagKey == dto.Key); + + if (existing is not null) + { + var existingType = GetFlagType(existing); + if (existingType != dto.Type) + { + db.FlagEntries.Remove(existing); + db.SaveChanges(); + existing = null; + } + } + if (existing is null) { - existing = new FlagEntry { FileId = fileId, FlagKey = dto.Key }; + existing = CreateFlagEntry(dto.Type, fileId, dto.Key); db.FlagEntries.Add(existing); } - existing.Type = dto.Type; - existing.State = dto.State; - existing.ValueJson = dto.ValueJson; - existing.MetadataJson = dto.MetadataJson; - existing.TargetingJson = dto.TargetingJson; + existing.State = Enum.Parse(dto.State); + existing.GlobalTimeWindowId = dto.GlobalTimeWindow?.TimeWindowId; + + SetFlagValue(existing, dto); + SetGlobalTimeWindowValue(existing, dto.GlobalTimeWindow); + + // Replace metadata + db.FlagMetadataEntries.RemoveRange(existing.Metadata); + existing.Metadata.Clear(); + if (dto.Metadata is not null) + { + foreach (var entry in dto.Metadata) + { + existing.Metadata.Add(new FlagMetadataEntry + { + Key = entry.Key, + StringValue = entry.StringValue, + NumberValue = entry.NumberValue, + BooleanValue = entry.BooleanValue + }); + } + } + + // Replace per-environment definitions (resolve env names → IDs) + db.PerEnvironmentDefinitions.RemoveRange(existing.PerEnvironmentDefinitions); + existing.PerEnvironmentDefinitions.Clear(); + if (dto.PerEnvironmentDefinitions is not null) + { + var envNameToId = db.EnvironmentEntries + .Where(e => e.FileId == fileId) + .ToDictionary(e => e.Name, e => e.Id); + + foreach (var (envName, envDef) in dto.PerEnvironmentDefinitions) + { + if (!envNameToId.TryGetValue(envName, out var envId)) + throw new KeyNotFoundException($"Environment '{envName}' not found in file {fileId}"); + + existing.PerEnvironmentDefinitions.Add(new PerEnvironmentDefinition + { + EnvironmentEntryId = envId, + BooleanValue = envDef.BooleanValue, + StringValue = envDef.StringValue, + NumberValue = envDef.NumberValue, + ObjectValue = envDef.ObjectValue, + TimeWindowId = envDef.TimeWindowId + }); + } + } + db.SaveChanges(); - return ToDto(existing); + var envLookup = db.EnvironmentEntries + .Where(e => e.FileId == fileId) + .ToDictionary(e => e.Id, e => e.Name); + return ToDto(existing, envLookup); } public void DeleteFlag(long fileId, string flagKey) @@ -145,61 +231,205 @@ public List GetEnvironments(long fileId) using var db = _contextFactory(); return db.EnvironmentEntries .Where(e => e.FileId == fileId) + .Include(e => e.Aliases) .OrderBy(e => e.Name) .ToList() .Select(ToDto) .ToList(); } - /// - /// Create or update an environment from a . - /// The DisplayName is title-cased; the key stored in the DB is its lower-case form. - /// public EnvironmentEntryDto UpsertEnvironment(long fileId, EnvironmentEntryDto dto) { - if (string.IsNullOrWhiteSpace(dto.DisplayName)) - throw new ArgumentException("Environment display name is required"); + if (string.IsNullOrWhiteSpace(dto.Name)) + throw new ArgumentException("Environment name is required"); - var nameLower = dto.DisplayName.ToLowerInvariant(); using var db = _contextFactory(); - var existing = db.EnvironmentEntries.FirstOrDefault(e => e.FileId == fileId && e.Name == nameLower); + var existing = db.EnvironmentEntries + .Include(e => e.Aliases) + .FirstOrDefault(e => e.FileId == fileId && e.Name == dto.Name); + if (existing is null) { - existing = new EnvironmentEntry { FileId = fileId, Name = nameLower }; + existing = new EnvironmentEntry + { + FileId = fileId, + Name = dto.Name + }; db.EnvironmentEntries.Add(existing); } - existing.AliasesJson = JsonSerializer.Serialize(dto.Aliases, JsonCtx.StringArray); - db.SaveChanges(); + // Replace aliases + db.EnvironmentAliases.RemoveRange(existing.Aliases); + existing.Aliases.Clear(); + foreach (var alias in dto.Aliases) + { + existing.Aliases.Add(new EnvironmentAlias { Alias = alias }); + } + db.SaveChanges(); return ToDto(existing); } public void DeleteEnvironment(long fileId, string name) { - var nameLower = name.ToLowerInvariant(); using var db = _contextFactory(); - var entry = db.EnvironmentEntries.FirstOrDefault(e => e.FileId == fileId && e.Name == nameLower) + var entry = db.EnvironmentEntries.FirstOrDefault(e => e.FileId == fileId && e.Name == name) ?? throw new KeyNotFoundException($"Environment '{name}' not found in file {fileId}"); db.EnvironmentEntries.Remove(entry); db.SaveChanges(); } + // ─── Time window management ─────────────────────────────────────────── + + public List GetTimeWindows(long fileId) + { + using var db = _contextFactory(); + return db.TimeWindows + .Where(t => t.FileId == fileId) + .OrderBy(t => t.Name) + .ToList() + .Select(ToDto) + .ToList(); + } + + public TimeWindowDto CreateTimeWindow(long fileId, TimeWindowDto dto) + { + if (string.IsNullOrWhiteSpace(dto.Name)) + throw new ArgumentException("Time window name is required"); + + using var db = _contextFactory(); + if (!db.FlagFiles.Any(f => f.Id == fileId)) + throw new KeyNotFoundException($"File {fileId} not found"); + + var tw = new TimeWindow + { + FileId = fileId, + Name = dto.Name, + StartTime = dto.StartTime, + EndTime = dto.EndTime + }; + db.TimeWindows.Add(tw); + db.SaveChanges(); + return ToDto(tw); + } + + public TimeWindowDto UpdateTimeWindow(long fileId, long timeWindowId, TimeWindowDto dto) + { + using var db = _contextFactory(); + var tw = db.TimeWindows.FirstOrDefault(t => t.Id == timeWindowId && t.FileId == fileId) + ?? throw new KeyNotFoundException($"Time window {timeWindowId} not found in file {fileId}"); + + tw.Name = dto.Name; + tw.StartTime = dto.StartTime; + tw.EndTime = dto.EndTime; + db.SaveChanges(); + return ToDto(tw); + } + + public void DeleteTimeWindow(long fileId, long timeWindowId) + { + using var db = _contextFactory(); + var tw = db.TimeWindows.FirstOrDefault(t => t.Id == timeWindowId && t.FileId == fileId) + ?? throw new KeyNotFoundException($"Time window {timeWindowId} not found in file {fileId}"); + db.TimeWindows.Remove(tw); + db.SaveChanges(); + } + // ─── Private helpers ────────────────────────────────────────────────── - private static FlagFileDto ToDto(FlagFile f) => - new(f.Id, f.Name, f.CreatedAt, f.MetadataJson); + private static string GetFlagType(FlagEntry entry) => entry switch + { + BooleanFlagEntry => "boolean", + StringFlagEntry => "string", + NumberFlagEntry => "number", + ObjectFlagEntry => "object", + _ => throw new InvalidOperationException($"Unknown flag entry type: {entry.GetType()}") + }; + + private static FlagEntry CreateFlagEntry(string type, long fileId, string flagKey) => type switch + { + "boolean" => new BooleanFlagEntry { FileId = fileId, FlagKey = flagKey }, + "string" => new StringFlagEntry { FileId = fileId, FlagKey = flagKey }, + "number" => new NumberFlagEntry { FileId = fileId, FlagKey = flagKey }, + "object" => new ObjectFlagEntry { FileId = fileId, FlagKey = flagKey }, + _ => throw new ArgumentException($"Unknown flag type: {type}") + }; - private static FlagEntryDto ToDto(FlagEntry e) => - new(e.FlagKey, e.Type, e.State, e.ValueJson, e.MetadataJson, e.TargetingJson); + private static void SetFlagValue(FlagEntry entry, FlagEntryDto dto) + { + switch (entry) + { + case BooleanFlagEntry b: b.Value = dto.BooleanValue; break; + case StringFlagEntry s: s.Value = dto.StringValue; break; + case NumberFlagEntry n: n.Value = dto.NumberValue; break; + case ObjectFlagEntry o: o.ObjectValue = dto.ObjectValue; break; + } + } - private static EnvironmentEntryDto ToDto(EnvironmentEntry e) + private static void SetGlobalTimeWindowValue(FlagEntry entry, GlobalTimeWindowDto? tw) { - var aliases = JsonSerializer.Deserialize(e.AliasesJson, FlagdJsonContext.Default.StringArray) ?? []; - var displayName = string.IsNullOrEmpty(e.Name) - ? e.Name - : char.ToUpperInvariant(e.Name[0]) + e.Name[1..]; - return new EnvironmentEntryDto(e.Name, displayName, aliases); + switch (entry) + { + case BooleanFlagEntry b: b.GlobalTimeWindowValue = tw?.BooleanValue; break; + case StringFlagEntry s: s.GlobalTimeWindowValue = tw?.StringValue; break; + case NumberFlagEntry n: n.GlobalTimeWindowValue = tw?.NumberValue; break; + case ObjectFlagEntry o: o.GlobalTimeWindowObjectValue = tw?.ObjectValue; break; + } } + + private static FlagFileDto ToDto(FlagFile f) => + new(f.Id, f.Name, f.CreatedAt, + f.Metadata.Count > 0 + ? f.Metadata.Select(m => new MetadataEntryDto(m.Key, m.StringValue, m.NumberValue, m.BooleanValue)).ToList() + : null); + + private static FlagEntryDto ToDto(FlagEntry e, Dictionary envIdToName) + { + var (type, boolVal, strVal, numVal, objVal) = e switch + { + BooleanFlagEntry b => ("boolean", b.Value, (string?)null, (double?)null, (string?)null), + StringFlagEntry s => ("string", (bool?)null, s.Value, (double?)null, (string?)null), + NumberFlagEntry n => ("number", (bool?)null, (string?)null, n.Value, (string?)null), + ObjectFlagEntry o => ("object", (bool?)null, (string?)null, (double?)null, o.ObjectValue), + _ => throw new InvalidOperationException($"Unknown flag entry type: {e.GetType()}") + }; + + var metadata = e.Metadata.Count > 0 + ? e.Metadata.Select(m => new MetadataEntryDto(m.Key, m.StringValue, m.NumberValue, m.BooleanValue)).ToList() + : null; + + var perEnvDefs = e.PerEnvironmentDefinitions.Count > 0 + ? e.PerEnvironmentDefinitions.ToDictionary( + d => envIdToName.GetValueOrDefault(d.EnvironmentEntryId, d.EnvironmentEntryId.ToString()), + d => new PerEnvironmentDefinitionDto( + d.BooleanValue, d.StringValue, d.NumberValue, d.ObjectValue, + d.TimeWindowId)) + : null; + + GlobalTimeWindowDto? globalTw = null; + if (e.GlobalTimeWindowId.HasValue) + { + var (twBool, twStr, twNum, twObj) = e switch + { + BooleanFlagEntry b => (b.GlobalTimeWindowValue, (string?)null, (double?)null, (string?)null), + StringFlagEntry s => ((bool?)null, s.GlobalTimeWindowValue, (double?)null, (string?)null), + NumberFlagEntry n => ((bool?)null, (string?)null, n.GlobalTimeWindowValue, (string?)null), + ObjectFlagEntry o => ((bool?)null, (string?)null, (double?)null, o.GlobalTimeWindowObjectValue), + _ => ((bool?)null, (string?)null, (double?)null, (string?)null) + }; + globalTw = new GlobalTimeWindowDto(e.GlobalTimeWindowId.Value, twBool, twStr, twNum, twObj); + } + + return new FlagEntryDto( + e.FlagKey, type, e.State.ToString(), + boolVal, strVal, numVal, objVal, + metadata, perEnvDefs, globalTw); + } + + private static EnvironmentEntryDto ToDto(EnvironmentEntry e) => + new(e.Name, e.Aliases.Select(a => a.Alias).ToArray()); + + private static TimeWindowDto ToDto(TimeWindow t) => + new(t.Id, t.Name, t.StartTime, t.EndTime); } diff --git a/ui/package.json b/ui/package.json index ec525f3..df8386b 100644 --- a/ui/package.json +++ b/ui/package.json @@ -71,4 +71,4 @@ "typescript-eslint": "8.50.1", "vitest": "^4.0.8" } -} +} \ No newline at end of file diff --git a/ui/src/environments.ts b/ui/src/environments.ts index ce1b75f..c3bc9ee 100644 --- a/ui/src/environments.ts +++ b/ui/src/environments.ts @@ -1,5 +1,5 @@ /* This file is generated by scripts/generate-environments.js. */ export const GIT_TAG: string | null = null; -export const GIT_COMMIT_HASH: string | null = "5c7cea5414ca606c1c7a2526ea9b9bbadfc33bc1"; +export const GIT_COMMIT_HASH: string | null = "f678340df990629c4ebf68b03f8c5e04a2756904"; export const DEFAULT_BACKEND_ROOT: string | null = null; From b8e5900b878182f23c029765ed73b26debf72196 Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Sat, 7 Mar 2026 00:33:43 +0000 Subject: [PATCH 13/38] rename collection --- src/OpenFeatureManager.Api/Program.cs | 60 ++--- .../FlagdSchemaServiceTests.cs | 242 +++++++++--------- src/OpenFeatureManager/Data/FlagdDbContext.cs | 28 +- ...ataEntry.cs => CollectionMetadataEntry.cs} | 8 +- .../Entities/EnvironmentEntry.cs | 4 +- src/OpenFeatureManager/Entities/FlagEntry.cs | 4 +- .../{FlagFile.cs => FlagsCollection.cs} | 6 +- src/OpenFeatureManager/Entities/TimeWindow.cs | 4 +- .../Models/FlagdJsonContext.cs | 8 +- .../Services/FlagdSchemaService.cs | 28 +- .../Services/FlagdService.cs | 148 +++++------ 11 files changed, 270 insertions(+), 270 deletions(-) rename src/OpenFeatureManager/Entities/{FileMetadataEntry.cs => CollectionMetadataEntry.cs} (79%) rename src/OpenFeatureManager/Entities/{FlagFile.cs => FlagsCollection.cs} (82%) diff --git a/src/OpenFeatureManager.Api/Program.cs b/src/OpenFeatureManager.Api/Program.cs index 56be5ab..9835d6d 100644 --- a/src/OpenFeatureManager.Api/Program.cs +++ b/src/OpenFeatureManager.Api/Program.cs @@ -37,42 +37,42 @@ app.MapOpenApi(); -// ─── File endpoints ─────────────────────────────────────────────────── +// ─── Collection endpoints ───────────────────────────────────────────── -app.MapGet("/api/files", (FlagdService svc) => TypedResults.Ok(svc.GetFiles())) - .WithName("listFiles").WithTags("files"); +app.MapGet("/api/collections", (FlagdService svc) => TypedResults.Ok(svc.GetCollections())) + .WithName("listCollections").WithTags("collections"); -app.MapPost("/api/files", (CreateFileRequest req, FlagdService svc) => +app.MapPost("/api/collections", (CreateCollectionRequest req, FlagdService svc) => { - var file = svc.CreateFile(req.Name); - return TypedResults.Created($"/api/files/{file.Id}", file); -}).WithName("createFile").WithTags("files"); + var collection = svc.CreateCollection(req.Name); + return TypedResults.Created($"/api/collections/{collection.Id}", collection); +}).WithName("createCollection").WithTags("collections"); -app.MapPut("/api/files/{id}", (long id, RenameFileRequest req, FlagdService svc) => - TypedResults.Ok(svc.RenameFile(id, req.Name))) - .WithName("renameFile").WithTags("files"); +app.MapPut("/api/collections/{id}", (long id, RenameCollectionRequest req, FlagdService svc) => + TypedResults.Ok(svc.RenameCollection(id, req.Name))) + .WithName("renameCollection").WithTags("collections"); -app.MapDelete("/api/files/{id}", (long id, FlagdService svc) => +app.MapDelete("/api/collections/{id}", (long id, FlagdService svc) => { - svc.DeleteFile(id); + svc.DeleteCollection(id); return TypedResults.NoContent(); -}).WithName("deleteFile").WithTags("files"); +}).WithName("deleteCollection").WithTags("collections"); // ─── Flag endpoints ─────────────────────────────────────────────────── -app.MapGet("/api/files/{id}/flags", (long id, FlagdService svc) => +app.MapGet("/api/collections/{id}/flags", (long id, FlagdService svc) => TypedResults.Ok(svc.GetFlags(id))) .WithName("getFlags").WithTags("flags"); -app.MapPost("/api/files/{id}/flags", (long id, FlagEntryDto dto, FlagdService svc) => +app.MapPost("/api/collections/{id}/flags", (long id, FlagEntryDto dto, FlagdService svc) => TypedResults.Ok(svc.UpsertFlag(id, dto))) .WithName("createFlag").WithTags("flags"); -app.MapPut("/api/files/{id}/flags", (long id, FlagEntryDto dto, FlagdService svc) => +app.MapPut("/api/collections/{id}/flags", (long id, FlagEntryDto dto, FlagdService svc) => TypedResults.Ok(svc.UpsertFlag(id, dto))) .WithName("updateFlag").WithTags("flags"); -app.MapDelete("/api/files/{id}/flags/{key}", (long id, string key, FlagdService svc) => +app.MapDelete("/api/collections/{id}/flags/{key}", (long id, string key, FlagdService svc) => { svc.DeleteFlag(id, Uri.UnescapeDataString(key)); return TypedResults.NoContent(); @@ -80,19 +80,19 @@ // ─── Environment endpoints ──────────────────────────────────────────── -app.MapGet("/api/files/{id}/environments", (long id, FlagdService svc) => +app.MapGet("/api/collections/{id}/environments", (long id, FlagdService svc) => TypedResults.Ok(svc.GetEnvironments(id))) .WithName("getEnvironments").WithTags("environments"); -app.MapPost("/api/files/{id}/environments", (long id, EnvironmentEntryDto dto, FlagdService svc) => +app.MapPost("/api/collections/{id}/environments", (long id, EnvironmentEntryDto dto, FlagdService svc) => TypedResults.Ok(svc.UpsertEnvironment(id, dto))) .WithName("createEnvironment").WithTags("environments"); -app.MapPut("/api/files/{id}/environments", (long id, EnvironmentEntryDto dto, FlagdService svc) => +app.MapPut("/api/collections/{id}/environments", (long id, EnvironmentEntryDto dto, FlagdService svc) => TypedResults.Ok(svc.UpsertEnvironment(id, dto))) .WithName("updateEnvironment").WithTags("environments"); -app.MapDelete("/api/files/{id}/environments/{name}", (long id, string name, FlagdService svc) => +app.MapDelete("/api/collections/{id}/environments/{name}", (long id, string name, FlagdService svc) => { svc.DeleteEnvironment(id, Uri.UnescapeDataString(name)); return TypedResults.NoContent(); @@ -100,21 +100,21 @@ // ─── Time window endpoints ──────────────────────────────────────────── -app.MapGet("/api/files/{id}/timewindows", (long id, FlagdService svc) => +app.MapGet("/api/collections/{id}/timewindows", (long id, FlagdService svc) => TypedResults.Ok(svc.GetTimeWindows(id))) .WithName("getTimeWindows").WithTags("timewindows"); -app.MapPost("/api/files/{id}/timewindows", (long id, TimeWindowDto dto, FlagdService svc) => +app.MapPost("/api/collections/{id}/timewindows", (long id, TimeWindowDto dto, FlagdService svc) => { var tw = svc.CreateTimeWindow(id, dto); - return TypedResults.Created($"/api/files/{id}/timewindows/{tw.Id}", tw); + return TypedResults.Created($"/api/collections/{id}/timewindows/{tw.Id}", tw); }).WithName("createTimeWindow").WithTags("timewindows"); -app.MapPut("/api/files/{id}/timewindows/{twId}", (long id, long twId, TimeWindowDto dto, FlagdService svc) => +app.MapPut("/api/collections/{id}/timewindows/{twId}", (long id, long twId, TimeWindowDto dto, FlagdService svc) => TypedResults.Ok(svc.UpdateTimeWindow(id, twId, dto))) .WithName("updateTimeWindow").WithTags("timewindows"); -app.MapDelete("/api/files/{id}/timewindows/{twId}", (long id, long twId, FlagdService svc) => +app.MapDelete("/api/collections/{id}/timewindows/{twId}", (long id, long twId, FlagdService svc) => { svc.DeleteTimeWindow(id, twId); return TypedResults.NoContent(); @@ -122,11 +122,11 @@ // ─── Schema endpoints ───────────────────────────────────────────────── -app.MapGet("/api/files/{id}/schema", (long id, FlagdSchemaService svc) => +app.MapGet("/api/collections/{id}/schema", (long id, FlagdSchemaService svc) => TypedResults.Text(svc.ExportSchema(id), "application/json")) .WithName("exportSchema").WithTags("schema"); -app.MapPost("/api/files/{id}/schema", async (long id, HttpRequest request, FlagdSchemaService svc) => +app.MapPost("/api/collections/{id}/schema", async (long id, HttpRequest request, FlagdSchemaService svc) => { using var reader = new StreamReader(request.Body); var body = await reader.ReadToEndAsync(); @@ -153,5 +153,5 @@ // ─── Request DTOs ───────────────────────────────────────────────────── -record CreateFileRequest(string Name); -record RenameFileRequest(string Name); +record CreateCollectionRequest(string Name); +record RenameCollectionRequest(string Name); diff --git a/src/OpenFeatureManager.Tests/FlagdSchemaServiceTests.cs b/src/OpenFeatureManager.Tests/FlagdSchemaServiceTests.cs index 2519865..1f8bf38 100644 --- a/src/OpenFeatureManager.Tests/FlagdSchemaServiceTests.cs +++ b/src/OpenFeatureManager.Tests/FlagdSchemaServiceTests.cs @@ -47,7 +47,7 @@ public void Dispose() [Fact] public void ImportSchema_BooleanFlag_StoresCorrectly() { - var file = _flagdService.CreateFile("test"); + var collection = _flagdService.CreateCollection("test"); var json = """ { "$schema": "https://flagd.dev/schema/v0/flags.json", @@ -61,9 +61,9 @@ public void ImportSchema_BooleanFlag_StoresCorrectly() } """; - _schemaService.ImportSchema(file.Id, json); + _schemaService.ImportSchema(collection.Id, json); - var flags = _flagdService.GetFlags(file.Id); + var flags = _flagdService.GetFlags(collection.Id); Assert.Single(flags); var flag = flags[0]; Assert.Equal("my-flag", flag.Key); @@ -75,7 +75,7 @@ public void ImportSchema_BooleanFlag_StoresCorrectly() [Fact] public void ImportSchema_StringFlag_StoresCorrectly() { - var file = _flagdService.CreateFile("test"); + var collection = _flagdService.CreateCollection("test"); var json = """ { "$schema": "https://flagd.dev/schema/v0/flags.json", @@ -89,9 +89,9 @@ public void ImportSchema_StringFlag_StoresCorrectly() } """; - _schemaService.ImportSchema(file.Id, json); + _schemaService.ImportSchema(collection.Id, json); - var flags = _flagdService.GetFlags(file.Id); + var flags = _flagdService.GetFlags(collection.Id); Assert.Single(flags); var flag = flags[0]; Assert.Equal("color", flag.Key); @@ -103,7 +103,7 @@ public void ImportSchema_StringFlag_StoresCorrectly() [Fact] public void ImportSchema_NumberFlag_StoresCorrectly() { - var file = _flagdService.CreateFile("test"); + var collection = _flagdService.CreateCollection("test"); var json = """ { "$schema": "https://flagd.dev/schema/v0/flags.json", @@ -117,9 +117,9 @@ public void ImportSchema_NumberFlag_StoresCorrectly() } """; - _schemaService.ImportSchema(file.Id, json); + _schemaService.ImportSchema(collection.Id, json); - var flags = _flagdService.GetFlags(file.Id); + var flags = _flagdService.GetFlags(collection.Id); Assert.Single(flags); Assert.Equal("number", flags[0].Type); Assert.Equal(100.0, flags[0].NumberValue); @@ -128,7 +128,7 @@ public void ImportSchema_NumberFlag_StoresCorrectly() [Fact] public void ImportSchema_ObjectFlag_StoresCorrectly() { - var file = _flagdService.CreateFile("test"); + var collection = _flagdService.CreateCollection("test"); var json = """ { "$schema": "https://flagd.dev/schema/v0/flags.json", @@ -142,9 +142,9 @@ public void ImportSchema_ObjectFlag_StoresCorrectly() } """; - _schemaService.ImportSchema(file.Id, json); + _schemaService.ImportSchema(collection.Id, json); - var flags = _flagdService.GetFlags(file.Id); + var flags = _flagdService.GetFlags(collection.Id); Assert.Single(flags); Assert.Equal("object", flags[0].Type); Assert.NotNull(flags[0].ObjectValue); @@ -155,7 +155,7 @@ public void ImportSchema_ObjectFlag_StoresCorrectly() [Fact] public void ImportSchema_WithFlagMetadata_StoresMetadata() { - var file = _flagdService.CreateFile("test"); + var collection = _flagdService.CreateCollection("test"); var json = """ { "$schema": "https://flagd.dev/schema/v0/flags.json", @@ -170,9 +170,9 @@ public void ImportSchema_WithFlagMetadata_StoresMetadata() } """; - _schemaService.ImportSchema(file.Id, json); + _schemaService.ImportSchema(collection.Id, json); - var flags = _flagdService.GetFlags(file.Id); + var flags = _flagdService.GetFlags(collection.Id); Assert.NotNull(flags[0].Metadata); Assert.Single(flags[0].Metadata!); var entry = flags[0].Metadata![0]; @@ -183,7 +183,7 @@ public void ImportSchema_WithFlagMetadata_StoresMetadata() [Fact] public void ImportSchema_WithEvaluators_StoresEnvironments() { - var file = _flagdService.CreateFile("test"); + var collection = _flagdService.CreateCollection("test"); var json = """ { "$schema": "https://flagd.dev/schema/v0/flags.json", @@ -205,9 +205,9 @@ public void ImportSchema_WithEvaluators_StoresEnvironments() } """; - _schemaService.ImportSchema(file.Id, json); + _schemaService.ImportSchema(collection.Id, json); - var envs = _flagdService.GetEnvironments(file.Id); + var envs = _flagdService.GetEnvironments(collection.Id); Assert.Equal(2, envs.Count); var prod = envs.First(e => e.Name == "Production"); @@ -218,9 +218,9 @@ public void ImportSchema_WithEvaluators_StoresEnvironments() } [Fact] - public void ImportSchema_WithFileMetadata_StoresOnFile() + public void ImportSchema_WithCollectionMetadata_StoresOnCollection() { - var file = _flagdService.CreateFile("test"); + var collection = _flagdService.CreateCollection("test"); var json = """ { "$schema": "https://flagd.dev/schema/v0/flags.json", @@ -235,19 +235,19 @@ public void ImportSchema_WithFileMetadata_StoresOnFile() } """; - _schemaService.ImportSchema(file.Id, json); + _schemaService.ImportSchema(collection.Id, json); - var updatedFile = _flagdService.GetFile(file.Id); - Assert.NotNull(updatedFile.Metadata); - Assert.Single(updatedFile.Metadata!); - Assert.Equal("version", updatedFile.Metadata![0].Key); - Assert.Equal("1.0", updatedFile.Metadata![0].StringValue); + var updatedCollection = _flagdService.GetCollection(collection.Id); + Assert.NotNull(updatedCollection.Metadata); + Assert.Single(updatedCollection.Metadata!); + Assert.Equal("version", updatedCollection.Metadata![0].Key); + Assert.Equal("1.0", updatedCollection.Metadata![0].StringValue); } [Fact] public void ImportSchema_MultipleFlags_StoresAll() { - var file = _flagdService.CreateFile("test"); + var collection = _flagdService.CreateCollection("test"); var json = """ { "$schema": "https://flagd.dev/schema/v0/flags.json", @@ -271,9 +271,9 @@ public void ImportSchema_MultipleFlags_StoresAll() } """; - _schemaService.ImportSchema(file.Id, json); + _schemaService.ImportSchema(collection.Id, json); - var flags = _flagdService.GetFlags(file.Id); + var flags = _flagdService.GetFlags(collection.Id); Assert.Equal(3, flags.Count); Assert.Equal("boolean", flags.First(f => f.Key == "alpha").Type); Assert.Equal("string", flags.First(f => f.Key == "beta").Type); @@ -283,7 +283,7 @@ public void ImportSchema_MultipleFlags_StoresAll() [Fact] public void ImportSchema_ReplacesExistingData() { - var file = _flagdService.CreateFile("test"); + var collection = _flagdService.CreateCollection("test"); // First import var json1 = """ @@ -298,8 +298,8 @@ public void ImportSchema_ReplacesExistingData() } } """; - _schemaService.ImportSchema(file.Id, json1); - Assert.Single(_flagdService.GetFlags(file.Id)); + _schemaService.ImportSchema(collection.Id, json1); + Assert.Single(_flagdService.GetFlags(collection.Id)); // Second import replaces var json2 = """ @@ -314,15 +314,15 @@ public void ImportSchema_ReplacesExistingData() } } """; - _schemaService.ImportSchema(file.Id, json2); + _schemaService.ImportSchema(collection.Id, json2); - var flags = _flagdService.GetFlags(file.Id); + var flags = _flagdService.GetFlags(collection.Id); Assert.Single(flags); Assert.Equal("new-flag", flags[0].Key); } [Fact] - public void ImportSchema_NonexistentFile_ThrowsKeyNotFoundException() + public void ImportSchema_NonexistentCollection_ThrowsKeyNotFoundException() { var json = """ { @@ -345,10 +345,10 @@ public void ImportSchema_NonexistentFile_ThrowsKeyNotFoundException() [Fact] public void ExportSchema_BooleanFlag_ProducesValidJson() { - var file = _flagdService.CreateFile("test"); - _flagdService.UpsertFlag(file.Id, new FlagEntryDto("my-flag", "boolean", "ENABLED", BooleanValue: true)); + var collection = _flagdService.CreateCollection("test"); + _flagdService.UpsertFlag(collection.Id, new FlagEntryDto("my-flag", "boolean", "ENABLED", BooleanValue: true)); - var json = _schemaService.ExportSchema(file.Id); + var json = _schemaService.ExportSchema(collection.Id); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; @@ -363,10 +363,10 @@ public void ExportSchema_BooleanFlag_ProducesValidJson() [Fact] public void ExportSchema_NonBooleanFlag_UsesDefaultVariantKey() { - var file = _flagdService.CreateFile("test"); - _flagdService.UpsertFlag(file.Id, new FlagEntryDto("color", "string", "ENABLED", StringValue: "red")); + var collection = _flagdService.CreateCollection("test"); + _flagdService.UpsertFlag(collection.Id, new FlagEntryDto("color", "string", "ENABLED", StringValue: "red")); - var json = _schemaService.ExportSchema(file.Id); + var json = _schemaService.ExportSchema(collection.Id); using var doc = JsonDocument.Parse(json); var flag = doc.RootElement.GetProperty("flags").GetProperty("color"); @@ -377,11 +377,11 @@ public void ExportSchema_NonBooleanFlag_UsesDefaultVariantKey() [Fact] public void ExportSchema_WithFlagMetadata_IncludesMetadataBlock() { - var file = _flagdService.CreateFile("test"); + var collection = _flagdService.CreateCollection("test"); var metadata = new List { new("owner", StringValue: "team-a") }; - _flagdService.UpsertFlag(file.Id, new FlagEntryDto("feature", "boolean", "ENABLED", BooleanValue: true, Metadata: metadata)); + _flagdService.UpsertFlag(collection.Id, new FlagEntryDto("feature", "boolean", "ENABLED", BooleanValue: true, Metadata: metadata)); - var json = _schemaService.ExportSchema(file.Id); + var json = _schemaService.ExportSchema(collection.Id); using var doc = JsonDocument.Parse(json); var flag = doc.RootElement.GetProperty("flags").GetProperty("feature"); @@ -392,11 +392,11 @@ public void ExportSchema_WithFlagMetadata_IncludesMetadataBlock() [Fact] public void ExportSchema_WithEnvironments_IncludesEvaluators() { - var file = _flagdService.CreateFile("test"); - _flagdService.UpsertFlag(file.Id, new FlagEntryDto("feature", "boolean", "ENABLED", BooleanValue: true)); - _flagdService.UpsertEnvironment(file.Id, new EnvironmentEntryDto("Production", ["prod", "production"])); + var collection = _flagdService.CreateCollection("test"); + _flagdService.UpsertFlag(collection.Id, new FlagEntryDto("feature", "boolean", "ENABLED", BooleanValue: true)); + _flagdService.UpsertEnvironment(collection.Id, new EnvironmentEntryDto("Production", ["prod", "production"])); - var json = _schemaService.ExportSchema(file.Id); + var json = _schemaService.ExportSchema(collection.Id); using var doc = JsonDocument.Parse(json); var evaluators = doc.RootElement.GetProperty("$evaluators"); @@ -408,13 +408,13 @@ public void ExportSchema_WithEnvironments_IncludesEvaluators() } [Fact] - public void ExportSchema_WithFileMetadata_IncludesTopLevelMetadata() + public void ExportSchema_WithCollectionMetadata_IncludesTopLevelMetadata() { - var file = _flagdService.CreateFile("test"); - _flagdService.UpsertFlag(file.Id, new FlagEntryDto("feature", "boolean", "ENABLED", BooleanValue: true)); - _flagdService.UpdateFileMetadata(file.Id, [new MetadataEntryDto("version", StringValue: "2.0")]); + var collection = _flagdService.CreateCollection("test"); + _flagdService.UpsertFlag(collection.Id, new FlagEntryDto("feature", "boolean", "ENABLED", BooleanValue: true)); + _flagdService.UpdateCollectionMetadata(collection.Id, [new MetadataEntryDto("version", StringValue: "2.0")]); - var json = _schemaService.ExportSchema(file.Id); + var json = _schemaService.ExportSchema(collection.Id); using var doc = JsonDocument.Parse(json); var meta = doc.RootElement.GetProperty("metadata"); @@ -422,11 +422,11 @@ public void ExportSchema_WithFileMetadata_IncludesTopLevelMetadata() } [Fact] - public void ExportSchema_EmptyFile_ProducesMinimalValidJson() + public void ExportSchema_EmptyCollection_ProducesMinimalValidJson() { - var file = _flagdService.CreateFile("test"); + var collection = _flagdService.CreateCollection("test"); - var json = _schemaService.ExportSchema(file.Id); + var json = _schemaService.ExportSchema(collection.Id); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; @@ -435,7 +435,7 @@ public void ExportSchema_EmptyFile_ProducesMinimalValidJson() } [Fact] - public void ExportSchema_NonexistentFile_ThrowsKeyNotFoundException() + public void ExportSchema_NonexistentCollection_ThrowsKeyNotFoundException() { Assert.Throws(() => _schemaService.ExportSchema(999)); } @@ -445,7 +445,7 @@ public void ExportSchema_NonexistentFile_ThrowsKeyNotFoundException() [Fact] public void ImportThenExport_PreservesFlags() { - var file = _flagdService.CreateFile("test"); + var collection = _flagdService.CreateCollection("test"); var input = """ { "$schema": "https://flagd.dev/schema/v0/flags.json", @@ -464,8 +464,8 @@ public void ImportThenExport_PreservesFlags() } """; - _schemaService.ImportSchema(file.Id, input); - var output = _schemaService.ExportSchema(file.Id); + _schemaService.ImportSchema(collection.Id, input); + var output = _schemaService.ExportSchema(collection.Id); using var doc = JsonDocument.Parse(output); var flags = doc.RootElement.GetProperty("flags"); @@ -483,7 +483,7 @@ public void ImportThenExport_PreservesFlags() [Fact] public void ImportThenExport_PreservesEnvironments() { - var file = _flagdService.CreateFile("test"); + var collection = _flagdService.CreateCollection("test"); var input = """ { "$schema": "https://flagd.dev/schema/v0/flags.json", @@ -502,8 +502,8 @@ public void ImportThenExport_PreservesEnvironments() } """; - _schemaService.ImportSchema(file.Id, input); - var output = _schemaService.ExportSchema(file.Id); + _schemaService.ImportSchema(collection.Id, input); + var output = _schemaService.ExportSchema(collection.Id); using var doc = JsonDocument.Parse(output); var evaluators = doc.RootElement.GetProperty("$evaluators"); @@ -518,10 +518,10 @@ public void ImportThenExport_PreservesEnvironments() [Fact] public void UpsertFlag_BooleanFlag_RoundTrips() { - var file = _flagdService.CreateFile("test"); - _flagdService.UpsertFlag(file.Id, new FlagEntryDto("toggle", "boolean", "ENABLED", BooleanValue: true)); + var collection = _flagdService.CreateCollection("test"); + _flagdService.UpsertFlag(collection.Id, new FlagEntryDto("toggle", "boolean", "ENABLED", BooleanValue: true)); - var flags = _flagdService.GetFlags(file.Id); + var flags = _flagdService.GetFlags(collection.Id); Assert.Single(flags); Assert.Equal("boolean", flags[0].Type); Assert.Equal(true, flags[0].BooleanValue); @@ -533,10 +533,10 @@ public void UpsertFlag_BooleanFlag_RoundTrips() [Fact] public void UpsertFlag_StringFlag_RoundTrips() { - var file = _flagdService.CreateFile("test"); - _flagdService.UpsertFlag(file.Id, new FlagEntryDto("color", "string", "ENABLED", StringValue: "blue")); + var collection = _flagdService.CreateCollection("test"); + _flagdService.UpsertFlag(collection.Id, new FlagEntryDto("color", "string", "ENABLED", StringValue: "blue")); - var flags = _flagdService.GetFlags(file.Id); + var flags = _flagdService.GetFlags(collection.Id); Assert.Single(flags); Assert.Equal("string", flags[0].Type); Assert.Equal("blue", flags[0].StringValue); @@ -545,10 +545,10 @@ public void UpsertFlag_StringFlag_RoundTrips() [Fact] public void UpsertFlag_NumberFlag_RoundTrips() { - var file = _flagdService.CreateFile("test"); - _flagdService.UpsertFlag(file.Id, new FlagEntryDto("rate", "number", "ENABLED", NumberValue: 42.5)); + var collection = _flagdService.CreateCollection("test"); + _flagdService.UpsertFlag(collection.Id, new FlagEntryDto("rate", "number", "ENABLED", NumberValue: 42.5)); - var flags = _flagdService.GetFlags(file.Id); + var flags = _flagdService.GetFlags(collection.Id); Assert.Single(flags); Assert.Equal("number", flags[0].Type); Assert.Equal(42.5, flags[0].NumberValue); @@ -557,10 +557,10 @@ public void UpsertFlag_NumberFlag_RoundTrips() [Fact] public void UpsertFlag_ObjectFlag_RoundTrips() { - var file = _flagdService.CreateFile("test"); - _flagdService.UpsertFlag(file.Id, new FlagEntryDto("config", "object", "ENABLED", ObjectValue: """{"a":1}""")); + var collection = _flagdService.CreateCollection("test"); + _flagdService.UpsertFlag(collection.Id, new FlagEntryDto("config", "object", "ENABLED", ObjectValue: """{"a":1}""")); - var flags = _flagdService.GetFlags(file.Id); + var flags = _flagdService.GetFlags(collection.Id); Assert.Single(flags); Assert.Equal("object", flags[0].Type); Assert.Equal("""{"a":1}""", flags[0].ObjectValue); @@ -569,11 +569,11 @@ public void UpsertFlag_ObjectFlag_RoundTrips() [Fact] public void UpsertFlag_TypeChange_ReplacesEntity() { - var file = _flagdService.CreateFile("test"); - _flagdService.UpsertFlag(file.Id, new FlagEntryDto("flag", "boolean", "ENABLED", BooleanValue: true)); - _flagdService.UpsertFlag(file.Id, new FlagEntryDto("flag", "string", "ENABLED", StringValue: "hello")); + var collection = _flagdService.CreateCollection("test"); + _flagdService.UpsertFlag(collection.Id, new FlagEntryDto("flag", "boolean", "ENABLED", BooleanValue: true)); + _flagdService.UpsertFlag(collection.Id, new FlagEntryDto("flag", "string", "ENABLED", StringValue: "hello")); - var flags = _flagdService.GetFlags(file.Id); + var flags = _flagdService.GetFlags(collection.Id); Assert.Single(flags); Assert.Equal("string", flags[0].Type); Assert.Equal("hello", flags[0].StringValue); @@ -582,16 +582,16 @@ public void UpsertFlag_TypeChange_ReplacesEntity() [Fact] public void UpsertFlag_WithMetadata_StoresAndRetrieves() { - var file = _flagdService.CreateFile("test"); + var collection = _flagdService.CreateCollection("test"); var metadata = new List { new("owner", StringValue: "team-a"), new("priority", NumberValue: 1.0), new("active", BooleanValue: true) }; - _flagdService.UpsertFlag(file.Id, new FlagEntryDto("flag", "boolean", "ENABLED", BooleanValue: true, Metadata: metadata)); + _flagdService.UpsertFlag(collection.Id, new FlagEntryDto("flag", "boolean", "ENABLED", BooleanValue: true, Metadata: metadata)); - var flags = _flagdService.GetFlags(file.Id); + var flags = _flagdService.GetFlags(collection.Id); Assert.NotNull(flags[0].Metadata); Assert.Equal(3, flags[0].Metadata!.Count); Assert.Equal("team-a", flags[0].Metadata!.First(m => m.Key == "owner").StringValue); @@ -602,18 +602,18 @@ public void UpsertFlag_WithMetadata_StoresAndRetrieves() [Fact] public void UpsertFlag_WithPerEnvironmentDefinitions_StoresAndRetrieves() { - var file = _flagdService.CreateFile("test"); + var collection = _flagdService.CreateCollection("test"); // Environments must exist before referencing them in per-env definitions - _flagdService.UpsertEnvironment(file.Id, new EnvironmentEntryDto("Production", ["prod"])); - _flagdService.UpsertEnvironment(file.Id, new EnvironmentEntryDto("Staging", ["staging"])); + _flagdService.UpsertEnvironment(collection.Id, new EnvironmentEntryDto("Production", ["prod"])); + _flagdService.UpsertEnvironment(collection.Id, new EnvironmentEntryDto("Staging", ["staging"])); var perEnv = new Dictionary { ["Production"] = new(BooleanValue: false), ["Staging"] = new(BooleanValue: true) }; - _flagdService.UpsertFlag(file.Id, new FlagEntryDto("flag", "boolean", "ENABLED", BooleanValue: true, PerEnvironmentDefinitions: perEnv)); + _flagdService.UpsertFlag(collection.Id, new FlagEntryDto("flag", "boolean", "ENABLED", BooleanValue: true, PerEnvironmentDefinitions: perEnv)); - var flags = _flagdService.GetFlags(file.Id); + var flags = _flagdService.GetFlags(collection.Id); Assert.NotNull(flags[0].PerEnvironmentDefinitions); Assert.Equal(2, flags[0].PerEnvironmentDefinitions!.Count); Assert.Equal(false, flags[0].PerEnvironmentDefinitions!["Production"].BooleanValue); @@ -623,14 +623,14 @@ public void UpsertFlag_WithPerEnvironmentDefinitions_StoresAndRetrieves() [Fact] public void UpsertFlag_WithGlobalTimeWindow_StoresAndRetrieves() { - var file = _flagdService.CreateFile("test"); + var collection = _flagdService.CreateCollection("test"); var start = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); var end = new DateTime(2026, 12, 31, 23, 59, 59, DateTimeKind.Utc); - var tw = _flagdService.CreateTimeWindow(file.Id, new TimeWindowDto(0, "Test Window", start, end)); + var tw = _flagdService.CreateTimeWindow(collection.Id, new TimeWindowDto(0, "Test Window", start, end)); var globalTw = new GlobalTimeWindowDto(tw.Id, BooleanValue: false); - _flagdService.UpsertFlag(file.Id, new FlagEntryDto("flag", "boolean", "ENABLED", BooleanValue: true, GlobalTimeWindow: globalTw)); + _flagdService.UpsertFlag(collection.Id, new FlagEntryDto("flag", "boolean", "ENABLED", BooleanValue: true, GlobalTimeWindow: globalTw)); - var flags = _flagdService.GetFlags(file.Id); + var flags = _flagdService.GetFlags(collection.Id); Assert.NotNull(flags[0].GlobalTimeWindow); Assert.Equal(tw.Id, flags[0].GlobalTimeWindow!.TimeWindowId); Assert.Equal(false, flags[0].GlobalTimeWindow!.BooleanValue); @@ -641,22 +641,22 @@ public void UpsertFlag_WithGlobalTimeWindow_StoresAndRetrieves() [Fact] public void ImportSchema_InvalidJson_Throws() { - var file = _flagdService.CreateFile("test"); - Assert.ThrowsAny(() => _schemaService.ImportSchema(file.Id, "not json at all")); + var collection = _flagdService.CreateCollection("test"); + Assert.ThrowsAny(() => _schemaService.ImportSchema(collection.Id, "not json at all")); } [Fact] public void ImportSchema_MissingFlags_ThrowsSchemaValidationException() { - var file = _flagdService.CreateFile("test"); + var collection = _flagdService.CreateCollection("test"); var json = """{ "notFlags": {} }"""; - Assert.Throws(() => _schemaService.ImportSchema(file.Id, json)); + Assert.Throws(() => _schemaService.ImportSchema(collection.Id, json)); } [Fact] public void ImportSchema_InvalidFlagDefinition_ThrowsSchemaValidationException() { - var file = _flagdService.CreateFile("test"); + var collection = _flagdService.CreateCollection("test"); var json = """ { "$schema": "https://flagd.dev/schema/v0/flags.json", @@ -667,13 +667,13 @@ public void ImportSchema_InvalidFlagDefinition_ThrowsSchemaValidationException() } } """; - Assert.Throws(() => _schemaService.ImportSchema(file.Id, json)); + Assert.Throws(() => _schemaService.ImportSchema(collection.Id, json)); } [Fact] public void ImportSchema_InvalidState_ThrowsSchemaValidationException() { - var file = _flagdService.CreateFile("test"); + var collection = _flagdService.CreateCollection("test"); var json = """ { "$schema": "https://flagd.dev/schema/v0/flags.json", @@ -686,13 +686,13 @@ public void ImportSchema_InvalidState_ThrowsSchemaValidationException() } } """; - Assert.Throws(() => _schemaService.ImportSchema(file.Id, json)); + Assert.Throws(() => _schemaService.ImportSchema(collection.Id, json)); } [Fact] public void ImportSchema_FlagWithInvalidVariantType_ThrowsSchemaValidationException() { - var file = _flagdService.CreateFile("test"); + var collection = _flagdService.CreateCollection("test"); var json = """ { "$schema": "https://flagd.dev/schema/v0/flags.json", @@ -705,16 +705,16 @@ public void ImportSchema_FlagWithInvalidVariantType_ThrowsSchemaValidationExcept } } """; - Assert.Throws(() => _schemaService.ImportSchema(file.Id, json)); + Assert.Throws(() => _schemaService.ImportSchema(collection.Id, json)); } [Fact] public void SchemaValidationException_ContainsErrorDetails() { - var file = _flagdService.CreateFile("test"); + var collection = _flagdService.CreateCollection("test"); var json = """{ "notFlags": {} }"""; - var ex = Assert.Throws(() => _schemaService.ImportSchema(file.Id, json)); + var ex = Assert.Throws(() => _schemaService.ImportSchema(collection.Id, json)); Assert.NotEmpty(ex.Errors); Assert.Contains("error", ex.Message, StringComparison.OrdinalIgnoreCase); } @@ -724,10 +724,10 @@ public void SchemaValidationException_ContainsErrorDetails() [Fact] public void CreateTimeWindow_RoundTrips() { - var file = _flagdService.CreateFile("test"); + var collection = _flagdService.CreateCollection("test"); var start = new DateTime(2026, 12, 24, 0, 0, 0, DateTimeKind.Utc); var end = new DateTime(2026, 12, 26, 23, 59, 59, DateTimeKind.Utc); - var tw = _flagdService.CreateTimeWindow(file.Id, new TimeWindowDto(0, "Christmas Time", start, end)); + var tw = _flagdService.CreateTimeWindow(collection.Id, new TimeWindowDto(0, "Christmas Time", start, end)); Assert.True(tw.Id > 0); Assert.Equal("Christmas Time", tw.Name); @@ -738,21 +738,21 @@ public void CreateTimeWindow_RoundTrips() [Fact] public void GetTimeWindows_ReturnsAll() { - var file = _flagdService.CreateFile("test"); - _flagdService.CreateTimeWindow(file.Id, new TimeWindowDto(0, "Window A")); - _flagdService.CreateTimeWindow(file.Id, new TimeWindowDto(0, "Window B")); + var collection = _flagdService.CreateCollection("test"); + _flagdService.CreateTimeWindow(collection.Id, new TimeWindowDto(0, "Window A")); + _flagdService.CreateTimeWindow(collection.Id, new TimeWindowDto(0, "Window B")); - var windows = _flagdService.GetTimeWindows(file.Id); + var windows = _flagdService.GetTimeWindows(collection.Id); Assert.Equal(2, windows.Count); } [Fact] public void UpdateTimeWindow_ChangesFields() { - var file = _flagdService.CreateFile("test"); - var tw = _flagdService.CreateTimeWindow(file.Id, new TimeWindowDto(0, "Old Name")); + var collection = _flagdService.CreateCollection("test"); + var tw = _flagdService.CreateTimeWindow(collection.Id, new TimeWindowDto(0, "Old Name")); var newStart = new DateTime(2027, 1, 1, 0, 0, 0, DateTimeKind.Utc); - var updated = _flagdService.UpdateTimeWindow(file.Id, tw.Id, new TimeWindowDto(tw.Id, "New Name", newStart)); + var updated = _flagdService.UpdateTimeWindow(collection.Id, tw.Id, new TimeWindowDto(tw.Id, "New Name", newStart)); Assert.Equal("New Name", updated.Name); Assert.Equal(newStart, updated.StartTime); @@ -761,27 +761,27 @@ public void UpdateTimeWindow_ChangesFields() [Fact] public void DeleteTimeWindow_RemovesEntry() { - var file = _flagdService.CreateFile("test"); - var tw = _flagdService.CreateTimeWindow(file.Id, new TimeWindowDto(0, "Temporary")); + var collection = _flagdService.CreateCollection("test"); + var tw = _flagdService.CreateTimeWindow(collection.Id, new TimeWindowDto(0, "Temporary")); - _flagdService.DeleteTimeWindow(file.Id, tw.Id); + _flagdService.DeleteTimeWindow(collection.Id, tw.Id); - Assert.Empty(_flagdService.GetTimeWindows(file.Id)); + Assert.Empty(_flagdService.GetTimeWindows(collection.Id)); } [Fact] public void PerEnvironmentDefinition_WithTimeWindowReference_RoundTrips() { - var file = _flagdService.CreateFile("test"); - _flagdService.UpsertEnvironment(file.Id, new EnvironmentEntryDto("Production", ["prod"])); - var tw = _flagdService.CreateTimeWindow(file.Id, new TimeWindowDto(0, "Holiday Window")); + var collection = _flagdService.CreateCollection("test"); + _flagdService.UpsertEnvironment(collection.Id, new EnvironmentEntryDto("Production", ["prod"])); + var tw = _flagdService.CreateTimeWindow(collection.Id, new TimeWindowDto(0, "Holiday Window")); var perEnv = new Dictionary { ["Production"] = new(BooleanValue: false, TimeWindowId: tw.Id) }; - _flagdService.UpsertFlag(file.Id, new FlagEntryDto("flag", "boolean", "ENABLED", BooleanValue: true, PerEnvironmentDefinitions: perEnv)); + _flagdService.UpsertFlag(collection.Id, new FlagEntryDto("flag", "boolean", "ENABLED", BooleanValue: true, PerEnvironmentDefinitions: perEnv)); - var flags = _flagdService.GetFlags(file.Id); + var flags = _flagdService.GetFlags(collection.Id); Assert.NotNull(flags[0].PerEnvironmentDefinitions); Assert.Equal(tw.Id, flags[0].PerEnvironmentDefinitions!["Production"].TimeWindowId); } diff --git a/src/OpenFeatureManager/Data/FlagdDbContext.cs b/src/OpenFeatureManager/Data/FlagdDbContext.cs index 13c674b..342e270 100644 --- a/src/OpenFeatureManager/Data/FlagdDbContext.cs +++ b/src/OpenFeatureManager/Data/FlagdDbContext.cs @@ -8,7 +8,7 @@ public class FlagdDbContext : DbContext { private readonly string? _connectionString; - public DbSet FlagFiles => Set(); + public DbSet FlagsCollections => Set(); public DbSet FlagEntries => Set(); public DbSet BooleanFlagEntries => Set(); public DbSet StringFlagEntries => Set(); @@ -17,7 +17,7 @@ public class FlagdDbContext : DbContext public DbSet EnvironmentEntries => Set(); public DbSet EnvironmentAliases => Set(); public DbSet FlagMetadataEntries => Set(); - public DbSet FileMetadataEntries => Set(); + public DbSet CollectionMetadataEntries => Set(); public DbSet PerEnvironmentDefinitions => Set(); public DbSet TimeWindows => Set(); @@ -38,17 +38,17 @@ protected override void OnConfiguring(DbContextOptionsBuilder options) protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { - entity.ToTable("flag_files"); + entity.ToTable("flags_collections"); entity.HasKey(e => e.Id); entity.Property(e => e.Id).ValueGeneratedOnAdd(); entity.Property(e => e.Name).IsRequired(); entity.Property(e => e.CreatedAt).HasDefaultValueSql("datetime('now')"); - entity.HasMany(e => e.Metadata).WithOne().HasForeignKey(e => e.FileId).OnDelete(DeleteBehavior.Cascade); - entity.HasMany(e => e.Flags).WithOne().HasForeignKey(e => e.FileId).OnDelete(DeleteBehavior.Cascade); - entity.HasMany(e => e.Environments).WithOne().HasForeignKey(e => e.FileId).OnDelete(DeleteBehavior.Cascade); - entity.HasMany(e => e.TimeWindows).WithOne().HasForeignKey(e => e.FileId).OnDelete(DeleteBehavior.Cascade); + entity.HasMany(e => e.Metadata).WithOne().HasForeignKey(e => e.CollectionId).OnDelete(DeleteBehavior.Cascade); + entity.HasMany(e => e.Flags).WithOne().HasForeignKey(e => e.CollectionId).OnDelete(DeleteBehavior.Cascade); + entity.HasMany(e => e.Environments).WithOne().HasForeignKey(e => e.CollectionId).OnDelete(DeleteBehavior.Cascade); + entity.HasMany(e => e.TimeWindows).WithOne().HasForeignKey(e => e.CollectionId).OnDelete(DeleteBehavior.Cascade); }); modelBuilder.Entity(entity => @@ -57,7 +57,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasKey(e => e.Id); entity.Property(e => e.Id).ValueGeneratedOnAdd(); entity.Property(e => e.FlagKey).IsRequired(); - entity.HasIndex(e => new { e.FileId, e.FlagKey }).IsUnique(); + entity.HasIndex(e => new { e.CollectionId, e.FlagKey }).IsUnique(); entity.Property(e => e.State).HasConversion(new EnumToStringConverter()); entity.HasDiscriminator("type") .HasValue("boolean") @@ -75,7 +75,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasKey(e => e.Id); entity.Property(e => e.Id).ValueGeneratedOnAdd(); entity.Property(e => e.Name).IsRequired(); - entity.HasIndex(e => new { e.FileId, e.Name }).IsUnique(); + entity.HasIndex(e => new { e.CollectionId, e.Name }).IsUnique(); entity.HasMany(e => e.Aliases).WithOne().HasForeignKey(e => e.EnvironmentEntryId).OnDelete(DeleteBehavior.Cascade); }); @@ -87,13 +87,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.Alias).IsRequired(); }); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { - entity.ToTable("file_metadata_entries"); + entity.ToTable("collection_metadata_entries"); entity.HasKey(e => e.Id); entity.Property(e => e.Id).ValueGeneratedOnAdd(); entity.Property(e => e.Key).IsRequired(); - entity.HasIndex(e => new { e.FileId, e.Key }).IsUnique(); + entity.HasIndex(e => new { e.CollectionId, e.Key }).IsUnique(); }); modelBuilder.Entity(entity => @@ -121,7 +121,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasKey(e => e.Id); entity.Property(e => e.Id).ValueGeneratedOnAdd(); entity.Property(e => e.Name).IsRequired(); - entity.HasIndex(e => new { e.FileId, e.Name }).IsUnique(); + entity.HasIndex(e => new { e.CollectionId, e.Name }).IsUnique(); }); } } diff --git a/src/OpenFeatureManager/Entities/FileMetadataEntry.cs b/src/OpenFeatureManager/Entities/CollectionMetadataEntry.cs similarity index 79% rename from src/OpenFeatureManager/Entities/FileMetadataEntry.cs rename to src/OpenFeatureManager/Entities/CollectionMetadataEntry.cs index f0df57b..ecae3f3 100644 --- a/src/OpenFeatureManager/Entities/FileMetadataEntry.cs +++ b/src/OpenFeatureManager/Entities/CollectionMetadataEntry.cs @@ -3,16 +3,16 @@ namespace OpenFeatureManager.Entities; -[Table("file_metadata_entries")] -public class FileMetadataEntry +[Table("collection_metadata_entries")] +public class CollectionMetadataEntry { [Key] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] [Column("id")] public long Id { get; set; } - [Column("file_id")] - public long FileId { get; set; } + [Column("collection_id")] + public long CollectionId { get; set; } [Required] [Column("key")] diff --git a/src/OpenFeatureManager/Entities/EnvironmentEntry.cs b/src/OpenFeatureManager/Entities/EnvironmentEntry.cs index b8ba269..afc8555 100644 --- a/src/OpenFeatureManager/Entities/EnvironmentEntry.cs +++ b/src/OpenFeatureManager/Entities/EnvironmentEntry.cs @@ -11,8 +11,8 @@ public class EnvironmentEntry [Column("id")] public long Id { get; set; } - [Column("file_id")] - public long FileId { get; set; } + [Column("collection_id")] + public long CollectionId { get; set; } /// Human-readable environment name, e.g. "Production", "Staging" [Required] diff --git a/src/OpenFeatureManager/Entities/FlagEntry.cs b/src/OpenFeatureManager/Entities/FlagEntry.cs index b462854..5c6045c 100644 --- a/src/OpenFeatureManager/Entities/FlagEntry.cs +++ b/src/OpenFeatureManager/Entities/FlagEntry.cs @@ -11,8 +11,8 @@ public abstract class FlagEntry [Column("id")] public long Id { get; set; } - [Column("file_id")] - public long FileId { get; set; } + [Column("collection_id")] + public long CollectionId { get; set; } [Required] [Column("flag_key")] diff --git a/src/OpenFeatureManager/Entities/FlagFile.cs b/src/OpenFeatureManager/Entities/FlagsCollection.cs similarity index 82% rename from src/OpenFeatureManager/Entities/FlagFile.cs rename to src/OpenFeatureManager/Entities/FlagsCollection.cs index 3dd6c5d..8a69d6e 100644 --- a/src/OpenFeatureManager/Entities/FlagFile.cs +++ b/src/OpenFeatureManager/Entities/FlagsCollection.cs @@ -3,8 +3,8 @@ namespace OpenFeatureManager.Entities; -[Table("flag_files")] -public class FlagFile +[Table("flags_collections")] +public class FlagsCollection { [Key] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] @@ -18,7 +18,7 @@ public class FlagFile [Column("created_at")] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - public List Metadata { get; set; } = []; + public List Metadata { get; set; } = []; public List Flags { get; set; } = []; public List Environments { get; set; } = []; public List TimeWindows { get; set; } = []; diff --git a/src/OpenFeatureManager/Entities/TimeWindow.cs b/src/OpenFeatureManager/Entities/TimeWindow.cs index 0f8ed82..4890e7d 100644 --- a/src/OpenFeatureManager/Entities/TimeWindow.cs +++ b/src/OpenFeatureManager/Entities/TimeWindow.cs @@ -11,8 +11,8 @@ public class TimeWindow [Column("id")] public long Id { get; set; } - [Column("file_id")] - public long FileId { get; set; } + [Column("collection_id")] + public long CollectionId { get; set; } /// Human-readable name, e.g. "Christmas Time", "Black Friday" [Required] diff --git a/src/OpenFeatureManager/Models/FlagdJsonContext.cs b/src/OpenFeatureManager/Models/FlagdJsonContext.cs index 71654a1..c26bdb2 100644 --- a/src/OpenFeatureManager/Models/FlagdJsonContext.cs +++ b/src/OpenFeatureManager/Models/FlagdJsonContext.cs @@ -2,8 +2,8 @@ namespace OpenFeatureManager.Models; -/// DTO for a flag file (schema document). -public record FlagFileDto( +/// DTO for a flags collection (schema document). +public record FlagsCollectionDto( long Id, string Name, DateTime CreatedAt, @@ -56,8 +56,8 @@ public record EnvironmentEntryDto(string Name, string[] Aliases); public record TimeWindowDto(long Id, string Name, DateTime? StartTime = null, DateTime? EndTime = null); [JsonSerializable(typeof(string[]))] -[JsonSerializable(typeof(FlagFileDto))] -[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(FlagsCollectionDto))] +[JsonSerializable(typeof(List))] [JsonSerializable(typeof(FlagEntryDto))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(MetadataEntryDto))] diff --git a/src/OpenFeatureManager/Services/FlagdSchemaService.cs b/src/OpenFeatureManager/Services/FlagdSchemaService.cs index 930c740..61901eb 100644 --- a/src/OpenFeatureManager/Services/FlagdSchemaService.cs +++ b/src/OpenFeatureManager/Services/FlagdSchemaService.cs @@ -33,23 +33,23 @@ public FlagdSchemaService(FlagdService flagdService, SchemaValidator? validator /// then parses the JSON document directly. /// Existing flags and environments for the file are replaced. /// - public void ImportSchema(long fileId, string schemaJson) + public void ImportSchema(long collectionId, string schemaJson) { _validator?.ValidateOrThrow(schemaJson); using var doc = JsonDocument.Parse(schemaJson); var root = doc.RootElement; - // Clear existing data for this file - _flagdService.ClearFileData(fileId); + // Clear existing data for this collection + _flagdService.ClearCollectionData(collectionId); - // Update file-level metadata if present + // Update collection-level metadata if present if (root.TryGetProperty("metadata", out var metadataElem) && metadataElem.ValueKind == JsonValueKind.Object && metadataElem.EnumerateObject().Any()) { var metadata = ParseMetadata(metadataElem); - _flagdService.UpdateFileMetadata(fileId, metadata); + _flagdService.UpdateCollectionMetadata(collectionId, metadata); } // Parse $evaluators → environments (pattern: "isXxx") @@ -83,7 +83,7 @@ public void ImportSchema(long fileId, string schemaJson) .Where(a => !string.IsNullOrEmpty(a)) .ToArray(); - _flagdService.UpsertEnvironment(fileId, new EnvironmentEntryDto(envName, aliases)); + _flagdService.UpsertEnvironment(collectionId, new EnvironmentEntryDto(envName, aliases)); } } @@ -148,7 +148,7 @@ public void ImportSchema(long fileId, string schemaJson) flagMetadata = ParseMetadata(flagMetaElem); } - _flagdService.UpsertFlag(fileId, new FlagEntryDto( + _flagdService.UpsertFlag(collectionId, new FlagEntryDto( flagKey, flagType, state, boolVal, strVal, numVal, objVal, flagMetadata)); @@ -160,11 +160,11 @@ public void ImportSchema(long fileId, string schemaJson) /// Reconstruct and return a FlagdSchema JSON string from the stored data. /// Validates the output against the flagd schema (if a validator is configured). /// - public string ExportSchema(long fileId) + public string ExportSchema(long collectionId) { - var file = _flagdService.GetFile(fileId); - var flags = _flagdService.GetFlags(fileId); - var environments = _flagdService.GetEnvironments(fileId); + var collection = _flagdService.GetCollection(collectionId); + var flags = _flagdService.GetFlags(collectionId); + var environments = _flagdService.GetEnvironments(collectionId); using var stream = new MemoryStream(); using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); @@ -227,11 +227,11 @@ public string ExportSchema(long fileId) writer.WriteEndObject(); // $evaluators } - // file-level metadata - if (file.Metadata is { Count: > 0 }) + // collection-level metadata + if (collection.Metadata is { Count: > 0 }) { writer.WritePropertyName("metadata"); - WriteMetadata(writer, file.Metadata); + WriteMetadata(writer, collection.Metadata); } writer.WriteEndObject(); // root diff --git a/src/OpenFeatureManager/Services/FlagdService.cs b/src/OpenFeatureManager/Services/FlagdService.cs index 9a23e0d..bc54a08 100644 --- a/src/OpenFeatureManager/Services/FlagdService.cs +++ b/src/OpenFeatureManager/Services/FlagdService.cs @@ -20,76 +20,76 @@ public FlagdService(Func contextFactory) _contextFactory = contextFactory; } - // ─── File management ────────────────────────────────────────────────── + // ─── Collection management ────────────────────────────────────────── - public FlagFileDto GetFile(long id) + public FlagsCollectionDto GetCollection(long id) { using var db = _contextFactory(); - var file = db.FlagFiles.Include(f => f.Metadata).FirstOrDefault(f => f.Id == id) - ?? throw new KeyNotFoundException($"File {id} not found"); - return ToDto(file); + var collection = db.FlagsCollections.Include(f => f.Metadata).FirstOrDefault(f => f.Id == id) + ?? throw new KeyNotFoundException($"Collection {id} not found"); + return ToDto(collection); } - public FlagFileDto CreateFile(string name) + public FlagsCollectionDto CreateCollection(string name) { using var db = _contextFactory(); - var file = new FlagFile { Name = name }; - db.FlagFiles.Add(file); + var collection = new FlagsCollection { Name = name }; + db.FlagsCollections.Add(collection); db.SaveChanges(); - return ToDto(file); + return ToDto(collection); } - public List GetFiles() + public List GetCollections() { using var db = _contextFactory(); - return db.FlagFiles.Include(f => f.Metadata).OrderBy(f => f.Id).ToList().Select(ToDto).ToList(); + return db.FlagsCollections.Include(f => f.Metadata).OrderBy(f => f.Id).ToList().Select(ToDto).ToList(); } - public FlagFileDto RenameFile(long id, string name) + public FlagsCollectionDto RenameCollection(long id, string name) { using var db = _contextFactory(); - var file = db.FlagFiles.Include(f => f.Metadata).FirstOrDefault(f => f.Id == id) - ?? throw new KeyNotFoundException($"File {id} not found"); - file.Name = name; + var collection = db.FlagsCollections.Include(f => f.Metadata).FirstOrDefault(f => f.Id == id) + ?? throw new KeyNotFoundException($"Collection {id} not found"); + collection.Name = name; db.SaveChanges(); - return ToDto(file); + return ToDto(collection); } - public void DeleteFile(long id) + public void DeleteCollection(long id) { using var db = _contextFactory(); - var file = db.FlagFiles.Find(id) ?? throw new KeyNotFoundException($"File {id} not found"); - db.FileMetadataEntries.RemoveRange(db.FileMetadataEntries.Where(m => m.FileId == id)); - db.FlagEntries.RemoveRange(db.FlagEntries.Where(f => f.FileId == id)); - db.EnvironmentEntries.RemoveRange(db.EnvironmentEntries.Where(e => e.FileId == id)); - db.TimeWindows.RemoveRange(db.TimeWindows.Where(t => t.FileId == id)); - db.FlagFiles.Remove(file); + var collection = db.FlagsCollections.Find(id) ?? throw new KeyNotFoundException($"Collection {id} not found"); + db.CollectionMetadataEntries.RemoveRange(db.CollectionMetadataEntries.Where(m => m.CollectionId == id)); + db.FlagEntries.RemoveRange(db.FlagEntries.Where(f => f.CollectionId == id)); + db.EnvironmentEntries.RemoveRange(db.EnvironmentEntries.Where(e => e.CollectionId == id)); + db.TimeWindows.RemoveRange(db.TimeWindows.Where(t => t.CollectionId == id)); + db.FlagsCollections.Remove(collection); db.SaveChanges(); } - public void ClearFileData(long fileId) + public void ClearCollectionData(long collectionId) { using var db = _contextFactory(); - if (!db.FlagFiles.Any(f => f.Id == fileId)) - throw new KeyNotFoundException($"File {fileId} not found"); - db.FileMetadataEntries.RemoveRange(db.FileMetadataEntries.Where(m => m.FileId == fileId)); - db.EnvironmentEntries.RemoveRange(db.EnvironmentEntries.Where(e => e.FileId == fileId)); - db.FlagEntries.RemoveRange(db.FlagEntries.Where(f => f.FileId == fileId)); - db.TimeWindows.RemoveRange(db.TimeWindows.Where(t => t.FileId == fileId)); + if (!db.FlagsCollections.Any(f => f.Id == collectionId)) + throw new KeyNotFoundException($"Collection {collectionId} not found"); + db.CollectionMetadataEntries.RemoveRange(db.CollectionMetadataEntries.Where(m => m.CollectionId == collectionId)); + db.EnvironmentEntries.RemoveRange(db.EnvironmentEntries.Where(e => e.CollectionId == collectionId)); + db.FlagEntries.RemoveRange(db.FlagEntries.Where(f => f.CollectionId == collectionId)); + db.TimeWindows.RemoveRange(db.TimeWindows.Where(t => t.CollectionId == collectionId)); db.SaveChanges(); } - public void UpdateFileMetadata(long fileId, List metadata) + public void UpdateCollectionMetadata(long collectionId, List metadata) { using var db = _contextFactory(); - var file = db.FlagFiles.Find(fileId) ?? throw new KeyNotFoundException($"File {fileId} not found"); + var collection = db.FlagsCollections.Find(collectionId) ?? throw new KeyNotFoundException($"Collection {collectionId} not found"); - db.FileMetadataEntries.RemoveRange(db.FileMetadataEntries.Where(m => m.FileId == fileId)); + db.CollectionMetadataEntries.RemoveRange(db.CollectionMetadataEntries.Where(m => m.CollectionId == collectionId)); foreach (var entry in metadata) { - db.FileMetadataEntries.Add(new FileMetadataEntry + db.CollectionMetadataEntries.Add(new CollectionMetadataEntry { - FileId = fileId, + CollectionId = collectionId, Key = entry.Key, StringValue = entry.StringValue, NumberValue = entry.NumberValue, @@ -101,15 +101,15 @@ public void UpdateFileMetadata(long fileId, List metadata) // ─── Flag management ────────────────────────────────────────────────── - public List GetFlags(long fileId) + public List GetFlags(long collectionId) { using var db = _contextFactory(); var envLookup = db.EnvironmentEntries - .Where(e => e.FileId == fileId) + .Where(e => e.CollectionId == collectionId) .ToDictionary(e => e.Id, e => e.Name); return db.FlagEntries - .Where(f => f.FileId == fileId) + .Where(f => f.CollectionId == collectionId) .Include(f => f.Metadata) .Include(f => f.PerEnvironmentDefinitions) .OrderBy(f => f.FlagKey) @@ -125,21 +125,21 @@ public List GetFlags(long fileId) /// Per-environment definitions are keyed by environment name; the service /// resolves names to EnvironmentEntry IDs. /// - public FlagEntryDto UpsertFlag(long fileId, FlagEntryDto dto) + public FlagEntryDto UpsertFlag(long collectionId, FlagEntryDto dto) { using var db = _contextFactory(); // Handle rename: remove the old entry when the key changes if (!string.IsNullOrEmpty(dto.PreviousKey) && dto.PreviousKey != dto.Key) { - var old = db.FlagEntries.FirstOrDefault(f => f.FileId == fileId && f.FlagKey == dto.PreviousKey); + var old = db.FlagEntries.FirstOrDefault(f => f.CollectionId == collectionId && f.FlagKey == dto.PreviousKey); if (old is not null) db.FlagEntries.Remove(old); } var existing = db.FlagEntries .Include(f => f.Metadata) .Include(f => f.PerEnvironmentDefinitions) - .FirstOrDefault(f => f.FileId == fileId && f.FlagKey == dto.Key); + .FirstOrDefault(f => f.CollectionId == collectionId && f.FlagKey == dto.Key); if (existing is not null) { @@ -154,7 +154,7 @@ public FlagEntryDto UpsertFlag(long fileId, FlagEntryDto dto) if (existing is null) { - existing = CreateFlagEntry(dto.Type, fileId, dto.Key); + existing = CreateFlagEntry(dto.Type, collectionId, dto.Key); db.FlagEntries.Add(existing); } @@ -187,13 +187,13 @@ public FlagEntryDto UpsertFlag(long fileId, FlagEntryDto dto) if (dto.PerEnvironmentDefinitions is not null) { var envNameToId = db.EnvironmentEntries - .Where(e => e.FileId == fileId) + .Where(e => e.CollectionId == collectionId) .ToDictionary(e => e.Name, e => e.Id); foreach (var (envName, envDef) in dto.PerEnvironmentDefinitions) { if (!envNameToId.TryGetValue(envName, out var envId)) - throw new KeyNotFoundException($"Environment '{envName}' not found in file {fileId}"); + throw new KeyNotFoundException($"Environment '{envName}' not found in collection {collectionId}"); existing.PerEnvironmentDefinitions.Add(new PerEnvironmentDefinition { @@ -210,27 +210,27 @@ public FlagEntryDto UpsertFlag(long fileId, FlagEntryDto dto) db.SaveChanges(); var envLookup = db.EnvironmentEntries - .Where(e => e.FileId == fileId) + .Where(e => e.CollectionId == collectionId) .ToDictionary(e => e.Id, e => e.Name); return ToDto(existing, envLookup); } - public void DeleteFlag(long fileId, string flagKey) + public void DeleteFlag(long collectionId, string flagKey) { using var db = _contextFactory(); - var entry = db.FlagEntries.FirstOrDefault(f => f.FileId == fileId && f.FlagKey == flagKey) - ?? throw new KeyNotFoundException($"Flag '{flagKey}' not found in file {fileId}"); + var entry = db.FlagEntries.FirstOrDefault(f => f.CollectionId == collectionId && f.FlagKey == flagKey) + ?? throw new KeyNotFoundException($"Flag '{flagKey}' not found in collection {collectionId}"); db.FlagEntries.Remove(entry); db.SaveChanges(); } // ─── Environment management ─────────────────────────────────────────── - public List GetEnvironments(long fileId) + public List GetEnvironments(long collectionId) { using var db = _contextFactory(); return db.EnvironmentEntries - .Where(e => e.FileId == fileId) + .Where(e => e.CollectionId == collectionId) .Include(e => e.Aliases) .OrderBy(e => e.Name) .ToList() @@ -238,7 +238,7 @@ public List GetEnvironments(long fileId) .ToList(); } - public EnvironmentEntryDto UpsertEnvironment(long fileId, EnvironmentEntryDto dto) + public EnvironmentEntryDto UpsertEnvironment(long collectionId, EnvironmentEntryDto dto) { if (string.IsNullOrWhiteSpace(dto.Name)) throw new ArgumentException("Environment name is required"); @@ -247,13 +247,13 @@ public EnvironmentEntryDto UpsertEnvironment(long fileId, EnvironmentEntryDto dt var existing = db.EnvironmentEntries .Include(e => e.Aliases) - .FirstOrDefault(e => e.FileId == fileId && e.Name == dto.Name); + .FirstOrDefault(e => e.CollectionId == collectionId && e.Name == dto.Name); if (existing is null) { existing = new EnvironmentEntry { - FileId = fileId, + CollectionId = collectionId, Name = dto.Name }; db.EnvironmentEntries.Add(existing); @@ -271,40 +271,40 @@ public EnvironmentEntryDto UpsertEnvironment(long fileId, EnvironmentEntryDto dt return ToDto(existing); } - public void DeleteEnvironment(long fileId, string name) + public void DeleteEnvironment(long collectionId, string name) { using var db = _contextFactory(); - var entry = db.EnvironmentEntries.FirstOrDefault(e => e.FileId == fileId && e.Name == name) - ?? throw new KeyNotFoundException($"Environment '{name}' not found in file {fileId}"); + var entry = db.EnvironmentEntries.FirstOrDefault(e => e.CollectionId == collectionId && e.Name == name) + ?? throw new KeyNotFoundException($"Environment '{name}' not found in collection {collectionId}"); db.EnvironmentEntries.Remove(entry); db.SaveChanges(); } // ─── Time window management ─────────────────────────────────────────── - public List GetTimeWindows(long fileId) + public List GetTimeWindows(long collectionId) { using var db = _contextFactory(); return db.TimeWindows - .Where(t => t.FileId == fileId) + .Where(t => t.CollectionId == collectionId) .OrderBy(t => t.Name) .ToList() .Select(ToDto) .ToList(); } - public TimeWindowDto CreateTimeWindow(long fileId, TimeWindowDto dto) + public TimeWindowDto CreateTimeWindow(long collectionId, TimeWindowDto dto) { if (string.IsNullOrWhiteSpace(dto.Name)) throw new ArgumentException("Time window name is required"); using var db = _contextFactory(); - if (!db.FlagFiles.Any(f => f.Id == fileId)) - throw new KeyNotFoundException($"File {fileId} not found"); + if (!db.FlagsCollections.Any(f => f.Id == collectionId)) + throw new KeyNotFoundException($"Collection {collectionId} not found"); var tw = new TimeWindow { - FileId = fileId, + CollectionId = collectionId, Name = dto.Name, StartTime = dto.StartTime, EndTime = dto.EndTime @@ -314,11 +314,11 @@ public TimeWindowDto CreateTimeWindow(long fileId, TimeWindowDto dto) return ToDto(tw); } - public TimeWindowDto UpdateTimeWindow(long fileId, long timeWindowId, TimeWindowDto dto) + public TimeWindowDto UpdateTimeWindow(long collectionId, long timeWindowId, TimeWindowDto dto) { using var db = _contextFactory(); - var tw = db.TimeWindows.FirstOrDefault(t => t.Id == timeWindowId && t.FileId == fileId) - ?? throw new KeyNotFoundException($"Time window {timeWindowId} not found in file {fileId}"); + var tw = db.TimeWindows.FirstOrDefault(t => t.Id == timeWindowId && t.CollectionId == collectionId) + ?? throw new KeyNotFoundException($"Time window {timeWindowId} not found in collection {collectionId}"); tw.Name = dto.Name; tw.StartTime = dto.StartTime; @@ -327,11 +327,11 @@ public TimeWindowDto UpdateTimeWindow(long fileId, long timeWindowId, TimeWindow return ToDto(tw); } - public void DeleteTimeWindow(long fileId, long timeWindowId) + public void DeleteTimeWindow(long collectionId, long timeWindowId) { using var db = _contextFactory(); - var tw = db.TimeWindows.FirstOrDefault(t => t.Id == timeWindowId && t.FileId == fileId) - ?? throw new KeyNotFoundException($"Time window {timeWindowId} not found in file {fileId}"); + var tw = db.TimeWindows.FirstOrDefault(t => t.Id == timeWindowId && t.CollectionId == collectionId) + ?? throw new KeyNotFoundException($"Time window {timeWindowId} not found in collection {collectionId}"); db.TimeWindows.Remove(tw); db.SaveChanges(); } @@ -347,12 +347,12 @@ public void DeleteTimeWindow(long fileId, long timeWindowId) _ => throw new InvalidOperationException($"Unknown flag entry type: {entry.GetType()}") }; - private static FlagEntry CreateFlagEntry(string type, long fileId, string flagKey) => type switch + private static FlagEntry CreateFlagEntry(string type, long collectionId, string flagKey) => type switch { - "boolean" => new BooleanFlagEntry { FileId = fileId, FlagKey = flagKey }, - "string" => new StringFlagEntry { FileId = fileId, FlagKey = flagKey }, - "number" => new NumberFlagEntry { FileId = fileId, FlagKey = flagKey }, - "object" => new ObjectFlagEntry { FileId = fileId, FlagKey = flagKey }, + "boolean" => new BooleanFlagEntry { CollectionId = collectionId, FlagKey = flagKey }, + "string" => new StringFlagEntry { CollectionId = collectionId, FlagKey = flagKey }, + "number" => new NumberFlagEntry { CollectionId = collectionId, FlagKey = flagKey }, + "object" => new ObjectFlagEntry { CollectionId = collectionId, FlagKey = flagKey }, _ => throw new ArgumentException($"Unknown flag type: {type}") }; @@ -378,7 +378,7 @@ private static void SetGlobalTimeWindowValue(FlagEntry entry, GlobalTimeWindowDt } } - private static FlagFileDto ToDto(FlagFile f) => + private static FlagsCollectionDto ToDto(FlagsCollection f) => new(f.Id, f.Name, f.CreatedAt, f.Metadata.Count > 0 ? f.Metadata.Select(m => new MetadataEntryDto(m.Key, m.StringValue, m.NumberValue, m.BooleanValue)).ToList() From d35cb7f906e4bc5f4f60b0892f3c099b8f87e9ad Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Sat, 7 Mar 2026 23:54:58 +0000 Subject: [PATCH 14/38] add new flag store --- ui/src/app/state/flag-store.actions.ts | 162 +++++++ ui/src/app/state/flag-store.state.ts | 574 +++++++++++++++++++++++++ 2 files changed, 736 insertions(+) create mode 100644 ui/src/app/state/flag-store.actions.ts create mode 100644 ui/src/app/state/flag-store.state.ts diff --git a/ui/src/app/state/flag-store.actions.ts b/ui/src/app/state/flag-store.actions.ts new file mode 100644 index 0000000..387ef3c --- /dev/null +++ b/ui/src/app/state/flag-store.actions.ts @@ -0,0 +1,162 @@ +import { FlagEntryDto } from '../api-client/model/flagEntryDto'; +import { EnvironmentEntryDto } from '../api-client/model/environmentEntryDto'; +import { TimeWindowDto } from '../api-client/model/timeWindowDto'; +import { MetadataEntryDto } from '../api-client/model/metadataEntryDto'; +import { RenameCollectionIdParameter } from '../api-client/model/renameCollectionIdParameter'; + +// ============================================================================ +// Collection actions +// ============================================================================ + +export class LoadCollections { + static readonly type = '[FlagStore] Load Collections'; +} + +export class CreateCollection { + static readonly type = '[FlagStore] Create Collection'; + constructor(readonly name: string) {} +} + +export class RenameCollection { + static readonly type = '[FlagStore] Rename Collection'; + constructor( + readonly id: RenameCollectionIdParameter, + readonly name: string, + ) {} +} + +export class DeleteCollection { + static readonly type = '[FlagStore] Delete Collection'; + constructor(readonly id: RenameCollectionIdParameter) {} +} + +export class SelectCollection { + static readonly type = '[FlagStore] Select Collection'; + constructor(readonly id: RenameCollectionIdParameter | null) {} +} + +// ============================================================================ +// Flag actions +// ============================================================================ + +export class LoadFlags { + static readonly type = '[FlagStore] Load Flags'; + constructor(readonly collectionId: RenameCollectionIdParameter) {} +} + +export class CreateFlag { + static readonly type = '[FlagStore] Create Flag'; + constructor( + readonly collectionId: RenameCollectionIdParameter, + readonly flag: FlagEntryDto, + ) {} +} + +export class UpdateFlag { + static readonly type = '[FlagStore] Update Flag'; + constructor( + readonly collectionId: RenameCollectionIdParameter, + readonly flag: FlagEntryDto, + ) {} +} + +export class DeleteFlag { + static readonly type = '[FlagStore] Delete Flag'; + constructor( + readonly collectionId: RenameCollectionIdParameter, + readonly flagKey: string, + ) {} +} + +// ============================================================================ +// Environment actions +// ============================================================================ + +export class LoadEnvironments { + static readonly type = '[FlagStore] Load Environments'; + constructor(readonly collectionId: RenameCollectionIdParameter) {} +} + +export class CreateEnvironment { + static readonly type = '[FlagStore] Create Environment'; + constructor( + readonly collectionId: RenameCollectionIdParameter, + readonly environment: EnvironmentEntryDto, + ) {} +} + +export class UpdateEnvironment { + static readonly type = '[FlagStore] Update Environment'; + constructor( + readonly collectionId: RenameCollectionIdParameter, + readonly environment: EnvironmentEntryDto, + ) {} +} + +export class DeleteEnvironment { + static readonly type = '[FlagStore] Delete Environment'; + constructor( + readonly collectionId: RenameCollectionIdParameter, + readonly name: string, + ) {} +} + +// ============================================================================ +// Time window actions +// ============================================================================ + +export class LoadTimeWindows { + static readonly type = '[FlagStore] Load Time Windows'; + constructor(readonly collectionId: RenameCollectionIdParameter) {} +} + +export class CreateTimeWindow { + static readonly type = '[FlagStore] Create Time Window'; + constructor( + readonly collectionId: RenameCollectionIdParameter, + readonly timeWindow: TimeWindowDto, + ) {} +} + +export class UpdateTimeWindow { + static readonly type = '[FlagStore] Update Time Window'; + constructor( + readonly collectionId: RenameCollectionIdParameter, + readonly timeWindowId: RenameCollectionIdParameter, + readonly timeWindow: TimeWindowDto, + ) {} +} + +export class DeleteTimeWindow { + static readonly type = '[FlagStore] Delete Time Window'; + constructor( + readonly collectionId: RenameCollectionIdParameter, + readonly timeWindowId: RenameCollectionIdParameter, + ) {} +} + +// ============================================================================ +// Schema actions +// ============================================================================ + +export class ExportSchema { + static readonly type = '[FlagStore] Export Schema'; + constructor(readonly collectionId: RenameCollectionIdParameter) {} +} + +export class ImportSchema { + static readonly type = '[FlagStore] Import Schema'; + constructor(readonly collectionId: RenameCollectionIdParameter) {} +} + +// ============================================================================ +// Collection metadata actions +// ============================================================================ + +export class SetCollectionMetadata { + static readonly type = '[FlagStore] Set Collection Metadata'; + constructor( + readonly collectionId: RenameCollectionIdParameter, + readonly metadata: MetadataEntryDto[], + ) {} +} diff --git a/ui/src/app/state/flag-store.state.ts b/ui/src/app/state/flag-store.state.ts new file mode 100644 index 0000000..faa5191 --- /dev/null +++ b/ui/src/app/state/flag-store.state.ts @@ -0,0 +1,574 @@ +import { inject, Injectable } from '@angular/core'; +import { Action, createSelector, NgxsOnInit, Selector, State, StateContext } from '@ngxs/store'; +import { firstValueFrom } from 'rxjs'; +import { CollectionsService } from '../api-client/api/collections.service'; +import { FlagsService } from '../api-client/api/flags.service'; +import { EnvironmentsService } from '../api-client/api/environments.service'; +import { TimewindowsService } from '../api-client/api/timewindows.service'; +import { SchemaService } from '../api-client/api/schema.service'; +import { FlagsCollectionDto } from '../api-client/model/flagsCollectionDto'; +import { FlagEntryDto } from '../api-client/model/flagEntryDto'; +import { EnvironmentEntryDto } from '../api-client/model/environmentEntryDto'; +import { TimeWindowDto } from '../api-client/model/timeWindowDto'; +import { RenameCollectionIdParameter } from '../api-client/model/renameCollectionIdParameter'; +import { + LoadCollections, + CreateCollection, + RenameCollection, + DeleteCollection, + SelectCollection, + LoadFlags, + CreateFlag, + UpdateFlag, + DeleteFlag, + LoadEnvironments, + CreateEnvironment, + UpdateEnvironment, + DeleteEnvironment, + LoadTimeWindows, + CreateTimeWindow, + UpdateTimeWindow, + DeleteTimeWindow, + ExportSchema, + ImportSchema, + SetCollectionMetadata, +} from './flag-store.actions'; + +export interface FlagStoreStateModel { + collections: FlagsCollectionDto[]; + collectionsLoading: boolean; + + selectedCollectionId: RenameCollectionIdParameter | null; + + flags: FlagEntryDto[]; + flagsLoading: boolean; + + environments: EnvironmentEntryDto[]; + environmentsLoading: boolean; + + timeWindows: TimeWindowDto[]; + timeWindowsLoading: boolean; + + error: string | null; +} + +@State({ + name: 'flagStore', + defaults: { + collections: [], + collectionsLoading: false, + selectedCollectionId: null, + flags: [], + flagsLoading: false, + environments: [], + environmentsLoading: false, + timeWindows: [], + timeWindowsLoading: false, + error: null, + }, +}) +@Injectable() +export class FlagStoreState implements NgxsOnInit { + private readonly collectionsService = inject(CollectionsService); + private readonly flagsService = inject(FlagsService); + private readonly environmentsService = inject(EnvironmentsService); + private readonly timeWindowsService = inject(TimewindowsService); + private readonly schemaService = inject(SchemaService); + + ngxsOnInit(ctx: StateContext): void { + ctx.dispatch(new LoadCollections()); + } + + // ============================================================================ + // SELECTORS + // ============================================================================ + + @Selector() + static collections(state: FlagStoreStateModel): FlagsCollectionDto[] { + return state.collections; + } + + @Selector() + static collectionsLoading(state: FlagStoreStateModel): boolean { + return state.collectionsLoading; + } + + @Selector() + static selectedCollectionId(state: FlagStoreStateModel): RenameCollectionIdParameter | null { + return state.selectedCollectionId; + } + + @Selector() + static selectedCollection(state: FlagStoreStateModel): FlagsCollectionDto | undefined { + if (!state.selectedCollectionId) return undefined; + return state.collections.find((c) => c.id === state.selectedCollectionId); + } + + @Selector() + static flags(state: FlagStoreStateModel): FlagEntryDto[] { + return state.flags; + } + + @Selector() + static flagsLoading(state: FlagStoreStateModel): boolean { + return state.flagsLoading; + } + + static flagByKey(key: string) { + return createSelector( + [FlagStoreState], + (state: FlagStoreStateModel): FlagEntryDto | undefined => + state.flags.find((f) => f.key === key), + ); + } + + @Selector() + static environments(state: FlagStoreStateModel): EnvironmentEntryDto[] { + return state.environments; + } + + @Selector() + static environmentsLoading(state: FlagStoreStateModel): boolean { + return state.environmentsLoading; + } + + @Selector() + static timeWindows(state: FlagStoreStateModel): TimeWindowDto[] { + return state.timeWindows; + } + + @Selector() + static timeWindowsLoading(state: FlagStoreStateModel): boolean { + return state.timeWindowsLoading; + } + + @Selector() + static error(state: FlagStoreStateModel): string | null { + return state.error; + } + + // ============================================================================ + // COLLECTION ACTIONS + // ============================================================================ + + @Action(LoadCollections) + async loadCollections(ctx: StateContext): Promise { + ctx.patchState({ collectionsLoading: true, error: null }); + try { + const collections = await firstValueFrom(this.collectionsService.listCollections()); + ctx.patchState({ collections, collectionsLoading: false }); + } catch (e) { + ctx.patchState({ + collectionsLoading: false, + error: e instanceof Error ? e.message : 'Failed to load collections', + }); + } + } + + @Action(CreateCollection) + async createCollection( + ctx: StateContext, + action: CreateCollection, + ): Promise { + ctx.patchState({ error: null }); + try { + const collection = await firstValueFrom( + this.collectionsService.createCollection({ name: action.name }), + ); + const state = ctx.getState(); + ctx.patchState({ collections: [...state.collections, collection] }); + } catch (e) { + ctx.patchState({ + error: e instanceof Error ? e.message : 'Failed to create collection', + }); + } + } + + @Action(RenameCollection) + async renameCollection( + ctx: StateContext, + action: RenameCollection, + ): Promise { + ctx.patchState({ error: null }); + try { + const updated = await firstValueFrom( + this.collectionsService.renameCollection(action.id, { name: action.name }), + ); + const state = ctx.getState(); + ctx.patchState({ + collections: state.collections.map((c) => (c.id === action.id ? updated : c)), + }); + } catch (e) { + ctx.patchState({ + error: e instanceof Error ? e.message : 'Failed to rename collection', + }); + } + } + + @Action(DeleteCollection) + async deleteCollection( + ctx: StateContext, + action: DeleteCollection, + ): Promise { + ctx.patchState({ error: null }); + try { + await firstValueFrom(this.collectionsService.deleteCollection(action.id)); + const state = ctx.getState(); + const collections = state.collections.filter((c) => c.id !== action.id); + const patch: Partial = { collections }; + + if (state.selectedCollectionId === action.id) { + patch.selectedCollectionId = null; + patch.flags = []; + patch.environments = []; + patch.timeWindows = []; + } + + ctx.patchState(patch); + } catch (e) { + ctx.patchState({ + error: e instanceof Error ? e.message : 'Failed to delete collection', + }); + } + } + + @Action(SelectCollection) + selectCollection(ctx: StateContext, action: SelectCollection): void { + const state = ctx.getState(); + if (state.selectedCollectionId === action.id) { + return; + } + + ctx.patchState({ + selectedCollectionId: action.id, + flags: [], + flagsLoading: false, + environments: [], + environmentsLoading: false, + timeWindows: [], + timeWindowsLoading: false, + error: null, + }); + + if (action.id) { + ctx.dispatch([ + new LoadFlags(action.id), + new LoadEnvironments(action.id), + new LoadTimeWindows(action.id), + ]); + } + } + + // ============================================================================ + // FLAG ACTIONS + // ============================================================================ + + @Action(LoadFlags) + async loadFlags(ctx: StateContext, action: LoadFlags): Promise { + ctx.patchState({ flagsLoading: true, error: null }); + try { + const flags = await firstValueFrom(this.flagsService.getFlags(action.collectionId)); + ctx.patchState({ flags, flagsLoading: false }); + } catch (e) { + ctx.patchState({ + flagsLoading: false, + error: e instanceof Error ? e.message : 'Failed to load flags', + }); + } + } + + @Action(CreateFlag) + async createFlag(ctx: StateContext, action: CreateFlag): Promise { + ctx.patchState({ error: null }); + try { + const created = await firstValueFrom( + this.flagsService.createFlag(action.collectionId, action.flag), + ); + const state = ctx.getState(); + ctx.patchState({ flags: [...state.flags, created] }); + } catch (e) { + ctx.patchState({ + error: e instanceof Error ? e.message : 'Failed to create flag', + }); + } + } + + @Action(UpdateFlag) + async updateFlag(ctx: StateContext, action: UpdateFlag): Promise { + ctx.patchState({ error: null }); + try { + const updated = await firstValueFrom( + this.flagsService.updateFlag(action.collectionId, action.flag), + ); + const state = ctx.getState(); + const oldKey = action.flag.previousKey ?? action.flag.key; + ctx.patchState({ + flags: state.flags.map((f) => (f.key === oldKey ? updated : f)), + }); + } catch (e) { + ctx.patchState({ + error: e instanceof Error ? e.message : 'Failed to update flag', + }); + } + } + + @Action(DeleteFlag) + async deleteFlag(ctx: StateContext, action: DeleteFlag): Promise { + ctx.patchState({ error: null }); + try { + await firstValueFrom(this.flagsService.deleteFlag(action.collectionId, action.flagKey)); + const state = ctx.getState(); + ctx.patchState({ + flags: state.flags.filter((f) => f.key !== action.flagKey), + }); + } catch (e) { + ctx.patchState({ + error: e instanceof Error ? e.message : 'Failed to delete flag', + }); + } + } + + // ============================================================================ + // ENVIRONMENT ACTIONS + // ============================================================================ + + @Action(LoadEnvironments) + async loadEnvironments( + ctx: StateContext, + action: LoadEnvironments, + ): Promise { + ctx.patchState({ environmentsLoading: true, error: null }); + try { + const environments = await firstValueFrom( + this.environmentsService.getEnvironments(action.collectionId), + ); + ctx.patchState({ environments, environmentsLoading: false }); + } catch (e) { + ctx.patchState({ + environmentsLoading: false, + error: e instanceof Error ? e.message : 'Failed to load environments', + }); + } + } + + @Action(CreateEnvironment) + async createEnvironment( + ctx: StateContext, + action: CreateEnvironment, + ): Promise { + ctx.patchState({ error: null }); + try { + const created = await firstValueFrom( + this.environmentsService.createEnvironment(action.collectionId, action.environment), + ); + const state = ctx.getState(); + ctx.patchState({ environments: [...state.environments, created] }); + } catch (e) { + ctx.patchState({ + error: e instanceof Error ? e.message : 'Failed to create environment', + }); + } + } + + @Action(UpdateEnvironment) + async updateEnvironment( + ctx: StateContext, + action: UpdateEnvironment, + ): Promise { + ctx.patchState({ error: null }); + try { + const updated = await firstValueFrom( + this.environmentsService.updateEnvironment(action.collectionId, action.environment), + ); + const state = ctx.getState(); + ctx.patchState({ + environments: state.environments.map((e) => + e.name === action.environment.name ? updated : e, + ), + }); + } catch (e) { + ctx.patchState({ + error: e instanceof Error ? e.message : 'Failed to update environment', + }); + } + } + + @Action(DeleteEnvironment) + async deleteEnvironment( + ctx: StateContext, + action: DeleteEnvironment, + ): Promise { + ctx.patchState({ error: null }); + try { + await firstValueFrom( + this.environmentsService.deleteEnvironment(action.collectionId, action.name), + ); + const state = ctx.getState(); + ctx.patchState({ + environments: state.environments.filter((e) => e.name !== action.name), + }); + } catch (e) { + ctx.patchState({ + error: e instanceof Error ? e.message : 'Failed to delete environment', + }); + } + } + + // ============================================================================ + // TIME WINDOW ACTIONS + // ============================================================================ + + @Action(LoadTimeWindows) + async loadTimeWindows( + ctx: StateContext, + action: LoadTimeWindows, + ): Promise { + ctx.patchState({ timeWindowsLoading: true, error: null }); + try { + const timeWindows = await firstValueFrom( + this.timeWindowsService.getTimeWindows(action.collectionId), + ); + ctx.patchState({ timeWindows, timeWindowsLoading: false }); + } catch (e) { + ctx.patchState({ + timeWindowsLoading: false, + error: e instanceof Error ? e.message : 'Failed to load time windows', + }); + } + } + + @Action(CreateTimeWindow) + async createTimeWindow( + ctx: StateContext, + action: CreateTimeWindow, + ): Promise { + ctx.patchState({ error: null }); + try { + const created = await firstValueFrom( + this.timeWindowsService.createTimeWindow(action.collectionId, action.timeWindow), + ); + const state = ctx.getState(); + ctx.patchState({ timeWindows: [...state.timeWindows, created] }); + } catch (e) { + ctx.patchState({ + error: e instanceof Error ? e.message : 'Failed to create time window', + }); + } + } + + @Action(UpdateTimeWindow) + async updateTimeWindow( + ctx: StateContext, + action: UpdateTimeWindow, + ): Promise { + ctx.patchState({ error: null }); + try { + const updated = await firstValueFrom( + this.timeWindowsService.updateTimeWindow( + action.collectionId, + action.timeWindowId, + action.timeWindow, + ), + ); + const state = ctx.getState(); + ctx.patchState({ + timeWindows: state.timeWindows.map((tw) => (tw.id === action.timeWindowId ? updated : tw)), + }); + } catch (e) { + ctx.patchState({ + error: e instanceof Error ? e.message : 'Failed to update time window', + }); + } + } + + @Action(DeleteTimeWindow) + async deleteTimeWindow( + ctx: StateContext, + action: DeleteTimeWindow, + ): Promise { + ctx.patchState({ error: null }); + try { + await firstValueFrom( + this.timeWindowsService.deleteTimeWindow(action.collectionId, action.timeWindowId), + ); + const state = ctx.getState(); + ctx.patchState({ + timeWindows: state.timeWindows.filter((tw) => tw.id !== action.timeWindowId), + }); + } catch (e) { + ctx.patchState({ + error: e instanceof Error ? e.message : 'Failed to delete time window', + }); + } + } + + // ============================================================================ + // SCHEMA ACTIONS + // ============================================================================ + + @Action(ExportSchema) + async exportSchema( + ctx: StateContext, + action: ExportSchema, + ): Promise | null> { + ctx.patchState({ error: null }); + try { + const schema = await firstValueFrom(this.schemaService.exportSchema(action.collectionId)); + return schema as Record; + } catch (e) { + ctx.patchState({ + error: e instanceof Error ? e.message : 'Failed to export schema', + }); + return null; + } + } + + @Action(ImportSchema) + async importSchema(ctx: StateContext, action: ImportSchema): Promise { + ctx.patchState({ error: null }); + try { + await firstValueFrom(this.schemaService.importSchema(action.collectionId)); + // Reload all sub-resources after import + ctx.dispatch([ + new LoadFlags(action.collectionId), + new LoadEnvironments(action.collectionId), + new LoadTimeWindows(action.collectionId), + ]); + } catch (e) { + ctx.patchState({ + error: e instanceof Error ? e.message : 'Failed to import schema', + }); + } + } + + // ============================================================================ + // COLLECTION METADATA ACTIONS + // ============================================================================ + + @Action(SetCollectionMetadata) + async setCollectionMetadata( + ctx: StateContext, + action: SetCollectionMetadata, + ): Promise { + const state = ctx.getState(); + const collection = state.collections.find((c) => c.id === action.collectionId); + if (!collection) { + ctx.patchState({ error: `Collection not found` }); + return; + } + + // Metadata is part of the collection - update by renaming with same name to trigger update + // The collection DTO carries metadata; a rename call preserves it + // For direct metadata updates, the collection itself would need to be patched + const updatedCollection: FlagsCollectionDto = { + ...collection, + metadata: action.metadata, + }; + + ctx.patchState({ + collections: state.collections.map((c) => + c.id === action.collectionId ? updatedCollection : c, + ), + }); + } +} From f17da0e24a79688900a961170f7195a46e0deb2e Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Sun, 8 Mar 2026 00:10:47 +0000 Subject: [PATCH 15/38] add wasm backend --- .../DatabaseWasmService.cs | 44 ++++++ .../FlagdWasmService.cs | 80 ++++++++++ .../IDatabaseWasmService.cs | 11 ++ .../IFlagdWasmService.cs | 44 ++++++ .../OpenFeatureManager.Wasm.csproj | 2 + src/OpenFeatureManager.Wasm/Program.cs | 15 ++ src/OpenFeatureManager.Wasm/WasmBindings.cs | 60 +------- src/OpenFeatureManager.Wasm/WasmRuntime.cs | 31 ++++ ui/src/app/services/wasm-backend.service.ts | 140 ++++++++++++++---- 9 files changed, 347 insertions(+), 80 deletions(-) create mode 100644 src/OpenFeatureManager.Wasm/DatabaseWasmService.cs create mode 100644 src/OpenFeatureManager.Wasm/FlagdWasmService.cs create mode 100644 src/OpenFeatureManager.Wasm/IDatabaseWasmService.cs create mode 100644 src/OpenFeatureManager.Wasm/IFlagdWasmService.cs create mode 100644 src/OpenFeatureManager.Wasm/WasmRuntime.cs diff --git a/src/OpenFeatureManager.Wasm/DatabaseWasmService.cs b/src/OpenFeatureManager.Wasm/DatabaseWasmService.cs new file mode 100644 index 0000000..054b35d --- /dev/null +++ b/src/OpenFeatureManager.Wasm/DatabaseWasmService.cs @@ -0,0 +1,44 @@ +namespace OpenFeatureManager.Wasm; + +public class DatabaseWasmService : IDatabaseWasmService +{ + private readonly WasmRuntime _runtime; + + public DatabaseWasmService(WasmRuntime runtime) + { + _runtime = runtime; + } + + public string InitDatabase() + { + try + { + _runtime.DbManager.InitializeDatabase(); + _runtime.InitializeServices(); + return "Database initialized successfully."; + } + catch (Exception ex) + { + return $"Error: {ex.Message}"; + } + } + + public string ImportDatabase(byte[] data) + { + try + { + _runtime.DbManager.ImportDatabase(data); + _runtime.InitializeServices(); + return "Database imported successfully."; + } + catch (Exception ex) + { + return $"Error: {ex.Message}"; + } + } + + public byte[] ExportDatabase() + { + return _runtime.DbManager.ExportDatabase(); + } +} diff --git a/src/OpenFeatureManager.Wasm/FlagdWasmService.cs b/src/OpenFeatureManager.Wasm/FlagdWasmService.cs new file mode 100644 index 0000000..379d777 --- /dev/null +++ b/src/OpenFeatureManager.Wasm/FlagdWasmService.cs @@ -0,0 +1,80 @@ +using OpenFeatureManager.Models; + +namespace OpenFeatureManager.Wasm; + +public class FlagdWasmService : IFlagdWasmService +{ + private readonly WasmRuntime _runtime; + + public FlagdWasmService(WasmRuntime runtime) + { + _runtime = runtime; + } + + // ─── Collections ────────────────────────────────────────────────── + + public FlagsCollectionDto[] GetCollections() => + _runtime.RequireService().GetCollections().ToArray(); + + public FlagsCollectionDto GetCollection(long id) => + _runtime.RequireService().GetCollection(id); + + public FlagsCollectionDto CreateCollection(string name) => + _runtime.RequireService().CreateCollection(name); + + public FlagsCollectionDto RenameCollection(long id, string name) => + _runtime.RequireService().RenameCollection(id, name); + + public void DeleteCollection(long id) => + _runtime.RequireService().DeleteCollection(id); + + public void ClearCollectionData(long collectionId) => + _runtime.RequireService().ClearCollectionData(collectionId); + + public void UpdateCollectionMetadata(long collectionId, MetadataEntryDto[] metadata) => + _runtime.RequireService().UpdateCollectionMetadata(collectionId, metadata.ToList()); + + // ─── Flags ──────────────────────────────────────────────────────── + + public FlagEntryDto[] GetFlags(long collectionId) => + _runtime.RequireService().GetFlags(collectionId).ToArray(); + + public FlagEntryDto UpsertFlag(long collectionId, FlagEntryDto dto) => + _runtime.RequireService().UpsertFlag(collectionId, dto); + + public void DeleteFlag(long collectionId, string flagKey) => + _runtime.RequireService().DeleteFlag(collectionId, flagKey); + + // ─── Environments ───────────────────────────────────────────────── + + public EnvironmentEntryDto[] GetEnvironments(long collectionId) => + _runtime.RequireService().GetEnvironments(collectionId).ToArray(); + + public EnvironmentEntryDto UpsertEnvironment(long collectionId, EnvironmentEntryDto dto) => + _runtime.RequireService().UpsertEnvironment(collectionId, dto); + + public void DeleteEnvironment(long collectionId, string name) => + _runtime.RequireService().DeleteEnvironment(collectionId, name); + + // ─── Time Windows ───────────────────────────────────────────────── + + public TimeWindowDto[] GetTimeWindows(long collectionId) => + _runtime.RequireService().GetTimeWindows(collectionId).ToArray(); + + public TimeWindowDto CreateTimeWindow(long collectionId, TimeWindowDto dto) => + _runtime.RequireService().CreateTimeWindow(collectionId, dto); + + public TimeWindowDto UpdateTimeWindow(long collectionId, long timeWindowId, TimeWindowDto dto) => + _runtime.RequireService().UpdateTimeWindow(collectionId, timeWindowId, dto); + + public void DeleteTimeWindow(long collectionId, long timeWindowId) => + _runtime.RequireService().DeleteTimeWindow(collectionId, timeWindowId); + + // ─── Schema ─────────────────────────────────────────────────────── + + public string ExportSchema(long collectionId) => + _runtime.RequireSchemaService().ExportSchema(collectionId); + + public void ImportSchema(long collectionId, string schemaJson) => + _runtime.RequireSchemaService().ImportSchema(collectionId, schemaJson); +} diff --git a/src/OpenFeatureManager.Wasm/IDatabaseWasmService.cs b/src/OpenFeatureManager.Wasm/IDatabaseWasmService.cs new file mode 100644 index 0000000..612af69 --- /dev/null +++ b/src/OpenFeatureManager.Wasm/IDatabaseWasmService.cs @@ -0,0 +1,11 @@ +namespace OpenFeatureManager.Wasm; + +/// +/// Database lifecycle operations exposed to JavaScript via Bootsharp. +/// +public interface IDatabaseWasmService +{ + string InitDatabase(); + string ImportDatabase(byte[] data); + byte[] ExportDatabase(); +} diff --git a/src/OpenFeatureManager.Wasm/IFlagdWasmService.cs b/src/OpenFeatureManager.Wasm/IFlagdWasmService.cs new file mode 100644 index 0000000..07e36e9 --- /dev/null +++ b/src/OpenFeatureManager.Wasm/IFlagdWasmService.cs @@ -0,0 +1,44 @@ +using OpenFeatureManager.Models; + +namespace OpenFeatureManager.Wasm; + +/// +/// All flagd domain operations exposed to JavaScript via Bootsharp. +/// Mirrors the endpoints in the ASP.NET API project. +/// +public interface IFlagdWasmService +{ + // ─── Collections ────────────────────────────────────────────────── + + FlagsCollectionDto[] GetCollections(); + FlagsCollectionDto GetCollection(long id); + FlagsCollectionDto CreateCollection(string name); + FlagsCollectionDto RenameCollection(long id, string name); + void DeleteCollection(long id); + void ClearCollectionData(long collectionId); + void UpdateCollectionMetadata(long collectionId, MetadataEntryDto[] metadata); + + // ─── Flags ──────────────────────────────────────────────────────── + + FlagEntryDto[] GetFlags(long collectionId); + FlagEntryDto UpsertFlag(long collectionId, FlagEntryDto dto); + void DeleteFlag(long collectionId, string flagKey); + + // ─── Environments ───────────────────────────────────────────────── + + EnvironmentEntryDto[] GetEnvironments(long collectionId); + EnvironmentEntryDto UpsertEnvironment(long collectionId, EnvironmentEntryDto dto); + void DeleteEnvironment(long collectionId, string name); + + // ─── Time Windows ───────────────────────────────────────────────── + + TimeWindowDto[] GetTimeWindows(long collectionId); + TimeWindowDto CreateTimeWindow(long collectionId, TimeWindowDto dto); + TimeWindowDto UpdateTimeWindow(long collectionId, long timeWindowId, TimeWindowDto dto); + void DeleteTimeWindow(long collectionId, long timeWindowId); + + // ─── Schema ─────────────────────────────────────────────────────── + + string ExportSchema(long collectionId); + void ImportSchema(long collectionId, string schemaJson); +} diff --git a/src/OpenFeatureManager.Wasm/OpenFeatureManager.Wasm.csproj b/src/OpenFeatureManager.Wasm/OpenFeatureManager.Wasm.csproj index 37ecd77..c7a4c36 100644 --- a/src/OpenFeatureManager.Wasm/OpenFeatureManager.Wasm.csproj +++ b/src/OpenFeatureManager.Wasm/OpenFeatureManager.Wasm.csproj @@ -16,6 +16,8 @@ + + diff --git a/src/OpenFeatureManager.Wasm/Program.cs b/src/OpenFeatureManager.Wasm/Program.cs index 66bc1d5..c32951f 100644 --- a/src/OpenFeatureManager.Wasm/Program.cs +++ b/src/OpenFeatureManager.Wasm/Program.cs @@ -1,12 +1,27 @@ using Bootsharp; +using Bootsharp.Inject; +using Microsoft.Extensions.DependencyInjection; using OpenFeatureManager.Wasm; +[assembly: JSExport( + typeof(IDatabaseWasmService), + typeof(IFlagdWasmService))] + public static partial class Program { public static void Main() { // Ensure trimmer preserves EF Core types needed at runtime TrimmerRoots.PreserveTypes(); + + new ServiceCollection() + .AddBootsharp() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .BuildServiceProvider() + .RunBootsharp(); + OnReady("Backend .NET runtime initialized."); } diff --git a/src/OpenFeatureManager.Wasm/WasmBindings.cs b/src/OpenFeatureManager.Wasm/WasmBindings.cs index 4029064..206efef 100644 --- a/src/OpenFeatureManager.Wasm/WasmBindings.cs +++ b/src/OpenFeatureManager.Wasm/WasmBindings.cs @@ -1,55 +1,5 @@ -using Bootsharp; -using OpenFeatureManager.Data; -using OpenFeatureManager.Services; - -namespace OpenFeatureManager.Wasm; - -/// -/// WASM bindings for database lifecycle operations. -/// Exposes InitDatabase, ImportDatabase, and ExportDatabase to JavaScript via Bootsharp. -/// All domain logic lives in . -/// -public static class WasmBindings -{ - private const string ConnectionString = "Data Source=FlagdDb;Mode=Memory;Cache=Shared"; - private static readonly DatabaseManager DbManager = new(ConnectionString); - - /// The shared service instance, available after or . - public static FlagdService? Service { get; private set; } - - [JSInvokable] - public static string InitDatabase() - { - try - { - DbManager.InitializeDatabase(); - Service = new FlagdService(() => new FlagdDbContext(ConnectionString)); - return "Database initialized successfully."; - } - catch (Exception ex) - { - return $"Error: {ex.Message}"; - } - } - - [JSInvokable] - public static string ImportDatabase(byte[] data) - { - try - { - DbManager.ImportDatabase(data); - Service = new FlagdService(() => new FlagdDbContext(ConnectionString)); - return "Database imported successfully."; - } - catch (Exception ex) - { - return $"Error: {ex.Message}"; - } - } - - [JSInvokable] - public static byte[] ExportDatabase() - { - return DbManager.ExportDatabase(); - } -} +// This file is intentionally left minimal. +// Database and flagd operations are now exposed via DI-based interop interfaces: +// - IDatabaseWasmService (DatabaseWasmService.cs) +// - IFlagdWasmService (FlagdWasmService.cs) +// See Program.cs for the DI wiring. diff --git a/src/OpenFeatureManager.Wasm/WasmRuntime.cs b/src/OpenFeatureManager.Wasm/WasmRuntime.cs new file mode 100644 index 0000000..131ec24 --- /dev/null +++ b/src/OpenFeatureManager.Wasm/WasmRuntime.cs @@ -0,0 +1,31 @@ +using OpenFeatureManager.Data; +using OpenFeatureManager.Services; + +namespace OpenFeatureManager.Wasm; + +/// +/// Shared singleton holding the database manager and lazily-created services. +/// Services become available after is called +/// (triggered by or +/// ). +/// +public class WasmRuntime +{ + private const string ConnectionString = "Data Source=FlagdDb;Mode=Memory;Cache=Shared"; + + public DatabaseManager DbManager { get; } = new(ConnectionString); + public FlagdService? FlagdService { get; private set; } + public FlagdSchemaService? SchemaService { get; private set; } + + public void InitializeServices() + { + FlagdService = new FlagdService(() => new FlagdDbContext(ConnectionString)); + SchemaService = new FlagdSchemaService(FlagdService); + } + + public FlagdService RequireService() => + FlagdService ?? throw new InvalidOperationException("Database not initialized. Call InitDatabase first."); + + public FlagdSchemaService RequireSchemaService() => + SchemaService ?? throw new InvalidOperationException("Database not initialized. Call InitDatabase first."); +} diff --git a/ui/src/app/services/wasm-backend.service.ts b/ui/src/app/services/wasm-backend.service.ts index ea65ef7..c40a6d4 100644 --- a/ui/src/app/services/wasm-backend.service.ts +++ b/ui/src/app/services/wasm-backend.service.ts @@ -1,15 +1,23 @@ import { Injectable, signal } from '@angular/core'; +// Re-export generated DTO types from bootsharp for consumers +export type { OpenFeatureManager as OpenFeatureManagerTypes } from 'bootsharp'; + +export type FlagsCollectionDto = import('bootsharp').OpenFeatureManager.Models.FlagsCollectionDto; +export type MetadataEntryDto = import('bootsharp').OpenFeatureManager.Models.MetadataEntryDto; +export type FlagEntryDto = import('bootsharp').OpenFeatureManager.Models.FlagEntryDto; +export type GlobalTimeWindowDto = import('bootsharp').OpenFeatureManager.Models.GlobalTimeWindowDto; +export type EnvironmentEntryDto = import('bootsharp').OpenFeatureManager.Models.EnvironmentEntryDto; +export type TimeWindowDto = import('bootsharp').OpenFeatureManager.Models.TimeWindowDto; + export type WasmBackendStatus = 'not-initialized' | 'booting' | 'ready' | 'error'; /** * Angular service wrapping the Bootsharp-compiled .NET WASM backend. * * Call {@link boot} once to initialise the .NET runtime and in-memory database. - * After the returned promise resolves the bindings are available via - * {@link initDatabase}, {@link importDatabase}, and {@link exportDatabase}. - * - * Usage is NOT yet wired into any component — this service only exposes the API. + * After the returned promise resolves, all database lifecycle and flagd domain + * operations are available. */ @Injectable({ providedIn: 'root' }) export class WasmBackendService { @@ -48,36 +56,108 @@ export class WasmBackendService { } } - /** - * Initialise a fresh in-memory SQLite database inside the WASM runtime. - * The runtime must be booted first. - */ + // ─── Database lifecycle ─────────────────────────────────────────────── + initDatabase(): string { - this.ensureReady(); - const { OpenFeatureManager } = this.getBindings(); - return OpenFeatureManager.Wasm.WasmBindings.initDatabase(); + return this.db().initDatabase(); } - /** - * Import an SQLite database from raw bytes. - * The runtime must be booted first. - */ importDatabase(data: Uint8Array): string { - this.ensureReady(); - const { OpenFeatureManager } = this.getBindings(); - return OpenFeatureManager.Wasm.WasmBindings.importDatabase(data); + return this.db().importDatabase(data); } - /** - * Export the current in-memory SQLite database as raw bytes. - * The runtime must be booted first. - */ exportDatabase(): Uint8Array { - this.ensureReady(); - const { OpenFeatureManager } = this.getBindings(); - return OpenFeatureManager.Wasm.WasmBindings.exportDatabase(); + return this.db().exportDatabase(); + } + + // ─── Collections ────────────────────────────────────────────────────── + + getCollections(): FlagsCollectionDto[] { + return this.flagd().getCollections(); } + getCollection(id: bigint): FlagsCollectionDto { + return this.flagd().getCollection(id); + } + + createCollection(name: string): FlagsCollectionDto { + return this.flagd().createCollection(name); + } + + renameCollection(id: bigint, name: string): FlagsCollectionDto { + return this.flagd().renameCollection(id, name); + } + + deleteCollection(id: bigint): void { + this.flagd().deleteCollection(id); + } + + clearCollectionData(collectionId: bigint): void { + this.flagd().clearCollectionData(collectionId); + } + + updateCollectionMetadata(collectionId: bigint, metadata: MetadataEntryDto[]): void { + this.flagd().updateCollectionMetadata(collectionId, metadata); + } + + // ─── Flags ──────────────────────────────────────────────────────────── + + getFlags(collectionId: bigint): FlagEntryDto[] { + return this.flagd().getFlags(collectionId); + } + + upsertFlag(collectionId: bigint, dto: FlagEntryDto): FlagEntryDto { + return this.flagd().upsertFlag(collectionId, dto); + } + + deleteFlag(collectionId: bigint, flagKey: string): void { + this.flagd().deleteFlag(collectionId, flagKey); + } + + // ─── Environments ───────────────────────────────────────────────────── + + getEnvironments(collectionId: bigint): EnvironmentEntryDto[] { + return this.flagd().getEnvironments(collectionId); + } + + upsertEnvironment(collectionId: bigint, dto: EnvironmentEntryDto): EnvironmentEntryDto { + return this.flagd().upsertEnvironment(collectionId, dto); + } + + deleteEnvironment(collectionId: bigint, name: string): void { + this.flagd().deleteEnvironment(collectionId, name); + } + + // ─── Time Windows ───────────────────────────────────────────────────── + + getTimeWindows(collectionId: bigint): TimeWindowDto[] { + return this.flagd().getTimeWindows(collectionId); + } + + createTimeWindow(collectionId: bigint, dto: TimeWindowDto): TimeWindowDto { + return this.flagd().createTimeWindow(collectionId, dto); + } + + updateTimeWindow(collectionId: bigint, timeWindowId: bigint, dto: TimeWindowDto): TimeWindowDto { + return this.flagd().updateTimeWindow(collectionId, timeWindowId, dto); + } + + deleteTimeWindow(collectionId: bigint, timeWindowId: bigint): void { + this.flagd().deleteTimeWindow(collectionId, timeWindowId); + } + + // ─── Schema ─────────────────────────────────────────────────────────── + + exportSchema(collectionId: bigint): string { + return this.flagd().exportSchema(collectionId); + } + + importSchema(collectionId: bigint, schemaJson: string): void { + this.flagd().importSchema(collectionId, schemaJson); + } + + // ─── Private helpers ────────────────────────────────────────────────── + private ensureReady(): void { if (this.status() !== 'ready') { throw new Error('WasmBackendService is not ready. Call boot() first.'); @@ -90,4 +170,14 @@ export class WasmBackendService { } return this.bootsharpModule; } + + private db() { + this.ensureReady(); + return this.getBindings().OpenFeatureManager.Wasm.DatabaseWasmService; + } + + private flagd() { + this.ensureReady(); + return this.getBindings().OpenFeatureManager.Wasm.FlagdWasmService; + } } From 842af6634fdace61d9a40df635c7663b1d0f8b3f Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Mon, 9 Mar 2026 10:00:28 +0000 Subject: [PATCH 16/38] wip --- ui/scripts/generate-environments.js | 8 + ui/src/app/app.config.ts | 18 +- ui/src/app/services/flag-backend.ts | 110 ++++++++ ui/src/app/services/rest-flag-backend.ts | 329 +++++++++++++++++++++++ ui/src/app/services/wasm-flag-backend.ts | 299 ++++++++++++++++++++ ui/src/app/state/flag-store.actions.ts | 62 ++--- ui/src/app/state/flag-store.state.ts | 149 +++++----- ui/src/environments.ts | 1 + 8 files changed, 856 insertions(+), 120 deletions(-) create mode 100644 ui/src/app/services/flag-backend.ts create mode 100644 ui/src/app/services/rest-flag-backend.ts create mode 100644 ui/src/app/services/wasm-flag-backend.ts diff --git a/ui/scripts/generate-environments.js b/ui/scripts/generate-environments.js index 4fa863a..941366a 100644 --- a/ui/scripts/generate-environments.js +++ b/ui/scripts/generate-environments.js @@ -30,15 +30,23 @@ if (!backendRoot || backendRoot.toLowerCase() === 'null') { backendRoot = backendRoot.replace(/\/+$/, ''); } +const rawBackendType = process.env.FLAGD_UI_BACKEND_TYPE; +let backendType = 'rest'; +if (rawBackendType && (rawBackendType === 'rest' || rawBackendType === 'wasm')) { + backendType = rawBackendType; +} + const tagValue = gitTag ? JSON.stringify(gitTag) : 'null'; const commitValue = gitCommit ? JSON.stringify(gitCommit) : 'null'; const backendValue = backendRoot ? JSON.stringify(backendRoot) : 'null'; +const backendTypeValue = JSON.stringify(backendType); const fileContents = `/* This file is generated by scripts/generate-environments.js. */ export const GIT_TAG: string | null = ${tagValue}; export const GIT_COMMIT_HASH: string | null = ${commitValue}; export const DEFAULT_BACKEND_ROOT: string | null = ${backendValue}; +export const BACKEND_TYPE: 'rest' | 'wasm' = ${backendTypeValue} as 'rest' | 'wasm'; `; fs.mkdirSync(path.dirname(targetFile), { recursive: true }); diff --git a/ui/src/app/app.config.ts b/ui/src/app/app.config.ts index b8e8115..e2c1848 100644 --- a/ui/src/app/app.config.ts +++ b/ui/src/app/app.config.ts @@ -7,10 +7,14 @@ import { provideStore } from '@ngxs/store'; import { withNgxsStoragePlugin } from '@ngxs/storage-plugin'; import { withNgxsRouterPlugin } from '@ngxs/router-plugin'; import { provideApi } from './api-client/provide-api'; -import { DEFAULT_BACKEND_ROOT } from '../environments'; +import { BACKEND_TYPE, DEFAULT_BACKEND_ROOT } from '../environments'; import { routes } from './app.routes'; import { globalLoadingInterceptor } from './interceptors/global-loading.interceptor'; +import { FLAG_BACKEND } from './services/flag-backend'; +import { RestFlagBackend } from './services/rest-flag-backend'; +import { WasmFlagBackend } from './services/wasm-flag-backend'; import { FlagFileStore } from './state/flag-file-store.state'; +import { FlagStoreState } from './state/flag-store.state'; import { CurrentFlagStoreState } from './state/current-flag-store.state'; import { UiPreferencesState } from './state/ui-preferences.state'; import { PlaygroundPreferencesState } from './state/playground-preferences.state'; @@ -22,8 +26,18 @@ export const appConfig: ApplicationConfig = { provideHttpClient(withInterceptors([globalLoadingInterceptor])), provideNativeDateAdapter(), provideApi({ basePath: DEFAULT_BACKEND_ROOT ?? '' }), + { + provide: FLAG_BACKEND, + useExisting: BACKEND_TYPE === 'wasm' ? WasmFlagBackend : RestFlagBackend, + }, provideStore( - [FlagFileStore, CurrentFlagStoreState, UiPreferencesState, PlaygroundPreferencesState], + [ + FlagFileStore, + FlagStoreState, + CurrentFlagStoreState, + UiPreferencesState, + PlaygroundPreferencesState, + ], withNgxsStoragePlugin({ keys: [UiPreferencesState, PlaygroundPreferencesState, FlagFileStore], }), diff --git a/ui/src/app/services/flag-backend.ts b/ui/src/app/services/flag-backend.ts new file mode 100644 index 0000000..3684e56 --- /dev/null +++ b/ui/src/app/services/flag-backend.ts @@ -0,0 +1,110 @@ +import { InjectionToken } from '@angular/core'; + +// ============================================================================ +// Canonical DTO types used across both backends +// ============================================================================ + +export interface CollectionDto { + id: number; + name: string; + createdAt: string; + metadata?: MetadataDto[] | null; +} + +export interface FlagDto { + key: string; + type: string; + state: string; + booleanValue?: boolean | null; + stringValue?: string | null; + numberValue?: number | null; + objectValue?: string | null; + metadata?: MetadataDto[] | null; + perEnvironmentDefinitions?: Record; + globalTimeWindow?: GlobalTimeWindowDto | null; + previousKey?: string | null; +} + +export interface MetadataDto { + key: string; + stringValue?: string | null; + numberValue?: number | null; + booleanValue?: boolean | null; +} + +export interface EnvironmentDto { + name: string; + aliases: string[]; +} + +export interface TimeWindowDto { + id: number; + name: string; + startTime?: string | null; + endTime?: string | null; +} + +export interface GlobalTimeWindowDto { + timeWindowId: number; + booleanValue?: boolean | null; + stringValue?: string | null; + numberValue?: number | null; + objectValue?: string | null; +} + +export interface PerEnvironmentDefinitionDto { + booleanValue?: boolean | null; + stringValue?: string | null; + numberValue?: number | null; + objectValue?: string | null; + timeWindowId?: number | null; +} + +// ============================================================================ +// Backend interface +// ============================================================================ + +export interface FlagBackend { + /** + * Optional initialization (e.g. booting the WASM runtime). + * Called once before any other method. + */ + init?(): Promise; + + // Collections + listCollections(): Promise; + createCollection(name: string): Promise; + renameCollection(id: number, name: string): Promise; + deleteCollection(id: number): Promise; + + // Flags + getFlags(collectionId: number): Promise; + createFlag(collectionId: number, flag: FlagDto): Promise; + updateFlag(collectionId: number, flag: FlagDto): Promise; + deleteFlag(collectionId: number, flagKey: string): Promise; + + // Environments + getEnvironments(collectionId: number): Promise; + createEnvironment(collectionId: number, env: EnvironmentDto): Promise; + updateEnvironment(collectionId: number, env: EnvironmentDto): Promise; + deleteEnvironment(collectionId: number, name: string): Promise; + + // Time windows + getTimeWindows(collectionId: number): Promise; + createTimeWindow(collectionId: number, tw: TimeWindowDto): Promise; + updateTimeWindow( + collectionId: number, + timeWindowId: number, + tw: TimeWindowDto, + ): Promise; + deleteTimeWindow(collectionId: number, timeWindowId: number): Promise; + + // Schema + exportSchema(collectionId: number): Promise>; + importSchema(collectionId: number): Promise; + + // Collection metadata + updateCollectionMetadata(collectionId: number, metadata: MetadataDto[]): Promise; +} + +export const FLAG_BACKEND = new InjectionToken('FlagBackend'); diff --git a/ui/src/app/services/rest-flag-backend.ts b/ui/src/app/services/rest-flag-backend.ts new file mode 100644 index 0000000..123bb4a --- /dev/null +++ b/ui/src/app/services/rest-flag-backend.ts @@ -0,0 +1,329 @@ +import { inject, Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { CollectionsService } from '../api-client/api/collections.service'; +import { FlagsService } from '../api-client/api/flags.service'; +import { EnvironmentsService } from '../api-client/api/environments.service'; +import { TimewindowsService } from '../api-client/api/timewindows.service'; +import { SchemaService } from '../api-client/api/schema.service'; +import { FlagsCollectionDto } from '../api-client/model/flagsCollectionDto'; +import { FlagEntryDto } from '../api-client/model/flagEntryDto'; +import { MetadataEntryDto } from '../api-client/model/metadataEntryDto'; +import { GlobalTimeWindowDto as ApiGlobalTimeWindowDto } from '../api-client/model/globalTimeWindowDto'; +import { PerEnvironmentDefinitionDto as ApiPerEnvDto } from '../api-client/model/perEnvironmentDefinitionDto'; +import { TimeWindowDto as ApiTimeWindowDto } from '../api-client/model/timeWindowDto'; +import { RenameCollectionIdParameter } from '../api-client/model/renameCollectionIdParameter'; +import { + CollectionDto, + EnvironmentDto, + FlagBackend, + FlagDto, + GlobalTimeWindowDto, + MetadataDto, + PerEnvironmentDefinitionDto, + TimeWindowDto, +} from './flag-backend'; + +/** + * FlagBackend implementation that delegates to the generated REST API client. + */ +@Injectable({ providedIn: 'root' }) +export class RestFlagBackend implements FlagBackend { + private readonly collections = inject(CollectionsService); + private readonly flags = inject(FlagsService); + private readonly environments = inject(EnvironmentsService); + private readonly timeWindows = inject(TimewindowsService); + private readonly schema = inject(SchemaService); + + // ── Collections ──────────────────────────────────────────────────────── + + async listCollections(): Promise { + const list = await firstValueFrom(this.collections.listCollections()); + return list.map((c) => this.toCollectionDto(c)); + } + + async createCollection(name: string): Promise { + const c = await firstValueFrom(this.collections.createCollection({ name })); + return this.toCollectionDto(c); + } + + async renameCollection(id: number, name: string): Promise { + const c = await firstValueFrom( + this.collections.renameCollection(this.toId(id), { name }), + ); + return this.toCollectionDto(c); + } + + async deleteCollection(id: number): Promise { + await firstValueFrom(this.collections.deleteCollection(this.toId(id))); + } + + // ── Flags ────────────────────────────────────────────────────────────── + + async getFlags(collectionId: number): Promise { + const list = await firstValueFrom(this.flags.getFlags(this.toId(collectionId))); + return list.map((f) => this.toFlagDto(f)); + } + + async createFlag(collectionId: number, flag: FlagDto): Promise { + const f = await firstValueFrom( + this.flags.createFlag(this.toId(collectionId), this.toApiFlagEntry(flag)), + ); + return this.toFlagDto(f); + } + + async updateFlag(collectionId: number, flag: FlagDto): Promise { + const f = await firstValueFrom( + this.flags.updateFlag(this.toId(collectionId), this.toApiFlagEntry(flag)), + ); + return this.toFlagDto(f); + } + + async deleteFlag(collectionId: number, flagKey: string): Promise { + await firstValueFrom(this.flags.deleteFlag(this.toId(collectionId), flagKey)); + } + + // ── Environments ─────────────────────────────────────────────────────── + + async getEnvironments(collectionId: number): Promise { + const list = await firstValueFrom( + this.environments.getEnvironments(this.toId(collectionId)), + ); + return list.map((e) => ({ name: e.name, aliases: e.aliases ?? [] })); + } + + async createEnvironment(collectionId: number, env: EnvironmentDto): Promise { + const e = await firstValueFrom( + this.environments.createEnvironment(this.toId(collectionId), { + name: env.name, + aliases: env.aliases, + }), + ); + return { name: e.name, aliases: e.aliases ?? [] }; + } + + async updateEnvironment(collectionId: number, env: EnvironmentDto): Promise { + const e = await firstValueFrom( + this.environments.updateEnvironment(this.toId(collectionId), { + name: env.name, + aliases: env.aliases, + }), + ); + return { name: e.name, aliases: e.aliases ?? [] }; + } + + async deleteEnvironment(collectionId: number, name: string): Promise { + await firstValueFrom(this.environments.deleteEnvironment(this.toId(collectionId), name)); + } + + // ── Time Windows ─────────────────────────────────────────────────────── + + async getTimeWindows(collectionId: number): Promise { + const list = await firstValueFrom(this.timeWindows.getTimeWindows(this.toId(collectionId))); + return list.map((tw) => this.toTimeWindowDto(tw)); + } + + async createTimeWindow(collectionId: number, tw: TimeWindowDto): Promise { + const created = await firstValueFrom( + this.timeWindows.createTimeWindow(this.toId(collectionId), this.toApiTimeWindow(tw)), + ); + return this.toTimeWindowDto(created); + } + + async updateTimeWindow( + collectionId: number, + timeWindowId: number, + tw: TimeWindowDto, + ): Promise { + const updated = await firstValueFrom( + this.timeWindows.updateTimeWindow( + this.toId(collectionId), + this.toId(timeWindowId), + this.toApiTimeWindow(tw), + ), + ); + return this.toTimeWindowDto(updated); + } + + async deleteTimeWindow(collectionId: number, timeWindowId: number): Promise { + await firstValueFrom( + this.timeWindows.deleteTimeWindow(this.toId(collectionId), this.toId(timeWindowId)), + ); + } + + // ── Schema ───────────────────────────────────────────────────────────── + + async exportSchema(collectionId: number): Promise> { + const result = await firstValueFrom(this.schema.exportSchema(this.toId(collectionId))); + return result as Record; + } + + async importSchema(collectionId: number): Promise { + await firstValueFrom(this.schema.importSchema(this.toId(collectionId))); + } + + // ── Collection Metadata ──────────────────────────────────────────────── + + async updateCollectionMetadata(collectionId: number, metadata: MetadataDto[]): Promise { + // The REST API doesn't have a direct metadata endpoint; metadata is part of the collection DTO. + // A rename call with the same name can be used to trigger a PUT. + const collections = await firstValueFrom(this.collections.listCollections()); + const collection = collections.find( + (c) => this.fromId(c.id) === collectionId, + ); + if (!collection) { + throw new Error(`Collection ${collectionId} not found`); + } + await firstValueFrom( + this.collections.renameCollection(collection.id, { name: collection.name }), + ); + } + + // ── Type Mapping Helpers ─────────────────────────────────────────────── + + private toId(n: number): RenameCollectionIdParameter { + return n as unknown as RenameCollectionIdParameter; + } + + private fromId(id: RenameCollectionIdParameter): number { + return id as unknown as number; + } + + private toCollectionDto(c: FlagsCollectionDto): CollectionDto { + return { + id: this.fromId(c.id), + name: c.name, + createdAt: c.createdAt, + metadata: c.metadata?.map((m) => this.toMetadataDto(m)) ?? null, + }; + } + + private toFlagDto(f: FlagEntryDto): FlagDto { + return { + key: f.key, + type: f.type, + state: f.state, + booleanValue: f.booleanValue ?? null, + stringValue: f.stringValue ?? null, + numberValue: (f.numberValue as unknown as number) ?? null, + objectValue: f.objectValue ?? null, + metadata: f.metadata?.map((m) => this.toMetadataDto(m)) ?? null, + perEnvironmentDefinitions: f.perEnvironmentDefinitions + ? this.mapPerEnvDefs(f.perEnvironmentDefinitions) + : undefined, + globalTimeWindow: f.globalTimeWindow + ? this.toGlobalTimeWindowDto(f.globalTimeWindow) + : null, + previousKey: f.previousKey ?? null, + }; + } + + private toApiFlagEntry(flag: FlagDto): FlagEntryDto { + return { + key: flag.key, + type: flag.type, + state: flag.state, + booleanValue: flag.booleanValue, + stringValue: flag.stringValue, + numberValue: flag.numberValue as unknown as FlagEntryDto['numberValue'], + objectValue: flag.objectValue, + metadata: flag.metadata?.map((m) => this.toApiMetadata(m)), + perEnvironmentDefinitions: flag.perEnvironmentDefinitions + ? this.mapToApiPerEnvDefs(flag.perEnvironmentDefinitions) + : undefined, + globalTimeWindow: flag.globalTimeWindow + ? this.toApiGlobalTimeWindow(flag.globalTimeWindow) + : null, + previousKey: flag.previousKey, + }; + } + + private toMetadataDto(m: MetadataEntryDto): MetadataDto { + return { + key: m.key, + stringValue: m.stringValue ?? null, + numberValue: (m.numberValue as unknown as number) ?? null, + booleanValue: m.booleanValue ?? null, + }; + } + + private toApiMetadata(m: MetadataDto): MetadataEntryDto { + return { + key: m.key, + stringValue: m.stringValue, + numberValue: m.numberValue as unknown as MetadataEntryDto['numberValue'], + booleanValue: m.booleanValue, + }; + } + + private toTimeWindowDto(tw: ApiTimeWindowDto): TimeWindowDto { + return { + id: this.fromId(tw.id), + name: tw.name, + startTime: tw.startTime ?? null, + endTime: tw.endTime ?? null, + }; + } + + private toApiTimeWindow(tw: TimeWindowDto): ApiTimeWindowDto { + return { + id: this.toId(tw.id), + name: tw.name, + startTime: tw.startTime, + endTime: tw.endTime, + }; + } + + private toGlobalTimeWindowDto(g: ApiGlobalTimeWindowDto): GlobalTimeWindowDto { + return { + timeWindowId: this.fromId(g.timeWindowId), + booleanValue: g.booleanValue ?? null, + stringValue: g.stringValue ?? null, + numberValue: (g.numberValue as unknown as number) ?? null, + objectValue: g.objectValue ?? null, + }; + } + + private toApiGlobalTimeWindow(g: GlobalTimeWindowDto): ApiGlobalTimeWindowDto { + return { + timeWindowId: this.toId(g.timeWindowId), + booleanValue: g.booleanValue, + stringValue: g.stringValue, + numberValue: g.numberValue as unknown as ApiGlobalTimeWindowDto['numberValue'], + objectValue: g.objectValue, + }; + } + + private mapPerEnvDefs( + defs: Record, + ): Record { + const result: Record = {}; + for (const [key, def] of Object.entries(defs)) { + result[key] = { + booleanValue: def.booleanValue ?? null, + stringValue: def.stringValue ?? null, + numberValue: (def.numberValue as unknown as number) ?? null, + objectValue: def.objectValue ?? null, + timeWindowId: def.timeWindowId + ? (def.timeWindowId as unknown as number) + : null, + }; + } + return result; + } + + private mapToApiPerEnvDefs( + defs: Record, + ): Record { + const result: Record = {}; + for (const [key, def] of Object.entries(defs)) { + result[key] = { + booleanValue: def.booleanValue, + stringValue: def.stringValue, + numberValue: def.numberValue as unknown as ApiPerEnvDto['numberValue'], + objectValue: def.objectValue, + timeWindowId: def.timeWindowId as unknown as ApiPerEnvDto['timeWindowId'], + }; + } + return result; + } +} diff --git a/ui/src/app/services/wasm-flag-backend.ts b/ui/src/app/services/wasm-flag-backend.ts new file mode 100644 index 0000000..24d6f11 --- /dev/null +++ b/ui/src/app/services/wasm-flag-backend.ts @@ -0,0 +1,299 @@ +import { inject, Injectable } from '@angular/core'; +import { WasmBackendService } from './wasm-backend.service'; +import { + CollectionDto, + EnvironmentDto, + FlagBackend, + FlagDto, + GlobalTimeWindowDto, + MetadataDto, + PerEnvironmentDefinitionDto, + TimeWindowDto, +} from './flag-backend'; +import type { + FlagsCollectionDto as WasmCollectionDto, + FlagEntryDto as WasmFlagDto, + MetadataEntryDto as WasmMetadataDto, + EnvironmentEntryDto as WasmEnvironmentDto, + TimeWindowDto as WasmTimeWindowDto, + GlobalTimeWindowDto as WasmGlobalTimeWindowDto, +} from './wasm-backend.service'; + +type WasmPerEnvDto = + import('bootsharp').OpenFeatureManager.Models.PerEnvironmentDefinitionDto; + +/** + * FlagBackend implementation that delegates to the in-browser .NET WASM runtime. + */ +@Injectable({ providedIn: 'root' }) +export class WasmFlagBackend implements FlagBackend { + private readonly wasm = inject(WasmBackendService); + + async init(): Promise { + await this.wasm.boot(); + this.wasm.initDatabase(); + } + + // ── Collections ──────────────────────────────────────────────────────── + + async listCollections(): Promise { + return this.wasm.getCollections().map((c) => this.toCollectionDto(c)); + } + + async createCollection(name: string): Promise { + return this.toCollectionDto(this.wasm.createCollection(name)); + } + + async renameCollection(id: number, name: string): Promise { + return this.toCollectionDto(this.wasm.renameCollection(BigInt(id), name)); + } + + async deleteCollection(id: number): Promise { + this.wasm.deleteCollection(BigInt(id)); + } + + // ── Flags ────────────────────────────────────────────────────────────── + + async getFlags(collectionId: number): Promise { + return this.wasm.getFlags(BigInt(collectionId)).map((f) => this.toFlagDto(f)); + } + + async createFlag(collectionId: number, flag: FlagDto): Promise { + return this.toFlagDto( + this.wasm.upsertFlag(BigInt(collectionId), this.toWasmFlag(flag)), + ); + } + + async updateFlag(collectionId: number, flag: FlagDto): Promise { + return this.toFlagDto( + this.wasm.upsertFlag(BigInt(collectionId), this.toWasmFlag(flag)), + ); + } + + async deleteFlag(collectionId: number, flagKey: string): Promise { + this.wasm.deleteFlag(BigInt(collectionId), flagKey); + } + + // ── Environments ─────────────────────────────────────────────────────── + + async getEnvironments(collectionId: number): Promise { + return this.wasm + .getEnvironments(BigInt(collectionId)) + .map((e) => ({ name: e.name, aliases: [...e.aliases] })); + } + + async createEnvironment(collectionId: number, env: EnvironmentDto): Promise { + const e = this.wasm.upsertEnvironment(BigInt(collectionId), { + name: env.name, + aliases: env.aliases, + } as WasmEnvironmentDto); + return { name: e.name, aliases: [...e.aliases] }; + } + + async updateEnvironment(collectionId: number, env: EnvironmentDto): Promise { + const e = this.wasm.upsertEnvironment(BigInt(collectionId), { + name: env.name, + aliases: env.aliases, + } as WasmEnvironmentDto); + return { name: e.name, aliases: [...e.aliases] }; + } + + async deleteEnvironment(collectionId: number, name: string): Promise { + this.wasm.deleteEnvironment(BigInt(collectionId), name); + } + + // ── Time Windows ─────────────────────────────────────────────────────── + + async getTimeWindows(collectionId: number): Promise { + return this.wasm + .getTimeWindows(BigInt(collectionId)) + .map((tw) => this.toTimeWindowDto(tw)); + } + + async createTimeWindow(collectionId: number, tw: TimeWindowDto): Promise { + const created = this.wasm.createTimeWindow( + BigInt(collectionId), + this.toWasmTimeWindow(tw), + ); + return this.toTimeWindowDto(created); + } + + async updateTimeWindow( + collectionId: number, + timeWindowId: number, + tw: TimeWindowDto, + ): Promise { + const updated = this.wasm.updateTimeWindow( + BigInt(collectionId), + BigInt(timeWindowId), + this.toWasmTimeWindow(tw), + ); + return this.toTimeWindowDto(updated); + } + + async deleteTimeWindow(collectionId: number, timeWindowId: number): Promise { + this.wasm.deleteTimeWindow(BigInt(collectionId), BigInt(timeWindowId)); + } + + // ── Schema ───────────────────────────────────────────────────────────── + + async exportSchema(collectionId: number): Promise> { + const json = this.wasm.exportSchema(BigInt(collectionId)); + return JSON.parse(json) as Record; + } + + async importSchema(collectionId: number): Promise { + // WASM importSchema requires schema JSON; for the interface contract the + // caller would need to supply it. For now this is a no-op placeholder + // matching the REST behaviour where the server resolves the schema. + throw new Error('importSchema is not supported on the WASM backend'); + } + + // ── Collection Metadata ──────────────────────────────────────────────── + + async updateCollectionMetadata(collectionId: number, metadata: MetadataDto[]): Promise { + this.wasm.updateCollectionMetadata( + BigInt(collectionId), + metadata.map((m) => this.toWasmMetadata(m)), + ); + } + + // ── Type Mapping Helpers ─────────────────────────────────────────────── + + private toCollectionDto(c: WasmCollectionDto): CollectionDto { + return { + id: Number(c.id), + name: c.name, + createdAt: c.createdAt instanceof Date ? c.createdAt.toISOString() : String(c.createdAt), + metadata: c.metadata?.map((m) => this.toMetadataDto(m)) ?? null, + }; + } + + private toFlagDto(f: WasmFlagDto): FlagDto { + return { + key: f.key, + type: f.type, + state: f.state, + booleanValue: f.booleanValue ?? null, + stringValue: f.stringValue ?? null, + numberValue: f.numberValue ?? null, + objectValue: f.objectValue ?? null, + metadata: f.metadata?.map((m) => this.toMetadataDto(m)) ?? null, + perEnvironmentDefinitions: f.perEnvironmentDefinitions + ? this.mapPerEnvDefs(f.perEnvironmentDefinitions as Record) + : undefined, + globalTimeWindow: f.globalTimeWindow + ? this.toGlobalTimeWindowDto(f.globalTimeWindow as WasmGlobalTimeWindowDto) + : null, + previousKey: f.previousKey ?? null, + }; + } + + private toWasmFlag(flag: FlagDto): WasmFlagDto { + return { + key: flag.key, + type: flag.type, + state: flag.state, + booleanValue: flag.booleanValue ?? undefined, + stringValue: flag.stringValue ?? undefined, + numberValue: flag.numberValue ?? undefined, + objectValue: flag.objectValue ?? undefined, + metadata: flag.metadata?.map((m) => this.toWasmMetadata(m)), + perEnvironmentDefinitions: flag.perEnvironmentDefinitions + ? this.mapToWasmPerEnvDefs(flag.perEnvironmentDefinitions) + : undefined, + globalTimeWindow: flag.globalTimeWindow + ? this.toWasmGlobalTimeWindow(flag.globalTimeWindow) + : undefined, + previousKey: flag.previousKey ?? undefined, + } as WasmFlagDto; + } + + private toMetadataDto(m: WasmMetadataDto): MetadataDto { + return { + key: m.key, + stringValue: m.stringValue ?? null, + numberValue: m.numberValue ?? null, + booleanValue: m.booleanValue ?? null, + }; + } + + private toWasmMetadata(m: MetadataDto): WasmMetadataDto { + return { + key: m.key, + stringValue: m.stringValue ?? undefined, + numberValue: m.numberValue ?? undefined, + booleanValue: m.booleanValue ?? undefined, + } as WasmMetadataDto; + } + + private toTimeWindowDto(tw: WasmTimeWindowDto): TimeWindowDto { + return { + id: Number(tw.id), + name: tw.name, + startTime: tw.startTime instanceof Date ? tw.startTime.toISOString() : (tw.startTime ?? null), + endTime: tw.endTime instanceof Date ? tw.endTime.toISOString() : (tw.endTime ?? null), + }; + } + + private toWasmTimeWindow(tw: TimeWindowDto): WasmTimeWindowDto { + return { + id: BigInt(tw.id), + name: tw.name, + startTime: tw.startTime ? new Date(tw.startTime) : undefined, + endTime: tw.endTime ? new Date(tw.endTime) : undefined, + } as WasmTimeWindowDto; + } + + private toGlobalTimeWindowDto(g: WasmGlobalTimeWindowDto): GlobalTimeWindowDto { + return { + timeWindowId: Number(g.timeWindowId), + booleanValue: g.booleanValue ?? null, + stringValue: g.stringValue ?? null, + numberValue: g.numberValue ?? null, + objectValue: g.objectValue ?? null, + }; + } + + private toWasmGlobalTimeWindow(g: GlobalTimeWindowDto): WasmGlobalTimeWindowDto { + return { + timeWindowId: BigInt(g.timeWindowId), + booleanValue: g.booleanValue ?? undefined, + stringValue: g.stringValue ?? undefined, + numberValue: g.numberValue ?? undefined, + objectValue: g.objectValue ?? undefined, + } as WasmGlobalTimeWindowDto; + } + + private mapPerEnvDefs( + defs: Record, + ): Record { + const result: Record = {}; + for (const [key, def] of Object.entries(defs)) { + result[key] = { + booleanValue: def.booleanValue ?? null, + stringValue: def.stringValue ?? null, + numberValue: def.numberValue ?? null, + objectValue: def.objectValue ?? null, + timeWindowId: def.timeWindowId != null ? Number(def.timeWindowId) : null, + }; + } + return result; + } + + private mapToWasmPerEnvDefs( + defs: Record, + ): Record { + const result: Record = {}; + for (const [key, def] of Object.entries(defs)) { + result[key] = { + booleanValue: def.booleanValue ?? undefined, + stringValue: def.stringValue ?? undefined, + numberValue: def.numberValue ?? undefined, + objectValue: def.objectValue ?? undefined, + timeWindowId: def.timeWindowId != null ? BigInt(def.timeWindowId) : undefined, + } as WasmPerEnvDto; + } + return result; + } +} diff --git a/ui/src/app/state/flag-store.actions.ts b/ui/src/app/state/flag-store.actions.ts index 387ef3c..95490fc 100644 --- a/ui/src/app/state/flag-store.actions.ts +++ b/ui/src/app/state/flag-store.actions.ts @@ -1,8 +1,10 @@ -import { FlagEntryDto } from '../api-client/model/flagEntryDto'; -import { EnvironmentEntryDto } from '../api-client/model/environmentEntryDto'; -import { TimeWindowDto } from '../api-client/model/timeWindowDto'; -import { MetadataEntryDto } from '../api-client/model/metadataEntryDto'; -import { RenameCollectionIdParameter } from '../api-client/model/renameCollectionIdParameter'; +import { + CollectionDto, + EnvironmentDto, + FlagDto, + MetadataDto, + TimeWindowDto, +} from '../services/flag-backend'; // ============================================================================ // Collection actions @@ -20,19 +22,19 @@ export class CreateCollection { export class RenameCollection { static readonly type = '[FlagStore] Rename Collection'; constructor( - readonly id: RenameCollectionIdParameter, + readonly id: number, readonly name: string, ) {} } export class DeleteCollection { static readonly type = '[FlagStore] Delete Collection'; - constructor(readonly id: RenameCollectionIdParameter) {} + constructor(readonly id: number) {} } export class SelectCollection { static readonly type = '[FlagStore] Select Collection'; - constructor(readonly id: RenameCollectionIdParameter | null) {} + constructor(readonly id: number | null) {} } // ============================================================================ @@ -41,29 +43,29 @@ export class SelectCollection { export class LoadFlags { static readonly type = '[FlagStore] Load Flags'; - constructor(readonly collectionId: RenameCollectionIdParameter) {} + constructor(readonly collectionId: number) {} } export class CreateFlag { static readonly type = '[FlagStore] Create Flag'; constructor( - readonly collectionId: RenameCollectionIdParameter, - readonly flag: FlagEntryDto, + readonly collectionId: number, + readonly flag: FlagDto, ) {} } export class UpdateFlag { static readonly type = '[FlagStore] Update Flag'; constructor( - readonly collectionId: RenameCollectionIdParameter, - readonly flag: FlagEntryDto, + readonly collectionId: number, + readonly flag: FlagDto, ) {} } export class DeleteFlag { static readonly type = '[FlagStore] Delete Flag'; constructor( - readonly collectionId: RenameCollectionIdParameter, + readonly collectionId: number, readonly flagKey: string, ) {} } @@ -74,29 +76,29 @@ export class DeleteFlag { export class LoadEnvironments { static readonly type = '[FlagStore] Load Environments'; - constructor(readonly collectionId: RenameCollectionIdParameter) {} + constructor(readonly collectionId: number) {} } export class CreateEnvironment { static readonly type = '[FlagStore] Create Environment'; constructor( - readonly collectionId: RenameCollectionIdParameter, - readonly environment: EnvironmentEntryDto, + readonly collectionId: number, + readonly environment: EnvironmentDto, ) {} } export class UpdateEnvironment { static readonly type = '[FlagStore] Update Environment'; constructor( - readonly collectionId: RenameCollectionIdParameter, - readonly environment: EnvironmentEntryDto, + readonly collectionId: number, + readonly environment: EnvironmentDto, ) {} } export class DeleteEnvironment { static readonly type = '[FlagStore] Delete Environment'; constructor( - readonly collectionId: RenameCollectionIdParameter, + readonly collectionId: number, readonly name: string, ) {} } @@ -107,13 +109,13 @@ export class DeleteEnvironment { export class LoadTimeWindows { static readonly type = '[FlagStore] Load Time Windows'; - constructor(readonly collectionId: RenameCollectionIdParameter) {} + constructor(readonly collectionId: number) {} } export class CreateTimeWindow { static readonly type = '[FlagStore] Create Time Window'; constructor( - readonly collectionId: RenameCollectionIdParameter, + readonly collectionId: number, readonly timeWindow: TimeWindowDto, ) {} } @@ -121,8 +123,8 @@ export class CreateTimeWindow { export class UpdateTimeWindow { static readonly type = '[FlagStore] Update Time Window'; constructor( - readonly collectionId: RenameCollectionIdParameter, - readonly timeWindowId: RenameCollectionIdParameter, + readonly collectionId: number, + readonly timeWindowId: number, readonly timeWindow: TimeWindowDto, ) {} } @@ -130,8 +132,8 @@ export class UpdateTimeWindow { export class DeleteTimeWindow { static readonly type = '[FlagStore] Delete Time Window'; constructor( - readonly collectionId: RenameCollectionIdParameter, - readonly timeWindowId: RenameCollectionIdParameter, + readonly collectionId: number, + readonly timeWindowId: number, ) {} } @@ -141,12 +143,12 @@ export class DeleteTimeWindow { export class ExportSchema { static readonly type = '[FlagStore] Export Schema'; - constructor(readonly collectionId: RenameCollectionIdParameter) {} + constructor(readonly collectionId: number) {} } export class ImportSchema { static readonly type = '[FlagStore] Import Schema'; - constructor(readonly collectionId: RenameCollectionIdParameter) {} + constructor(readonly collectionId: number) {} } // ============================================================================ @@ -156,7 +158,7 @@ export class ImportSchema { export class SetCollectionMetadata { static readonly type = '[FlagStore] Set Collection Metadata'; constructor( - readonly collectionId: RenameCollectionIdParameter, - readonly metadata: MetadataEntryDto[], + readonly collectionId: number, + readonly metadata: MetadataDto[], ) {} } diff --git a/ui/src/app/state/flag-store.state.ts b/ui/src/app/state/flag-store.state.ts index faa5191..f86b9e7 100644 --- a/ui/src/app/state/flag-store.state.ts +++ b/ui/src/app/state/flag-store.state.ts @@ -1,16 +1,13 @@ import { inject, Injectable } from '@angular/core'; import { Action, createSelector, NgxsOnInit, Selector, State, StateContext } from '@ngxs/store'; -import { firstValueFrom } from 'rxjs'; -import { CollectionsService } from '../api-client/api/collections.service'; -import { FlagsService } from '../api-client/api/flags.service'; -import { EnvironmentsService } from '../api-client/api/environments.service'; -import { TimewindowsService } from '../api-client/api/timewindows.service'; -import { SchemaService } from '../api-client/api/schema.service'; -import { FlagsCollectionDto } from '../api-client/model/flagsCollectionDto'; -import { FlagEntryDto } from '../api-client/model/flagEntryDto'; -import { EnvironmentEntryDto } from '../api-client/model/environmentEntryDto'; -import { TimeWindowDto } from '../api-client/model/timeWindowDto'; -import { RenameCollectionIdParameter } from '../api-client/model/renameCollectionIdParameter'; +import { + CollectionDto, + EnvironmentDto, + FLAG_BACKEND, + FlagBackend, + FlagDto, + TimeWindowDto, +} from '../services/flag-backend'; import { LoadCollections, CreateCollection, @@ -35,15 +32,15 @@ import { } from './flag-store.actions'; export interface FlagStoreStateModel { - collections: FlagsCollectionDto[]; + collections: CollectionDto[]; collectionsLoading: boolean; - selectedCollectionId: RenameCollectionIdParameter | null; + selectedCollectionId: number | null; - flags: FlagEntryDto[]; + flags: FlagDto[]; flagsLoading: boolean; - environments: EnvironmentEntryDto[]; + environments: EnvironmentDto[]; environmentsLoading: boolean; timeWindows: TimeWindowDto[]; @@ -69,13 +66,12 @@ export interface FlagStoreStateModel { }) @Injectable() export class FlagStoreState implements NgxsOnInit { - private readonly collectionsService = inject(CollectionsService); - private readonly flagsService = inject(FlagsService); - private readonly environmentsService = inject(EnvironmentsService); - private readonly timeWindowsService = inject(TimewindowsService); - private readonly schemaService = inject(SchemaService); + private readonly backend: FlagBackend = inject(FLAG_BACKEND); - ngxsOnInit(ctx: StateContext): void { + async ngxsOnInit(ctx: StateContext): Promise { + if (this.backend.init) { + await this.backend.init(); + } ctx.dispatch(new LoadCollections()); } @@ -84,7 +80,7 @@ export class FlagStoreState implements NgxsOnInit { // ============================================================================ @Selector() - static collections(state: FlagStoreStateModel): FlagsCollectionDto[] { + static collections(state: FlagStoreStateModel): CollectionDto[] { return state.collections; } @@ -94,18 +90,18 @@ export class FlagStoreState implements NgxsOnInit { } @Selector() - static selectedCollectionId(state: FlagStoreStateModel): RenameCollectionIdParameter | null { + static selectedCollectionId(state: FlagStoreStateModel): number | null { return state.selectedCollectionId; } @Selector() - static selectedCollection(state: FlagStoreStateModel): FlagsCollectionDto | undefined { + static selectedCollection(state: FlagStoreStateModel): CollectionDto | undefined { if (!state.selectedCollectionId) return undefined; return state.collections.find((c) => c.id === state.selectedCollectionId); } @Selector() - static flags(state: FlagStoreStateModel): FlagEntryDto[] { + static flags(state: FlagStoreStateModel): FlagDto[] { return state.flags; } @@ -115,15 +111,13 @@ export class FlagStoreState implements NgxsOnInit { } static flagByKey(key: string) { - return createSelector( - [FlagStoreState], - (state: FlagStoreStateModel): FlagEntryDto | undefined => - state.flags.find((f) => f.key === key), + return createSelector([FlagStoreState], (state: FlagStoreStateModel): FlagDto | undefined => + state.flags.find((f) => f.key === key), ); } @Selector() - static environments(state: FlagStoreStateModel): EnvironmentEntryDto[] { + static environments(state: FlagStoreStateModel): EnvironmentDto[] { return state.environments; } @@ -155,7 +149,7 @@ export class FlagStoreState implements NgxsOnInit { async loadCollections(ctx: StateContext): Promise { ctx.patchState({ collectionsLoading: true, error: null }); try { - const collections = await firstValueFrom(this.collectionsService.listCollections()); + const collections = await this.backend.listCollections(); ctx.patchState({ collections, collectionsLoading: false }); } catch (e) { ctx.patchState({ @@ -172,9 +166,7 @@ export class FlagStoreState implements NgxsOnInit { ): Promise { ctx.patchState({ error: null }); try { - const collection = await firstValueFrom( - this.collectionsService.createCollection({ name: action.name }), - ); + const collection = await this.backend.createCollection(action.name); const state = ctx.getState(); ctx.patchState({ collections: [...state.collections, collection] }); } catch (e) { @@ -191,9 +183,7 @@ export class FlagStoreState implements NgxsOnInit { ): Promise { ctx.patchState({ error: null }); try { - const updated = await firstValueFrom( - this.collectionsService.renameCollection(action.id, { name: action.name }), - ); + const updated = await this.backend.renameCollection(action.id, action.name); const state = ctx.getState(); ctx.patchState({ collections: state.collections.map((c) => (c.id === action.id ? updated : c)), @@ -212,7 +202,7 @@ export class FlagStoreState implements NgxsOnInit { ): Promise { ctx.patchState({ error: null }); try { - await firstValueFrom(this.collectionsService.deleteCollection(action.id)); + await this.backend.deleteCollection(action.id); const state = ctx.getState(); const collections = state.collections.filter((c) => c.id !== action.id); const patch: Partial = { collections }; @@ -267,7 +257,7 @@ export class FlagStoreState implements NgxsOnInit { async loadFlags(ctx: StateContext, action: LoadFlags): Promise { ctx.patchState({ flagsLoading: true, error: null }); try { - const flags = await firstValueFrom(this.flagsService.getFlags(action.collectionId)); + const flags = await this.backend.getFlags(action.collectionId); ctx.patchState({ flags, flagsLoading: false }); } catch (e) { ctx.patchState({ @@ -281,9 +271,7 @@ export class FlagStoreState implements NgxsOnInit { async createFlag(ctx: StateContext, action: CreateFlag): Promise { ctx.patchState({ error: null }); try { - const created = await firstValueFrom( - this.flagsService.createFlag(action.collectionId, action.flag), - ); + const created = await this.backend.createFlag(action.collectionId, action.flag); const state = ctx.getState(); ctx.patchState({ flags: [...state.flags, created] }); } catch (e) { @@ -297,9 +285,7 @@ export class FlagStoreState implements NgxsOnInit { async updateFlag(ctx: StateContext, action: UpdateFlag): Promise { ctx.patchState({ error: null }); try { - const updated = await firstValueFrom( - this.flagsService.updateFlag(action.collectionId, action.flag), - ); + const updated = await this.backend.updateFlag(action.collectionId, action.flag); const state = ctx.getState(); const oldKey = action.flag.previousKey ?? action.flag.key; ctx.patchState({ @@ -316,7 +302,7 @@ export class FlagStoreState implements NgxsOnInit { async deleteFlag(ctx: StateContext, action: DeleteFlag): Promise { ctx.patchState({ error: null }); try { - await firstValueFrom(this.flagsService.deleteFlag(action.collectionId, action.flagKey)); + await this.backend.deleteFlag(action.collectionId, action.flagKey); const state = ctx.getState(); ctx.patchState({ flags: state.flags.filter((f) => f.key !== action.flagKey), @@ -339,9 +325,7 @@ export class FlagStoreState implements NgxsOnInit { ): Promise { ctx.patchState({ environmentsLoading: true, error: null }); try { - const environments = await firstValueFrom( - this.environmentsService.getEnvironments(action.collectionId), - ); + const environments = await this.backend.getEnvironments(action.collectionId); ctx.patchState({ environments, environmentsLoading: false }); } catch (e) { ctx.patchState({ @@ -358,9 +342,7 @@ export class FlagStoreState implements NgxsOnInit { ): Promise { ctx.patchState({ error: null }); try { - const created = await firstValueFrom( - this.environmentsService.createEnvironment(action.collectionId, action.environment), - ); + const created = await this.backend.createEnvironment(action.collectionId, action.environment); const state = ctx.getState(); ctx.patchState({ environments: [...state.environments, created] }); } catch (e) { @@ -377,9 +359,7 @@ export class FlagStoreState implements NgxsOnInit { ): Promise { ctx.patchState({ error: null }); try { - const updated = await firstValueFrom( - this.environmentsService.updateEnvironment(action.collectionId, action.environment), - ); + const updated = await this.backend.updateEnvironment(action.collectionId, action.environment); const state = ctx.getState(); ctx.patchState({ environments: state.environments.map((e) => @@ -400,9 +380,7 @@ export class FlagStoreState implements NgxsOnInit { ): Promise { ctx.patchState({ error: null }); try { - await firstValueFrom( - this.environmentsService.deleteEnvironment(action.collectionId, action.name), - ); + await this.backend.deleteEnvironment(action.collectionId, action.name); const state = ctx.getState(); ctx.patchState({ environments: state.environments.filter((e) => e.name !== action.name), @@ -425,9 +403,7 @@ export class FlagStoreState implements NgxsOnInit { ): Promise { ctx.patchState({ timeWindowsLoading: true, error: null }); try { - const timeWindows = await firstValueFrom( - this.timeWindowsService.getTimeWindows(action.collectionId), - ); + const timeWindows = await this.backend.getTimeWindows(action.collectionId); ctx.patchState({ timeWindows, timeWindowsLoading: false }); } catch (e) { ctx.patchState({ @@ -444,9 +420,7 @@ export class FlagStoreState implements NgxsOnInit { ): Promise { ctx.patchState({ error: null }); try { - const created = await firstValueFrom( - this.timeWindowsService.createTimeWindow(action.collectionId, action.timeWindow), - ); + const created = await this.backend.createTimeWindow(action.collectionId, action.timeWindow); const state = ctx.getState(); ctx.patchState({ timeWindows: [...state.timeWindows, created] }); } catch (e) { @@ -463,12 +437,10 @@ export class FlagStoreState implements NgxsOnInit { ): Promise { ctx.patchState({ error: null }); try { - const updated = await firstValueFrom( - this.timeWindowsService.updateTimeWindow( - action.collectionId, - action.timeWindowId, - action.timeWindow, - ), + const updated = await this.backend.updateTimeWindow( + action.collectionId, + action.timeWindowId, + action.timeWindow, ); const state = ctx.getState(); ctx.patchState({ @@ -488,9 +460,7 @@ export class FlagStoreState implements NgxsOnInit { ): Promise { ctx.patchState({ error: null }); try { - await firstValueFrom( - this.timeWindowsService.deleteTimeWindow(action.collectionId, action.timeWindowId), - ); + await this.backend.deleteTimeWindow(action.collectionId, action.timeWindowId); const state = ctx.getState(); ctx.patchState({ timeWindows: state.timeWindows.filter((tw) => tw.id !== action.timeWindowId), @@ -513,8 +483,7 @@ export class FlagStoreState implements NgxsOnInit { ): Promise | null> { ctx.patchState({ error: null }); try { - const schema = await firstValueFrom(this.schemaService.exportSchema(action.collectionId)); - return schema as Record; + return await this.backend.exportSchema(action.collectionId); } catch (e) { ctx.patchState({ error: e instanceof Error ? e.message : 'Failed to export schema', @@ -527,7 +496,7 @@ export class FlagStoreState implements NgxsOnInit { async importSchema(ctx: StateContext, action: ImportSchema): Promise { ctx.patchState({ error: null }); try { - await firstValueFrom(this.schemaService.importSchema(action.collectionId)); + await this.backend.importSchema(action.collectionId); // Reload all sub-resources after import ctx.dispatch([ new LoadFlags(action.collectionId), @@ -557,18 +526,22 @@ export class FlagStoreState implements NgxsOnInit { return; } - // Metadata is part of the collection - update by renaming with same name to trigger update - // The collection DTO carries metadata; a rename call preserves it - // For direct metadata updates, the collection itself would need to be patched - const updatedCollection: FlagsCollectionDto = { - ...collection, - metadata: action.metadata, - }; + try { + await this.backend.updateCollectionMetadata(action.collectionId, action.metadata); + const updatedCollection: CollectionDto = { + ...collection, + metadata: action.metadata, + }; - ctx.patchState({ - collections: state.collections.map((c) => - c.id === action.collectionId ? updatedCollection : c, - ), - }); + ctx.patchState({ + collections: state.collections.map((c) => + c.id === action.collectionId ? updatedCollection : c, + ), + }); + } catch (e) { + ctx.patchState({ + error: e instanceof Error ? e.message : 'Failed to update collection metadata', + }); + } } } diff --git a/ui/src/environments.ts b/ui/src/environments.ts index c3bc9ee..f0e2b0b 100644 --- a/ui/src/environments.ts +++ b/ui/src/environments.ts @@ -3,3 +3,4 @@ export const GIT_TAG: string | null = null; export const GIT_COMMIT_HASH: string | null = "f678340df990629c4ebf68b03f8c5e04a2756904"; export const DEFAULT_BACKEND_ROOT: string | null = null; +export const BACKEND_TYPE: 'rest' | 'wasm' = 'rest'; From ca675f0b266762e1816813265f386be04249c4c6 Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Mon, 9 Mar 2026 10:00:50 +0000 Subject: [PATCH 17/38] format --- ui/src/app/services/rest-flag-backend.ts | 20 +++++--------------- ui/src/app/services/wasm-flag-backend.ts | 20 +++++--------------- ui/src/environments.ts | 2 +- 3 files changed, 11 insertions(+), 31 deletions(-) diff --git a/ui/src/app/services/rest-flag-backend.ts b/ui/src/app/services/rest-flag-backend.ts index 123bb4a..cfc0ef6 100644 --- a/ui/src/app/services/rest-flag-backend.ts +++ b/ui/src/app/services/rest-flag-backend.ts @@ -47,9 +47,7 @@ export class RestFlagBackend implements FlagBackend { } async renameCollection(id: number, name: string): Promise { - const c = await firstValueFrom( - this.collections.renameCollection(this.toId(id), { name }), - ); + const c = await firstValueFrom(this.collections.renameCollection(this.toId(id), { name })); return this.toCollectionDto(c); } @@ -85,9 +83,7 @@ export class RestFlagBackend implements FlagBackend { // ── Environments ─────────────────────────────────────────────────────── async getEnvironments(collectionId: number): Promise { - const list = await firstValueFrom( - this.environments.getEnvironments(this.toId(collectionId)), - ); + const list = await firstValueFrom(this.environments.getEnvironments(this.toId(collectionId))); return list.map((e) => ({ name: e.name, aliases: e.aliases ?? [] })); } @@ -167,9 +163,7 @@ export class RestFlagBackend implements FlagBackend { // The REST API doesn't have a direct metadata endpoint; metadata is part of the collection DTO. // A rename call with the same name can be used to trigger a PUT. const collections = await firstValueFrom(this.collections.listCollections()); - const collection = collections.find( - (c) => this.fromId(c.id) === collectionId, - ); + const collection = collections.find((c) => this.fromId(c.id) === collectionId); if (!collection) { throw new Error(`Collection ${collectionId} not found`); } @@ -210,9 +204,7 @@ export class RestFlagBackend implements FlagBackend { perEnvironmentDefinitions: f.perEnvironmentDefinitions ? this.mapPerEnvDefs(f.perEnvironmentDefinitions) : undefined, - globalTimeWindow: f.globalTimeWindow - ? this.toGlobalTimeWindowDto(f.globalTimeWindow) - : null, + globalTimeWindow: f.globalTimeWindow ? this.toGlobalTimeWindowDto(f.globalTimeWindow) : null, previousKey: f.previousKey ?? null, }; } @@ -303,9 +295,7 @@ export class RestFlagBackend implements FlagBackend { stringValue: def.stringValue ?? null, numberValue: (def.numberValue as unknown as number) ?? null, objectValue: def.objectValue ?? null, - timeWindowId: def.timeWindowId - ? (def.timeWindowId as unknown as number) - : null, + timeWindowId: def.timeWindowId ? (def.timeWindowId as unknown as number) : null, }; } return result; diff --git a/ui/src/app/services/wasm-flag-backend.ts b/ui/src/app/services/wasm-flag-backend.ts index 24d6f11..3c283a6 100644 --- a/ui/src/app/services/wasm-flag-backend.ts +++ b/ui/src/app/services/wasm-flag-backend.ts @@ -19,8 +19,7 @@ import type { GlobalTimeWindowDto as WasmGlobalTimeWindowDto, } from './wasm-backend.service'; -type WasmPerEnvDto = - import('bootsharp').OpenFeatureManager.Models.PerEnvironmentDefinitionDto; +type WasmPerEnvDto = import('bootsharp').OpenFeatureManager.Models.PerEnvironmentDefinitionDto; /** * FlagBackend implementation that delegates to the in-browser .NET WASM runtime. @@ -59,15 +58,11 @@ export class WasmFlagBackend implements FlagBackend { } async createFlag(collectionId: number, flag: FlagDto): Promise { - return this.toFlagDto( - this.wasm.upsertFlag(BigInt(collectionId), this.toWasmFlag(flag)), - ); + return this.toFlagDto(this.wasm.upsertFlag(BigInt(collectionId), this.toWasmFlag(flag))); } async updateFlag(collectionId: number, flag: FlagDto): Promise { - return this.toFlagDto( - this.wasm.upsertFlag(BigInt(collectionId), this.toWasmFlag(flag)), - ); + return this.toFlagDto(this.wasm.upsertFlag(BigInt(collectionId), this.toWasmFlag(flag))); } async deleteFlag(collectionId: number, flagKey: string): Promise { @@ -105,16 +100,11 @@ export class WasmFlagBackend implements FlagBackend { // ── Time Windows ─────────────────────────────────────────────────────── async getTimeWindows(collectionId: number): Promise { - return this.wasm - .getTimeWindows(BigInt(collectionId)) - .map((tw) => this.toTimeWindowDto(tw)); + return this.wasm.getTimeWindows(BigInt(collectionId)).map((tw) => this.toTimeWindowDto(tw)); } async createTimeWindow(collectionId: number, tw: TimeWindowDto): Promise { - const created = this.wasm.createTimeWindow( - BigInt(collectionId), - this.toWasmTimeWindow(tw), - ); + const created = this.wasm.createTimeWindow(BigInt(collectionId), this.toWasmTimeWindow(tw)); return this.toTimeWindowDto(created); } diff --git a/ui/src/environments.ts b/ui/src/environments.ts index f0e2b0b..905f792 100644 --- a/ui/src/environments.ts +++ b/ui/src/environments.ts @@ -1,6 +1,6 @@ /* This file is generated by scripts/generate-environments.js. */ export const GIT_TAG: string | null = null; -export const GIT_COMMIT_HASH: string | null = "f678340df990629c4ebf68b03f8c5e04a2756904"; +export const GIT_COMMIT_HASH: string | null = 'f678340df990629c4ebf68b03f8c5e04a2756904'; export const DEFAULT_BACKEND_ROOT: string | null = null; export const BACKEND_TYPE: 'rest' | 'wasm' = 'rest'; From d89a00ce16d4b0a163da86c5749caba9a205c442 Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Wed, 15 Apr 2026 18:21:57 +0000 Subject: [PATCH 18/38] remove unecessary files --- Cargo.toml | 53 ------------------------------------ build.rs | 65 --------------------------------------------- ui/test-output.txt | Bin 20832 -> 0 bytes 3 files changed, 118 deletions(-) delete mode 100644 Cargo.toml delete mode 100644 build.rs delete mode 100644 ui/test-output.txt diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index 22f7ca8..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,53 +0,0 @@ -[package] -name = "flagd-ui" -version = "0.1.0" -edition = "2021" -rust-version = "1.93" - -[profile.release] -lto = true - -[features] -build-script = [] -azurite-local-auth = ["dep:jsonwebtoken"] - -[dependencies] -axum = "0.7" -tokio = { version = "1", features = ["full"] } -tower = "0.4" -tower-http = { version = "0.5", features = [ - "fs", - "trace", - "compression-gzip", - "cors", -] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -jsonschema = "0.26" -utoipa = { version = "5.3", features = ["axum_extras"] } -utoipa-swagger-ui = { version = "8.1", features = ["axum"] } -clap = { version = "4.5", features = ["derive", "env"] } -azure_storage_blob = "0.8" -azure_identity = "0.31" -azure_core = "0.31" -async-trait = "0.1" -futures = "0.3" -jsonwebtoken = { version = "10.3.0", features = [ - "rust_crypto", -], optional = true } - -[build-dependencies] -axum = "0.7" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -jsonschema = "0.26" -utoipa = { version = "5.3", features = ["axum_extras"] } -tracing = "0.1" -clap = { version = "4.5", features = ["derive", "env"] } -azure_storage_blob = "0.8" -azure_identity = "0.31" -azure_core = "0.31" -async-trait = "0.1" -futures = "0.3" diff --git a/build.rs b/build.rs deleted file mode 100644 index 4b5171a..0000000 --- a/build.rs +++ /dev/null @@ -1,65 +0,0 @@ -use std::{fs, path::PathBuf}; -use utoipa::OpenApi; - -#[path = "src/config.rs"] -mod config; -#[path = "src/error.rs"] -mod error; - -// Stub storage module for build script -mod storage { - pub use crate::error::AppResult; - use async_trait::async_trait; - use std::sync::Arc; - - #[async_trait] - pub trait StorageBackend: Send + Sync { - async fn list_flags(&self) -> AppResult>; - async fn read_flag(&self, name: &str) -> AppResult; - async fn write_flag(&self, name: &str, content: &serde_json::Value) -> AppResult<()>; - async fn delete_flag(&self, name: &str) -> AppResult<()>; - async fn flag_exists(&self, name: &str) -> AppResult; - } - - pub struct LocalStorage; - pub struct AzureStorage; - - pub fn create_storage_backend(_uri: &str) -> AppResult> { - panic!("Storage backend should not be used in build script") - } -} - -#[path = "src/handlers/api/flags.rs"] -pub mod flags_impl; - -mod handlers { - pub mod api { - pub use crate::flags_impl as flags; - pub use crate::flags_impl::ListFlagsResponse; - } -} - -#[path = "src/openapi_doc.rs"] -mod openapi_doc; - -use openapi_doc::ApiDoc; - -fn main() { - println!("cargo:rerun-if-changed=src/openapi_doc.rs"); - println!("cargo:rerun-if-changed=src/handlers/api/flags.rs"); - - let manifest_dir = - PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be set")); - let output_path = manifest_dir.join("ui/openapi.json"); - - let openapi_json = - serde_json::to_string_pretty(&ApiDoc::openapi()).expect("Failed to serialize OpenAPI spec"); - - fs::write(&output_path, openapi_json).unwrap_or_else(|err| { - panic!( - "Failed to write OpenAPI spec to {}: {}", - output_path.display(), - err - ) - }); -} diff --git a/ui/test-output.txt b/ui/test-output.txt deleted file mode 100644 index 23bcbac4655af7530c1ca7ae674d6c07dce11f29..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20832 zcmeI4+iqLO6@~}!U4T46ZZ;Gkb{Rb3U~S@=FY(V2J4;cox!VpxoCdAbx! zzR_Fjb0Mtv%P$Se-xV}_;X&94&G41rd86NMI0%Qj|5_0D!Z(6^Q+Msq3j2D(`=4~Z zsVnYZ>#nEwTYBEo2-;yjj4lN2*Z>x+NV8VMW(F!UX^O`ajlnC;U!V zJ(WC(Pr(fRAL!XndV8e%1KoEtHV#_nZ96{cgm*d@8kOM(oZQi|AzbiJuRvDDmSdRn zOJRc1cfzZzWExE|m>1fznW2~4!pE*^f}39Wazfpwg1;j?F+N7s752ai^UBzc;vLZc zCj6owPv2=yI%*x5+WisV*Ig0&0}~s9q=@s;QVZOHmEY>w7y8Y`S^go=_kre#p6TzA zpxssNZunt+LufC*iU1 z!7O>1{LD-}(Ys!F-k+r%m4yo0nukJEVbnH>3JoXK^KBGoy>6%t&`%%PrD{`&PKv_<9BeybRElGn^ zE3eT|JKdo%S5s520bDjsC({hGl!e-%p(CA{qe3unhkiiz@;@r2n2%9OH9B84ReU0> zBFkq<70%h5kzGZ2p*bq+mmKqOu+8#2m2QL|2U>{Q-$uSfb|Dea5ZDMD=i@7*jngfL zq<2hXv?Jf6pPB=mj5UJAfZgF*zZrSySoeRAWnah90sFSbhJ4wOzF(K+b65XZ0!xyd zE%*;YU}*}h8SzdUTM2wMxn*KDwb?TE&JqL3uBqA$Z3*s8Wjt1xzc zos={J)*tpVqf9z$en7iGSk`Y&oy)R>!S#}C)JeF0s8-RE$liU)X|prOadk_junLbv z{p0M+i{qVmeBiK z{|f)k>@T-r@tY}y9>I@duHni(;p{j*vA6D-o6n6MHJ#;nG5cU%Glj=!zP4G=c;-8T z%%3&{i}{phVm1boTSWM|`qgl+fvqg(6w(WX2*UttmBPnF};B9!RJ>k)=UANl^U8CD-0Tuvp zb|OGtvj{({HOf`nH~A_-t|c0>M6fR{$}~K7@5O91Jft#|4y}!e)fR`ijD<(?I&F>O zd9>Fo^7b_A*?TJDn$2i0U0HbtF(N@sG{IcRE3^11&;O+O?00c>#-e~0r?vHsp6VNX zJ_b0&f`XuGN1fJGqa`ZMPbLWThnz- z*EL;_Tgj;9buF(=AJp>NZKJ(@tf;m_urY`&lLa!J9kTe5+96xQLLRZhF8Zsi4_2SZ zkGZe++qK0Zi_$IZn`&_^-AD??imvV5n@@eU{Is=&+GD$^^0TJknu2Xbv8Leb6?3R< zSmK=@S2UPxgsu*cK=+jEOLUYtaUN$TQfxWsh4SY*D)lzTfr)0%jg6{O!?NXwqRvGx zr>BzS*^wofB1erFy?vu+dBmS=zB^gDZa+(pye1;P<0H9bJoVHsyAe18#cj2lywDf@ z5qA6eF8CX@wQ&7qt*zlfQ)Hwci2lBh(SGP5%_%u3te>P(>=$8GfO5Qb`W~iYqt!&G zQ0=T}b<*m@Ftv_fKd~Jdv7Z3_EoO9`}(Qv-bk8j zzNE3L*QlK`*#kC8qsvh?S@xXAk@HpSN$c~=TBr1gS=H+NRbtC`f)Mq!^;K4tTavu& zDr0q=?ArM##a~BBO*W#fkF(1N2~JjWxt-xP|7v`)XV9||&w@|S9AM|taJ`i4@L{p( z^jD@nayYSi*v$nuoef_LrTop|kwb}-DDe7WY&^F%GzQCpfQ!$HokUHeygLaq)8);0 zj)7&ZzQ6S!WfNEb1A);ZSH6Rb9A(?DNoGG;9N6a1#cgCnz=wrunbknO;XT3Ds%2K5 z4ue6MAYl|f5`)BHV;A9l<$!#vzIm21ZVUqFrCDW1<+F@E-jogn z)9&w_WhTg^>cys8T9&Qrry zhhgPIXoo7OiqbbNe1>Wrz;{K60Ap#7w+|*^6&#M=rC-KsE$vU+{|zRuQrb_NQD$?z zOb5c#x;Eqm+iz($?0~Pd`KBT9fY;KXKmm-Hvc3VKn9G{^~vsmDr(t( zwy%XyLY`QfoXRHq0I(*1hp^C$(m)!mJ;!_oJU>{1>|54C{!tT0f=B8`)vcrL86j|tq`YG%Shq4;jvX}>71vOFt{;w$<1WGQ>psXU4m zNi@Eo;jv*beOqORzaT_U#Ln4627Y^L^-$MK!Xjh0H@RKKF2jaqWv&0tqLo-!yjfE{ Tuf;yF(XX3$k9i!H(ee8~F&MzW From bf303e85955f8522dc6e9ffd44059b300ad39a90 Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Wed, 15 Apr 2026 18:22:35 +0000 Subject: [PATCH 19/38] fix build errors --- .devcontainer/devcontainer.json | 3 +- README.md | 11 ++ src/OpenFeatureManager.Api/Program.cs | 6 + .../FlagdWasmService.cs | 2 + .../IFlagdWasmService.cs | 2 + ui/scripts/setup-api-client.js | 5 +- ui/src/app/app.config.ts | 3 +- ui/src/app/state/flag-file-store.state.ts | 110 ++++++++---------- ui/src/environments.development.ts | 1 + ui/src/environments.ts | 4 +- 10 files changed, 80 insertions(+), 67 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f32351e..877e98a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -21,7 +21,8 @@ "ms-playwright.playwright", "vitest.explorer", "dbaeumer.vscode-eslint", - "tamasfe.even-better-toml" + "tamasfe.even-better-toml", + "ms-dotnettools.csdevkit" ], "settings": {} } diff --git a/README.md b/README.md index c1c6b49..378e500 100644 --- a/README.md +++ b/README.md @@ -19,3 +19,14 @@ This project provides a user-friendly web interface for the OpenFeature flagd se ## UI ![flagd-ui editing a flag](https://raw.githubusercontent.com/DanielHabenicht/flagd-ui/main/docs/assets/images/ui-editing-flag.png) + +## Development + +``` +cd src +dotnet publish + +cd ui +np install +npm start +``` diff --git a/src/OpenFeatureManager.Api/Program.cs b/src/OpenFeatureManager.Api/Program.cs index 9835d6d..83c229b 100644 --- a/src/OpenFeatureManager.Api/Program.cs +++ b/src/OpenFeatureManager.Api/Program.cs @@ -134,6 +134,12 @@ return TypedResults.Ok("Schema imported successfully."); }).WithName("importSchema").WithTags("schema"); +// Dummy +app.MapPost("/dummy", async (long id, HttpRequest request) => +{ + return TypedResults.Ok(new PerEnvironmentDefinitionDto(BooleanValue: true)); +}).WithName("dummy").WithTags("schema"); + app.Run(); // ─── Local helpers ──────────────────────────────────────────────────── diff --git a/src/OpenFeatureManager.Wasm/FlagdWasmService.cs b/src/OpenFeatureManager.Wasm/FlagdWasmService.cs index 379d777..e17054e 100644 --- a/src/OpenFeatureManager.Wasm/FlagdWasmService.cs +++ b/src/OpenFeatureManager.Wasm/FlagdWasmService.cs @@ -77,4 +77,6 @@ public string ExportSchema(long collectionId) => public void ImportSchema(long collectionId, string schemaJson) => _runtime.RequireSchemaService().ImportSchema(collectionId, schemaJson); + + public PerEnvironmentDefinitionDto Dummy() => new PerEnvironmentDefinitionDto(BooleanValue: true); } diff --git a/src/OpenFeatureManager.Wasm/IFlagdWasmService.cs b/src/OpenFeatureManager.Wasm/IFlagdWasmService.cs index 07e36e9..4f02211 100644 --- a/src/OpenFeatureManager.Wasm/IFlagdWasmService.cs +++ b/src/OpenFeatureManager.Wasm/IFlagdWasmService.cs @@ -41,4 +41,6 @@ public interface IFlagdWasmService string ExportSchema(long collectionId); void ImportSchema(long collectionId, string schemaJson); + + PerEnvironmentDefinitionDto Dummy(); } diff --git a/ui/scripts/setup-api-client.js b/ui/scripts/setup-api-client.js index 0f9c56e..d71b1c6 100644 --- a/ui/scripts/setup-api-client.js +++ b/ui/scripts/setup-api-client.js @@ -41,7 +41,10 @@ const cwd = uiRoot; console.log(`✓ Working directory: ${cwd}`); // Run Docker command with absolute path -const dockerCmd = `docker run --rm -v "${cwd}:/local" openapitools/openapi-generator-cli:v7.19.0 generate -i /local/openapi.json -g typescript-angular -o /local/src/app/api-client`; +// Use --user to avoid file permission issues on Linux/macOS +const userFlag = + process.platform === 'win32' ? '' : ` --user ${process.getuid()}:${process.getgid()}`; +const dockerCmd = `docker run --rm${userFlag} -v "${cwd}:/local" openapitools/openapi-generator-cli:v7.21.0 generate -i /local/openapi.json -g typescript-angular -o /local/src/app/api-client`; const result = spawnSync(dockerCmd, { shell: true, diff --git a/ui/src/app/app.config.ts b/ui/src/app/app.config.ts index e2c1848..4133781 100644 --- a/ui/src/app/app.config.ts +++ b/ui/src/app/app.config.ts @@ -6,7 +6,6 @@ import { provideNativeDateAdapter } from '@angular/material/core'; import { provideStore } from '@ngxs/store'; import { withNgxsStoragePlugin } from '@ngxs/storage-plugin'; import { withNgxsRouterPlugin } from '@ngxs/router-plugin'; -import { provideApi } from './api-client/provide-api'; import { BACKEND_TYPE, DEFAULT_BACKEND_ROOT } from '../environments'; import { routes } from './app.routes'; import { globalLoadingInterceptor } from './interceptors/global-loading.interceptor'; @@ -25,7 +24,7 @@ export const appConfig: ApplicationConfig = { provideRouter(routes), provideHttpClient(withInterceptors([globalLoadingInterceptor])), provideNativeDateAdapter(), - provideApi({ basePath: DEFAULT_BACKEND_ROOT ?? '' }), + // provideApi({ basePath: DEFAULT_BACKEND_ROOT ?? '' }), { provide: FLAG_BACKEND, useExisting: BACKEND_TYPE === 'wasm' ? WasmFlagBackend : RestFlagBackend, diff --git a/ui/src/app/state/flag-file-store.state.ts b/ui/src/app/state/flag-file-store.state.ts index 93eff94..c37d12c 100644 --- a/ui/src/app/state/flag-file-store.state.ts +++ b/ui/src/app/state/flag-file-store.state.ts @@ -1,10 +1,7 @@ import { inject, Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Action, createSelector, NgxsOnInit, Selector, State, StateContext } from '@ngxs/store'; -import { firstValueFrom } from 'rxjs'; import { DEFAULT_BACKEND_ROOT } from '../../environments'; -import { FlagsService } from '../api-client/api/flags.service'; -import { UpdateFlagRequest } from '../api-client/model/updateFlagRequest'; import { FlagFileContent } from '../models/flag.models'; import { FileSystemAccess } from '../services/file-system-access'; import { @@ -358,42 +355,36 @@ export class FlagFileStore implements NgxsOnInit { ctx: StateContext, backend: Backend, ): Promise { - const api = new FlagsService(this.httpClient, backend.uri); - - try { - const listResponse = await firstValueFrom(api.listFlags()); - const remoteFileNames = new Set(listResponse?.files ?? []); - const localFileNames = new Set(backend.files.map((file) => file.name)); - - for (const file of backend.files) { - const parsed = this.parseFlagFileContent(file.content); - if (!parsed) { - continue; - } - - const updatePayload: UpdateFlagRequest = { - $evaluators: parsed.$evaluators ?? {}, - flags: parsed.flags, - metadata: parsed.metadata ?? {}, - }; - - if (remoteFileNames.has(file.name)) { - await firstValueFrom(api.updateFlag(file.name, updatePayload)); - } else { - await firstValueFrom(api.createFlag(file.name, updatePayload)); - } - } - - for (const remoteFileName of remoteFileNames) { - if (!localFileNames.has(remoteFileName)) { - await firstValueFrom(api.deleteFlag(remoteFileName)); - } - } - - this.markBackendFilesSynced(ctx, 'remote', backend.uri); - } catch { - return; - } + // const api = new FlagsService(this.httpClient, backend.uri); + // try { + // const listResponse = await firstValueFrom(api.listFlags()); + // const remoteFileNames = new Set(listResponse?.files ?? []); + // const localFileNames = new Set(backend.files.map((file) => file.name)); + // for (const file of backend.files) { + // const parsed = this.parseFlagFileContent(file.content); + // if (!parsed) { + // continue; + // } + // const updatePayload: UpdateFlagRequest = { + // $evaluators: parsed.$evaluators ?? {}, + // flags: parsed.flags, + // metadata: parsed.metadata ?? {}, + // }; + // if (remoteFileNames.has(file.name)) { + // await firstValueFrom(api.updateFlag(file.name, updatePayload)); + // } else { + // await firstValueFrom(api.createFlag(file.name, updatePayload)); + // } + // } + // for (const remoteFileName of remoteFileNames) { + // if (!localFileNames.has(remoteFileName)) { + // await firstValueFrom(api.deleteFlag(remoteFileName)); + // } + // } + // this.markBackendFilesSynced(ctx, 'remote', backend.uri); + // } catch { + // return; + // } } private async syncDiskBackend(ctx: StateContext): Promise { @@ -423,28 +414,25 @@ export class FlagFileStore implements NgxsOnInit { ctx: StateContext, backend: Backend, ): Promise { - const api = new FlagsService(this.httpClient, backend.uri); - - try { - const listResponse = await firstValueFrom(api.listFlags()); - const files = listResponse?.files ?? []; - - const imported = await Promise.all( - files.map(async (name) => { - const content = await firstValueFrom(api.getFlag(name)); - return { - name, - content: this.toDeterministicContent(JSON.stringify(content, null, 2)), - isDirty: false, - }; - }), - ); - - this.upsertBackendFiles(ctx, 'remote', backend.uri, imported); - this.markBackendHydrated(ctx, 'remote', backend.uri); - } catch { - return; - } + // const api = new FlagsService(this.httpClient, backend.uri); + // try { + // const listResponse = await firstValueFrom(api.listFlags()); + // const files = listResponse?.files ?? []; + // const imported = await Promise.all( + // files.map(async (name) => { + // const content = await firstValueFrom(api.getFlag(name)); + // return { + // name, + // content: this.toDeterministicContent(JSON.stringify(content, null, 2)), + // isDirty: false, + // }; + // }), + // ); + // this.upsertBackendFiles(ctx, 'remote', backend.uri, imported); + // this.markBackendHydrated(ctx, 'remote', backend.uri); + // } catch { + // return; + // } } private async importDiskBackend(ctx: StateContext): Promise { diff --git a/ui/src/environments.development.ts b/ui/src/environments.development.ts index aadbb43..6535571 100644 --- a/ui/src/environments.development.ts +++ b/ui/src/environments.development.ts @@ -3,3 +3,4 @@ export const GIT_TAG: string | null = 'v0.0.0-dev'; export const GIT_COMMIT_HASH: string | null = 'devdevdevdevdevdevdevdevdevdevdev'; export const DEFAULT_BACKEND_ROOT: string | null = ''; +export const BACKEND_TYPE: 'rest' | 'wasm' = 'wasm'; diff --git a/ui/src/environments.ts b/ui/src/environments.ts index 905f792..8694edf 100644 --- a/ui/src/environments.ts +++ b/ui/src/environments.ts @@ -1,6 +1,6 @@ /* This file is generated by scripts/generate-environments.js. */ export const GIT_TAG: string | null = null; -export const GIT_COMMIT_HASH: string | null = 'f678340df990629c4ebf68b03f8c5e04a2756904'; +export const GIT_COMMIT_HASH: string | null = "ca675f0b266762e1816813265f386be04249c4c6"; export const DEFAULT_BACKEND_ROOT: string | null = null; -export const BACKEND_TYPE: 'rest' | 'wasm' = 'rest'; +export const BACKEND_TYPE: 'rest' | 'wasm' = "rest" as 'rest' | 'wasm'; From 4ef1aef49ef2f9e69bf2e9ee165ffdb7ca3d3c97 Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Wed, 15 Apr 2026 19:44:19 +0000 Subject: [PATCH 20/38] working --- ui/proxy.conf.json | 4 +- ui/src/app/app.config.ts | 3 +- .../new-flags-file-form.ts | 78 +++++++------- ui/src/app/services/remote-api.ts | 31 ------ ui/src/app/state/flag-file-store.state.ts | 101 +++++++++--------- ui/src/environments.development.ts | 2 +- ui/src/environments.ts | 2 +- 7 files changed, 97 insertions(+), 124 deletions(-) delete mode 100644 ui/src/app/services/remote-api.ts diff --git a/ui/proxy.conf.json b/ui/proxy.conf.json index 63dd627..4ffd4c5 100644 --- a/ui/proxy.conf.json +++ b/ui/proxy.conf.json @@ -1,6 +1,6 @@ { "/api": { - "target": "http://localhost:3000", + "target": "http://localhost:5000", "secure": false } -} +} \ No newline at end of file diff --git a/ui/src/app/app.config.ts b/ui/src/app/app.config.ts index 4133781..95eef38 100644 --- a/ui/src/app/app.config.ts +++ b/ui/src/app/app.config.ts @@ -17,6 +17,7 @@ import { FlagStoreState } from './state/flag-store.state'; import { CurrentFlagStoreState } from './state/current-flag-store.state'; import { UiPreferencesState } from './state/ui-preferences.state'; import { PlaygroundPreferencesState } from './state/playground-preferences.state'; +import { provideApi } from './api-client'; export const appConfig: ApplicationConfig = { providers: [ @@ -24,7 +25,7 @@ export const appConfig: ApplicationConfig = { provideRouter(routes), provideHttpClient(withInterceptors([globalLoadingInterceptor])), provideNativeDateAdapter(), - // provideApi({ basePath: DEFAULT_BACKEND_ROOT ?? '' }), + provideApi({ basePath: DEFAULT_BACKEND_ROOT ?? '' }), { provide: FLAG_BACKEND, useExisting: BACKEND_TYPE === 'wasm' ? WasmFlagBackend : RestFlagBackend, diff --git a/ui/src/app/components/new-flags-file-form/new-flags-file-form.ts b/ui/src/app/components/new-flags-file-form/new-flags-file-form.ts index 61cc70f..5517d34 100644 --- a/ui/src/app/components/new-flags-file-form/new-flags-file-form.ts +++ b/ui/src/app/components/new-flags-file-form/new-flags-file-form.ts @@ -9,12 +9,12 @@ import { MatInputModule } from '@angular/material/input'; import { MatIconModule } from '@angular/material/icon'; import { MatTabsModule } from '@angular/material/tabs'; import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { RemoteApi } from '../../services/remote-api'; import { FileSystemAccess } from '../../services/file-system-access'; import { AddBackend, AddFile } from '../../state/flag-file-store.actions'; import { LocalBackendUris } from '../../state/flag-file-store.state'; import { FlagdSchemaAbstraction } from '../../models/abstraction'; import { stringifyFlagdSchema } from '../../models/flagd-schema.parser'; +import { RestFlagBackend } from '../../services/rest-flag-backend'; export interface NewFlagsFileFormResult { type: 'empty' | 'url' | 'disk' | 'backend'; @@ -41,7 +41,7 @@ export class NewFlagsFileFormComponent { private readonly store = inject(Store); private readonly http = inject(HttpClient); private readonly router = inject(Router); - private readonly remoteApi = inject(RemoteApi); + private readonly remoteApi = inject(RestFlagBackend); private readonly fileSystemAccess = inject(FileSystemAccess); // Empty flags-file tab @@ -268,23 +268,23 @@ export class NewFlagsFileFormComponent { this.backendError = ''; this.discoveredFiles = []; - this.remoteApi.listFlagsFiles(url).subscribe({ - next: (files) => { - queueMicrotask(() => { - this.discoveredFiles = files; - this.backendLoading = false; - if (files.length === 0) { - this.backendError = 'No flag files found on this backend'; - } - }); - }, - error: () => { - queueMicrotask(() => { - this.backendError = 'Failed to connect to backend. Ensure CORS is enabled.'; - this.backendLoading = false; - }); - }, - }); + // this.remoteApi.listCollections(url).subscribe({ + // next: (files) => { + // queueMicrotask(() => { + // this.discoveredFiles = files; + // this.backendLoading = false; + // if (files.length === 0) { + // this.backendError = 'No flag files found on this backend'; + // } + // }); + // }, + // error: () => { + // queueMicrotask(() => { + // this.backendError = 'Failed to connect to backend. Ensure CORS is enabled.'; + // this.backendLoading = false; + // }); + // }, + // }); } addBackend(): void { @@ -301,26 +301,26 @@ export class NewFlagsFileFormComponent { return; } - let remaining = discoveredFiles.length; - for (const fileName of discoveredFiles) { - this.remoteApi.getFlagsFile(url, fileName).subscribe({ - next: (content) => { - this.store.dispatch( - new AddFile('remote', url, fileName, JSON.stringify(content, null, 2)), - ); - remaining -= 1; - if (remaining === 0) { - this.formSubmitted.emit({ type: 'backend' }); - } - }, - error: () => { - remaining -= 1; - if (remaining === 0) { - this.formSubmitted.emit({ type: 'backend' }); - } - }, - }); - } + // let remaining = discoveredFiles.length; + // for (const fileName of discoveredFiles) { + // this.remoteApi.getFlagsFile(url, fileName).subscribe({ + // next: (content) => { + // this.store.dispatch( + // new AddFile('remote', url, fileName, JSON.stringify(content, null, 2)), + // ); + // remaining -= 1; + // if (remaining === 0) { + // this.formSubmitted.emit({ type: 'backend' }); + // } + // }, + // error: () => { + // remaining -= 1; + // if (remaining === 0) { + // this.formSubmitted.emit({ type: 'backend' }); + // } + // }, + // }); + // } } private async navigateToFlagsFile( diff --git a/ui/src/app/services/remote-api.ts b/ui/src/app/services/remote-api.ts deleted file mode 100644 index 6b23173..0000000 --- a/ui/src/app/services/remote-api.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { inject, Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { map, Observable } from 'rxjs'; -import { FlagFileContent } from '../models/flag.models'; - -@Injectable({ providedIn: 'root' }) -export class RemoteApi { - private readonly http = inject(HttpClient); - - listFlagsFiles(backendUrl: string): Observable { - return this.http - .get<{ files: string[] }>(`${backendUrl}/api/flags`) - .pipe(map((res) => res.files ?? [])); - } - - getFlagsFile(backendUrl: string, name: string): Observable { - return this.http.get(`${backendUrl}/api/flags/${encodeURIComponent(name)}`); - } - - createFlagsFile(backendUrl: string, name: string, content: FlagFileContent): Observable { - return this.http.post(`${backendUrl}/api/flags`, { name, ...content }); - } - - updateFlagsFile(backendUrl: string, name: string, content: FlagFileContent): Observable { - return this.http.put(`${backendUrl}/api/flags/${encodeURIComponent(name)}`, content); - } - - deleteFlagsFile(backendUrl: string, name: string): Observable { - return this.http.delete(`${backendUrl}/api/flags/${encodeURIComponent(name)}`); - } -} diff --git a/ui/src/app/state/flag-file-store.state.ts b/ui/src/app/state/flag-file-store.state.ts index c37d12c..610b1ae 100644 --- a/ui/src/app/state/flag-file-store.state.ts +++ b/ui/src/app/state/flag-file-store.state.ts @@ -13,6 +13,9 @@ import { BackendType, SyncBackends, } from './flag-file-store.actions'; +import { CollectionsService, FlagsService, SchemaService } from '../api-client'; +import { firstValueFrom } from 'rxjs'; +import { FlagEntryDto } from '../services/wasm-backend.service'; /** * Represents a flag file in a backend. @@ -355,36 +358,36 @@ export class FlagFileStore implements NgxsOnInit { ctx: StateContext, backend: Backend, ): Promise { - // const api = new FlagsService(this.httpClient, backend.uri); - // try { - // const listResponse = await firstValueFrom(api.listFlags()); - // const remoteFileNames = new Set(listResponse?.files ?? []); - // const localFileNames = new Set(backend.files.map((file) => file.name)); - // for (const file of backend.files) { - // const parsed = this.parseFlagFileContent(file.content); - // if (!parsed) { - // continue; - // } - // const updatePayload: UpdateFlagRequest = { - // $evaluators: parsed.$evaluators ?? {}, - // flags: parsed.flags, - // metadata: parsed.metadata ?? {}, - // }; - // if (remoteFileNames.has(file.name)) { - // await firstValueFrom(api.updateFlag(file.name, updatePayload)); - // } else { - // await firstValueFrom(api.createFlag(file.name, updatePayload)); - // } - // } - // for (const remoteFileName of remoteFileNames) { - // if (!localFileNames.has(remoteFileName)) { - // await firstValueFrom(api.deleteFlag(remoteFileName)); - // } - // } - // this.markBackendFilesSynced(ctx, 'remote', backend.uri); - // } catch { - // return; - // } + const apiCollections = new CollectionsService(this.httpClient, backend.uri); + const apiFlags = new FlagsService(this.httpClient, backend.uri); + try { + const remoteFiles = await firstValueFrom(apiCollections.listCollections()); + const localFileNames = new Set(backend.files.map((file) => file.name)); + for (const file of backend.files) { + const parsed = this.parseFlagFileContent(file.content); + if (!parsed) { + continue; + } + // const updatePayload: FlagEntryDto = { + // $evaluators: parsed.$evaluators ?? {}, + // flags: parsed.flags, + // metadata: parsed.metadata ?? {}, + // }; + // if (remoteFiles.map((remoteFile) => remoteFile.name).includes(file.name)) { + // await firstValueFrom(apiFlags.updateFlag(file.name, updatePayload)); + // } else { + // await firstValueFrom(apiFlags.createFlag(file.name, updatePayload)); + // } + } + for (const remoteFile of remoteFiles) { + if (!localFileNames.has(remoteFile.name)) { + await firstValueFrom(apiCollections.deleteCollection(remoteFile.name)); + } + } + this.markBackendFilesSynced(ctx, 'remote', backend.uri); + } catch { + return; + } } private async syncDiskBackend(ctx: StateContext): Promise { @@ -414,25 +417,25 @@ export class FlagFileStore implements NgxsOnInit { ctx: StateContext, backend: Backend, ): Promise { - // const api = new FlagsService(this.httpClient, backend.uri); - // try { - // const listResponse = await firstValueFrom(api.listFlags()); - // const files = listResponse?.files ?? []; - // const imported = await Promise.all( - // files.map(async (name) => { - // const content = await firstValueFrom(api.getFlag(name)); - // return { - // name, - // content: this.toDeterministicContent(JSON.stringify(content, null, 2)), - // isDirty: false, - // }; - // }), - // ); - // this.upsertBackendFiles(ctx, 'remote', backend.uri, imported); - // this.markBackendHydrated(ctx, 'remote', backend.uri); - // } catch { - // return; - // } + const apiCollection = new CollectionsService(this.httpClient, backend.uri); + const apiSchema = new SchemaService(this.httpClient, backend.uri); + try { + const collections = await firstValueFrom(apiCollection.listCollections()); + const imported = await Promise.all( + collections.map(async (collection) => { + const content = await firstValueFrom(apiSchema.exportSchema(collection.id)); + return { + name: collection.name, + content: this.toDeterministicContent(JSON.stringify(content, null, 2)), + isDirty: false, + }; + }), + ); + this.upsertBackendFiles(ctx, 'remote', backend.uri, imported); + this.markBackendHydrated(ctx, 'remote', backend.uri); + } catch { + return; + } } private async importDiskBackend(ctx: StateContext): Promise { diff --git a/ui/src/environments.development.ts b/ui/src/environments.development.ts index 6535571..5ca52c0 100644 --- a/ui/src/environments.development.ts +++ b/ui/src/environments.development.ts @@ -3,4 +3,4 @@ export const GIT_TAG: string | null = 'v0.0.0-dev'; export const GIT_COMMIT_HASH: string | null = 'devdevdevdevdevdevdevdevdevdevdev'; export const DEFAULT_BACKEND_ROOT: string | null = ''; -export const BACKEND_TYPE: 'rest' | 'wasm' = 'wasm'; +export const BACKEND_TYPE: 'rest' | 'wasm' = 'rest'; diff --git a/ui/src/environments.ts b/ui/src/environments.ts index 8694edf..112b68f 100644 --- a/ui/src/environments.ts +++ b/ui/src/environments.ts @@ -1,6 +1,6 @@ /* This file is generated by scripts/generate-environments.js. */ export const GIT_TAG: string | null = null; -export const GIT_COMMIT_HASH: string | null = "ca675f0b266762e1816813265f386be04249c4c6"; +export const GIT_COMMIT_HASH: string | null = "bf303e85955f8522dc6e9ffd44059b300ad39a90"; export const DEFAULT_BACKEND_ROOT: string | null = null; export const BACKEND_TYPE: 'rest' | 'wasm' = "rest" as 'rest' | 'wasm'; From 1d29fbc59d4d585c3d5c67b02a3123c1d6b6a3bf Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Wed, 15 Apr 2026 21:27:44 +0000 Subject: [PATCH 21/38] refactoring --- .devcontainer/devcontainer.json | 3 +- ui/src/app/app.config.ts | 12 +- ui/src/app/app.html | 4 +- ui/src/app/app.ts | 58 +- .../environment-manager.html | 4 +- .../environment-manager.ts | 39 +- .../components/flag-editor/flag-editor.html | 42 +- .../app/components/flag-editor/flag-editor.ts | 127 +- .../flags-file-detail/flags-file-detail.ts | 102 +- .../flags-file-edit-page.html | 1 - .../flags-file-edit-page.ts | 35 +- .../flags-file-list/flags-file-list.html | 109 +- .../flags-file-list/flags-file-list.ts | 106 +- .../flags-file-settings-page.ts | 21 +- .../metadata-editor/metadata-editor.html | 8 +- .../metadata-editor/metadata-editor.ts | 54 +- .../new-flags-file-form.ts | 127 +- .../playground-drawer/playground-drawer.ts | 7 +- .../abstraction/flagd-abstraction-models.ts | 38 +- .../abstraction/flagd-schema-abstraction.ts | 720 +++++----- ui/src/app/models/abstraction/index.ts | 2 +- ui/src/app/services/flag-backend.ts | 8 +- ui/src/app/services/rest-flag-backend.ts | 8 +- ui/src/app/services/wasm-flag-backend.ts | 6 +- .../app/state/current-flag-store.actions.ts | 96 +- ui/src/app/state/current-flag-store.state.ts | 602 ++++---- ui/src/app/state/flag-file-store.actions.ts | 104 +- .../app/state/flag-file-store.state.spec.ts | 2 - ui/src/app/state/flag-file-store.state.ts | 1235 ++++++++--------- ui/src/app/state/flag-store.actions.ts | 15 +- ui/src/app/state/flag-store.state.ts | 23 +- ui/src/environments.development.ts | 2 +- ui/src/environments.ts | 2 +- 33 files changed, 1797 insertions(+), 1925 deletions(-) delete mode 100644 ui/src/app/state/flag-file-store.state.spec.ts diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 877e98a..c2da527 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -22,7 +22,8 @@ "vitest.explorer", "dbaeumer.vscode-eslint", "tamasfe.even-better-toml", - "ms-dotnettools.csdevkit" + "ms-dotnettools.csdevkit", + "Angular.ng-template" ], "settings": {} } diff --git a/ui/src/app/app.config.ts b/ui/src/app/app.config.ts index 95eef38..488eb13 100644 --- a/ui/src/app/app.config.ts +++ b/ui/src/app/app.config.ts @@ -12,9 +12,7 @@ import { globalLoadingInterceptor } from './interceptors/global-loading.intercep import { FLAG_BACKEND } from './services/flag-backend'; import { RestFlagBackend } from './services/rest-flag-backend'; import { WasmFlagBackend } from './services/wasm-flag-backend'; -import { FlagFileStore } from './state/flag-file-store.state'; import { FlagStoreState } from './state/flag-store.state'; -import { CurrentFlagStoreState } from './state/current-flag-store.state'; import { UiPreferencesState } from './state/ui-preferences.state'; import { PlaygroundPreferencesState } from './state/playground-preferences.state'; import { provideApi } from './api-client'; @@ -31,15 +29,9 @@ export const appConfig: ApplicationConfig = { useExisting: BACKEND_TYPE === 'wasm' ? WasmFlagBackend : RestFlagBackend, }, provideStore( - [ - FlagFileStore, - FlagStoreState, - CurrentFlagStoreState, - UiPreferencesState, - PlaygroundPreferencesState, - ], + [FlagStoreState, UiPreferencesState, PlaygroundPreferencesState], withNgxsStoragePlugin({ - keys: [UiPreferencesState, PlaygroundPreferencesState, FlagFileStore], + keys: [UiPreferencesState, PlaygroundPreferencesState], }), withNgxsRouterPlugin(), ), diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 6c18065..e9ec669 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -64,11 +64,11 @@

    [routerLink]="flagsFileDetailRoute()" queryParamsHandling="preserve" > - {{ displayedFlagsFileName() }} + {{ displayedCollectionName() }}

    } @else { -

    {{ displayedFlagsFileName() }}

    +

    {{ displayedCollectionName() }}

    } @if (detailBreadcrumb()) { diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index 85daf1d..43882e8 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -23,9 +23,7 @@ import { PlaygroundDrawerComponent } from './components/playground-drawer/playgr import { GlobalLoadingService } from './services/global-loading.service'; import { SetThemeMode, ThemeMode } from './state/ui-preferences.actions'; import { UiPreferencesState } from './state/ui-preferences.state'; -import { CurrentFlagStoreState } from './state/current-flag-store.state'; -import { AddFile } from './state/flag-file-store.actions'; -import { FlagFileStore } from './state/flag-file-store.state'; +import { FlagStoreState } from './state/flag-store.state'; type AppTheme = 'light' | 'dark'; @@ -54,10 +52,8 @@ export class App implements OnDestroy { readonly themeMode = this.ngxsStore.selectSignal(UiPreferencesState.themeMode); // FlagStore selectors - readonly currentFlagsFileName = this.ngxsStore.selectSignal(CurrentFlagStoreState.fileName); - readonly currentFlagsBackendType = this.ngxsStore.selectSignal(CurrentFlagStoreState.backendType); - readonly currentFlagsBackendUri = this.ngxsStore.selectSignal(CurrentFlagStoreState.backendUri); - readonly backendsMap = this.ngxsStore.selectSignal(FlagFileStore.backendsMap); + readonly selectedCollection = this.ngxsStore.selectSignal(FlagStoreState.selectedCollection); + // readonly backendsMap = this.ngxsStore.selectSignal(FlagFileStore.backendsMap); readonly prefersDark = signal(this.systemPrefersDark()); readonly theme = computed(() => { const mode = this.themeMode(); @@ -75,7 +71,7 @@ export class App implements OnDestroy { const path = this.currentUrl().split('?')[0]; return path === '/' || path === ''; }); - readonly displayedFlagsFileName = computed(() => this.currentFlagsFileName() || ''); + readonly displayedCollectionName = computed(() => this.selectedCollection()?.name || ''); readonly showFlagsContextHeader = computed(() => !this.isRootRoute()); readonly showPageHeader = computed(() => !this.isRootRoute()); readonly isOverviewComponentActive = computed( @@ -117,30 +113,32 @@ export class App implements OnDestroy { return null; }); readonly sourceBreadcrumb = computed(() => { - const backendType = this.currentFlagsBackendType(); - const backendUri = this.currentFlagsBackendUri(); - if (!backendType || !backendUri) return null; - - const backend = this.backendsMap()?.[backendType]?.[backendUri]; - if (backendType === 'local') { - return ( - backend?.label ?? (backendUri === 'disk' ? 'Local Files · Disk' : 'Local Files · Browser') - ); - } - - return backend?.label ?? backendUri; + return 'Breadcrumb'; + // const backendType = this.currentFlagsBackendType(); + // const backendUri = this.currentFlagsBackendUri(); + // if (!backendType || !backendUri) return null; + + // const backend = this.backendsMap()?.[backendType]?.[backendUri]; + // if (backendType === 'local') { + // return ( + // backend?.label ?? (backendUri === 'disk' ? 'Local Files · Disk' : 'Local Files · Browser') + // ); + // } + + // return backend?.label ?? backendUri; }); readonly sourceBreadcrumbRoute = computed(() => ['/']); readonly flagsFileDetailRoute = computed(() => { - const backendType = this.currentFlagsBackendType(); - const backendUri = this.currentFlagsBackendUri(); - const fileName = this.currentFlagsFileName(); + return null; + // const backendType = this.currentFlagsBackendType(); + // const backendUri = this.currentFlagsBackendUri(); + // const fileName = this.currentCollection(); - if (backendType && backendUri && fileName) { - return ['/', backendType, backendUri, fileName]; - } + // if (backendType && backendUri && fileName) { + // return ['/', backendType, backendUri, fileName]; + // } - return null; + // return null; }); navOpen = signal(!this.isCompactLayout()); @@ -244,9 +242,9 @@ export class App implements OnDestroy { reader.onload = () => { try { if (!file.name) throw new Error('File must have a name'); - this.ngxsStore.dispatch( - new AddFile('local', 'browser', file.name, reader.result as string), - ); + // this.ngxsStore.dispatch( + // new AddFile('local', 'browser', file.name, reader.result as string), + // ); } catch { console.error(`Failed to parse ${file.name}`); } diff --git a/ui/src/app/components/environment-manager/environment-manager.html b/ui/src/app/components/environment-manager/environment-manager.html index a0347fe..dec394f 100644 --- a/ui/src/app/components/environment-manager/environment-manager.html +++ b/ui/src/app/components/environment-manager/environment-manager.html @@ -28,10 +28,10 @@

    Current Environments

    No environments match the current filter.

    } @else {
    - @for (item of filteredEnvironments(); track item.env.displayName) { + @for (item of filteredEnvironments(); track item.env.name) {
    - {{ item.env.displayName }} + {{ item.env.name }} Aliases: {{ item.env.aliases.join(', ') }}
    diff --git a/ui/src/app/components/environment-manager/environment-manager.ts b/ui/src/app/components/environment-manager/environment-manager.ts index 516ce5b..759e4f8 100644 --- a/ui/src/app/components/environment-manager/environment-manager.ts +++ b/ui/src/app/components/environment-manager/environment-manager.ts @@ -10,11 +10,13 @@ import { MatInputModule } from '@angular/material/input'; import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips'; import { MatDialogRef } from '@angular/material/dialog'; import { Environment } from '../../models/abstraction/flagd-abstraction-models'; -import { CurrentFlagStoreState } from '../../state/current-flag-store.state'; +import { FlagStoreState } from '../../state/flag-store.state'; +import { EnvironmentDto } from '../../services/flag-backend'; import { - CreateOrUpdateEnvironment, + CreateEnvironment, DeleteEnvironment, -} from '../../state/current-flag-store.actions'; + UpdateEnvironment, +} from '../../state/flag-store.actions'; interface EnvironmentForm { name: string; @@ -44,9 +46,10 @@ export class EnvironmentManagerComponent { optional: true, }); - readonly currentEnvironments = this.ngxsStore.selectSignal(CurrentFlagStoreState.environments); + readonly currentEnvironments = this.ngxsStore.selectSignal(FlagStoreState.environments); + readonly currentCollectionId = this.ngxsStore.selectSignal(FlagStoreState.selectedCollectionId); - readonly environments = signal([]); + readonly environments = signal([]); readonly environmentFilter = signal(''); readonly editingIndex = signal(null); readonly aliasesInputValue = signal(''); @@ -69,7 +72,7 @@ export class EnvironmentManagerComponent { } return entries.filter(({ env }) => { - if (env.displayName.toLowerCase().includes(query)) return true; + if (env.name.toLowerCase().includes(query)) return true; return env.aliases.some((alias) => alias.toLowerCase().includes(query)); }); }); @@ -95,17 +98,17 @@ export class EnvironmentManagerComponent { const name = formValue.name.trim(); const aliases = formValue.aliases.map((a) => a.trim()).filter((a) => a.length > 0); - const newEnv: Environment = { - displayName: name.charAt(0).toUpperCase() + name.slice(1), + const newEnv: EnvironmentDto = { + name: name.charAt(0).toUpperCase() + name.slice(1), aliases, }; const editIndex = this.editingIndex(); - let previousDisplayName: string | null = null; + let previousName: string | null = null; if (editIndex !== null) { // Update existing const updated = [...this.environments()]; - previousDisplayName = updated[editIndex]?.displayName ?? null; + previousName = updated[editIndex]?.name ?? null; updated[editIndex] = newEnv; this.environments.set(updated); this.editingIndex.set(null); @@ -115,10 +118,10 @@ export class EnvironmentManagerComponent { } if (this.embedded()) { - if (previousDisplayName && previousDisplayName !== newEnv.displayName) { - this.ngxsStore.dispatch(new DeleteEnvironment(previousDisplayName)); + if (previousName && previousName !== newEnv.name) { + this.ngxsStore.dispatch(new UpdateEnvironment(previousName, newEnv)); } - this.ngxsStore.dispatch(new CreateOrUpdateEnvironment(newEnv)); + this.ngxsStore.dispatch(new CreateEnvironment(newEnv)); } else { this.hasLocalChanges.set(true); } @@ -132,7 +135,7 @@ export class EnvironmentManagerComponent { if (!env) return; this.form.patchValue({ - name: env.displayName, + name: env.name, aliases: [...env.aliases], }); this.aliasesInputValue.set(''); @@ -146,7 +149,7 @@ export class EnvironmentManagerComponent { if (this.embedded()) { if (removed) { - this.ngxsStore.dispatch(new DeleteEnvironment(removed.displayName)); + this.ngxsStore.dispatch(new DeleteEnvironment(removed.name)); } return; } @@ -221,9 +224,9 @@ export class EnvironmentManagerComponent { save(): void { // Dispatch actions for each environment - for (const env of this.environments()) { - this.ngxsStore.dispatch(new CreateOrUpdateEnvironment(env)); - } + // for (const env of this.environments()) { + // this.ngxsStore.dispatch(new CreateOrUpdateEnvironment(env)); + // } this.hasLocalChanges.set(false); this.dialogRef?.close(true); diff --git a/ui/src/app/components/flag-editor/flag-editor.html b/ui/src/app/components/flag-editor/flag-editor.html index c290ca4..5b649dc 100644 --- a/ui/src/app/components/flag-editor/flag-editor.html +++ b/ui/src/app/components/flag-editor/flag-editor.html @@ -203,15 +203,15 @@

    {{ isEditing() ? 'Edit Flag' : 'Create Flag' }}

    No environments match the current filter.

    } @else {
    - @for (env of filteredEnvironments(); track env.displayName) { + @for (env of filteredEnvironments(); track env.name) {
    - {{ env.displayName }} - @if (!hasEnvironmentTimeWindow(env.displayName.toLowerCase())) { + {{ env.name }} + @if (!hasEnvironmentTimeWindow(env.name.toLowerCase())) { @@ -219,14 +219,14 @@

    {{ isEditing() ? 'Edit Flag' : 'Create Flag' }}

    }
    - @if (hasEnvironmentTimeWindow(env.displayName.toLowerCase())) { + @if (hasEnvironmentTimeWindow(env.name.toLowerCase())) {
    Activate after
    @@ -236,13 +236,12 @@

    {{ isEditing() ? 'Edit Flag' : 'Create Flag' }}

    matInput [matDatepicker]="envStartDatePicker" [ngModel]=" - getEnvironmentTimeWindow(env.displayName.toLowerCase()) - .startDate + getEnvironmentTimeWindow(env.name.toLowerCase()).startDate " [ngModelOptions]="{ standalone: true }" (ngModelChange)=" onEnvironmentTimeWindowChange( - env.displayName.toLowerCase(), + env.name.toLowerCase(), 'startDate', $event ) @@ -261,13 +260,12 @@

    {{ isEditing() ? 'Edit Flag' : 'Create Flag' }}

    matInput [matTimepicker]="envStartTimePicker" [ngModel]=" - getEnvironmentTimeWindow(env.displayName.toLowerCase()) - .startTime + getEnvironmentTimeWindow(env.name.toLowerCase()).startTime " [ngModelOptions]="{ standalone: true }" (ngModelChange)=" onEnvironmentTimeWindowChange( - env.displayName.toLowerCase(), + env.name.toLowerCase(), 'startTime', $event ) @@ -289,13 +287,12 @@

    {{ isEditing() ? 'Edit Flag' : 'Create Flag' }}

    matInput [matDatepicker]="envEndDatePicker" [ngModel]=" - getEnvironmentTimeWindow(env.displayName.toLowerCase()) - .endDate + getEnvironmentTimeWindow(env.name.toLowerCase()).endDate " [ngModelOptions]="{ standalone: true }" (ngModelChange)=" onEnvironmentTimeWindowChange( - env.displayName.toLowerCase(), + env.name.toLowerCase(), 'endDate', $event ) @@ -314,13 +311,12 @@

    {{ isEditing() ? 'Edit Flag' : 'Create Flag' }}

    matInput [matTimepicker]="envEndTimePicker" [ngModel]=" - getEnvironmentTimeWindow(env.displayName.toLowerCase()) - .endTime + getEnvironmentTimeWindow(env.name.toLowerCase()).endTime " [ngModelOptions]="{ standalone: true }" (ngModelChange)=" onEnvironmentTimeWindowChange( - env.displayName.toLowerCase(), + env.name.toLowerCase(), 'endTime', $event ) @@ -336,7 +332,7 @@

    {{ isEditing() ? 'Edit Flag' : 'Create Flag' }}

    } - @if (form.get('flagType')!.value === 'boolean') { +
    }
    diff --git a/ui/src/app/components/flag-editor/flag-editor.ts b/ui/src/app/components/flag-editor/flag-editor.ts index 75f9a59..24e0e1c 100644 --- a/ui/src/app/components/flag-editor/flag-editor.ts +++ b/ui/src/app/components/flag-editor/flag-editor.ts @@ -35,14 +35,18 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { FlagState, FlagType, - DisplayFlag, - ValueDefinition, TimeWindowValue, } from '../../models/abstraction/flagd-abstraction-models'; import { MetadataEditorComponent } from '../metadata-editor/metadata-editor'; import { Store } from '@ngxs/store'; -import { CurrentFlagStoreState } from '../../state/current-flag-store.state'; import { Subscription } from 'rxjs'; +import { + FlagDto, + GlobalTimeWindowDto, + MetadataDto, + PerEnvironmentDefinitionDto, +} from '../../services/flag-backend'; +import { FlagStoreState } from '../../state/flag-store.state'; export type EditorMode = 'interactive' | 'json'; @@ -84,20 +88,20 @@ export class FlagEditorComponent implements OnInit, OnChanges, OnDestroy { readonly allowMaximize = input(false); readonly maximizeIcon = input('open_in_full'); readonly maximizeTitle = input('Open editor as page'); - readonly flag = input(null); + readonly flag = input(null); readonly existingKeys = input([]); readonly showMetadata = input(false); readonly autoSave = input(true); - readonly save = output<{ key: string; flag: DisplayFlag; originalKey?: string }>(); + readonly save = output<{ key: string; flag: FlagDto; originalKey?: string }>(); readonly cancelled = output(); readonly maximize = output(); form!: FormGroup; flagValue = signal(null); - perEnvironmentDefinitions = signal>>({}); - globalTimeWindow = signal | undefined>(undefined); - flagType = signal('boolean'); - metadata = signal | undefined>(undefined); + perEnvironmentDefinitions = signal>({}); + globalTimeWindow = signal(undefined); + flagType = signal('boolean'); + metadata = signal(undefined); editorMode = signal('interactive'); // Environment mode state @@ -108,7 +112,7 @@ export class FlagEditorComponent implements OnInit, OnChanges, OnDestroy { // Expose JSON to template for object editing readonly JSON = JSON; - readonly environments = this.ngxsStore.selectSignal(CurrentFlagStoreState.environments); + readonly environments = this.ngxsStore.selectSignal(FlagStoreState.environments); readonly filteredEnvironments = computed(() => { const filterValue = this.environmentFilter().trim().toLowerCase(); const allEnvironments = this.environments(); @@ -116,7 +120,7 @@ export class FlagEditorComponent implements OnInit, OnChanges, OnDestroy { return allEnvironments.filter((environment) => { const aliases = Array.isArray(environment.aliases) ? environment.aliases : []; - const haystack = [environment.displayName, ...aliases] + const haystack = [environment.name, ...aliases] .filter((value): value is string => typeof value === 'string') .map((value) => value.toLowerCase()); return haystack.some((value) => value.includes(filterValue)); @@ -133,15 +137,16 @@ export class FlagEditorComponent implements OnInit, OnChanges, OnDestroy { readonly canCollapseEnvironmentOverrides = computed(() => { const environments = this.environments(); - if (environments.length === 0) return true; + return false; + // if (environments.length === 0) return true; - const perEnvDefs = this.perEnvironmentDefinitions(); - const globalValue = this.flagValue(); + // const perEnvDefs = this.perEnvironmentDefinitions(); + // const globalValue = this.flagValue(); - return environments.every((env) => { - const envDef = perEnvDefs[env.displayName]; - return !envDef || JSON.stringify(envDef.value) === JSON.stringify(globalValue); - }); + // return environments.every((env) => { + // const envDef = perEnvDefs[env.displayName]; + // return !envDef || JSON.stringify(envDef.value) === JSON.stringify(globalValue); + // }); }); showEnvironmentOverrides = signal(false); @@ -189,8 +194,8 @@ export class FlagEditorComponent implements OnInit, OnChanges, OnDestroy { ngOnInit(): void { const f = this.flag(); - const initialType: FlagType = f?.type ?? 'boolean'; - const initialValue = f?.value ?? this.getDefaultValueForType(initialType); + const initialType: string = f?.type ?? 'boolean'; + const initialValue = f?.booleanValue ?? this.getDefaultValueForType(initialType); this.flagType.set(initialType); this.flagValue.set(initialValue); @@ -204,8 +209,8 @@ export class FlagEditorComponent implements OnInit, OnChanges, OnDestroy { Validators.pattern(/^[a-zA-Z0-9._-]+$/), this.duplicateKeyValidator(), ]), - state: new FormControl(f?.state ?? 'ENABLED', { nonNullable: true }), - flagType: new FormControl(initialType, { nonNullable: true }), + state: new FormControl(f?.state ?? 'ENABLED', { nonNullable: true }), + flagType: new FormControl(initialType, { nonNullable: true }), // Value controls for different types booleanValue: new FormControl( initialType === 'boolean' ? ((initialValue as boolean) ?? false) : false, @@ -248,7 +253,7 @@ export class FlagEditorComponent implements OnInit, OnChanges, OnDestroy { this.applyFlagToForm(this.flag()); } - private applyFlagToForm(f: DisplayFlag | null): void { + private applyFlagToForm(f: FlagDto | null): void { if (!f) { this.form.reset({ state: 'ENABLED', flagType: 'boolean' }); this.flagType.set('boolean'); @@ -263,29 +268,29 @@ export class FlagEditorComponent implements OnInit, OnChanges, OnDestroy { const shouldShowEnvironments = this.hasEnvironments(); this.flagType.set(flagType); - this.flagValue.set(f.value); + this.flagValue.set(f.booleanValue); this.perEnvironmentDefinitions.set(f.perEnvironmentDefinitions ?? {}); this.globalTimeWindow.set(f.globalTimeWindow); this.metadata.set(f.metadata); // Update form value controls based on type const valueByType: Record = { - boolean: flagType === 'boolean' ? ((f.value as boolean) ?? false) : false, - string: flagType === 'string' ? String(f.value ?? '') : '', - number: flagType === 'number' ? ((f.value as number) ?? 0) : 0, + boolean: flagType === 'boolean' ? ((f.booleanValue as boolean) ?? false) : false, + string: flagType === 'string' ? String(f.stringValue ?? '') : '', + number: flagType === 'number' ? ((f.numberValue as number) ?? 0) : 0, object: - flagType === 'object' && f.value && typeof f.value === 'object' - ? JSON.stringify(f.value, null, 2) + flagType === 'object' && f.objectValue && typeof f.objectValue === 'object' + ? JSON.stringify(f.objectValue, null, 2) : '{}', }; // Update global time window from globalTimeWindow let globalStartDate: Date | null = null; let globalEndDate: Date | null = null; - if (f.globalTimeWindow?.timeWindow) { - const tw = f.globalTimeWindow.timeWindow; - globalStartDate = tw.startTime ?? null; - globalEndDate = tw.endTime ?? null; + if (f.globalTimeWindow?.timeWindowId) { + const tw = f.globalTimeWindow.timeWindowId; + // globalStartDate = tw.startTime ?? null; + // globalEndDate = tw.endTime ?? null; } this.form.patchValue( @@ -319,18 +324,15 @@ export class FlagEditorComponent implements OnInit, OnChanges, OnDestroy { // Load time windows from perEnvironmentDefinitions const envTimeWindows: Record = {}; for (const [envName, envDef] of Object.entries(this.perEnvironmentDefinitions())) { - if (envDef.timeWindow) { - envTimeWindows[envName.toLowerCase()] = { - startDate: envDef.timeWindow.startTime ?? null, - startTime: envDef.timeWindow.startTime ?? null, - endDate: envDef.timeWindow.endTime ?? null, - endTime: envDef.timeWindow.endTime ?? null, - }; + if (envDef.timeWindowId) { + // envTimeWindows[envName.toLowerCase()] = { + // timeWindowId: envDef.timeWindowId, + // }; } } this.environmentTimeWindows.set(envTimeWindows); - this.globalEnvironmentTimeEnabled.set(!!f.globalTimeWindow?.timeWindow); - this.showGlobalTimeWindow.set(!!f.globalTimeWindow?.timeWindow); + this.globalEnvironmentTimeEnabled.set(!!f.globalTimeWindow?.timeWindowId); + this.showGlobalTimeWindow.set(!!f.globalTimeWindow?.timeWindowId); this.form.markAsPristine(); this.form.markAsUntouched(); @@ -441,7 +443,7 @@ export class FlagEditorComponent implements OnInit, OnChanges, OnDestroy { this.scheduleAutoSave(); } - onMetadataChange(metadata: Record | undefined): void { + onMetadataChange(metadata: MetadataDto[]): void { this.metadata.set(metadata); this.scheduleAutoSave(); } @@ -523,8 +525,10 @@ export class FlagEditorComponent implements OnInit, OnChanges, OnDestroy { const perEnvDefs = { ...this.perEnvironmentDefinitions() }; const existing = perEnvDefs[envDisplayName]; perEnvDefs[envDisplayName] = { - value, - timeWindow: existing?.timeWindow, + booleanValue: value as boolean, + stringValue: value as string, + numberValue: value as number, + timeWindowId: existing?.timeWindowId, }; this.perEnvironmentDefinitions.set(perEnvDefs); this.scheduleAutoSave(); @@ -532,7 +536,7 @@ export class FlagEditorComponent implements OnInit, OnChanges, OnDestroy { getEnvironmentValue(envDisplayName: string): unknown { const perEnvDef = this.perEnvironmentDefinitions()[envDisplayName]; - return perEnvDef?.value ?? this.flagValue(); + return perEnvDef?.booleanValue ?? this.flagValue(); } onEnvironmentFilterInput(event: Event): void { @@ -624,7 +628,7 @@ export class FlagEditorComponent implements OnInit, OnChanges, OnDestroy { } // Build environment definitions - const perEnvDefs: Record> = {}; + const perEnvDefs: Record = {}; for (const [envName, envDef] of Object.entries(this.perEnvironmentDefinitions())) { const timeWindowState = this.environmentTimeWindows()[envName.toLowerCase()]; let timeWindow: Record | undefined; @@ -648,23 +652,26 @@ export class FlagEditorComponent implements OnInit, OnChanges, OnDestroy { } perEnvDefs[envName] = { - value: envDef.value, + booleanValue: envDef.booleanValue, + stringValue: envDef.stringValue, + numberValue: envDef.numberValue, + objectValue: envDef.objectValue, ...(timeWindow && { - timeWindow: timeWindow as unknown as ValueDefinition['timeWindow'], + timeWindow: timeWindow as unknown as PerEnvironmentDefinitionDto, }), }; } - const displayFlag: DisplayFlag = { + const displayFlag: FlagDto = { key, type: flagType, state: this.form.get('state')!.value as FlagState, - value: currentValue, + // value: currentValue, ...(Object.keys(perEnvDefs).length > 0 && { perEnvironmentDefinitions: perEnvDefs }), ...(globalTimeWindow && { globalTimeWindow }), ...(this.metadata() && Object.keys(this.metadata()!).length > 0 && { metadata: this.metadata() }), - } as DisplayFlag; + } as any; this.save.emit({ key, @@ -711,12 +718,12 @@ export class FlagEditorComponent implements OnInit, OnChanges, OnDestroy { } // For now, serialize as minimal JSON representation - const displayFlag = { + const displayFlag: FlagDto = { key: this.form.get('key')!.value, type: flagType, state: this.form.get('state')!.value, - value, - } as DisplayFlag; + booleanValue: value as boolean, + }; if ( this.perEnvironmentDefinitions() && @@ -807,18 +814,19 @@ export class FlagEditorComponent implements OnInit, OnChanges, OnDestroy { break; } - const displayFlag: DisplayFlag = { + const displayFlag: FlagDto = { key, type: flagType, state: this.form.get('state')!.value as FlagState, - value: currentValue, + metadata: [] as MetadataDto[], + // value: currentValue, ...(Object.keys(this.perEnvironmentDefinitions()).length > 0 && { perEnvironmentDefinitions: this.perEnvironmentDefinitions(), }), ...(this.globalTimeWindow() && { globalTimeWindow: this.globalTimeWindow() }), ...(this.metadata() && Object.keys(this.metadata()!).length > 0 && { metadata: this.metadata() }), - } as DisplayFlag; + } as any; this.save.emit({ key, @@ -929,7 +937,7 @@ export class FlagEditorComponent implements OnInit, OnChanges, OnDestroy { } } - private getDefaultValueForType(flagType: FlagType): unknown { + private getDefaultValueForType(flagType: string): unknown { switch (flagType) { case 'boolean': return false; @@ -940,6 +948,7 @@ export class FlagEditorComponent implements OnInit, OnChanges, OnDestroy { case 'object': return {}; } + return null; } private parseTimestampDate(value: unknown): Date | null { diff --git a/ui/src/app/components/flags-file-detail/flags-file-detail.ts b/ui/src/app/components/flags-file-detail/flags-file-detail.ts index 26f9027..6b2694d 100644 --- a/ui/src/app/components/flags-file-detail/flags-file-detail.ts +++ b/ui/src/app/components/flags-file-detail/flags-file-detail.ts @@ -12,9 +12,9 @@ import { MatSortModule, Sort } from '@angular/material/sort'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatTooltipModule } from '@angular/material/tooltip'; import { FlagEditorComponent } from '../flag-editor/flag-editor'; -import { DisplayFlag } from '../../models/abstraction/flagd-abstraction-models'; -import { CurrentFlagStoreState } from '../../state/current-flag-store.state'; -import { CreateOrUpdateFlag, DeleteFlag } from '../../state/current-flag-store.actions'; +import { FlagStoreState } from '../../state/flag-store.state'; +import { FlagDto } from '../../services/flag-backend'; +import { CreateFlag, DeleteFlag, UpdateFlag } from '../../state/flag-store.actions'; @Component({ selector: 'app-flags-file-detail', @@ -42,20 +42,11 @@ export class FlagsFileDetailComponent implements OnInit { typeof window !== 'undefined' && window.innerWidth >= this.inlineEditorMinWidth; private readonly routeSelectedFlagKey = signal(null); - readonly flagEntries = this.ngxsStore.selectSignal(CurrentFlagStoreState.flags); - readonly currentFlagsFileName = this.ngxsStore.selectSignal(CurrentFlagStoreState.fileName); - readonly currentMetadata = this.ngxsStore.selectSignal(CurrentFlagStoreState.metadata); - readonly currentSchema = this.ngxsStore.selectSignal(CurrentFlagStoreState.schema); - readonly currentEvaluators = computed(() => { - const schema = this.currentSchema(); - if (!schema) return undefined; - const evaluators = (schema as { $evaluators?: Record }).$evaluators; - if (!evaluators || Object.keys(evaluators).length === 0) return undefined; - return evaluators; - }); + readonly flagEntries = this.ngxsStore.selectSignal(FlagStoreState.flags); + readonly currentCollectionId = this.ngxsStore.selectSignal(FlagStoreState.selectedCollectionId); showEditor = signal(false); - editingFlag = signal(null); + editingFlag = signal(null); readonly editingDisplayFlag = computed(() => this.editingFlag()); isWideLayout = signal(this.initialWideLayout); readonly selectedFlagKey = computed(() => this.editingFlag()?.key ?? null); @@ -104,20 +95,21 @@ export class FlagsFileDetailComponent implements OnInit { this.showEditor.set(true); }); - getFlagType(flag: DisplayFlag): string { + getFlagType(flag: FlagDto): string { return flag.type; } - getDefaultValue(flag: DisplayFlag): unknown { - return flag.value ?? null; + getDefaultValue(flag: FlagDto): unknown { + // TODO: Return any of the values + return flag.booleanValue ?? null; } - getDefaultValueDisplay(flag: DisplayFlag): string { + getDefaultValueDisplay(flag: FlagDto): string { return this.stringifyValue(this.getDefaultValue(flag)); } getOverrideValueCounts( - flag: DisplayFlag, + flag: FlagDto, ): { value: string; count: number; environmentNames: string[] }[] { const perEnvDefs = flag.perEnvironmentDefinitions ?? {}; const entries = Object.entries(perEnvDefs); @@ -126,7 +118,7 @@ export class FlagsFileDetailComponent implements OnInit { const counts = new Map(); for (const [envName, envDef] of entries) { - const value = this.stringifyValue(envDef?.value); + const value = this.stringifyValue(envDef?.booleanValue); const existing = counts.get(value); if (existing) { @@ -149,13 +141,13 @@ export class FlagsFileDetailComponent implements OnInit { .sort((left, right) => left.value.localeCompare(right.value)); } - hasTargeting(flag: DisplayFlag): boolean { + hasTargeting(flag: FlagDto): boolean { const perEnvDefs = flag.perEnvironmentDefinitions ?? {}; - return Object.keys(perEnvDefs).length > 0 || !!flag.globalTimeWindow?.timeWindow; + return Object.keys(perEnvDefs).length > 0 || !!flag.globalTimeWindow?.timeWindowId; } - hasGlobalTimeWindow(flag: DisplayFlag): boolean { - return !!flag.globalTimeWindow?.timeWindow; + hasGlobalTimeWindow(flag: FlagDto): boolean { + return !!flag.globalTimeWindow?.timeWindowId; } onSearchInput(event: Event): void { @@ -173,20 +165,20 @@ export class FlagsFileDetailComponent implements OnInit { this.sortDirection.set(sort.direction); } - onFlagStateToggle(flag: DisplayFlag, checked: boolean): void { - const updatedFlag: DisplayFlag = { + onFlagStateToggle(flag: FlagDto, checked: boolean): void { + const updatedFlag: FlagDto = { ...flag, state: checked ? 'ENABLED' : 'DISABLED', }; - this.ngxsStore.dispatch(new CreateOrUpdateFlag(updatedFlag)); + this.ngxsStore.dispatch(new CreateFlag(this.currentCollectionId() as any, updatedFlag)); if (this.editingFlag()?.key === flag.key) { this.editingFlag.set(updatedFlag); } } - confirmDelete(flag: DisplayFlag): void { + confirmDelete(flag: FlagDto): void { if (confirm(`Delete flag "${flag.key}"?`)) { this.onDeleteFlag(flag.key); } @@ -219,7 +211,7 @@ export class FlagsFileDetailComponent implements OnInit { this.showEditor.set(true); } - openEditFlagEditor(flag: DisplayFlag): void { + openEditFlagEditor(flag: FlagDto): void { if (!this.isWideLayout()) { this.navigateToEditRoute(flag.key); return; @@ -238,13 +230,20 @@ export class FlagsFileDetailComponent implements OnInit { this.editingFlag.set(null); } - onSaveFlag(event: { key: string; flag: DisplayFlag; originalKey?: string }): void { - const updatedFlag: DisplayFlag = { + onSaveFlag(event: { key: string; flag: FlagDto; originalKey?: string }): void { + const updatedFlag: FlagDto = { ...event.flag, - key: event.key, + // key: event.key, }; - this.ngxsStore.dispatch(new CreateOrUpdateFlag(updatedFlag, event.originalKey)); + if (event.originalKey) { + this.ngxsStore.dispatch( + new UpdateFlag(this.currentCollectionId() as any, updatedFlag, event.originalKey), + ); + } else { + this.ngxsStore.dispatch(new CreateFlag(this.currentCollectionId() as any, updatedFlag)); + } + this.editingFlag.set(updatedFlag); this.showEditor.set(true); } @@ -253,24 +252,23 @@ export class FlagsFileDetailComponent implements OnInit { if (this.editingFlag()?.key === key) { this.closeEditor(); } - this.ngxsStore.dispatch(new DeleteFlag(key)); + this.ngxsStore.dispatch(new DeleteFlag(this.currentCollectionId() as any, key)); } downloadFlagsFile(): void { - const fileName = this.currentFlagsFileName(); - const schema = this.currentSchema(); - if (!fileName || !schema) return; - - const downloadName = fileName.endsWith('.flagd.json') ? fileName : `${fileName}.flagd.json`; - const blob = new Blob([JSON.stringify(schema, null, 2)], { - type: 'application/json', - }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = downloadName; - a.click(); - URL.revokeObjectURL(url); + // const fileName = this.currentFlagsFileName(); + // const schema = this.currentSchema(); + // if (!fileName || !schema) return; + // const downloadName = fileName.endsWith('.flagd.json') ? fileName : `${fileName}.flagd.json`; + // const blob = new Blob([JSON.stringify(schema, null, 2)], { + // type: 'application/json', + // }); + // const url = URL.createObjectURL(blob); + // const a = document.createElement('a'); + // a.href = url; + // a.download = downloadName; + // a.click(); + // URL.revokeObjectURL(url); } openSettingsPage(): void { @@ -296,7 +294,7 @@ export class FlagsFileDetailComponent implements OnInit { ); } - private getSearchText(flag: DisplayFlag): string { + private getSearchText(flag: FlagDto): string { const perEnvDefs = flag.perEnvironmentDefinitions ?? {}; return [ flag.key, @@ -305,7 +303,7 @@ export class FlagsFileDetailComponent implements OnInit { this.getDefaultValueDisplay(flag), Object.keys(perEnvDefs).join(' '), Object.values(perEnvDefs) - .map((definition) => this.stringifyValue(definition?.value)) + .map((definition) => this.stringifyValue(definition?.booleanValue)) .join(' '), flag.metadata ? JSON.stringify(flag.metadata) : '', this.hasTargeting(flag) ? 'yes' : 'no', @@ -315,7 +313,7 @@ export class FlagsFileDetailComponent implements OnInit { } private getSortValue( - flag: DisplayFlag, + flag: FlagDto, column: 'key' | 'type' | 'state' | 'default' | 'targeting', ): string { switch (column) { diff --git a/ui/src/app/components/flags-file-edit-page/flags-file-edit-page.html b/ui/src/app/components/flags-file-edit-page/flags-file-edit-page.html index 3199748..aaf06a7 100644 --- a/ui/src/app/components/flags-file-edit-page/flags-file-edit-page.html +++ b/ui/src/app/components/flags-file-edit-page/flags-file-edit-page.html @@ -7,7 +7,6 @@ [maximizeIcon]="'close_fullscreen'" [maximizeTitle]="'Return to detail view'" [flag]="editingDisplayFlag()" - [existingKeys]="existingFlagKeys()" [showMetadata]="true" (maximize)="onUnexpand()" (save)="onSaveFlag($event)" diff --git a/ui/src/app/components/flags-file-edit-page/flags-file-edit-page.ts b/ui/src/app/components/flags-file-edit-page/flags-file-edit-page.ts index 7240bf9..d38b294 100644 --- a/ui/src/app/components/flags-file-edit-page/flags-file-edit-page.ts +++ b/ui/src/app/components/flags-file-edit-page/flags-file-edit-page.ts @@ -3,9 +3,9 @@ import { ActivatedRoute } from '@angular/router'; import { Store } from '@ngxs/store'; import { Navigate } from '@ngxs/router-plugin'; import { FlagEditorComponent } from '../flag-editor/flag-editor'; -import { DisplayFlag } from '../../models/abstraction/flagd-abstraction-models'; -import { CurrentFlagStoreState } from '../../state/current-flag-store.state'; -import { CreateOrUpdateFlag } from '../../state/current-flag-store.actions'; +import { FlagStoreState } from '../../state/flag-store.state'; +import { UpdateFlag } from '../../state/flag-store.actions'; +import { FlagDto } from '../../services/flag-backend'; @Component({ selector: 'app-flags-file-edit-page', @@ -20,11 +20,10 @@ export class FlagsFileEditPageComponent implements OnInit { private readonly routeFlagKey = signal(null); - readonly flagEntries = this.ngxsStore.selectSignal(CurrentFlagStoreState.flags); + readonly flagEntries = this.ngxsStore.selectSignal(FlagStoreState.flags); - editingFlag = signal(null); + editingFlag = signal(null); readonly editingDisplayFlag = computed(() => this.editingFlag()); - readonly existingFlagKeys = computed(() => this.flagEntries().map((f) => f.key)); readonly selectedFlagKey = computed(() => { const editingKey = this.editingFlag()?.key; if (editingKey) return editingKey; @@ -33,17 +32,6 @@ export class FlagsFileEditPageComponent implements OnInit { return routeKey && routeKey !== 'new' ? routeKey : null; }); - private readonly syncEditingFlagFromRoute = effect(() => { - const flagKey = this.routeFlagKey(); - if (!flagKey || flagKey === 'new') { - this.editingFlag.set(null); - return; - } - - const match = this.flagEntries().find((entry) => entry.key === flagKey); - this.editingFlag.set(match ?? null); - }); - ngOnInit(): void { this.route.paramMap.subscribe((params) => { const flagKey = params.get('flagKey'); @@ -51,13 +39,12 @@ export class FlagsFileEditPageComponent implements OnInit { }); } - onSaveFlag(event: { key: string; flag: DisplayFlag; originalKey?: string }): void { - const updatedFlag: DisplayFlag = { - ...event.flag, - key: event.key, - }; - - this.ngxsStore.dispatch(new CreateOrUpdateFlag(updatedFlag, event.originalKey)); + onSaveFlag(event: { key: string; flag: FlagDto; originalKey?: string }): void { + const currentCollectionId = this.ngxsStore.selectSnapshot(FlagStoreState.selectedCollectionId); + if (!currentCollectionId) { + throw new Error('No collection selected'); + } + this.ngxsStore.dispatch(new UpdateFlag(currentCollectionId, event.flag)); if (this.routeFlagKey() !== event.key) { this.navigateToEditRoute(event.key); diff --git a/ui/src/app/components/flags-file-list/flags-file-list.html b/ui/src/app/components/flags-file-list/flags-file-list.html index ba0bdbf..a0e781e 100644 --- a/ui/src/app/components/flags-file-list/flags-file-list.html +++ b/ui/src/app/components/flags-file-list/flags-file-list.html @@ -1,66 +1,55 @@
    - @if (hasFiles()) { - @for (group of fileGroups(); track group.backendType + group.backendUri) { -
    - {{ group.icon }} - {{ group.label }} - @if (group.canSync) { - - } - @if (group.canRemove) { - - } -
    - - @for (entry of group.entries; track entry.name + entry.backendUri + entry.backendType) { - - - @if (entry.isDirty) { - sync_problem - } - {{ entry.name }} - - - - } - + +
    + + Collections + + +
    + + @for (entry of collections(); track entry.name) { + + + {{ entry.name }} + + + } - } @else { -
    - No flag files yet -
    - } +
    +
    - @if (!rows.length) { + @if (!metadataCurrent.length) { } - @for (row of rows; track $index) { + @for (row of metadataCurrent; track $index) { - @for (entry of collections(); track entry.name) { + @for (entry of collections(); track entry.id) { ; + /** + * Optional saveState (e.g. persisting the current state). + */ + saveState?(): Promise; + // Collections listCollections(): Promise; createCollection(name: string): Promise; diff --git a/ui/src/app/services/wasm-backend.service.ts b/ui/src/app/services/wasm-backend.service.ts index c40a6d4..f861c62 100644 --- a/ui/src/app/services/wasm-backend.service.ts +++ b/ui/src/app/services/wasm-backend.service.ts @@ -11,6 +11,9 @@ export type EnvironmentEntryDto = import('bootsharp').OpenFeatureManager.Models. export type TimeWindowDto = import('bootsharp').OpenFeatureManager.Models.TimeWindowDto; export type WasmBackendStatus = 'not-initialized' | 'booting' | 'ready' | 'error'; +const IDB_NAME = 'flagd-schema-manager'; +const IDB_STORE = 'database'; +const IDB_KEY = 'sqlite-bytes'; /** * Angular service wrapping the Bootsharp-compiled .NET WASM backend. @@ -57,23 +60,68 @@ export class WasmBackendService { } // ─── Database lifecycle ─────────────────────────────────────────────── - - initDatabase(): string { - return this.db().initDatabase(); - } - - importDatabase(data: Uint8Array): string { - return this.db().importDatabase(data); + openIdb = (): Promise => + new Promise((res, rej) => { + const r = indexedDB.open(IDB_NAME, 1); + r.onupgradeneeded = () => r.result.createObjectStore(IDB_STORE); + r.onsuccess = () => res(r.result); + r.onerror = () => rej(r.error); + }); + async initDatabase(): Promise { + this.db().initDatabase(); + console.log('Initialized in-memory database in WASM backend.'); + try { + await this.loadDatabase(); + console.log('Loaded database state from IndexedDB into WASM backend.'); + } catch (err) { + console.warn( + 'No existing database found in IndexedDB, starting with a fresh database.', + err instanceof Error ? err.message : String(err), + ); + } } - exportDatabase(): Uint8Array { - return this.db().exportDatabase(); + async loadDatabase(): Promise { + const db = await this.openIdb(); + const data = await new Promise((res, rej) => { + const tx = db.transaction(IDB_STORE, 'readonly'); + const req = tx.objectStore(IDB_STORE).get(IDB_KEY); + req.onsuccess = () => { + db.close(); + res(req.result ?? null); + }; + req.onerror = () => { + db.close(); + rej(req.error); + }; + }); + this.db().importDatabase(data); + return; + } + + async saveDatabase(): Promise { + const bytes = this.db().exportDatabase(); + const db = await this.openIdb(); + await new Promise((res, rej) => { + const tx = db.transaction(IDB_STORE, 'readwrite'); + tx.objectStore(IDB_STORE).put(bytes, IDB_KEY); + tx.oncomplete = () => { + db.close(); + res(); + }; + tx.onerror = () => { + db.close(); + rej(tx.error); + }; + }); } // ─── Collections ────────────────────────────────────────────────────── getCollections(): FlagsCollectionDto[] { - return this.flagd().getCollections(); + const collections = this.flagd().getCollections(); + console.log('Fetched collections from WASM backend:', collections); + return collections; } getCollection(id: bigint): FlagsCollectionDto { @@ -81,6 +129,7 @@ export class WasmBackendService { } createCollection(name: string): FlagsCollectionDto { + console.log('Creating collection in WASM backend:', name); return this.flagd().createCollection(name); } diff --git a/ui/src/app/services/wasm-flag-backend.ts b/ui/src/app/services/wasm-flag-backend.ts index ab785d2..4c3a579 100644 --- a/ui/src/app/services/wasm-flag-backend.ts +++ b/ui/src/app/services/wasm-flag-backend.ts @@ -18,6 +18,7 @@ import type { TimeWindowDto as WasmTimeWindowDto, GlobalTimeWindowDto as WasmGlobalTimeWindowDto, } from './wasm-backend.service'; +import { GlobalLoadingService } from './global-loading.service'; type WasmPerEnvDto = import('bootsharp').OpenFeatureManager.Models.PerEnvironmentDefinitionDto; @@ -27,10 +28,17 @@ type WasmPerEnvDto = import('bootsharp').OpenFeatureManager.Models.PerEnvironmen @Injectable({ providedIn: 'root' }) export class WasmFlagBackend implements FlagBackend { private readonly wasm = inject(WasmBackendService); + readonly globalLoading = inject(GlobalLoadingService); async init(): Promise { + this.globalLoading.start(); await this.wasm.boot(); - this.wasm.initDatabase(); + await this.wasm.initDatabase(); + this.globalLoading.stop(); + } + + async saveState(): Promise { + await this.wasm.saveDatabase(); } // ── Collections ──────────────────────────────────────────────────────── diff --git a/ui/src/app/state/flag-store.actions.ts b/ui/src/app/state/flag-store.actions.ts index 481ace9..ee15fe2 100644 --- a/ui/src/app/state/flag-store.actions.ts +++ b/ui/src/app/state/flag-store.actions.ts @@ -146,6 +146,10 @@ export class ImportSchema { constructor(readonly collectionId: number) {} } +export class SaveDatabase { + static readonly type = '[FlagStore] Save Database'; +} + // ============================================================================ // Collection metadata actions // ============================================================================ diff --git a/ui/src/app/state/flag-store.state.ts b/ui/src/app/state/flag-store.state.ts index 667ad6f..450b102 100644 --- a/ui/src/app/state/flag-store.state.ts +++ b/ui/src/app/state/flag-store.state.ts @@ -1,4 +1,4 @@ -import { inject, Injectable } from '@angular/core'; +import { HostListener, inject, Injectable } from '@angular/core'; import { Action, createSelector, NgxsOnInit, Selector, State, StateContext } from '@ngxs/store'; import { CollectionDto, @@ -30,6 +30,7 @@ import { ExportSchema, ImportSchema, SetCollectionMetadata, + SaveDatabase, } from './flag-store.actions'; export interface FlagStoreStateModel { @@ -167,6 +168,17 @@ export class FlagStoreState implements NgxsOnInit { } } + @Action(SaveDatabase) + async saveDatabase(ctx: StateContext, action: SaveDatabase): Promise { + if (this.backend.saveState) { + try { + await this.backend.saveState(); + } catch (e) { + ctx.patchState({ error: e instanceof Error ? e.message : 'Failed to save database' }); + } + } + } + @Action(CreateCollection) async createCollection( ctx: StateContext, diff --git a/ui/src/environments.ts b/ui/src/environments.ts index 3cc8349..f52bb57 100644 --- a/ui/src/environments.ts +++ b/ui/src/environments.ts @@ -1,6 +1,6 @@ /* This file is generated by scripts/generate-environments.js. */ export const GIT_TAG: string | null = null; -export const GIT_COMMIT_HASH: string | null = "4ef1aef49ef2f9e69bf2e9ee165ffdb7ca3d3c97"; +export const GIT_COMMIT_HASH: string | null = "c40cea7f2cc486baceb11e0645c8c5bed919eb28"; export const DEFAULT_BACKEND_ROOT: string | null = null; export const BACKEND_TYPE: 'rest' | 'wasm' = "rest" as 'rest' | 'wasm'; From c4247606acbeaf9a3cfa903c47527d1aafe1433c Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Wed, 15 Apr 2026 22:52:45 +0000 Subject: [PATCH 24/38] better working --- .../package-lock.json | 0 package.json => e2e-tests/package.json | 0 .../playwright.config.ts | 3 +- e2e-tests/{ => tests}/basic.spec.ts | 0 .../boolean-flag-playground.spec.ts | 0 e2e-tests/{ => tests}/docs-screenshot.spec.ts | 0 .../{ => tests}/sidebar-overflow.spec.ts | 0 flagd-ui.code-workspace | 34 +- ui/package.json | 4 +- ui/src/app/app.routes.ts | 8 +- .../app/components/flag-editor/flag-editor.ts | 16 +- .../flags-file-list/flags-file-list.ts | 2 +- ui/src/app/services/wasm-backend.service.ts | 9 +- .../app/state/current-flag-store.actions.ts | 48 -- ui/src/app/state/current-flag-store.state.ts | 301 --------- ui/src/app/state/flag-file-store.actions.ts | 59 -- ui/src/app/state/flag-file-store.state.ts | 616 ------------------ ui/src/app/state/flag-store.actions.ts | 2 +- ui/src/app/state/flag-store.state.ts | 30 + ui/src/environments.ts | 2 +- 20 files changed, 77 insertions(+), 1057 deletions(-) rename package-lock.json => e2e-tests/package-lock.json (100%) rename package.json => e2e-tests/package.json (100%) rename playwright.config.ts => e2e-tests/playwright.config.ts (98%) rename e2e-tests/{ => tests}/basic.spec.ts (100%) rename e2e-tests/{ => tests}/boolean-flag-playground.spec.ts (100%) rename e2e-tests/{ => tests}/docs-screenshot.spec.ts (100%) rename e2e-tests/{ => tests}/sidebar-overflow.spec.ts (100%) delete mode 100644 ui/src/app/state/current-flag-store.actions.ts delete mode 100644 ui/src/app/state/current-flag-store.state.ts delete mode 100644 ui/src/app/state/flag-file-store.actions.ts delete mode 100644 ui/src/app/state/flag-file-store.state.ts diff --git a/package-lock.json b/e2e-tests/package-lock.json similarity index 100% rename from package-lock.json rename to e2e-tests/package-lock.json diff --git a/package.json b/e2e-tests/package.json similarity index 100% rename from package.json rename to e2e-tests/package.json diff --git a/playwright.config.ts b/e2e-tests/playwright.config.ts similarity index 98% rename from playwright.config.ts rename to e2e-tests/playwright.config.ts index 75a0fe1..2902817 100644 --- a/playwright.config.ts +++ b/e2e-tests/playwright.config.ts @@ -12,7 +12,7 @@ import { defineConfig, devices } from '@playwright/test'; * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - testDir: './e2e-tests', + testDir: './tests', /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ @@ -77,5 +77,6 @@ export default defineConfig({ reuseExistingServer: !process.env.CI, timeout: 120_000, gracefulShutdown: { signal: 'SIGTERM', timeout: 1000 }, + cwd: '..', }, }); diff --git a/e2e-tests/basic.spec.ts b/e2e-tests/tests/basic.spec.ts similarity index 100% rename from e2e-tests/basic.spec.ts rename to e2e-tests/tests/basic.spec.ts diff --git a/e2e-tests/boolean-flag-playground.spec.ts b/e2e-tests/tests/boolean-flag-playground.spec.ts similarity index 100% rename from e2e-tests/boolean-flag-playground.spec.ts rename to e2e-tests/tests/boolean-flag-playground.spec.ts diff --git a/e2e-tests/docs-screenshot.spec.ts b/e2e-tests/tests/docs-screenshot.spec.ts similarity index 100% rename from e2e-tests/docs-screenshot.spec.ts rename to e2e-tests/tests/docs-screenshot.spec.ts diff --git a/e2e-tests/sidebar-overflow.spec.ts b/e2e-tests/tests/sidebar-overflow.spec.ts similarity index 100% rename from e2e-tests/sidebar-overflow.spec.ts rename to e2e-tests/tests/sidebar-overflow.spec.ts diff --git a/flagd-ui.code-workspace b/flagd-ui.code-workspace index cd036f9..5b8cc55 100644 --- a/flagd-ui.code-workspace +++ b/flagd-ui.code-workspace @@ -1,24 +1,36 @@ { "folders": [ { - "path": "." - } + "name": "Api", + "path": "src/OpenFeatureManager.Api", + }, + { + "name": "Frontend", + "path": "ui", + }, + { + "name": "E2E Tests", + "path": "e2e-tests", + }, + { + "path": ".", + }, ], "settings": { "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", "[html]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "esbenp.prettier-vscode", }, "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "esbenp.prettier-vscode", }, "[scss]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "esbenp.prettier-vscode", }, "[json]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - } + "editor.defaultFormatter": "esbenp.prettier-vscode", + }, }, "extensions": { "recommendations": [ @@ -30,7 +42,7 @@ "vitest.explorer", "dbaeumer.vscode-eslint", "rust-lang.rust-analyzer", - "tamasfe.even-better-toml" - ] - } -} \ No newline at end of file + "tamasfe.even-better-toml", + ], + }, +} diff --git a/ui/package.json b/ui/package.json index df8386b..f7ee49d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -5,7 +5,7 @@ "ng": "ng", "postinstall": "npm run generate:api-client && npm run generate:schema-types", "prestart": "node scripts/generate-environments.js", - "start": "ng serve", + "start": "ng serve --host 0.0.0.0", "prebuild": "node scripts/generate-environments.js", "build": "ng build", "build-demo": "npm run build -- --configuration docs", @@ -71,4 +71,4 @@ "typescript-eslint": "8.50.1", "vitest": "^4.0.8" } -} \ No newline at end of file +} diff --git a/ui/src/app/app.routes.ts b/ui/src/app/app.routes.ts index 9304f34..bc75338 100644 --- a/ui/src/app/app.routes.ts +++ b/ui/src/app/app.routes.ts @@ -6,8 +6,8 @@ import { FlagsFileSettingsPageComponent } from './components/flags-file-settings export const routes: Routes = [ { path: '', component: WelcomeComponent }, - { path: ':uri/:collection', component: FlagsFileDetailComponent }, - { path: ':uri/:collection/settings', component: FlagsFileSettingsPageComponent }, - { path: ':uri/:collection/edit/new', component: FlagsFileEditPageComponent }, - { path: ':uri/:collection/edit/:flagKey', component: FlagsFileEditPageComponent }, + { path: ':uri/:collectionId', component: FlagsFileDetailComponent }, + { path: ':uri/:collectionId/settings', component: FlagsFileSettingsPageComponent }, + { path: ':uri/:collectionId/edit/new', component: FlagsFileEditPageComponent }, + { path: ':uri/:collectionId/edit/:flagKey', component: FlagsFileEditPageComponent }, ]; diff --git a/ui/src/app/components/flag-editor/flag-editor.ts b/ui/src/app/components/flag-editor/flag-editor.ts index 24e0e1c..c350887 100644 --- a/ui/src/app/components/flag-editor/flag-editor.ts +++ b/ui/src/app/components/flag-editor/flag-editor.ts @@ -45,6 +45,7 @@ import { GlobalTimeWindowDto, MetadataDto, PerEnvironmentDefinitionDto, + TimeWindowDto, } from '../../services/flag-backend'; import { FlagStoreState } from '../../state/flag-store.state'; @@ -602,7 +603,7 @@ export class FlagEditorComponent implements OnInit, OnChanges, OnDestroy { } // Build global time window if enabled - let globalTimeWindow: TimeWindowValue | undefined; + let globalTimeWindow: TimeWindowDto | undefined; if (this.globalEnvironmentTimeEnabled()) { const globalStartDate = this.form.get('globalStartDate')?.value; const globalStartTime = this.form.get('globalStartTime')?.value; @@ -613,16 +614,11 @@ export class FlagEditorComponent implements OnInit, OnChanges, OnDestroy { const endDate = this.combineDateAndTime(globalEndDate, globalEndTime); if (startDate || endDate) { - const timeWindow: Record = {}; - if (startDate) { - timeWindow['startTime'] = startDate; - } - if (endDate) { - timeWindow['endTime'] = endDate; - } globalTimeWindow = { - value: currentValue, - timeWindow: timeWindow as unknown as TimeWindowValue['timeWindow'], + id: 0, // Placeholder, backend should assign real ID + name: `${key}-global-time-window`, + startTime: startDate as any, + endTime: endDate as any, }; } } diff --git a/ui/src/app/components/flags-file-list/flags-file-list.ts b/ui/src/app/components/flags-file-list/flags-file-list.ts index 7d0caa0..d97cf93 100644 --- a/ui/src/app/components/flags-file-list/flags-file-list.ts +++ b/ui/src/app/components/flags-file-list/flags-file-list.ts @@ -39,7 +39,7 @@ export class FlagsFileListComponent { } getFlagsFileRoute(collection: CollectionDto): string[] { - return ['/', 'uri', collection.name]; + return ['/', 'uri', collection.id.toString()]; } deleteCollection(event: Event, collection: CollectionDto): void { diff --git a/ui/src/app/services/wasm-backend.service.ts b/ui/src/app/services/wasm-backend.service.ts index f861c62..ab05ff6 100644 --- a/ui/src/app/services/wasm-backend.service.ts +++ b/ui/src/app/services/wasm-backend.service.ts @@ -1,3 +1,4 @@ +import { C } from '@angular/cdk/keycodes'; import { Injectable, signal } from '@angular/core'; // Re-export generated DTO types from bootsharp for consumers @@ -152,11 +153,15 @@ export class WasmBackendService { // ─── Flags ──────────────────────────────────────────────────────────── getFlags(collectionId: bigint): FlagEntryDto[] { - return this.flagd().getFlags(collectionId); + const flags = this.flagd().getFlags(collectionId); + console.log(`Fetched flags for collection ${collectionId} from WASM backend:`, flags); + return flags; } upsertFlag(collectionId: bigint, dto: FlagEntryDto): FlagEntryDto { - return this.flagd().upsertFlag(collectionId, dto); + const flag = this.flagd().upsertFlag(collectionId, dto); + console.log(`Upserted flag for collection ${collectionId} in WASM backend:`, flag); + return flag; } deleteFlag(collectionId: bigint, flagKey: string): void { diff --git a/ui/src/app/state/current-flag-store.actions.ts b/ui/src/app/state/current-flag-store.actions.ts deleted file mode 100644 index 37a8009..0000000 --- a/ui/src/app/state/current-flag-store.actions.ts +++ /dev/null @@ -1,48 +0,0 @@ -// import { DisplayFlag, Environment } from '../models/abstraction/flagd-abstraction-models'; -// import { BackendType } from './flag-file-store.actions'; - -// /** -// * Actions for managing the current flag file state. -// * Inspired by FlagdSchemaAbstraction API. -// */ - -// export class LoadFlagFile { -// static readonly type = '[CurrentFlagStore] Load Flag File'; -// constructor( -// readonly backendType: BackendType, -// readonly backendUri: string, -// readonly fileName: string, -// ) {} -// } - -// export class ClearFlagFile { -// static readonly type = '[CurrentFlagStore] Clear Flag File'; -// } - -// export class CreateOrUpdateFlag { -// static readonly type = '[CurrentFlagStore] Create Or Update Flag'; -// constructor( -// readonly flag: DisplayFlag, -// readonly previousKey?: string, -// ) {} -// } - -// export class DeleteFlag { -// static readonly type = '[CurrentFlagStore] Delete Flag'; -// constructor(readonly flagKey: string) {} -// } - -// export class CreateOrUpdateEnvironment { -// static readonly type = '[CurrentFlagStore] Create Or Update Environment'; -// constructor(readonly environment: Environment) {} -// } - -// export class DeleteEnvironment { -// static readonly type = '[CurrentFlagStore] Delete Environment'; -// constructor(readonly displayName: string) {} -// } - -// export class SetMetadata { -// static readonly type = '[CurrentFlagStore] Set Metadata'; -// constructor(readonly metadata: Record) {} -// } diff --git a/ui/src/app/state/current-flag-store.state.ts b/ui/src/app/state/current-flag-store.state.ts deleted file mode 100644 index 6ced019..0000000 --- a/ui/src/app/state/current-flag-store.state.ts +++ /dev/null @@ -1,301 +0,0 @@ -// import { Injectable } from '@angular/core'; -// import { Action, Selector, State, StateContext, Store } from '@ngxs/store'; -// import { RouterNavigation } from '@ngxs/router-plugin'; -// import { FlagdSchemaAbstraction } from '../models/abstraction/flagd-schema-abstraction'; -// import { DisplayFlag, Environment } from '../models/abstraction/flagd-abstraction-models'; -// import { FlagdSchema } from '../models/generated/flagd-schema'; -// import { BackendType, UpdateFileContent } from './flag-file-store.actions'; -// import { FlagFile, FlagFileStoreStateModel } from './flag-file-store.state'; -// import { -// LoadFlagFile, -// ClearFlagFile, -// CreateOrUpdateFlag, -// DeleteFlag, -// CreateOrUpdateEnvironment, -// DeleteEnvironment, -// SetMetadata, -// } from './current-flag-store.actions'; - -// export interface CurrentFlagStoreStateModel { -// // Internal abstraction for the current flag file -// abstraction: FlagdSchemaAbstraction | null; - -// // Currently selected file information -// backendType: BackendType | null; -// backendUri: string | null; -// fileName: string | null; -// } - -// @State({ -// name: 'currentFlagStore', -// defaults: { -// abstraction: null, -// backendType: null, -// backendUri: null, -// fileName: null, -// }, -// }) -// @Injectable() -// export class CurrentFlagStoreState { -// // eslint-disable-next-line @angular-eslint/prefer-inject -// constructor(private store: Store) {} - -// private persistTimer: ReturnType | null = null; -// private readonly persistDebounceMs = 1000; - -// @Selector() -// static flags(state: CurrentFlagStoreStateModel): DisplayFlag[] { -// return state.abstraction?.getFlags() || []; -// } - -// @Selector() -// static environments(state: CurrentFlagStoreStateModel): Environment[] { -// return state.abstraction?.getEnvironments() || []; -// } - -// @Selector() -// static metadata( -// state: CurrentFlagStoreStateModel, -// ): Record | undefined { -// return state.abstraction?.getMetadata(); -// } - -// @Selector() -// static flagByKey(key: string) { -// return (state: CurrentFlagStoreStateModel): DisplayFlag | undefined => { -// return state.abstraction?.getFlagByKey(key); -// }; -// } - -// @Selector() -// static schema(state: CurrentFlagStoreStateModel): Record | null { -// return state.abstraction?.exportSchema() || null; -// } - -// @Selector() -// static backendType(state: CurrentFlagStoreStateModel): BackendType | null { -// return state.backendType; -// } - -// @Selector() -// static backendUri(state: CurrentFlagStoreStateModel): string | null { -// return state.backendUri; -// } - -// @Selector() -// static fileName(state: CurrentFlagStoreStateModel): string | null { -// return state.fileName; -// } - -// @Action(RouterNavigation) -// onNavigation( -// ctx: StateContext, -// action: RouterNavigation, -// ): void { -// this.flushPendingPersist(); - -// // eslint-disable-next-line @typescript-eslint/no-explicit-any -// const routerState = action.routerState as any; -// const params = this.collectRouteParams(routerState?.root); -// const backendType = params['backendType'] as BackendType | undefined; -// const backendUri = params['uri'] as string | undefined; -// const fileName = params['fileName'] as string | undefined; - -// const state = ctx.getState(); -// if (!backendType || !backendUri || !fileName) { -// this.persistCurrentFile(ctx); -// if (state.backendType || state.backendUri || state.fileName || state.abstraction) { -// ctx.patchState({ -// abstraction: null, -// backendType: null, -// backendUri: null, -// fileName: null, -// }); -// } -// return; -// } - -// if ( -// state.backendType === backendType && -// state.backendUri === backendUri && -// state.fileName === fileName -// ) { -// return; -// } - -// this.persistCurrentFile(ctx); - -// ctx.dispatch(new LoadFlagFile(backendType, backendUri, fileName)); -// } - -// @Action(LoadFlagFile) -// loadFlagFile(ctx: StateContext, action: LoadFlagFile): void { -// // Retrieve file content from flag-file-store -// const file = this.store.selectSnapshot((state: { flagFileStore: FlagFileStoreStateModel }) => { -// const backend = state.flagFileStore?.backends?.[action.backendType]?.[action.backendUri]; -// return backend?.files?.find((f: FlagFile) => f.name === action.fileName) || null; -// }); - -// if (!file) { -// throw new Error( -// `File "${action.fileName}" not found in backend "${action.backendUri}" of type "${action.backendType}"`, -// ); -// } - -// const abstraction = FlagdSchemaAbstraction.fromSchema(JSON.parse(file.content) as FlagdSchema); -// ctx.patchState({ -// abstraction, -// backendType: action.backendType, -// backendUri: action.backendUri, -// fileName: action.fileName, -// }); -// } - -// @Action(ClearFlagFile) -// clearFlagFile(ctx: StateContext): void { -// this.flushPendingPersist(); -// ctx.patchState({ -// abstraction: null, -// backendType: null, -// backendUri: null, -// fileName: null, -// }); -// } - -// @Action(CreateOrUpdateFlag) -// createOrUpdateFlag( -// ctx: StateContext, -// action: CreateOrUpdateFlag, -// ): void { -// const state = ctx.getState(); -// if (!state.abstraction) { -// throw new Error('No flag file loaded'); -// } - -// state.abstraction.createOrUpdateFlag(action.flag, action.previousKey); -// // Trigger state update by creating a new reference -// ctx.patchState({ abstraction: state.abstraction }); -// this.schedulePersist(); -// } - -// @Action(DeleteFlag) -// deleteFlag(ctx: StateContext, action: DeleteFlag): void { -// const state = ctx.getState(); -// if (!state.abstraction) { -// throw new Error('No flag file loaded'); -// } - -// state.abstraction.deleteFlag(action.flagKey); -// ctx.patchState({ abstraction: state.abstraction }); -// this.schedulePersist(); -// } - -// @Action(CreateOrUpdateEnvironment) -// createOrUpdateEnvironment( -// ctx: StateContext, -// action: CreateOrUpdateEnvironment, -// ): void { -// const state = ctx.getState(); -// if (!state.abstraction) { -// throw new Error('No flag file loaded'); -// } - -// state.abstraction.createOrUpdateEnvironment(action.environment); -// ctx.patchState({ abstraction: state.abstraction }); -// this.schedulePersist(); -// } - -// @Action(DeleteEnvironment) -// deleteEnvironment( -// ctx: StateContext, -// action: DeleteEnvironment, -// ): void { -// const state = ctx.getState(); -// if (!state.abstraction) { -// throw new Error('No flag file loaded'); -// } - -// state.abstraction.deleteEnvironment(action.displayName); -// ctx.patchState({ abstraction: state.abstraction }); -// this.schedulePersist(); -// } - -// @Action(SetMetadata) -// setMetadata(ctx: StateContext, action: SetMetadata): void { -// const state = ctx.getState(); -// if (!state.abstraction) { -// throw new Error('No flag file loaded'); -// } - -// state.abstraction.setMetadata(action.metadata); -// ctx.patchState({ abstraction: state.abstraction }); -// this.schedulePersist(); -// } - -// private collectRouteParams(route: RouteSnapshotLike | null): Record { -// if (!route) return {}; -// const merged = { ...(route.params ?? {}) }; -// const children = route.children ?? []; -// for (const child of children) { -// Object.assign(merged, this.collectRouteParams(child)); -// } -// return merged; -// } - -// private persistCurrentFile(ctx: StateContext): void { -// this.persistCurrentFileFromState(ctx.getState()); -// } - -// private schedulePersist(): void { -// this.clearPersistTimer(); -// this.persistTimer = setTimeout(() => { -// this.persistTimer = null; -// const state = this.store.selectSnapshot( -// (rootState: { currentFlagStore: CurrentFlagStoreStateModel }) => rootState.currentFlagStore, -// ); -// this.persistCurrentFileFromState(state); -// }, this.persistDebounceMs); -// } - -// private flushPendingPersist(): void { -// if (!this.persistTimer) { -// return; -// } - -// this.clearPersistTimer(); -// const state = this.store.selectSnapshot( -// (rootState: { currentFlagStore: CurrentFlagStoreStateModel }) => rootState.currentFlagStore, -// ); -// this.persistCurrentFileFromState(state); -// } - -// private clearPersistTimer(): void { -// if (!this.persistTimer) { -// return; -// } - -// clearTimeout(this.persistTimer); -// this.persistTimer = null; -// } - -// private persistCurrentFileFromState(state: CurrentFlagStoreStateModel): void { -// if (!state.abstraction || !state.backendType || !state.backendUri || !state.fileName) { -// return; -// } - -// const schema = state.abstraction.exportSchema(); -// if (!schema) { -// return; -// } - -// const content = JSON.stringify(schema, null, 2); -// this.store.dispatch( -// new UpdateFileContent(state.backendType, state.backendUri, state.fileName, content), -// ); -// } -// } - -// interface RouteSnapshotLike { -// params?: Record; -// children?: RouteSnapshotLike[]; -// } diff --git a/ui/src/app/state/flag-file-store.actions.ts b/ui/src/app/state/flag-file-store.actions.ts deleted file mode 100644 index 29fc067..0000000 --- a/ui/src/app/state/flag-file-store.actions.ts +++ /dev/null @@ -1,59 +0,0 @@ -// /** -// * Actions for managing flag file backends and files. -// */ - -// export type BackendType = 'local' | 'remote'; - -// export class AddBackend { -// static readonly type = '[FlagFileStore] Add Backend'; -// constructor( -// readonly label: string, -// readonly uri: string, -// ) {} -// } - -// export class RemoveBackend { -// static readonly type = '[FlagFileStore] Remove Backend'; -// constructor( -// readonly backendType: BackendType, -// readonly uri: string, -// ) {} -// } - -// export class AddFile { -// static readonly type = '[FlagFileStore] Add File'; -// constructor( -// readonly backendType: BackendType, -// readonly uri: string, -// readonly fileName: string, -// readonly content: string, -// readonly isDirty = false, -// ) {} -// } - -// export class RemoveFile { -// static readonly type = '[FlagFileStore] Remove File'; -// constructor( -// readonly backendType: BackendType, -// readonly uri: string, -// readonly fileName: string, -// ) {} -// } - -// export class UpdateFileContent { -// static readonly type = '[FlagFileStore] Update File Content'; -// constructor( -// readonly backendType: BackendType, -// readonly uri: string, -// readonly fileName: string, -// readonly content: string, -// ) {} -// } - -// export class SyncBackends { -// static readonly type = '[FlagFileStore] Sync Backends'; -// constructor( -// readonly backendType: BackendType, -// readonly uri: string, -// ) {} -// } diff --git a/ui/src/app/state/flag-file-store.state.ts b/ui/src/app/state/flag-file-store.state.ts deleted file mode 100644 index 7be77ce..0000000 --- a/ui/src/app/state/flag-file-store.state.ts +++ /dev/null @@ -1,616 +0,0 @@ -// import { inject, Injectable } from '@angular/core'; -// import { HttpClient } from '@angular/common/http'; -// import { Action, createSelector, NgxsOnInit, Selector, State, StateContext } from '@ngxs/store'; -// import { DEFAULT_BACKEND_ROOT } from '../../environments'; -// import { FlagFileContent } from '../models/flag.models'; -// import { FileSystemAccess } from '../services/file-system-access'; -// import { -// AddBackend, -// RemoveBackend, -// AddFile, -// RemoveFile, -// UpdateFileContent, -// BackendType, -// SyncBackends, -// } from './flag-file-store.actions'; -// import { CollectionsService, FlagsService, SchemaService } from '../api-client'; -// import { firstValueFrom } from 'rxjs'; -// import { FlagEntryDto } from '../services/wasm-backend.service'; -// import { FLAG_BACKEND } from '../services/flag-backend'; - -// /** -// * Represents a flag file in a backend. -// */ -// export interface FlagFile { -// name: string; -// content: string; -// isDirty?: boolean; -// } - -// /** -// * Represents a backend connection. -// * Local storage (both browser and disk) use hardcoded IDs 'local-browser' and 'local-disk'. -// * Remote backends have URL-based IDs. -// */ -// export interface Backend { -// uri: string; // 'local-browser', 'local-disk', or URL-based id for remote -// label: string; // Display label -// files: FlagFile[]; // Files in this backend -// isHydrated?: boolean; -// } - -// export interface FlagFileStoreStateModel { -// // Double map: storage type ('local' or 'remote') -> id -> backend -// backends: Record<'local' | 'remote', Record>; -// } - -// export const LocalBackendUris = { -// Browser: 'browser', -// Disk: 'disk', -// }; - -// @State({ -// name: 'flagFileStore', -// defaults: { -// backends: { -// local: { -// [LocalBackendUris.Browser]: { -// uri: LocalBackendUris.Browser, -// label: 'Local Files (Browser)', -// files: [], -// isHydrated: true, -// }, -// [LocalBackendUris.Disk]: { -// uri: LocalBackendUris.Disk, -// label: 'Local Files (Disk)', -// files: [], -// isHydrated: true, -// }, -// }, -// remote: {}, -// }, -// }, -// }) -// @Injectable() -// export class FlagFileStore implements NgxsOnInit { -// private readonly fileSystemAccess = inject(FileSystemAccess); -// private readonly flagBackend = inject(FLAG_BACKEND); - -// private isSyncTrackedBackend(backendType: BackendType, uri: string): boolean { -// return backendType === 'remote' || (backendType === 'local' && uri === LocalBackendUris.Disk); -// } - -// ngxsOnInit(ctx: StateContext): void { -// let defaultRoot = DEFAULT_BACKEND_ROOT; - -// // If DEFAULT_BACKEND_ROOT is null, undefined, or empty string, use current URL base -// if (!defaultRoot) { -// defaultRoot = window.location.origin; -// } - -// const normalized = this.normalizeUrl(defaultRoot); -// const state = ctx.getState(); -// const remoteBackends = state?.backends?.remote ?? {}; -// if (remoteBackends[normalized]) { -// ctx.dispatch(new SyncBackends('remote', normalized)); -// return; -// } - -// ctx.patchState({ -// backends: { -// ...state.backends, -// remote: { -// ...remoteBackends, -// [normalized]: { -// uri: normalized, -// label: defaultRoot, -// files: [], -// isHydrated: false, -// }, -// }, -// }, -// }); - -// ctx.dispatch(new SyncBackends('remote', normalized)); -// } - -// @Selector() -// static backends(state: FlagFileStoreStateModel): Backend[] { -// if (!state?.backends) { -// return []; -// } -// const backends: Backend[] = []; -// for (const typeMap of Object.values(state.backends)) { -// backends.push(...Object.values(typeMap)); -// } -// return backends; -// } - -// @Selector() -// static backendsMap( -// state: FlagFileStoreStateModel, -// ): Record<'local' | 'remote', Record> { -// return state?.backends ?? { local: {}, remote: {} }; -// } - -// static backendsByType(backendType: BackendType) { -// return createSelector([FlagFileStore], (state: FlagFileStoreStateModel): Backend[] => { -// return Object.values(state?.backends?.[backendType] || {}); -// }); -// } - -// static backend() { -// return (backendType: BackendType, uri: string) => -// createSelector( -// [FlagFileStore], -// (state: FlagFileStoreStateModel): Backend | undefined => -// state?.backends?.[backendType]?.[uri], -// ); -// } - -// @Action(AddBackend) -// addBackend(ctx: StateContext, action: AddBackend): void { -// const state = ctx.getState(); -// const normalized = this.normalizeUrl(action.uri); - -// // Check if backend already exists -// const existing = Object.values(state.backends.remote).find( -// (backend) => backend.uri === normalized, -// ); -// if (existing) { -// throw new Error('Backend already exists'); -// } - -// const backend: Backend = { -// uri: normalized, -// label: action.label, -// files: [], -// isHydrated: false, -// }; - -// ctx.patchState({ -// backends: { -// ...state.backends, -// remote: { -// ...state.backends.remote, -// [normalized]: backend, -// }, -// }, -// }); -// } - -// @Action(RemoveBackend) -// removeBackend(ctx: StateContext, action: RemoveBackend): void { -// // Cannot remove local backends -// if (action.backendType === 'local') { -// throw new Error('Cannot remove local backends'); -// } - -// const state = ctx.getState(); -// const uriMap = state.backends[action.backendType]; -// if (!uriMap || !uriMap[action.uri]) { -// throw new Error(`Backend "${action.uri}" not found`); -// } - -// const nextTypeMap = { ...uriMap }; -// delete nextTypeMap[action.uri]; - -// ctx.patchState({ -// backends: { -// ...state.backends, -// [action.backendType]: nextTypeMap, -// }, -// }); -// } - -// @Action(AddFile) -// addFile(ctx: StateContext, action: AddFile): void { -// const state = ctx.getState(); -// const uriMap = state.backends[action.backendType]; -// const backend = uriMap?.[action.uri]; - -// if (!backend) { -// throw new Error(`Backend "${action.uri}" of type "${action.backendType}" not found`); -// } - -// // Check if file already exists in this backend -// if (backend.files.some((f) => f.name === action.fileName)) { -// throw new Error(`File "${action.fileName}" already exists in this backend`); -// } - -// const updatedBackend: Backend = { -// ...backend, -// isHydrated: action.backendType === 'remote' ? (backend.isHydrated ?? false) || true : true, -// files: [ -// ...backend.files, -// { -// name: action.fileName, -// content: this.toDeterministicContent(action.content), -// isDirty: -// this.isSyncTrackedBackend(action.backendType, action.uri) && action.isDirty -// ? true -// : false, -// }, -// ], -// }; - -// ctx.patchState({ -// backends: { -// ...state.backends, -// [action.backendType]: { -// ...uriMap, -// [action.uri]: updatedBackend, -// }, -// }, -// }); -// } - -// @Action(RemoveFile) -// removeFile(ctx: StateContext, action: RemoveFile): void { -// const state = ctx.getState(); -// const uriMap = state.backends[action.backendType]; -// const backend = uriMap?.[action.uri]; - -// if (!backend) { -// throw new Error(`Backend "${action.uri}" of type "${action.backendType}" not found`); -// } - -// if (action.backendType === 'local' && action.uri === LocalBackendUris.Disk) { -// this.fileSystemAccess.unbindFlagsFile(action.fileName); -// } - -// const updatedBackend: Backend = { -// ...backend, -// files: backend.files.filter((f) => f.name !== action.fileName), -// }; - -// ctx.patchState({ -// backends: { -// ...state.backends, -// [action.backendType]: { -// ...uriMap, -// [action.uri]: updatedBackend, -// }, -// }, -// }); -// } - -// @Action(UpdateFileContent) -// updateFileContent(ctx: StateContext, action: UpdateFileContent): void { -// const state = ctx.getState(); -// const uriMap = state.backends[action.backendType]; -// const backend = uriMap?.[action.uri]; - -// if (!backend) { -// throw new Error(`Backend "${action.uri}" of type "${action.backendType}" not found`); -// } - -// const fileIndex = backend.files.findIndex((f) => f.name === action.fileName); -// if (fileIndex === -1) { -// throw new Error(`File "${action.fileName}" not found in backend "${action.uri}"`); -// } - -// const normalizedContent = this.toDeterministicContent(action.content); -// const file = backend.files[fileIndex]; -// const hasChanged = file.content !== normalizedContent; -// if (!hasChanged) { -// return; -// } - -// const updatedFiles = [...backend.files]; -// updatedFiles[fileIndex] = { -// ...updatedFiles[fileIndex], -// content: normalizedContent, -// isDirty: -// hasChanged && this.isSyncTrackedBackend(action.backendType, action.uri) -// ? true -// : updatedFiles[fileIndex].isDirty, -// }; - -// const updatedBackend: Backend = { -// ...backend, -// files: updatedFiles, -// }; - -// ctx.patchState({ -// backends: { -// ...state.backends, -// [action.backendType]: { -// ...uriMap, -// [action.uri]: updatedBackend, -// }, -// }, -// }); -// } - -// @Action(SyncBackends) -// async syncBackends( -// ctx: StateContext, -// action: SyncBackends, -// ): Promise { -// const state = ctx.getState(); - -// const backend = state.backends[action.backendType]?.[action.uri]; -// if (!backend) { -// return; -// } - -// if (action.backendType === 'remote') { -// if (!backend.isHydrated) { -// await this.importRemoteBackend(ctx, backend); -// return; -// } - -// await this.syncRemoteBackend(ctx, backend); -// await this.importRemoteBackend(ctx, backend); -// return; -// } - -// if (action.backendType === 'local' && action.uri === LocalBackendUris.Disk) { -// await this.syncDiskBackend(ctx); -// await this.importDiskBackend(ctx); -// } -// } - -// // ============================================================================ -// // PRIVATE HELPERS -// // ============================================================================ -// private async syncRemoteBackend( -// ctx: StateContext, -// backend: Backend, -// ): Promise { -// try { -// const remoteFiles = await this.flagBackend.listCollections(); -// const localFileNames = new Set(backend.files.map((file) => file.name)); -// for (const file of backend.files) { -// const parsed = this.parseFlagFileContent(file.content); -// if (!parsed) { -// continue; -// } -// // const updatePayload: FlagEntryDto = { -// // $evaluators: parsed.$evaluators ?? {}, -// // flags: parsed.flags, -// // metadata: parsed.metadata ?? {}, -// // }; -// // if (remoteFiles.map((remoteFile) => remoteFile.name).includes(file.name)) { -// // await firstValueFrom(apiFlags.updateFlag(file.name, updatePayload)); -// // } else { -// // await firstValueFrom(apiFlags.createFlag(file.name, updatePayload)); -// // } -// } -// for (const remoteFile of remoteFiles) { -// if (!localFileNames.has(remoteFile.name)) { -// await this.flagBackend.deleteCollection(remoteFile.id); -// } -// } -// this.markBackendFilesSynced(ctx, 'remote', backend.uri); -// } catch { -// return; -// } -// } - -// private async syncDiskBackend(ctx: StateContext): Promise { -// const state = ctx.getState(); -// const backend = state.backends.local[LocalBackendUris.Disk]; -// if (!backend) { -// return; -// } - -// try { -// for (const file of backend.files) { -// const parsed = this.parseFlagFileContent(file.content); -// if (!parsed) { -// continue; -// } - -// await this.fileSystemAccess.persistBoundFlagsFile(file.name, parsed); -// } - -// this.markBackendFilesSynced(ctx, 'local', LocalBackendUris.Disk); -// } catch { -// return; -// } -// } - -// private async importRemoteBackend( -// ctx: StateContext, -// backend: Backend, -// ): Promise { -// try { -// const collections = await this.flagBackend.listCollections(); -// const imported = await Promise.all( -// collections.map(async (collection) => { -// const content = await this.flagBackend.exportSchema(collection.id); -// return { -// name: collection.name, -// content: this.toDeterministicContent(JSON.stringify(content, null, 2)), -// isDirty: false, -// }; -// }), -// ); -// this.upsertBackendFiles(ctx, 'remote', backend.uri, imported); -// this.markBackendHydrated(ctx, 'remote', backend.uri); -// } catch { -// return; -// } -// } - -// private async importDiskBackend(ctx: StateContext): Promise { -// const bound = await this.fileSystemAccess.readBoundFlagsFiles(); -// if (!bound.length) { -// return; -// } - -// const imported = bound.map((entry) => ({ -// name: entry.name, -// content: this.toDeterministicContent(JSON.stringify(entry.content, null, 2)), -// isDirty: false, -// })); - -// this.upsertBackendFiles(ctx, 'local', LocalBackendUris.Disk, imported); -// } - -// private upsertBackendFiles( -// ctx: StateContext, -// backendType: BackendType, -// uri: string, -// imported: { name: string; content: string; isDirty?: boolean }[], -// ): void { -// const state = ctx.getState(); -// const uriMap = state.backends[backendType]; -// const backend = uriMap?.[uri]; -// if (!backend) { -// return; -// } - -// const incomingMap = new Map(imported.map((entry) => [entry.name, entry])); -// const updatedFiles = backend.files.map((file) => { -// const incoming = incomingMap.get(file.name); -// if (!incoming) { -// return file; -// } -// incomingMap.delete(file.name); -// return { -// name: file.name, -// content: this.toDeterministicContent(incoming.content), -// isDirty: incoming.isDirty ?? false, -// }; -// }); - -// for (const incoming of incomingMap.values()) { -// updatedFiles.push({ -// name: incoming.name, -// content: this.toDeterministicContent(incoming.content), -// isDirty: incoming.isDirty ?? false, -// }); -// } - -// ctx.patchState({ -// backends: { -// ...state.backends, -// [backendType]: { -// ...uriMap, -// [uri]: { -// ...backend, -// files: updatedFiles, -// }, -// }, -// }, -// }); -// } - -// private markBackendFilesSynced( -// ctx: StateContext, -// backendType: BackendType, -// uri: string, -// ): void { -// const state = ctx.getState(); -// const uriMap = state.backends[backendType]; -// const backend = uriMap?.[uri]; -// if (!backend) { -// return; -// } - -// const updatedBackend: Backend = { -// ...backend, -// files: backend.files.map((file) => ({ -// ...file, -// isDirty: false, -// })), -// }; - -// ctx.patchState({ -// backends: { -// ...state.backends, -// [backendType]: { -// ...uriMap, -// [uri]: updatedBackend, -// }, -// }, -// }); -// } - -// private normalizeUrl(url: string): string { -// // Remove trailing slashes and normalize the URL -// return url.replace(/\/$/, '').toLowerCase(); -// } - -// private markBackendHydrated( -// ctx: StateContext, -// backendType: BackendType, -// uri: string, -// ): void { -// const state = ctx.getState(); -// const uriMap = state.backends[backendType]; -// const backend = uriMap?.[uri]; -// if (!backend) { -// return; -// } - -// if (backend.isHydrated) { -// return; -// } - -// const updatedBackend: Backend = { -// ...backend, -// isHydrated: true, -// }; - -// ctx.patchState({ -// backends: { -// ...state.backends, -// [backendType]: { -// ...uriMap, -// [uri]: updatedBackend, -// }, -// }, -// }); -// } - -// private tryParseJson(content: string): unknown | undefined { -// try { -// return JSON.parse(content) as unknown; -// } catch { -// return undefined; -// } -// } - -// private toDeterministicContent(content: string): string { -// const parsed = this.tryParseJson(content); -// if (parsed === undefined) { -// return content; -// } - -// return JSON.stringify(this.sortJson(parsed)); -// } - -// private sortJson(value: unknown): unknown { -// if (Array.isArray(value)) { -// return value.map((item) => this.sortJson(item)); -// } - -// if (value && typeof value === 'object') { -// const record = value as Record; -// return Object.keys(record) -// .sort() -// .reduce>((acc, key) => { -// acc[key] = this.sortJson(record[key]); -// return acc; -// }, {}); -// } - -// return value; -// } - -// private parseFlagFileContent(content: string): FlagFileContent | null { -// try { -// const parsed = JSON.parse(content) as FlagFileContent; -// if (!parsed.flags || typeof parsed.flags !== 'object' || Array.isArray(parsed.flags)) { -// return null; -// } -// return parsed; -// } catch { -// return null; -// } -// } -// } diff --git a/ui/src/app/state/flag-store.actions.ts b/ui/src/app/state/flag-store.actions.ts index ee15fe2..bc30ee5 100644 --- a/ui/src/app/state/flag-store.actions.ts +++ b/ui/src/app/state/flag-store.actions.ts @@ -34,7 +34,7 @@ export class DeleteCollection { export class SelectCollection { static readonly type = '[FlagStore] Select Collection'; - constructor(readonly id: number | null) {} + constructor(readonly id: number | undefined) {} } // ============================================================================ diff --git a/ui/src/app/state/flag-store.state.ts b/ui/src/app/state/flag-store.state.ts index 450b102..9e20873 100644 --- a/ui/src/app/state/flag-store.state.ts +++ b/ui/src/app/state/flag-store.state.ts @@ -32,6 +32,7 @@ import { SetCollectionMetadata, SaveDatabase, } from './flag-store.actions'; +import { RouterNavigation } from '@ngxs/router-plugin'; export interface FlagStoreStateModel { collections: CollectionDto[]; @@ -77,6 +78,30 @@ export class FlagStoreState implements NgxsOnInit { ctx.dispatch(new LoadCollections()); } + @Action(RouterNavigation) + onNavigation(ctx: StateContext, action: RouterNavigation): void { + // this.flushPendingPersist(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const routerState = action.routerState as any; + const params = this.collectRouteParams(routerState?.root); + // const backendUri = params['uri'] as string | undefined; + const collectionId = params['collectionId'] as string | undefined; + + if (collectionId) { + ctx.dispatch(new SelectCollection(parseInt(collectionId, 10))); + } + } + + private collectRouteParams(route: RouteSnapshotLike | null): Record { + if (!route) return {}; + const merged = { ...(route.params ?? {}) }; + const children = route.children ?? []; + for (const child of children) { + Object.assign(merged, this.collectRouteParams(child)); + } + return merged; + } // ============================================================================ // SELECTORS // ============================================================================ @@ -574,3 +599,8 @@ export class FlagStoreState implements NgxsOnInit { } } } + +interface RouteSnapshotLike { + params?: Record; + children?: RouteSnapshotLike[]; +} diff --git a/ui/src/environments.ts b/ui/src/environments.ts index f52bb57..a6af907 100644 --- a/ui/src/environments.ts +++ b/ui/src/environments.ts @@ -1,6 +1,6 @@ /* This file is generated by scripts/generate-environments.js. */ export const GIT_TAG: string | null = null; -export const GIT_COMMIT_HASH: string | null = "c40cea7f2cc486baceb11e0645c8c5bed919eb28"; +export const GIT_COMMIT_HASH: string | null = "257d5168f81619eaabca0abd51ee198593bdf02f"; export const DEFAULT_BACKEND_ROOT: string | null = null; export const BACKEND_TYPE: 'rest' | 'wasm' = "rest" as 'rest' | 'wasm'; From 8387a6b282099bed9694e99b1bb056464c7cb3ae Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Wed, 15 Apr 2026 22:55:29 +0000 Subject: [PATCH 25/38] update --- flagd-ui.code-workspace | 8 +- src/index.html | 604 ---------------------------------------- 2 files changed, 6 insertions(+), 606 deletions(-) delete mode 100644 src/index.html diff --git a/flagd-ui.code-workspace b/flagd-ui.code-workspace index 5b8cc55..540b6f1 100644 --- a/flagd-ui.code-workspace +++ b/flagd-ui.code-workspace @@ -1,13 +1,17 @@ { "folders": [ { - "name": "Api", - "path": "src/OpenFeatureManager.Api", + "name": "Backend", + "path": "src", }, { "name": "Frontend", "path": "ui", }, + { + "name": "Docs", + "path": "docs", + }, { "name": "E2E Tests", "path": "e2e-tests", diff --git a/src/index.html b/src/index.html deleted file mode 100644 index d703a09..0000000 --- a/src/index.html +++ /dev/null @@ -1,604 +0,0 @@ - - - - - - Flagd Schema Manager — WASM - - - -
    -

    🏳️ Flagd Schema Manager — WASM

    - ⏳ Booting .NET runtime… -
    - -
    - -
    -

    Flag Files

    -
    -
    - - -
    -
    - - -
    -
    ← Select or create a flag file
    - - -
    -
    - -
    - - -
    -
    - - - - From d555b2f74580b37fed3dfd9c2309e84b323afd33 Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Thu, 16 Apr 2026 13:21:28 +0000 Subject: [PATCH 26/38] devcontainer --- .devcontainer/devcontainer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c2da527..20c306c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -21,9 +21,9 @@ "ms-playwright.playwright", "vitest.explorer", "dbaeumer.vscode-eslint", - "tamasfe.even-better-toml", "ms-dotnettools.csdevkit", - "Angular.ng-template" + "Angular.ng-template", + "ms-vscode-remote.remote-containers" ], "settings": {} } From aa11e02d99f8060b2d47aaed2991eb04664af839 Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Mon, 20 Apr 2026 18:54:44 +0000 Subject: [PATCH 27/38] fix schema import actions --- src/OpenFeatureManager.Api/Program.cs | 3 ++- ui/scripts/setup-api-client.js | 33 +++++++++++++++++++++--- ui/src/app/services/flag-backend.ts | 2 +- ui/src/app/services/rest-flag-backend.ts | 4 +-- ui/src/app/services/wasm-flag-backend.ts | 3 ++- ui/src/app/state/flag-store.actions.ts | 5 +++- ui/src/app/state/flag-store.state.ts | 2 +- ui/src/environments.ts | 2 +- 8 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/OpenFeatureManager.Api/Program.cs b/src/OpenFeatureManager.Api/Program.cs index 5a13e4b..0fc4482 100644 --- a/src/OpenFeatureManager.Api/Program.cs +++ b/src/OpenFeatureManager.Api/Program.cs @@ -135,7 +135,8 @@ var body = await reader.ReadToEndAsync(); svc.ImportSchema(id, body); return TypedResults.Ok("Schema imported successfully."); -}).WithName("importSchema").WithTags("schema"); +}).WithName("importSchema").WithTags("schema") + .Accepts("application/json"); // Dummy app.MapPost("/dummy", async (long id, HttpRequest request) => diff --git a/ui/scripts/setup-api-client.js b/ui/scripts/setup-api-client.js index 54bb57c..a340850 100644 --- a/ui/scripts/setup-api-client.js +++ b/ui/scripts/setup-api-client.js @@ -25,10 +25,11 @@ if (fs.existsSync(sourceFile)) { } // Check if OpenFeatureManager.Api.json exists -const openapiPath = path.join(uiRoot, 'OpenFeatureManager.Api.json'); +const openapiFile = 'OpenFeatureManager.Api.json'; +const openapiPath = path.join(uiRoot, openapiFile); if (!fs.existsSync(openapiPath)) { console.warn( - `⚠ Warning: OpenFeatureManager.Api.json not found. This is expected if the API project hasn't been built yet.`, + `⚠ Warning: ${openapiFile} not found. This is expected if the API project hasn't been built yet.`, ); console.warn( `⚠ Run 'dotnet build' in the src directory to generate the OpenAPI spec, then run 'npm install' again in the ui directory.`, @@ -44,11 +45,35 @@ console.log(`✓ Working directory: ${cwd}`); // Use --user to avoid file permission issues on Linux/macOS const userFlag = process.platform === 'win32' ? '' : ` --user ${process.getuid()}:${process.getgid()}`; -const dockerCmd = `docker run --rm${userFlag} -v "${cwd}:/local" openapitools/openapi-generator-cli:v7.21.0 generate -i /local/openapi.json -g typescript-angular -o /local/src/app/api-client`; +const dockerCmd = `docker run --rm${userFlag} -v "${cwd}/${openapiFile}:/local/openapi.json" -v "${cwd}/src/app/api-client:/local/src/app/api-client" openapitools/openapi-generator-cli:v7.21.0 generate -i /local/openapi.json -g typescript-angular -o /local/src/app/api-client`; const result = spawnSync(dockerCmd, { shell: true, stdio: 'inherit', }); -process.exit(result.status); +if (result.status !== 0) { + process.exit(result.status); +} + +// Post-generation patches for known openapi-generator bugs +const patches = [ + { + file: path.join(targetDir, 'model', 'flagEntryDto.ts'), + find: "import { Null } from './null';", + replace: "import { PerEnvironmentDefinitionDto } from './perEnvironmentDefinitionDto';", + description: 'Fix incorrect Null import for PerEnvironmentDefinitionDto', + }, +]; + +for (const patch of patches) { + if (!fs.existsSync(patch.file)) continue; + let content = fs.readFileSync(patch.file, 'utf8'); + if (content.includes(patch.find)) { + content = content.replace(patch.find, patch.replace); + fs.writeFileSync(patch.file, content, 'utf8'); + console.log(`✓ Patched ${path.relative(uiRoot, patch.file)}: ${patch.description}`); + } +} + +process.exit(0); diff --git a/ui/src/app/services/flag-backend.ts b/ui/src/app/services/flag-backend.ts index 2cdfa07..472aa7b 100644 --- a/ui/src/app/services/flag-backend.ts +++ b/ui/src/app/services/flag-backend.ts @@ -106,7 +106,7 @@ export interface FlagBackend { // Schema exportSchema(collectionId: number): Promise>; - importSchema(collectionId: number): Promise; + importSchema(collectionId: number, schema: string): Promise; // Collection metadata updateCollectionMetadata(collectionId: number, metadata: MetadataDto[]): Promise; diff --git a/ui/src/app/services/rest-flag-backend.ts b/ui/src/app/services/rest-flag-backend.ts index 3fb10c4..e70f001 100644 --- a/ui/src/app/services/rest-flag-backend.ts +++ b/ui/src/app/services/rest-flag-backend.ts @@ -153,8 +153,8 @@ export class RestFlagBackend implements FlagBackend { return result as Record; } - async importSchema(collectionId: number): Promise { - await firstValueFrom(this.schema.importSchema(this.toId(collectionId))); + async importSchema(collectionId: number, schema: string): Promise { + await firstValueFrom(this.schema.importSchema(this.toId(collectionId), schema)); } // ── Collection Metadata ──────────────────────────────────────────────── diff --git a/ui/src/app/services/wasm-flag-backend.ts b/ui/src/app/services/wasm-flag-backend.ts index 4c3a579..1cb66d8 100644 --- a/ui/src/app/services/wasm-flag-backend.ts +++ b/ui/src/app/services/wasm-flag-backend.ts @@ -140,7 +140,8 @@ export class WasmFlagBackend implements FlagBackend { return JSON.parse(json) as Record; } - async importSchema(collectionId: number): Promise { + async importSchema(collectionId: number, schema: string): Promise { + this.wasm.importSchema(BigInt(collectionId), schema); // WASM importSchema requires schema JSON; for the interface contract the // caller would need to supply it. For now this is a no-op placeholder // matching the REST behaviour where the server resolves the schema. diff --git a/ui/src/app/state/flag-store.actions.ts b/ui/src/app/state/flag-store.actions.ts index bc30ee5..ca9af14 100644 --- a/ui/src/app/state/flag-store.actions.ts +++ b/ui/src/app/state/flag-store.actions.ts @@ -143,7 +143,10 @@ export class ExportSchema { export class ImportSchema { static readonly type = '[FlagStore] Import Schema'; - constructor(readonly collectionId: number) {} + constructor( + readonly collectionId: number, + readonly schema: string, + ) {} } export class SaveDatabase { diff --git a/ui/src/app/state/flag-store.state.ts b/ui/src/app/state/flag-store.state.ts index 9e20873..80ffe75 100644 --- a/ui/src/app/state/flag-store.state.ts +++ b/ui/src/app/state/flag-store.state.ts @@ -550,7 +550,7 @@ export class FlagStoreState implements NgxsOnInit { async importSchema(ctx: StateContext, action: ImportSchema): Promise { ctx.patchState({ error: null }); try { - await this.backend.importSchema(action.collectionId); + await this.backend.importSchema(action.collectionId, action.schema); // Reload all sub-resources after import ctx.dispatch([ new LoadFlags(action.collectionId), diff --git a/ui/src/environments.ts b/ui/src/environments.ts index a6af907..f557454 100644 --- a/ui/src/environments.ts +++ b/ui/src/environments.ts @@ -1,6 +1,6 @@ /* This file is generated by scripts/generate-environments.js. */ export const GIT_TAG: string | null = null; -export const GIT_COMMIT_HASH: string | null = "257d5168f81619eaabca0abd51ee198593bdf02f"; +export const GIT_COMMIT_HASH: string | null = "d555b2f74580b37fed3dfd9c2309e84b323afd33"; export const DEFAULT_BACKEND_ROOT: string | null = null; export const BACKEND_TYPE: 'rest' | 'wasm' = "rest" as 'rest' | 'wasm'; From 9d046fd70a2572b2d42db6d2d801da340e242136 Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Mon, 20 Apr 2026 21:40:34 +0000 Subject: [PATCH 28/38] readd import functionality --- .../new-flags-file-form.ts | 96 +++++++++---------- ui/src/app/services/file-system-access.ts | 4 +- ui/src/app/services/wasm-flag-backend.ts | 5 +- ui/src/app/state/flag-store.actions.ts | 2 +- ui/src/app/state/flag-store.state.ts | 12 ++- 5 files changed, 58 insertions(+), 61 deletions(-) diff --git a/ui/src/app/components/new-flags-file-form/new-flags-file-form.ts b/ui/src/app/components/new-flags-file-form/new-flags-file-form.ts index de88c95..64e0e14 100644 --- a/ui/src/app/components/new-flags-file-form/new-flags-file-form.ts +++ b/ui/src/app/components/new-flags-file-form/new-flags-file-form.ts @@ -12,7 +12,7 @@ import { MatProgressBarModule } from '@angular/material/progress-bar'; import { FileSystemAccess } from '../../services/file-system-access'; import { stringifyFlagdSchema } from '../../models/flagd-schema.parser'; import { RestFlagBackend } from '../../services/rest-flag-backend'; -import { CreateCollection } from '../../state/flag-store.actions'; +import { CreateCollection, ImportSchema } from '../../state/flag-store.actions'; export interface NewFlagsFileFormResult { type: 'empty' | 'url' | 'disk' | 'backend'; @@ -185,28 +185,31 @@ export class NewFlagsFileFormComponent { this.urlLoading = true; this.urlError = ''; - // this.http.get(url, { responseType: 'text' }).subscribe({ - // next: (text) => { - // try { - // // Derive name from URL filename - // const urlPath = new URL(url).pathname; - // let name = urlPath.split('/').pop() ?? 'imported'; - // name = name.replace(/\.flagd\.json$/, '').replace(/\.json$/, ''); - // if (!name) name = 'imported'; - - // this.store.dispatch(new AddFile('local', LocalBackendUris.Browser, name, text)); - // void this.navigateToFlagsFile('local', LocalBackendUris.Browser, name); - // this.formSubmitted.emit({ type: 'url' }); - // } catch { - // this.urlError = 'Failed to parse JSON file'; - // this.urlLoading = false; - // } - // }, - // error: () => { - // this.urlError = 'Failed to fetch file from URL'; - // this.urlLoading = false; - // }, - // }); + this.http.get(url, { responseType: 'text' }).subscribe({ + next: (text) => { + try { + // Derive name from URL filename + const urlPath = new URL(url).pathname; + let name = urlPath.split('/').pop() ?? 'imported'; + name = name.replace(/\.flagd\.json$/, '').replace(/\.json$/, ''); + if (!name) name = 'imported'; + + this.store.dispatch(new ImportSchema(name, text)); + // this.router.navigate(['/', "uri", backendUri, fileName]); + + // void this.navigateToFlagsFile('local', LocalBackendUris.Browser, name); + this.formSubmitted.emit({ type: 'url' }); + this.urlLoading = false; + } catch { + this.urlError = 'Failed to parse JSON file'; + this.urlLoading = false; + } + }, + error: () => { + this.urlError = 'Failed to fetch file from URL'; + this.urlLoading = false; + }, + }); } async importFromDisk(): Promise { @@ -218,33 +221,26 @@ export class NewFlagsFileFormComponent { this.diskLoading = true; this.diskError = ''; - // try { - // const result = await this.fileSystemAccess.pickAndBindFlagsFile(); - // if (!result) { - // this.diskLoading = false; - // return; - // } - - // this.store.dispatch( - // new AddFile( - // 'local', - // LocalBackendUris.Disk, - // result.name, - // JSON.stringify(result.content, null, 2), - // ), - // ); - // void this.navigateToFlagsFile('local', LocalBackendUris.Disk, result.name); - // this.formSubmitted.emit({ type: 'disk' }); - // } catch (error) { - // this.diskLoading = false; - - // const message = error instanceof Error ? error.message : 'Failed to open local file'; - // if (message.includes('aborted') || message.includes('The user aborted a request')) { - // return; - // } - - // this.diskError = message; - // } + try { + const result = await this.fileSystemAccess.pickAndBindFlagsFile(); + if (!result) { + this.diskLoading = false; + return; + } + + this.store.dispatch(new ImportSchema(result.name, result.content)); + // void this.navigateToFlagsFile('local', LocalBackendUris.Disk, result.name); + this.formSubmitted.emit({ type: 'disk' }); + } catch (error) { + this.diskLoading = false; + + const message = error instanceof Error ? error.message : 'Failed to open local file'; + if (message.includes('aborted') || message.includes('The user aborted a request')) { + return; + } + + this.diskError = message; + } } discoverBackend(): void { diff --git a/ui/src/app/services/file-system-access.ts b/ui/src/app/services/file-system-access.ts index 3bd9bc4..80493cf 100644 --- a/ui/src/app/services/file-system-access.ts +++ b/ui/src/app/services/file-system-access.ts @@ -27,7 +27,7 @@ export class FileSystemAccess { return typeof (window as WindowWithFilePicker).showOpenFilePicker === 'function'; } - async pickAndBindFlagsFile(): Promise<{ name: string; content: FlagFileContent } | null> { + async pickAndBindFlagsFile(): Promise<{ name: string; content: string } | null> { const picker = (window as WindowWithFilePicker).showOpenFilePicker; if (!picker) { throw new Error('File picker API is not supported in this browser.'); @@ -65,7 +65,7 @@ export class FileSystemAccess { this.fileHandles.set(name, handle); - return { name, content }; + return { name, content: text }; } async persistBoundFlagsFile(name: string, content: FlagFileContent): Promise { diff --git a/ui/src/app/services/wasm-flag-backend.ts b/ui/src/app/services/wasm-flag-backend.ts index 1cb66d8..6e9cb77 100644 --- a/ui/src/app/services/wasm-flag-backend.ts +++ b/ui/src/app/services/wasm-flag-backend.ts @@ -141,11 +141,8 @@ export class WasmFlagBackend implements FlagBackend { } async importSchema(collectionId: number, schema: string): Promise { + console.log('Importing schema into WASM backend:', schema); this.wasm.importSchema(BigInt(collectionId), schema); - // WASM importSchema requires schema JSON; for the interface contract the - // caller would need to supply it. For now this is a no-op placeholder - // matching the REST behaviour where the server resolves the schema. - throw new Error('importSchema is not supported on the WASM backend'); } // ── Collection Metadata ──────────────────────────────────────────────── diff --git a/ui/src/app/state/flag-store.actions.ts b/ui/src/app/state/flag-store.actions.ts index ca9af14..2295bf2 100644 --- a/ui/src/app/state/flag-store.actions.ts +++ b/ui/src/app/state/flag-store.actions.ts @@ -144,7 +144,7 @@ export class ExportSchema { export class ImportSchema { static readonly type = '[FlagStore] Import Schema'; constructor( - readonly collectionId: number, + readonly newCollectionName: string, readonly schema: string, ) {} } diff --git a/ui/src/app/state/flag-store.state.ts b/ui/src/app/state/flag-store.state.ts index 80ffe75..fd890db 100644 --- a/ui/src/app/state/flag-store.state.ts +++ b/ui/src/app/state/flag-store.state.ts @@ -550,12 +550,16 @@ export class FlagStoreState implements NgxsOnInit { async importSchema(ctx: StateContext, action: ImportSchema): Promise { ctx.patchState({ error: null }); try { - await this.backend.importSchema(action.collectionId, action.schema); + const collection = await this.backend.createCollection(action.newCollectionName); + const state = ctx.getState(); + ctx.patchState({ collections: [...state.collections, collection] }); + + await this.backend.importSchema(collection.id, action.schema); // Reload all sub-resources after import ctx.dispatch([ - new LoadFlags(action.collectionId), - new LoadEnvironments(action.collectionId), - new LoadTimeWindows(action.collectionId), + new LoadFlags(collection.id), + new LoadEnvironments(collection.id), + new LoadTimeWindows(collection.id), ]); } catch (e) { ctx.patchState({ From 44af84ee1785266534b7e4ad44e10b1f51e6112c Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Mon, 20 Apr 2026 23:11:51 +0000 Subject: [PATCH 29/38] use guids --- src/OpenFeatureManager.Api/Program.cs | 34 +++---- .../FlagdSchemaServiceTests.cs | 32 ++++--- .../FlagdWasmService.cs | 68 ++++++------- .../IFlagdWasmService.cs | 34 +++---- src/OpenFeatureManager/Data/FlagdDbContext.cs | 16 ++-- .../Entities/CollectionMetadataEntry.cs | 5 +- .../Entities/EnvironmentAlias.cs | 5 +- .../Entities/EnvironmentEntry.cs | 5 +- src/OpenFeatureManager/Entities/FlagEntry.cs | 7 +- .../Entities/FlagMetadataEntry.cs | 5 +- .../Entities/FlagsCollection.cs | 3 +- .../Entities/PerEnvironmentDefinition.cs | 9 +- src/OpenFeatureManager/Entities/TimeWindow.cs | 5 +- .../Models/FlagdJsonContext.cs | 8 +- .../Services/FlagdSchemaService.cs | 4 +- .../Services/FlagdService.cs | 34 +++---- .../app/components/flag-editor/flag-editor.ts | 2 +- ui/src/app/services/flag-backend.ts | 44 ++++----- ui/src/app/services/rest-flag-backend.ts | 95 ++++++++----------- ui/src/app/services/wasm-backend.service.ts | 34 +++---- ui/src/app/services/wasm-flag-backend.ts | 90 +++++++++--------- ui/src/app/state/flag-store.actions.ts | 32 +++---- ui/src/app/state/flag-store.state.ts | 6 +- ui/src/environments.ts | 2 +- 24 files changed, 281 insertions(+), 298 deletions(-) diff --git a/src/OpenFeatureManager.Api/Program.cs b/src/OpenFeatureManager.Api/Program.cs index 0fc4482..c95bd92 100644 --- a/src/OpenFeatureManager.Api/Program.cs +++ b/src/OpenFeatureManager.Api/Program.cs @@ -51,11 +51,11 @@ return TypedResults.Created($"/api/collections/{collection.Id}", collection); }).WithName("createCollection").WithTags("collections"); -app.MapPut("/api/collections/{id}", (long id, RenameCollectionRequest req, FlagdService svc) => +app.MapPut("/api/collections/{id}", (Guid id, RenameCollectionRequest req, FlagdService svc) => TypedResults.Ok(svc.RenameCollection(id, req.Name))) .WithName("renameCollection").WithTags("collections"); -app.MapDelete("/api/collections/{id}", (long id, FlagdService svc) => +app.MapDelete("/api/collections/{id}", (Guid id, FlagdService svc) => { svc.DeleteCollection(id); return TypedResults.NoContent(); @@ -63,19 +63,19 @@ // ─── Flag endpoints ─────────────────────────────────────────────────── -app.MapGet("/api/collections/{id}/flags", (long id, FlagdService svc) => +app.MapGet("/api/collections/{id}/flags", (Guid id, FlagdService svc) => TypedResults.Ok(svc.GetFlags(id))) .WithName("getFlags").WithTags("flags"); -app.MapPost("/api/collections/{id}/flags", (long id, FlagEntryDto dto, FlagdService svc) => +app.MapPost("/api/collections/{id}/flags", (Guid id, FlagEntryDto dto, FlagdService svc) => TypedResults.Ok(svc.UpsertFlag(id, dto))) .WithName("createFlag").WithTags("flags"); -app.MapPut("/api/collections/{id}/flags", (long id, FlagEntryDto dto, FlagdService svc) => +app.MapPut("/api/collections/{id}/flags", (Guid id, FlagEntryDto dto, FlagdService svc) => TypedResults.Ok(svc.UpsertFlag(id, dto))) .WithName("updateFlag").WithTags("flags"); -app.MapDelete("/api/collections/{id}/flags/{key}", (long id, string key, FlagdService svc) => +app.MapDelete("/api/collections/{id}/flags/{key}", (Guid id, string key, FlagdService svc) => { svc.DeleteFlag(id, Uri.UnescapeDataString(key)); return TypedResults.NoContent(); @@ -83,19 +83,19 @@ // ─── Environment endpoints ──────────────────────────────────────────── -app.MapGet("/api/collections/{id}/environments", (long id, FlagdService svc) => +app.MapGet("/api/collections/{id}/environments", (Guid id, FlagdService svc) => TypedResults.Ok(svc.GetEnvironments(id))) .WithName("getEnvironments").WithTags("environments"); -app.MapPost("/api/collections/{id}/environments", (long id, EnvironmentEntryDto dto, FlagdService svc) => +app.MapPost("/api/collections/{id}/environments", (Guid id, EnvironmentEntryDto dto, FlagdService svc) => TypedResults.Ok(svc.UpsertEnvironment(id, dto))) .WithName("createEnvironment").WithTags("environments"); -app.MapPut("/api/collections/{id}/environments", (long id, EnvironmentEntryDto dto, FlagdService svc) => +app.MapPut("/api/collections/{id}/environments", (Guid id, EnvironmentEntryDto dto, FlagdService svc) => TypedResults.Ok(svc.UpsertEnvironment(id, dto))) .WithName("updateEnvironment").WithTags("environments"); -app.MapDelete("/api/collections/{id}/environments/{name}", (long id, string name, FlagdService svc) => +app.MapDelete("/api/collections/{id}/environments/{name}", (Guid id, string name, FlagdService svc) => { svc.DeleteEnvironment(id, Uri.UnescapeDataString(name)); return TypedResults.NoContent(); @@ -103,21 +103,21 @@ // ─── Time window endpoints ──────────────────────────────────────────── -app.MapGet("/api/collections/{id}/timewindows", (long id, FlagdService svc) => +app.MapGet("/api/collections/{id}/timewindows", (Guid id, FlagdService svc) => TypedResults.Ok(svc.GetTimeWindows(id))) .WithName("getTimeWindows").WithTags("timewindows"); -app.MapPost("/api/collections/{id}/timewindows", (long id, TimeWindowDto dto, FlagdService svc) => +app.MapPost("/api/collections/{id}/timewindows", (Guid id, TimeWindowDto dto, FlagdService svc) => { var tw = svc.CreateTimeWindow(id, dto); return TypedResults.Created($"/api/collections/{id}/timewindows/{tw.Id}", tw); }).WithName("createTimeWindow").WithTags("timewindows"); -app.MapPut("/api/collections/{id}/timewindows/{twId}", (long id, long twId, TimeWindowDto dto, FlagdService svc) => +app.MapPut("/api/collections/{id}/timewindows/{twId}", (Guid id, Guid twId, TimeWindowDto dto, FlagdService svc) => TypedResults.Ok(svc.UpdateTimeWindow(id, twId, dto))) .WithName("updateTimeWindow").WithTags("timewindows"); -app.MapDelete("/api/collections/{id}/timewindows/{twId}", (long id, long twId, FlagdService svc) => +app.MapDelete("/api/collections/{id}/timewindows/{twId}", (Guid id, Guid twId, FlagdService svc) => { svc.DeleteTimeWindow(id, twId); return TypedResults.NoContent(); @@ -125,11 +125,11 @@ // ─── Schema endpoints ───────────────────────────────────────────────── -app.MapGet("/api/collections/{id}/schema", (long id, FlagdSchemaService svc) => +app.MapGet("/api/collections/{id}/schema", (Guid id, FlagdSchemaService svc) => TypedResults.Text(svc.ExportSchema(id), "application/json")) .WithName("exportSchema").WithTags("schema"); -app.MapPost("/api/collections/{id}/schema", async (long id, HttpRequest request, FlagdSchemaService svc) => +app.MapPost("/api/collections/{id}/schema", async (Guid id, HttpRequest request, FlagdSchemaService svc) => { using var reader = new StreamReader(request.Body); var body = await reader.ReadToEndAsync(); @@ -139,7 +139,7 @@ .Accepts("application/json"); // Dummy -app.MapPost("/dummy", async (long id, HttpRequest request) => +app.MapPost("/dummy", async (Guid id, HttpRequest request) => { return TypedResults.Ok(new PerEnvironmentDefinitionDto(BooleanValue: true)); }).WithName("dummy").WithTags("schema"); diff --git a/src/OpenFeatureManager.Tests/FlagdSchemaServiceTests.cs b/src/OpenFeatureManager.Tests/FlagdSchemaServiceTests.cs index 1f8bf38..40ea458 100644 --- a/src/OpenFeatureManager.Tests/FlagdSchemaServiceTests.cs +++ b/src/OpenFeatureManager.Tests/FlagdSchemaServiceTests.cs @@ -211,7 +211,7 @@ public void ImportSchema_WithEvaluators_StoresEnvironments() Assert.Equal(2, envs.Count); var prod = envs.First(e => e.Name == "Production"); - Assert.Equal(["prod", "production"], prod.Aliases); + Assert.Equal(["prod", "production"], prod.Aliases.Order().ToArray()); var staging = envs.First(e => e.Name == "Staging"); Assert.Equal(["staging"], staging.Aliases); @@ -337,7 +337,7 @@ public void ImportSchema_NonexistentCollection_ThrowsKeyNotFoundException() } """; - Assert.Throws(() => _schemaService.ImportSchema(999, json)); + Assert.Throws(() => _schemaService.ImportSchema(Guid.NewGuid(), json)); } // ─── Export tests ───────────────────────────────────────────────────── @@ -403,8 +403,9 @@ public void ExportSchema_WithEnvironments_IncludesEvaluators() var prod = evaluators.GetProperty("isProduction"); var inArray = prod.GetProperty("in"); Assert.Equal("environment", inArray[0].GetProperty("var").GetString()); - Assert.Equal("prod", inArray[1][0].GetString()); - Assert.Equal("production", inArray[1][1].GetString()); + var aliasValues = inArray[1].EnumerateArray().Select(a => a.GetString()).Order().ToArray(); + Assert.Equal("prod", aliasValues[0]); + Assert.Equal("production", aliasValues[1]); } [Fact] @@ -437,7 +438,7 @@ public void ExportSchema_EmptyCollection_ProducesMinimalValidJson() [Fact] public void ExportSchema_NonexistentCollection_ThrowsKeyNotFoundException() { - Assert.Throws(() => _schemaService.ExportSchema(999)); + Assert.Throws(() => _schemaService.ExportSchema(Guid.NewGuid())); } // ─── Round-trip tests ───────────────────────────────────────────────── @@ -509,8 +510,9 @@ public void ImportThenExport_PreservesEnvironments() var evaluators = doc.RootElement.GetProperty("$evaluators"); var staging = evaluators.GetProperty("isStaging"); var aliases = staging.GetProperty("in")[1]; - Assert.Equal("stg", aliases[0].GetString()); - Assert.Equal("staging", aliases[1].GetString()); + var aliasValues = aliases.EnumerateArray().Select(a => a.GetString()).Order().ToArray(); + Assert.Equal("staging", aliasValues[0]); + Assert.Equal("stg", aliasValues[1]); } // ─── Typed flag CRUD tests ──────────────────────────────────────────── @@ -626,7 +628,7 @@ public void UpsertFlag_WithGlobalTimeWindow_StoresAndRetrieves() var collection = _flagdService.CreateCollection("test"); var start = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); var end = new DateTime(2026, 12, 31, 23, 59, 59, DateTimeKind.Utc); - var tw = _flagdService.CreateTimeWindow(collection.Id, new TimeWindowDto(0, "Test Window", start, end)); + var tw = _flagdService.CreateTimeWindow(collection.Id, new TimeWindowDto(Guid.Empty, "Test Window", start, end)); var globalTw = new GlobalTimeWindowDto(tw.Id, BooleanValue: false); _flagdService.UpsertFlag(collection.Id, new FlagEntryDto("flag", "boolean", "ENABLED", BooleanValue: true, GlobalTimeWindow: globalTw)); @@ -727,9 +729,9 @@ public void CreateTimeWindow_RoundTrips() var collection = _flagdService.CreateCollection("test"); var start = new DateTime(2026, 12, 24, 0, 0, 0, DateTimeKind.Utc); var end = new DateTime(2026, 12, 26, 23, 59, 59, DateTimeKind.Utc); - var tw = _flagdService.CreateTimeWindow(collection.Id, new TimeWindowDto(0, "Christmas Time", start, end)); + var tw = _flagdService.CreateTimeWindow(collection.Id, new TimeWindowDto(Guid.Empty, "Christmas Time", start, end)); - Assert.True(tw.Id > 0); + Assert.NotEqual(Guid.Empty, tw.Id); Assert.Equal("Christmas Time", tw.Name); Assert.Equal(start, tw.StartTime); Assert.Equal(end, tw.EndTime); @@ -739,8 +741,8 @@ public void CreateTimeWindow_RoundTrips() public void GetTimeWindows_ReturnsAll() { var collection = _flagdService.CreateCollection("test"); - _flagdService.CreateTimeWindow(collection.Id, new TimeWindowDto(0, "Window A")); - _flagdService.CreateTimeWindow(collection.Id, new TimeWindowDto(0, "Window B")); + _flagdService.CreateTimeWindow(collection.Id, new TimeWindowDto(Guid.Empty, "Window A")); + _flagdService.CreateTimeWindow(collection.Id, new TimeWindowDto(Guid.Empty, "Window B")); var windows = _flagdService.GetTimeWindows(collection.Id); Assert.Equal(2, windows.Count); @@ -750,7 +752,7 @@ public void GetTimeWindows_ReturnsAll() public void UpdateTimeWindow_ChangesFields() { var collection = _flagdService.CreateCollection("test"); - var tw = _flagdService.CreateTimeWindow(collection.Id, new TimeWindowDto(0, "Old Name")); + var tw = _flagdService.CreateTimeWindow(collection.Id, new TimeWindowDto(Guid.Empty, "Old Name")); var newStart = new DateTime(2027, 1, 1, 0, 0, 0, DateTimeKind.Utc); var updated = _flagdService.UpdateTimeWindow(collection.Id, tw.Id, new TimeWindowDto(tw.Id, "New Name", newStart)); @@ -762,7 +764,7 @@ public void UpdateTimeWindow_ChangesFields() public void DeleteTimeWindow_RemovesEntry() { var collection = _flagdService.CreateCollection("test"); - var tw = _flagdService.CreateTimeWindow(collection.Id, new TimeWindowDto(0, "Temporary")); + var tw = _flagdService.CreateTimeWindow(collection.Id, new TimeWindowDto(Guid.Empty, "Temporary")); _flagdService.DeleteTimeWindow(collection.Id, tw.Id); @@ -774,7 +776,7 @@ public void PerEnvironmentDefinition_WithTimeWindowReference_RoundTrips() { var collection = _flagdService.CreateCollection("test"); _flagdService.UpsertEnvironment(collection.Id, new EnvironmentEntryDto("Production", ["prod"])); - var tw = _flagdService.CreateTimeWindow(collection.Id, new TimeWindowDto(0, "Holiday Window")); + var tw = _flagdService.CreateTimeWindow(collection.Id, new TimeWindowDto(Guid.Empty, "Holiday Window")); var perEnv = new Dictionary { ["Production"] = new(BooleanValue: false, TimeWindowId: tw.Id) diff --git a/src/OpenFeatureManager.Wasm/FlagdWasmService.cs b/src/OpenFeatureManager.Wasm/FlagdWasmService.cs index e17054e..3817049 100644 --- a/src/OpenFeatureManager.Wasm/FlagdWasmService.cs +++ b/src/OpenFeatureManager.Wasm/FlagdWasmService.cs @@ -16,67 +16,67 @@ public FlagdWasmService(WasmRuntime runtime) public FlagsCollectionDto[] GetCollections() => _runtime.RequireService().GetCollections().ToArray(); - public FlagsCollectionDto GetCollection(long id) => - _runtime.RequireService().GetCollection(id); + public FlagsCollectionDto GetCollection(string id) => + _runtime.RequireService().GetCollection(Guid.Parse(id)); public FlagsCollectionDto CreateCollection(string name) => _runtime.RequireService().CreateCollection(name); - public FlagsCollectionDto RenameCollection(long id, string name) => - _runtime.RequireService().RenameCollection(id, name); + public FlagsCollectionDto RenameCollection(string id, string name) => + _runtime.RequireService().RenameCollection(Guid.Parse(id), name); - public void DeleteCollection(long id) => - _runtime.RequireService().DeleteCollection(id); + public void DeleteCollection(string id) => + _runtime.RequireService().DeleteCollection(Guid.Parse(id)); - public void ClearCollectionData(long collectionId) => - _runtime.RequireService().ClearCollectionData(collectionId); + public void ClearCollectionData(string collectionId) => + _runtime.RequireService().ClearCollectionData(Guid.Parse(collectionId)); - public void UpdateCollectionMetadata(long collectionId, MetadataEntryDto[] metadata) => - _runtime.RequireService().UpdateCollectionMetadata(collectionId, metadata.ToList()); + public void UpdateCollectionMetadata(string collectionId, MetadataEntryDto[] metadata) => + _runtime.RequireService().UpdateCollectionMetadata(Guid.Parse(collectionId), metadata.ToList()); // ─── Flags ──────────────────────────────────────────────────────── - public FlagEntryDto[] GetFlags(long collectionId) => - _runtime.RequireService().GetFlags(collectionId).ToArray(); + public FlagEntryDto[] GetFlags(string collectionId) => + _runtime.RequireService().GetFlags(Guid.Parse(collectionId)).ToArray(); - public FlagEntryDto UpsertFlag(long collectionId, FlagEntryDto dto) => - _runtime.RequireService().UpsertFlag(collectionId, dto); + public FlagEntryDto UpsertFlag(string collectionId, FlagEntryDto dto) => + _runtime.RequireService().UpsertFlag(Guid.Parse(collectionId), dto); - public void DeleteFlag(long collectionId, string flagKey) => - _runtime.RequireService().DeleteFlag(collectionId, flagKey); + public void DeleteFlag(string collectionId, string flagKey) => + _runtime.RequireService().DeleteFlag(Guid.Parse(collectionId), flagKey); // ─── Environments ───────────────────────────────────────────────── - public EnvironmentEntryDto[] GetEnvironments(long collectionId) => - _runtime.RequireService().GetEnvironments(collectionId).ToArray(); + public EnvironmentEntryDto[] GetEnvironments(string collectionId) => + _runtime.RequireService().GetEnvironments(Guid.Parse(collectionId)).ToArray(); - public EnvironmentEntryDto UpsertEnvironment(long collectionId, EnvironmentEntryDto dto) => - _runtime.RequireService().UpsertEnvironment(collectionId, dto); + public EnvironmentEntryDto UpsertEnvironment(string collectionId, EnvironmentEntryDto dto) => + _runtime.RequireService().UpsertEnvironment(Guid.Parse(collectionId), dto); - public void DeleteEnvironment(long collectionId, string name) => - _runtime.RequireService().DeleteEnvironment(collectionId, name); + public void DeleteEnvironment(string collectionId, string name) => + _runtime.RequireService().DeleteEnvironment(Guid.Parse(collectionId), name); // ─── Time Windows ───────────────────────────────────────────────── - public TimeWindowDto[] GetTimeWindows(long collectionId) => - _runtime.RequireService().GetTimeWindows(collectionId).ToArray(); + public TimeWindowDto[] GetTimeWindows(string collectionId) => + _runtime.RequireService().GetTimeWindows(Guid.Parse(collectionId)).ToArray(); - public TimeWindowDto CreateTimeWindow(long collectionId, TimeWindowDto dto) => - _runtime.RequireService().CreateTimeWindow(collectionId, dto); + public TimeWindowDto CreateTimeWindow(string collectionId, TimeWindowDto dto) => + _runtime.RequireService().CreateTimeWindow(Guid.Parse(collectionId), dto); - public TimeWindowDto UpdateTimeWindow(long collectionId, long timeWindowId, TimeWindowDto dto) => - _runtime.RequireService().UpdateTimeWindow(collectionId, timeWindowId, dto); + public TimeWindowDto UpdateTimeWindow(string collectionId, string timeWindowId, TimeWindowDto dto) => + _runtime.RequireService().UpdateTimeWindow(Guid.Parse(collectionId), Guid.Parse(timeWindowId), dto); - public void DeleteTimeWindow(long collectionId, long timeWindowId) => - _runtime.RequireService().DeleteTimeWindow(collectionId, timeWindowId); + public void DeleteTimeWindow(string collectionId, string timeWindowId) => + _runtime.RequireService().DeleteTimeWindow(Guid.Parse(collectionId), Guid.Parse(timeWindowId)); // ─── Schema ─────────────────────────────────────────────────────── - public string ExportSchema(long collectionId) => - _runtime.RequireSchemaService().ExportSchema(collectionId); + public string ExportSchema(string collectionId) => + _runtime.RequireSchemaService().ExportSchema(Guid.Parse(collectionId)); - public void ImportSchema(long collectionId, string schemaJson) => - _runtime.RequireSchemaService().ImportSchema(collectionId, schemaJson); + public void ImportSchema(string collectionId, string schemaJson) => + _runtime.RequireSchemaService().ImportSchema(Guid.Parse(collectionId), schemaJson); public PerEnvironmentDefinitionDto Dummy() => new PerEnvironmentDefinitionDto(BooleanValue: true); } diff --git a/src/OpenFeatureManager.Wasm/IFlagdWasmService.cs b/src/OpenFeatureManager.Wasm/IFlagdWasmService.cs index 4f02211..781fb84 100644 --- a/src/OpenFeatureManager.Wasm/IFlagdWasmService.cs +++ b/src/OpenFeatureManager.Wasm/IFlagdWasmService.cs @@ -11,36 +11,36 @@ public interface IFlagdWasmService // ─── Collections ────────────────────────────────────────────────── FlagsCollectionDto[] GetCollections(); - FlagsCollectionDto GetCollection(long id); + FlagsCollectionDto GetCollection(string id); FlagsCollectionDto CreateCollection(string name); - FlagsCollectionDto RenameCollection(long id, string name); - void DeleteCollection(long id); - void ClearCollectionData(long collectionId); - void UpdateCollectionMetadata(long collectionId, MetadataEntryDto[] metadata); + FlagsCollectionDto RenameCollection(string id, string name); + void DeleteCollection(string id); + void ClearCollectionData(string collectionId); + void UpdateCollectionMetadata(string collectionId, MetadataEntryDto[] metadata); // ─── Flags ──────────────────────────────────────────────────────── - FlagEntryDto[] GetFlags(long collectionId); - FlagEntryDto UpsertFlag(long collectionId, FlagEntryDto dto); - void DeleteFlag(long collectionId, string flagKey); + FlagEntryDto[] GetFlags(string collectionId); + FlagEntryDto UpsertFlag(string collectionId, FlagEntryDto dto); + void DeleteFlag(string collectionId, string flagKey); // ─── Environments ───────────────────────────────────────────────── - EnvironmentEntryDto[] GetEnvironments(long collectionId); - EnvironmentEntryDto UpsertEnvironment(long collectionId, EnvironmentEntryDto dto); - void DeleteEnvironment(long collectionId, string name); + EnvironmentEntryDto[] GetEnvironments(string collectionId); + EnvironmentEntryDto UpsertEnvironment(string collectionId, EnvironmentEntryDto dto); + void DeleteEnvironment(string collectionId, string name); // ─── Time Windows ───────────────────────────────────────────────── - TimeWindowDto[] GetTimeWindows(long collectionId); - TimeWindowDto CreateTimeWindow(long collectionId, TimeWindowDto dto); - TimeWindowDto UpdateTimeWindow(long collectionId, long timeWindowId, TimeWindowDto dto); - void DeleteTimeWindow(long collectionId, long timeWindowId); + TimeWindowDto[] GetTimeWindows(string collectionId); + TimeWindowDto CreateTimeWindow(string collectionId, TimeWindowDto dto); + TimeWindowDto UpdateTimeWindow(string collectionId, string timeWindowId, TimeWindowDto dto); + void DeleteTimeWindow(string collectionId, string timeWindowId); // ─── Schema ─────────────────────────────────────────────────────── - string ExportSchema(long collectionId); - void ImportSchema(long collectionId, string schemaJson); + string ExportSchema(string collectionId); + void ImportSchema(string collectionId, string schemaJson); PerEnvironmentDefinitionDto Dummy(); } diff --git a/src/OpenFeatureManager/Data/FlagdDbContext.cs b/src/OpenFeatureManager/Data/FlagdDbContext.cs index 342e270..d163768 100644 --- a/src/OpenFeatureManager/Data/FlagdDbContext.cs +++ b/src/OpenFeatureManager/Data/FlagdDbContext.cs @@ -42,7 +42,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.ToTable("flags_collections"); entity.HasKey(e => e.Id); - entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.Id).ValueGeneratedNever(); entity.Property(e => e.Name).IsRequired(); entity.Property(e => e.CreatedAt).HasDefaultValueSql("datetime('now')"); entity.HasMany(e => e.Metadata).WithOne().HasForeignKey(e => e.CollectionId).OnDelete(DeleteBehavior.Cascade); @@ -55,7 +55,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.ToTable("flag_entries"); entity.HasKey(e => e.Id); - entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.Id).ValueGeneratedNever(); entity.Property(e => e.FlagKey).IsRequired(); entity.HasIndex(e => new { e.CollectionId, e.FlagKey }).IsUnique(); entity.Property(e => e.State).HasConversion(new EnumToStringConverter()); @@ -73,7 +73,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.ToTable("environment_entries"); entity.HasKey(e => e.Id); - entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.Id).ValueGeneratedNever(); entity.Property(e => e.Name).IsRequired(); entity.HasIndex(e => new { e.CollectionId, e.Name }).IsUnique(); entity.HasMany(e => e.Aliases).WithOne().HasForeignKey(e => e.EnvironmentEntryId).OnDelete(DeleteBehavior.Cascade); @@ -83,7 +83,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.ToTable("environment_aliases"); entity.HasKey(e => e.Id); - entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.Id).ValueGeneratedNever(); entity.Property(e => e.Alias).IsRequired(); }); @@ -91,7 +91,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.ToTable("collection_metadata_entries"); entity.HasKey(e => e.Id); - entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.Id).ValueGeneratedNever(); entity.Property(e => e.Key).IsRequired(); entity.HasIndex(e => new { e.CollectionId, e.Key }).IsUnique(); }); @@ -100,7 +100,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.ToTable("flag_metadata_entries"); entity.HasKey(e => e.Id); - entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.Id).ValueGeneratedNever(); entity.Property(e => e.Key).IsRequired(); entity.HasIndex(e => new { e.FlagEntryId, e.Key }).IsUnique(); }); @@ -109,7 +109,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.ToTable("per_environment_definitions"); entity.HasKey(e => e.Id); - entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.Id).ValueGeneratedNever(); entity.HasIndex(e => new { e.FlagEntryId, e.EnvironmentEntryId }).IsUnique(); entity.HasOne(e => e.Environment).WithMany().HasForeignKey(e => e.EnvironmentEntryId).OnDelete(DeleteBehavior.Cascade); entity.HasOne(e => e.TimeWindow).WithMany().HasForeignKey(e => e.TimeWindowId).OnDelete(DeleteBehavior.SetNull); @@ -119,7 +119,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.ToTable("time_windows"); entity.HasKey(e => e.Id); - entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.Id).ValueGeneratedNever(); entity.Property(e => e.Name).IsRequired(); entity.HasIndex(e => new { e.CollectionId, e.Name }).IsUnique(); }); diff --git a/src/OpenFeatureManager/Entities/CollectionMetadataEntry.cs b/src/OpenFeatureManager/Entities/CollectionMetadataEntry.cs index ecae3f3..38188a4 100644 --- a/src/OpenFeatureManager/Entities/CollectionMetadataEntry.cs +++ b/src/OpenFeatureManager/Entities/CollectionMetadataEntry.cs @@ -7,12 +7,11 @@ namespace OpenFeatureManager.Entities; public class CollectionMetadataEntry { [Key] - [DatabaseGenerated(DatabaseGeneratedOption.Identity)] [Column("id")] - public long Id { get; set; } + public Guid Id { get; set; } = Guid.NewGuid(); [Column("collection_id")] - public long CollectionId { get; set; } + public Guid CollectionId { get; set; } [Required] [Column("key")] diff --git a/src/OpenFeatureManager/Entities/EnvironmentAlias.cs b/src/OpenFeatureManager/Entities/EnvironmentAlias.cs index 98288fd..cf3c97a 100644 --- a/src/OpenFeatureManager/Entities/EnvironmentAlias.cs +++ b/src/OpenFeatureManager/Entities/EnvironmentAlias.cs @@ -7,12 +7,11 @@ namespace OpenFeatureManager.Entities; public class EnvironmentAlias { [Key] - [DatabaseGenerated(DatabaseGeneratedOption.Identity)] [Column("id")] - public long Id { get; set; } + public Guid Id { get; set; } = Guid.NewGuid(); [Column("environment_entry_id")] - public long EnvironmentEntryId { get; set; } + public Guid EnvironmentEntryId { get; set; } [Required] [Column("alias")] diff --git a/src/OpenFeatureManager/Entities/EnvironmentEntry.cs b/src/OpenFeatureManager/Entities/EnvironmentEntry.cs index afc8555..e30353b 100644 --- a/src/OpenFeatureManager/Entities/EnvironmentEntry.cs +++ b/src/OpenFeatureManager/Entities/EnvironmentEntry.cs @@ -7,12 +7,11 @@ namespace OpenFeatureManager.Entities; public class EnvironmentEntry { [Key] - [DatabaseGenerated(DatabaseGeneratedOption.Identity)] [Column("id")] - public long Id { get; set; } + public Guid Id { get; set; } = Guid.NewGuid(); [Column("collection_id")] - public long CollectionId { get; set; } + public Guid CollectionId { get; set; } /// Human-readable environment name, e.g. "Production", "Staging" [Required] diff --git a/src/OpenFeatureManager/Entities/FlagEntry.cs b/src/OpenFeatureManager/Entities/FlagEntry.cs index 5c6045c..12f75cd 100644 --- a/src/OpenFeatureManager/Entities/FlagEntry.cs +++ b/src/OpenFeatureManager/Entities/FlagEntry.cs @@ -7,12 +7,11 @@ namespace OpenFeatureManager.Entities; public abstract class FlagEntry { [Key] - [DatabaseGenerated(DatabaseGeneratedOption.Identity)] [Column("id")] - public long Id { get; set; } + public Guid Id { get; set; } = Guid.NewGuid(); [Column("collection_id")] - public long CollectionId { get; set; } + public Guid CollectionId { get; set; } [Required] [Column("flag_key")] @@ -22,7 +21,7 @@ public abstract class FlagEntry public FlagState State { get; set; } = FlagState.ENABLED; [Column("global_time_window_id")] - public long? GlobalTimeWindowId { get; set; } + public Guid? GlobalTimeWindowId { get; set; } public TimeWindow? GlobalTimeWindow { get; set; } diff --git a/src/OpenFeatureManager/Entities/FlagMetadataEntry.cs b/src/OpenFeatureManager/Entities/FlagMetadataEntry.cs index c47c7fc..47eb0ef 100644 --- a/src/OpenFeatureManager/Entities/FlagMetadataEntry.cs +++ b/src/OpenFeatureManager/Entities/FlagMetadataEntry.cs @@ -7,12 +7,11 @@ namespace OpenFeatureManager.Entities; public class FlagMetadataEntry { [Key] - [DatabaseGenerated(DatabaseGeneratedOption.Identity)] [Column("id")] - public long Id { get; set; } + public Guid Id { get; set; } = Guid.NewGuid(); [Column("flag_entry_id")] - public long FlagEntryId { get; set; } + public Guid FlagEntryId { get; set; } [Required] [Column("key")] diff --git a/src/OpenFeatureManager/Entities/FlagsCollection.cs b/src/OpenFeatureManager/Entities/FlagsCollection.cs index 8a69d6e..14eb2da 100644 --- a/src/OpenFeatureManager/Entities/FlagsCollection.cs +++ b/src/OpenFeatureManager/Entities/FlagsCollection.cs @@ -7,9 +7,8 @@ namespace OpenFeatureManager.Entities; public class FlagsCollection { [Key] - [DatabaseGenerated(DatabaseGeneratedOption.Identity)] [Column("id")] - public long Id { get; set; } + public Guid Id { get; set; } = Guid.NewGuid(); [Required] [Column("name")] diff --git a/src/OpenFeatureManager/Entities/PerEnvironmentDefinition.cs b/src/OpenFeatureManager/Entities/PerEnvironmentDefinition.cs index a49b78f..acb6649 100644 --- a/src/OpenFeatureManager/Entities/PerEnvironmentDefinition.cs +++ b/src/OpenFeatureManager/Entities/PerEnvironmentDefinition.cs @@ -7,15 +7,14 @@ namespace OpenFeatureManager.Entities; public class PerEnvironmentDefinition { [Key] - [DatabaseGenerated(DatabaseGeneratedOption.Identity)] [Column("id")] - public long Id { get; set; } + public Guid Id { get; set; } = Guid.NewGuid(); [Column("flag_entry_id")] - public long FlagEntryId { get; set; } + public Guid FlagEntryId { get; set; } [Column("environment_entry_id")] - public long EnvironmentEntryId { get; set; } + public Guid EnvironmentEntryId { get; set; } public EnvironmentEntry Environment { get; set; } = null!; @@ -32,7 +31,7 @@ public class PerEnvironmentDefinition public string? ObjectValue { get; set; } [Column("time_window_id")] - public long? TimeWindowId { get; set; } + public Guid? TimeWindowId { get; set; } public TimeWindow? TimeWindow { get; set; } } diff --git a/src/OpenFeatureManager/Entities/TimeWindow.cs b/src/OpenFeatureManager/Entities/TimeWindow.cs index 4890e7d..1ce91c6 100644 --- a/src/OpenFeatureManager/Entities/TimeWindow.cs +++ b/src/OpenFeatureManager/Entities/TimeWindow.cs @@ -7,12 +7,11 @@ namespace OpenFeatureManager.Entities; public class TimeWindow { [Key] - [DatabaseGenerated(DatabaseGeneratedOption.Identity)] [Column("id")] - public long Id { get; set; } + public Guid Id { get; set; } = Guid.NewGuid(); [Column("collection_id")] - public long CollectionId { get; set; } + public Guid CollectionId { get; set; } /// Human-readable name, e.g. "Christmas Time", "Black Friday" [Required] diff --git a/src/OpenFeatureManager/Models/FlagdJsonContext.cs b/src/OpenFeatureManager/Models/FlagdJsonContext.cs index c26bdb2..20f24ec 100644 --- a/src/OpenFeatureManager/Models/FlagdJsonContext.cs +++ b/src/OpenFeatureManager/Models/FlagdJsonContext.cs @@ -4,7 +4,7 @@ namespace OpenFeatureManager.Models; /// DTO for a flags collection (schema document). public record FlagsCollectionDto( - long Id, + Guid Id, string Name, DateTime CreatedAt, List? Metadata = null); @@ -39,11 +39,11 @@ public record PerEnvironmentDefinitionDto( string? StringValue = null, double? NumberValue = null, string? ObjectValue = null, - long? TimeWindowId = null); + Guid? TimeWindowId = null); /// DTO for a global time-windowed value override referencing a TimeWindow entity. public record GlobalTimeWindowDto( - long TimeWindowId, + Guid TimeWindowId, bool? BooleanValue = null, string? StringValue = null, double? NumberValue = null, @@ -53,7 +53,7 @@ public record GlobalTimeWindowDto( public record EnvironmentEntryDto(string Name, string[] Aliases); /// DTO for a reusable time window. -public record TimeWindowDto(long Id, string Name, DateTime? StartTime = null, DateTime? EndTime = null); +public record TimeWindowDto(Guid Id, string Name, DateTime? StartTime = null, DateTime? EndTime = null); [JsonSerializable(typeof(string[]))] [JsonSerializable(typeof(FlagsCollectionDto))] diff --git a/src/OpenFeatureManager/Services/FlagdSchemaService.cs b/src/OpenFeatureManager/Services/FlagdSchemaService.cs index 61901eb..5ce1f27 100644 --- a/src/OpenFeatureManager/Services/FlagdSchemaService.cs +++ b/src/OpenFeatureManager/Services/FlagdSchemaService.cs @@ -33,7 +33,7 @@ public FlagdSchemaService(FlagdService flagdService, SchemaValidator? validator /// then parses the JSON document directly. /// Existing flags and environments for the file are replaced. /// - public void ImportSchema(long collectionId, string schemaJson) + public void ImportSchema(Guid collectionId, string schemaJson) { _validator?.ValidateOrThrow(schemaJson); @@ -160,7 +160,7 @@ public void ImportSchema(long collectionId, string schemaJson) /// Reconstruct and return a FlagdSchema JSON string from the stored data. /// Validates the output against the flagd schema (if a validator is configured). /// - public string ExportSchema(long collectionId) + public string ExportSchema(Guid collectionId) { var collection = _flagdService.GetCollection(collectionId); var flags = _flagdService.GetFlags(collectionId); diff --git a/src/OpenFeatureManager/Services/FlagdService.cs b/src/OpenFeatureManager/Services/FlagdService.cs index bc54a08..201886c 100644 --- a/src/OpenFeatureManager/Services/FlagdService.cs +++ b/src/OpenFeatureManager/Services/FlagdService.cs @@ -22,7 +22,7 @@ public FlagdService(Func contextFactory) // ─── Collection management ────────────────────────────────────────── - public FlagsCollectionDto GetCollection(long id) + public FlagsCollectionDto GetCollection(Guid id) { using var db = _contextFactory(); var collection = db.FlagsCollections.Include(f => f.Metadata).FirstOrDefault(f => f.Id == id) @@ -45,7 +45,7 @@ public List GetCollections() return db.FlagsCollections.Include(f => f.Metadata).OrderBy(f => f.Id).ToList().Select(ToDto).ToList(); } - public FlagsCollectionDto RenameCollection(long id, string name) + public FlagsCollectionDto RenameCollection(Guid id, string name) { using var db = _contextFactory(); var collection = db.FlagsCollections.Include(f => f.Metadata).FirstOrDefault(f => f.Id == id) @@ -55,7 +55,7 @@ public FlagsCollectionDto RenameCollection(long id, string name) return ToDto(collection); } - public void DeleteCollection(long id) + public void DeleteCollection(Guid id) { using var db = _contextFactory(); var collection = db.FlagsCollections.Find(id) ?? throw new KeyNotFoundException($"Collection {id} not found"); @@ -67,7 +67,7 @@ public void DeleteCollection(long id) db.SaveChanges(); } - public void ClearCollectionData(long collectionId) + public void ClearCollectionData(Guid collectionId) { using var db = _contextFactory(); if (!db.FlagsCollections.Any(f => f.Id == collectionId)) @@ -79,7 +79,7 @@ public void ClearCollectionData(long collectionId) db.SaveChanges(); } - public void UpdateCollectionMetadata(long collectionId, List metadata) + public void UpdateCollectionMetadata(Guid collectionId, List metadata) { using var db = _contextFactory(); var collection = db.FlagsCollections.Find(collectionId) ?? throw new KeyNotFoundException($"Collection {collectionId} not found"); @@ -101,7 +101,7 @@ public void UpdateCollectionMetadata(long collectionId, List m // ─── Flag management ────────────────────────────────────────────────── - public List GetFlags(long collectionId) + public List GetFlags(Guid collectionId) { using var db = _contextFactory(); var envLookup = db.EnvironmentEntries @@ -125,7 +125,7 @@ public List GetFlags(long collectionId) /// Per-environment definitions are keyed by environment name; the service /// resolves names to EnvironmentEntry IDs. /// - public FlagEntryDto UpsertFlag(long collectionId, FlagEntryDto dto) + public FlagEntryDto UpsertFlag(Guid collectionId, FlagEntryDto dto) { using var db = _contextFactory(); @@ -215,7 +215,7 @@ public FlagEntryDto UpsertFlag(long collectionId, FlagEntryDto dto) return ToDto(existing, envLookup); } - public void DeleteFlag(long collectionId, string flagKey) + public void DeleteFlag(Guid collectionId, string flagKey) { using var db = _contextFactory(); var entry = db.FlagEntries.FirstOrDefault(f => f.CollectionId == collectionId && f.FlagKey == flagKey) @@ -226,7 +226,7 @@ public void DeleteFlag(long collectionId, string flagKey) // ─── Environment management ─────────────────────────────────────────── - public List GetEnvironments(long collectionId) + public List GetEnvironments(Guid collectionId) { using var db = _contextFactory(); return db.EnvironmentEntries @@ -238,7 +238,7 @@ public List GetEnvironments(long collectionId) .ToList(); } - public EnvironmentEntryDto UpsertEnvironment(long collectionId, EnvironmentEntryDto dto) + public EnvironmentEntryDto UpsertEnvironment(Guid collectionId, EnvironmentEntryDto dto) { if (string.IsNullOrWhiteSpace(dto.Name)) throw new ArgumentException("Environment name is required"); @@ -271,7 +271,7 @@ public EnvironmentEntryDto UpsertEnvironment(long collectionId, EnvironmentEntry return ToDto(existing); } - public void DeleteEnvironment(long collectionId, string name) + public void DeleteEnvironment(Guid collectionId, string name) { using var db = _contextFactory(); var entry = db.EnvironmentEntries.FirstOrDefault(e => e.CollectionId == collectionId && e.Name == name) @@ -282,7 +282,7 @@ public void DeleteEnvironment(long collectionId, string name) // ─── Time window management ─────────────────────────────────────────── - public List GetTimeWindows(long collectionId) + public List GetTimeWindows(Guid collectionId) { using var db = _contextFactory(); return db.TimeWindows @@ -293,7 +293,7 @@ public List GetTimeWindows(long collectionId) .ToList(); } - public TimeWindowDto CreateTimeWindow(long collectionId, TimeWindowDto dto) + public TimeWindowDto CreateTimeWindow(Guid collectionId, TimeWindowDto dto) { if (string.IsNullOrWhiteSpace(dto.Name)) throw new ArgumentException("Time window name is required"); @@ -314,7 +314,7 @@ public TimeWindowDto CreateTimeWindow(long collectionId, TimeWindowDto dto) return ToDto(tw); } - public TimeWindowDto UpdateTimeWindow(long collectionId, long timeWindowId, TimeWindowDto dto) + public TimeWindowDto UpdateTimeWindow(Guid collectionId, Guid timeWindowId, TimeWindowDto dto) { using var db = _contextFactory(); var tw = db.TimeWindows.FirstOrDefault(t => t.Id == timeWindowId && t.CollectionId == collectionId) @@ -327,7 +327,7 @@ public TimeWindowDto UpdateTimeWindow(long collectionId, long timeWindowId, Time return ToDto(tw); } - public void DeleteTimeWindow(long collectionId, long timeWindowId) + public void DeleteTimeWindow(Guid collectionId, Guid timeWindowId) { using var db = _contextFactory(); var tw = db.TimeWindows.FirstOrDefault(t => t.Id == timeWindowId && t.CollectionId == collectionId) @@ -347,7 +347,7 @@ public void DeleteTimeWindow(long collectionId, long timeWindowId) _ => throw new InvalidOperationException($"Unknown flag entry type: {entry.GetType()}") }; - private static FlagEntry CreateFlagEntry(string type, long collectionId, string flagKey) => type switch + private static FlagEntry CreateFlagEntry(string type, Guid collectionId, string flagKey) => type switch { "boolean" => new BooleanFlagEntry { CollectionId = collectionId, FlagKey = flagKey }, "string" => new StringFlagEntry { CollectionId = collectionId, FlagKey = flagKey }, @@ -384,7 +384,7 @@ private static FlagsCollectionDto ToDto(FlagsCollection f) => ? f.Metadata.Select(m => new MetadataEntryDto(m.Key, m.StringValue, m.NumberValue, m.BooleanValue)).ToList() : null); - private static FlagEntryDto ToDto(FlagEntry e, Dictionary envIdToName) + private static FlagEntryDto ToDto(FlagEntry e, Dictionary envIdToName) { var (type, boolVal, strVal, numVal, objVal) = e switch { diff --git a/ui/src/app/components/flag-editor/flag-editor.ts b/ui/src/app/components/flag-editor/flag-editor.ts index c350887..20e93dc 100644 --- a/ui/src/app/components/flag-editor/flag-editor.ts +++ b/ui/src/app/components/flag-editor/flag-editor.ts @@ -615,7 +615,7 @@ export class FlagEditorComponent implements OnInit, OnChanges, OnDestroy { if (startDate || endDate) { globalTimeWindow = { - id: 0, // Placeholder, backend should assign real ID + id: '', // Placeholder, backend should assign real ID name: `${key}-global-time-window`, startTime: startDate as any, endTime: endDate as any, diff --git a/ui/src/app/services/flag-backend.ts b/ui/src/app/services/flag-backend.ts index 472aa7b..78c17f1 100644 --- a/ui/src/app/services/flag-backend.ts +++ b/ui/src/app/services/flag-backend.ts @@ -5,7 +5,7 @@ import { InjectionToken } from '@angular/core'; // ============================================================================ export interface CollectionDto { - id: number; + id: string; name: string; createdAt: string; metadata: MetadataDto[]; @@ -38,14 +38,14 @@ export interface EnvironmentDto { } export interface TimeWindowDto { - id: number; + id: string; name: string; startTime?: string | null; endTime?: string | null; } export interface GlobalTimeWindowDto { - timeWindowId: number; + timeWindowId: string; booleanValue?: boolean | null; stringValue?: string | null; numberValue?: number | null; @@ -57,7 +57,7 @@ export interface PerEnvironmentDefinitionDto { stringValue?: string | null; numberValue?: number | null; objectValue?: string | null; - timeWindowId?: number | null; + timeWindowId?: string | null; } // ============================================================================ @@ -79,37 +79,37 @@ export interface FlagBackend { // Collections listCollections(): Promise; createCollection(name: string): Promise; - renameCollection(id: number, name: string): Promise; - deleteCollection(id: number): Promise; + renameCollection(id: string, name: string): Promise; + deleteCollection(id: string): Promise; // Flags - getFlags(collectionId: number): Promise; - createFlag(collectionId: number, flag: FlagDto): Promise; - updateFlag(collectionId: number, flag: FlagDto): Promise; // TODO: Add originalKey parameter for key updates - deleteFlag(collectionId: number, flagKey: string): Promise; + getFlags(collectionId: string): Promise; + createFlag(collectionId: string, flag: FlagDto): Promise; + updateFlag(collectionId: string, flag: FlagDto): Promise; // TODO: Add originalKey parameter for key updates + deleteFlag(collectionId: string, flagKey: string): Promise; // Environments - getEnvironments(collectionId: number): Promise; - createEnvironment(collectionId: number, env: EnvironmentDto): Promise; - updateEnvironment(collectionId: number, env: EnvironmentDto): Promise; - deleteEnvironment(collectionId: number, name: string): Promise; + getEnvironments(collectionId: string): Promise; + createEnvironment(collectionId: string, env: EnvironmentDto): Promise; + updateEnvironment(collectionId: string, env: EnvironmentDto): Promise; + deleteEnvironment(collectionId: string, name: string): Promise; // Time windows - getTimeWindows(collectionId: number): Promise; - createTimeWindow(collectionId: number, tw: TimeWindowDto): Promise; + getTimeWindows(collectionId: string): Promise; + createTimeWindow(collectionId: string, tw: TimeWindowDto): Promise; updateTimeWindow( - collectionId: number, - timeWindowId: number, + collectionId: string, + timeWindowId: string, tw: TimeWindowDto, ): Promise; - deleteTimeWindow(collectionId: number, timeWindowId: number): Promise; + deleteTimeWindow(collectionId: string, timeWindowId: string): Promise; // Schema - exportSchema(collectionId: number): Promise>; - importSchema(collectionId: number, schema: string): Promise; + exportSchema(collectionId: string): Promise>; + importSchema(collectionId: string, schema: string): Promise; // Collection metadata - updateCollectionMetadata(collectionId: number, metadata: MetadataDto[]): Promise; + updateCollectionMetadata(collectionId: string, metadata: MetadataDto[]): Promise; } export const FLAG_BACKEND = new InjectionToken('FlagBackend'); diff --git a/ui/src/app/services/rest-flag-backend.ts b/ui/src/app/services/rest-flag-backend.ts index e70f001..e308b3e 100644 --- a/ui/src/app/services/rest-flag-backend.ts +++ b/ui/src/app/services/rest-flag-backend.ts @@ -11,7 +11,6 @@ import { MetadataEntryDto } from '../api-client/model/metadataEntryDto'; import { GlobalTimeWindowDto as ApiGlobalTimeWindowDto } from '../api-client/model/globalTimeWindowDto'; import { PerEnvironmentDefinitionDto as ApiPerEnvDto } from '../api-client/model/perEnvironmentDefinitionDto'; import { TimeWindowDto as ApiTimeWindowDto } from '../api-client/model/timeWindowDto'; -import { RenameCollectionIdParameter } from '../api-client/model/renameCollectionIdParameter'; import { CollectionDto, EnvironmentDto, @@ -46,50 +45,50 @@ export class RestFlagBackend implements FlagBackend { return this.toCollectionDto(c); } - async renameCollection(id: number, name: string): Promise { - const c = await firstValueFrom(this.collections.renameCollection(this.toId(id), { name })); + async renameCollection(id: string, name: string): Promise { + const c = await firstValueFrom(this.collections.renameCollection(id, { name })); return this.toCollectionDto(c); } - async deleteCollection(id: number): Promise { - await firstValueFrom(this.collections.deleteCollection(this.toId(id))); + async deleteCollection(id: string): Promise { + await firstValueFrom(this.collections.deleteCollection(id)); } // ── Flags ────────────────────────────────────────────────────────────── - async getFlags(collectionId: number): Promise { - const list = await firstValueFrom(this.flags.getFlags(this.toId(collectionId))); + async getFlags(collectionId: string): Promise { + const list = await firstValueFrom(this.flags.getFlags(collectionId)); return list.map((f) => this.toFlagDto(f)); } - async createFlag(collectionId: number, flag: FlagDto): Promise { + async createFlag(collectionId: string, flag: FlagDto): Promise { const f = await firstValueFrom( - this.flags.createFlag(this.toId(collectionId), this.toApiFlagEntry(flag)), + this.flags.createFlag(collectionId, this.toApiFlagEntry(flag)), ); return this.toFlagDto(f); } - async updateFlag(collectionId: number, flag: FlagDto): Promise { + async updateFlag(collectionId: string, flag: FlagDto): Promise { const f = await firstValueFrom( - this.flags.updateFlag(this.toId(collectionId), this.toApiFlagEntry(flag)), + this.flags.updateFlag(collectionId, this.toApiFlagEntry(flag)), ); return this.toFlagDto(f); } - async deleteFlag(collectionId: number, flagKey: string): Promise { - await firstValueFrom(this.flags.deleteFlag(this.toId(collectionId), flagKey)); + async deleteFlag(collectionId: string, flagKey: string): Promise { + await firstValueFrom(this.flags.deleteFlag(collectionId, flagKey)); } // ── Environments ─────────────────────────────────────────────────────── - async getEnvironments(collectionId: number): Promise { - const list = await firstValueFrom(this.environments.getEnvironments(this.toId(collectionId))); + async getEnvironments(collectionId: string): Promise { + const list = await firstValueFrom(this.environments.getEnvironments(collectionId)); return list.map((e) => ({ name: e.name, aliases: e.aliases ?? [] })); } - async createEnvironment(collectionId: number, env: EnvironmentDto): Promise { + async createEnvironment(collectionId: string, env: EnvironmentDto): Promise { const e = await firstValueFrom( - this.environments.createEnvironment(this.toId(collectionId), { + this.environments.createEnvironment(collectionId, { name: env.name, aliases: env.aliases, }), @@ -97,9 +96,9 @@ export class RestFlagBackend implements FlagBackend { return { name: e.name, aliases: e.aliases ?? [] }; } - async updateEnvironment(collectionId: number, env: EnvironmentDto): Promise { + async updateEnvironment(collectionId: string, env: EnvironmentDto): Promise { const e = await firstValueFrom( - this.environments.updateEnvironment(this.toId(collectionId), { + this.environments.updateEnvironment(collectionId, { name: env.name, aliases: env.aliases, }), @@ -107,63 +106,61 @@ export class RestFlagBackend implements FlagBackend { return { name: e.name, aliases: e.aliases ?? [] }; } - async deleteEnvironment(collectionId: number, name: string): Promise { - await firstValueFrom(this.environments.deleteEnvironment(this.toId(collectionId), name)); + async deleteEnvironment(collectionId: string, name: string): Promise { + await firstValueFrom(this.environments.deleteEnvironment(collectionId, name)); } // ── Time Windows ─────────────────────────────────────────────────────── - async getTimeWindows(collectionId: number): Promise { - const list = await firstValueFrom(this.timeWindows.getTimeWindows(this.toId(collectionId))); + async getTimeWindows(collectionId: string): Promise { + const list = await firstValueFrom(this.timeWindows.getTimeWindows(collectionId)); return list.map((tw) => this.toTimeWindowDto(tw)); } - async createTimeWindow(collectionId: number, tw: TimeWindowDto): Promise { + async createTimeWindow(collectionId: string, tw: TimeWindowDto): Promise { const created = await firstValueFrom( - this.timeWindows.createTimeWindow(this.toId(collectionId), this.toApiTimeWindow(tw)), + this.timeWindows.createTimeWindow(collectionId, this.toApiTimeWindow(tw)), ); return this.toTimeWindowDto(created); } async updateTimeWindow( - collectionId: number, - timeWindowId: number, + collectionId: string, + timeWindowId: string, tw: TimeWindowDto, ): Promise { const updated = await firstValueFrom( this.timeWindows.updateTimeWindow( - this.toId(collectionId), - this.toId(timeWindowId), + collectionId, + timeWindowId, this.toApiTimeWindow(tw), ), ); return this.toTimeWindowDto(updated); } - async deleteTimeWindow(collectionId: number, timeWindowId: number): Promise { + async deleteTimeWindow(collectionId: string, timeWindowId: string): Promise { await firstValueFrom( - this.timeWindows.deleteTimeWindow(this.toId(collectionId), this.toId(timeWindowId)), + this.timeWindows.deleteTimeWindow(collectionId, timeWindowId), ); } // ── Schema ───────────────────────────────────────────────────────────── - async exportSchema(collectionId: number): Promise> { - const result = await firstValueFrom(this.schema.exportSchema(this.toId(collectionId))); + async exportSchema(collectionId: string): Promise> { + const result = await firstValueFrom(this.schema.exportSchema(collectionId)); return result as Record; } - async importSchema(collectionId: number, schema: string): Promise { - await firstValueFrom(this.schema.importSchema(this.toId(collectionId), schema)); + async importSchema(collectionId: string, schema: string): Promise { + await firstValueFrom(this.schema.importSchema(collectionId, schema)); } // ── Collection Metadata ──────────────────────────────────────────────── - async updateCollectionMetadata(collectionId: number, metadata: MetadataDto[]): Promise { - // The REST API doesn't have a direct metadata endpoint; metadata is part of the collection DTO. - // A rename call with the same name can be used to trigger a PUT. + async updateCollectionMetadata(collectionId: string, metadata: MetadataDto[]): Promise { const collections = await firstValueFrom(this.collections.listCollections()); - const collection = collections.find((c) => this.fromId(c.id) === collectionId); + const collection = collections.find((c) => c.id === collectionId); if (!collection) { throw new Error(`Collection ${collectionId} not found`); } @@ -174,17 +171,9 @@ export class RestFlagBackend implements FlagBackend { // ── Type Mapping Helpers ─────────────────────────────────────────────── - private toId(n: number): RenameCollectionIdParameter { - return n as unknown as RenameCollectionIdParameter; - } - - private fromId(id: RenameCollectionIdParameter): number { - return id as unknown as number; - } - private toCollectionDto(c: FlagsCollectionDto): CollectionDto { return { - id: this.fromId(c.id), + id: c.id, name: c.name, createdAt: c.createdAt, metadata: c.metadata?.map((m) => this.toMetadataDto(m)) ?? [], @@ -251,7 +240,7 @@ export class RestFlagBackend implements FlagBackend { private toTimeWindowDto(tw: ApiTimeWindowDto): TimeWindowDto { return { - id: this.fromId(tw.id), + id: tw.id, name: tw.name, startTime: tw.startTime ?? null, endTime: tw.endTime ?? null, @@ -260,7 +249,7 @@ export class RestFlagBackend implements FlagBackend { private toApiTimeWindow(tw: TimeWindowDto): ApiTimeWindowDto { return { - id: this.toId(tw.id), + id: tw.id, name: tw.name, startTime: tw.startTime, endTime: tw.endTime, @@ -269,7 +258,7 @@ export class RestFlagBackend implements FlagBackend { private toGlobalTimeWindowDto(g: ApiGlobalTimeWindowDto): GlobalTimeWindowDto { return { - timeWindowId: this.fromId(g.timeWindowId), + timeWindowId: g.timeWindowId, booleanValue: g.booleanValue ?? null, stringValue: g.stringValue ?? null, numberValue: (g.numberValue as unknown as number) ?? null, @@ -279,7 +268,7 @@ export class RestFlagBackend implements FlagBackend { private toApiGlobalTimeWindow(g: GlobalTimeWindowDto): ApiGlobalTimeWindowDto { return { - timeWindowId: this.toId(g.timeWindowId), + timeWindowId: g.timeWindowId, booleanValue: g.booleanValue, stringValue: g.stringValue, numberValue: g.numberValue as unknown as ApiGlobalTimeWindowDto['numberValue'], @@ -297,7 +286,7 @@ export class RestFlagBackend implements FlagBackend { stringValue: def.stringValue ?? null, numberValue: (def.numberValue as unknown as number) ?? null, objectValue: def.objectValue ?? null, - timeWindowId: def.timeWindowId ? (def.timeWindowId as unknown as number) : null, + timeWindowId: def.timeWindowId ?? null, }; } return result; diff --git a/ui/src/app/services/wasm-backend.service.ts b/ui/src/app/services/wasm-backend.service.ts index ab05ff6..b04c267 100644 --- a/ui/src/app/services/wasm-backend.service.ts +++ b/ui/src/app/services/wasm-backend.service.ts @@ -125,7 +125,7 @@ export class WasmBackendService { return collections; } - getCollection(id: bigint): FlagsCollectionDto { + getCollection(id: string): FlagsCollectionDto { return this.flagd().getCollection(id); } @@ -134,79 +134,79 @@ export class WasmBackendService { return this.flagd().createCollection(name); } - renameCollection(id: bigint, name: string): FlagsCollectionDto { + renameCollection(id: string, name: string): FlagsCollectionDto { return this.flagd().renameCollection(id, name); } - deleteCollection(id: bigint): void { + deleteCollection(id: string): void { this.flagd().deleteCollection(id); } - clearCollectionData(collectionId: bigint): void { + clearCollectionData(collectionId: string): void { this.flagd().clearCollectionData(collectionId); } - updateCollectionMetadata(collectionId: bigint, metadata: MetadataEntryDto[]): void { + updateCollectionMetadata(collectionId: string, metadata: MetadataEntryDto[]): void { this.flagd().updateCollectionMetadata(collectionId, metadata); } // ─── Flags ──────────────────────────────────────────────────────────── - getFlags(collectionId: bigint): FlagEntryDto[] { + getFlags(collectionId: string): FlagEntryDto[] { const flags = this.flagd().getFlags(collectionId); console.log(`Fetched flags for collection ${collectionId} from WASM backend:`, flags); return flags; } - upsertFlag(collectionId: bigint, dto: FlagEntryDto): FlagEntryDto { + upsertFlag(collectionId: string, dto: FlagEntryDto): FlagEntryDto { const flag = this.flagd().upsertFlag(collectionId, dto); console.log(`Upserted flag for collection ${collectionId} in WASM backend:`, flag); return flag; } - deleteFlag(collectionId: bigint, flagKey: string): void { + deleteFlag(collectionId: string, flagKey: string): void { this.flagd().deleteFlag(collectionId, flagKey); } // ─── Environments ───────────────────────────────────────────────────── - getEnvironments(collectionId: bigint): EnvironmentEntryDto[] { + getEnvironments(collectionId: string): EnvironmentEntryDto[] { return this.flagd().getEnvironments(collectionId); } - upsertEnvironment(collectionId: bigint, dto: EnvironmentEntryDto): EnvironmentEntryDto { + upsertEnvironment(collectionId: string, dto: EnvironmentEntryDto): EnvironmentEntryDto { return this.flagd().upsertEnvironment(collectionId, dto); } - deleteEnvironment(collectionId: bigint, name: string): void { + deleteEnvironment(collectionId: string, name: string): void { this.flagd().deleteEnvironment(collectionId, name); } // ─── Time Windows ───────────────────────────────────────────────────── - getTimeWindows(collectionId: bigint): TimeWindowDto[] { + getTimeWindows(collectionId: string): TimeWindowDto[] { return this.flagd().getTimeWindows(collectionId); } - createTimeWindow(collectionId: bigint, dto: TimeWindowDto): TimeWindowDto { + createTimeWindow(collectionId: string, dto: TimeWindowDto): TimeWindowDto { return this.flagd().createTimeWindow(collectionId, dto); } - updateTimeWindow(collectionId: bigint, timeWindowId: bigint, dto: TimeWindowDto): TimeWindowDto { + updateTimeWindow(collectionId: string, timeWindowId: string, dto: TimeWindowDto): TimeWindowDto { return this.flagd().updateTimeWindow(collectionId, timeWindowId, dto); } - deleteTimeWindow(collectionId: bigint, timeWindowId: bigint): void { + deleteTimeWindow(collectionId: string, timeWindowId: string): void { this.flagd().deleteTimeWindow(collectionId, timeWindowId); } // ─── Schema ─────────────────────────────────────────────────────────── - exportSchema(collectionId: bigint): string { + exportSchema(collectionId: string): string { return this.flagd().exportSchema(collectionId); } - importSchema(collectionId: bigint, schemaJson: string): void { + importSchema(collectionId: string, schemaJson: string): void { this.flagd().importSchema(collectionId, schemaJson); } diff --git a/ui/src/app/services/wasm-flag-backend.ts b/ui/src/app/services/wasm-flag-backend.ts index 6e9cb77..46a5193 100644 --- a/ui/src/app/services/wasm-flag-backend.ts +++ b/ui/src/app/services/wasm-flag-backend.ts @@ -51,105 +51,105 @@ export class WasmFlagBackend implements FlagBackend { return this.toCollectionDto(this.wasm.createCollection(name)); } - async renameCollection(id: number, name: string): Promise { - return this.toCollectionDto(this.wasm.renameCollection(BigInt(id), name)); + async renameCollection(id: string, name: string): Promise { + return this.toCollectionDto(this.wasm.renameCollection(id, name)); } - async deleteCollection(id: number): Promise { - this.wasm.deleteCollection(BigInt(id)); + async deleteCollection(id: string): Promise { + this.wasm.deleteCollection(id); } // ── Flags ────────────────────────────────────────────────────────────── - async getFlags(collectionId: number): Promise { - return this.wasm.getFlags(BigInt(collectionId)).map((f) => this.toFlagDto(f)); + async getFlags(collectionId: string): Promise { + return this.wasm.getFlags(collectionId).map((f) => this.toFlagDto(f)); } - async createFlag(collectionId: number, flag: FlagDto): Promise { - return this.toFlagDto(this.wasm.upsertFlag(BigInt(collectionId), this.toWasmFlag(flag))); + async createFlag(collectionId: string, flag: FlagDto): Promise { + return this.toFlagDto(this.wasm.upsertFlag(collectionId, this.toWasmFlag(flag))); } - async updateFlag(collectionId: number, flag: FlagDto): Promise { - return this.toFlagDto(this.wasm.upsertFlag(BigInt(collectionId), this.toWasmFlag(flag))); + async updateFlag(collectionId: string, flag: FlagDto): Promise { + return this.toFlagDto(this.wasm.upsertFlag(collectionId, this.toWasmFlag(flag))); } - async deleteFlag(collectionId: number, flagKey: string): Promise { - this.wasm.deleteFlag(BigInt(collectionId), flagKey); + async deleteFlag(collectionId: string, flagKey: string): Promise { + this.wasm.deleteFlag(collectionId, flagKey); } // ── Environments ─────────────────────────────────────────────────────── - async getEnvironments(collectionId: number): Promise { + async getEnvironments(collectionId: string): Promise { return this.wasm - .getEnvironments(BigInt(collectionId)) + .getEnvironments(collectionId) .map((e) => ({ name: e.name, aliases: [...e.aliases] })); } - async createEnvironment(collectionId: number, env: EnvironmentDto): Promise { - const e = this.wasm.upsertEnvironment(BigInt(collectionId), { + async createEnvironment(collectionId: string, env: EnvironmentDto): Promise { + const e = this.wasm.upsertEnvironment(collectionId, { name: env.name, aliases: env.aliases, } as WasmEnvironmentDto); return { name: e.name, aliases: [...e.aliases] }; } - async updateEnvironment(collectionId: number, env: EnvironmentDto): Promise { - const e = this.wasm.upsertEnvironment(BigInt(collectionId), { + async updateEnvironment(collectionId: string, env: EnvironmentDto): Promise { + const e = this.wasm.upsertEnvironment(collectionId, { name: env.name, aliases: env.aliases, } as WasmEnvironmentDto); return { name: e.name, aliases: [...e.aliases] }; } - async deleteEnvironment(collectionId: number, name: string): Promise { - this.wasm.deleteEnvironment(BigInt(collectionId), name); + async deleteEnvironment(collectionId: string, name: string): Promise { + this.wasm.deleteEnvironment(collectionId, name); } // ── Time Windows ─────────────────────────────────────────────────────── - async getTimeWindows(collectionId: number): Promise { - return this.wasm.getTimeWindows(BigInt(collectionId)).map((tw) => this.toTimeWindowDto(tw)); + async getTimeWindows(collectionId: string): Promise { + return this.wasm.getTimeWindows(collectionId).map((tw) => this.toTimeWindowDto(tw)); } - async createTimeWindow(collectionId: number, tw: TimeWindowDto): Promise { - const created = this.wasm.createTimeWindow(BigInt(collectionId), this.toWasmTimeWindow(tw)); + async createTimeWindow(collectionId: string, tw: TimeWindowDto): Promise { + const created = this.wasm.createTimeWindow(collectionId, this.toWasmTimeWindow(tw)); return this.toTimeWindowDto(created); } async updateTimeWindow( - collectionId: number, - timeWindowId: number, + collectionId: string, + timeWindowId: string, tw: TimeWindowDto, ): Promise { const updated = this.wasm.updateTimeWindow( - BigInt(collectionId), - BigInt(timeWindowId), + collectionId, + timeWindowId, this.toWasmTimeWindow(tw), ); return this.toTimeWindowDto(updated); } - async deleteTimeWindow(collectionId: number, timeWindowId: number): Promise { - this.wasm.deleteTimeWindow(BigInt(collectionId), BigInt(timeWindowId)); + async deleteTimeWindow(collectionId: string, timeWindowId: string): Promise { + this.wasm.deleteTimeWindow(collectionId, timeWindowId); } // ── Schema ───────────────────────────────────────────────────────────── - async exportSchema(collectionId: number): Promise> { - const json = this.wasm.exportSchema(BigInt(collectionId)); + async exportSchema(collectionId: string): Promise> { + const json = this.wasm.exportSchema(collectionId); return JSON.parse(json) as Record; } - async importSchema(collectionId: number, schema: string): Promise { + async importSchema(collectionId: string, schema: string): Promise { console.log('Importing schema into WASM backend:', schema); - this.wasm.importSchema(BigInt(collectionId), schema); + this.wasm.importSchema(collectionId, schema); } // ── Collection Metadata ──────────────────────────────────────────────── - async updateCollectionMetadata(collectionId: number, metadata: MetadataDto[]): Promise { + async updateCollectionMetadata(collectionId: string, metadata: MetadataDto[]): Promise { this.wasm.updateCollectionMetadata( - BigInt(collectionId), + collectionId, metadata.map((m) => this.toWasmMetadata(m)), ); } @@ -158,10 +158,10 @@ export class WasmFlagBackend implements FlagBackend { private toCollectionDto(c: WasmCollectionDto): CollectionDto { return { - id: Number(c.id), + id: String(c.id), name: c.name, createdAt: c.createdAt instanceof Date ? c.createdAt.toISOString() : String(c.createdAt), - metadata: c.metadata?.map((m) => this.toMetadataDto(m)) ?? [], + metadata: c.metadata?.map((m: WasmMetadataDto) => this.toMetadataDto(m)) ?? [], }; } @@ -174,7 +174,7 @@ export class WasmFlagBackend implements FlagBackend { stringValue: f.stringValue ?? null, numberValue: f.numberValue ?? null, objectValue: f.objectValue ?? null, - metadata: f.metadata?.map((m) => this.toMetadataDto(m)), + metadata: f.metadata?.map((m: WasmMetadataDto) => this.toMetadataDto(m)), perEnvironmentDefinitions: f.perEnvironmentDefinitions ? this.mapPerEnvDefs(f.perEnvironmentDefinitions as Record) : undefined, @@ -225,7 +225,7 @@ export class WasmFlagBackend implements FlagBackend { private toTimeWindowDto(tw: WasmTimeWindowDto): TimeWindowDto { return { - id: Number(tw.id), + id: String(tw.id), name: tw.name, startTime: tw.startTime instanceof Date ? tw.startTime.toISOString() : (tw.startTime ?? null), endTime: tw.endTime instanceof Date ? tw.endTime.toISOString() : (tw.endTime ?? null), @@ -234,7 +234,7 @@ export class WasmFlagBackend implements FlagBackend { private toWasmTimeWindow(tw: TimeWindowDto): WasmTimeWindowDto { return { - id: BigInt(tw.id), + id: tw.id, name: tw.name, startTime: tw.startTime ? new Date(tw.startTime) : undefined, endTime: tw.endTime ? new Date(tw.endTime) : undefined, @@ -243,7 +243,7 @@ export class WasmFlagBackend implements FlagBackend { private toGlobalTimeWindowDto(g: WasmGlobalTimeWindowDto): GlobalTimeWindowDto { return { - timeWindowId: Number(g.timeWindowId), + timeWindowId: String(g.timeWindowId), booleanValue: g.booleanValue ?? null, stringValue: g.stringValue ?? null, numberValue: g.numberValue ?? null, @@ -253,7 +253,7 @@ export class WasmFlagBackend implements FlagBackend { private toWasmGlobalTimeWindow(g: GlobalTimeWindowDto): WasmGlobalTimeWindowDto { return { - timeWindowId: BigInt(g.timeWindowId), + timeWindowId: g.timeWindowId, booleanValue: g.booleanValue ?? undefined, stringValue: g.stringValue ?? undefined, numberValue: g.numberValue ?? undefined, @@ -271,7 +271,7 @@ export class WasmFlagBackend implements FlagBackend { stringValue: def.stringValue ?? null, numberValue: def.numberValue ?? null, objectValue: def.objectValue ?? null, - timeWindowId: def.timeWindowId != null ? Number(def.timeWindowId) : null, + timeWindowId: def.timeWindowId != null ? String(def.timeWindowId) : null, }; } return result; @@ -287,7 +287,7 @@ export class WasmFlagBackend implements FlagBackend { stringValue: def.stringValue ?? undefined, numberValue: def.numberValue ?? undefined, objectValue: def.objectValue ?? undefined, - timeWindowId: def.timeWindowId != null ? BigInt(def.timeWindowId) : undefined, + timeWindowId: def.timeWindowId != null ? def.timeWindowId : undefined, } as WasmPerEnvDto; } return result; diff --git a/ui/src/app/state/flag-store.actions.ts b/ui/src/app/state/flag-store.actions.ts index 2295bf2..716fff9 100644 --- a/ui/src/app/state/flag-store.actions.ts +++ b/ui/src/app/state/flag-store.actions.ts @@ -22,19 +22,19 @@ export class CreateCollection { export class RenameCollection { static readonly type = '[FlagStore] Rename Collection'; constructor( - readonly id: number, + readonly id: string, readonly name: string, ) {} } export class DeleteCollection { static readonly type = '[FlagStore] Delete Collection'; - constructor(readonly id: number) {} + constructor(readonly id: string) {} } export class SelectCollection { static readonly type = '[FlagStore] Select Collection'; - constructor(readonly id: number | undefined) {} + constructor(readonly id: string | undefined) {} } // ============================================================================ @@ -43,13 +43,13 @@ export class SelectCollection { export class LoadFlags { static readonly type = '[FlagStore] Load Flags'; - constructor(readonly collectionId: number) {} + constructor(readonly collectionId: string) {} } export class CreateFlag { static readonly type = '[FlagStore] Create Flag'; constructor( - readonly collectionId: number, + readonly collectionId: string, readonly flag: FlagDto, ) {} } @@ -57,7 +57,7 @@ export class CreateFlag { export class UpdateFlag { static readonly type = '[FlagStore] Update Flag'; constructor( - readonly collectionId: number, + readonly collectionId: string, readonly flag: FlagDto, readonly originalKey?: string, ) {} @@ -66,7 +66,7 @@ export class UpdateFlag { export class DeleteFlag { static readonly type = '[FlagStore] Delete Flag'; constructor( - readonly collectionId: number, + readonly collectionId: string, readonly flagKey: string, ) {} } @@ -77,7 +77,7 @@ export class DeleteFlag { export class LoadEnvironments { static readonly type = '[FlagStore] Load Environments'; - constructor(readonly collectionId: number) {} + constructor(readonly collectionId: string) {} } export class CreateEnvironment { @@ -104,13 +104,13 @@ export class DeleteEnvironment { export class LoadTimeWindows { static readonly type = '[FlagStore] Load Time Windows'; - constructor(readonly collectionId: number) {} + constructor(readonly collectionId: string) {} } export class CreateTimeWindow { static readonly type = '[FlagStore] Create Time Window'; constructor( - readonly collectionId: number, + readonly collectionId: string, readonly timeWindow: TimeWindowDto, ) {} } @@ -118,8 +118,8 @@ export class CreateTimeWindow { export class UpdateTimeWindow { static readonly type = '[FlagStore] Update Time Window'; constructor( - readonly collectionId: number, - readonly timeWindowId: number, + readonly collectionId: string, + readonly timeWindowId: string, readonly timeWindow: TimeWindowDto, ) {} } @@ -127,8 +127,8 @@ export class UpdateTimeWindow { export class DeleteTimeWindow { static readonly type = '[FlagStore] Delete Time Window'; constructor( - readonly collectionId: number, - readonly timeWindowId: number, + readonly collectionId: string, + readonly timeWindowId: string, ) {} } @@ -138,7 +138,7 @@ export class DeleteTimeWindow { export class ExportSchema { static readonly type = '[FlagStore] Export Schema'; - constructor(readonly collectionId: number) {} + constructor(readonly collectionId: string) {} } export class ImportSchema { @@ -160,7 +160,7 @@ export class SaveDatabase { export class SetCollectionMetadata { static readonly type = '[FlagStore] Set Collection Metadata'; constructor( - readonly collectionId: number, + readonly collectionId: string, readonly metadata: MetadataDto[], ) {} } diff --git a/ui/src/app/state/flag-store.state.ts b/ui/src/app/state/flag-store.state.ts index fd890db..a5c2a28 100644 --- a/ui/src/app/state/flag-store.state.ts +++ b/ui/src/app/state/flag-store.state.ts @@ -38,7 +38,7 @@ export interface FlagStoreStateModel { collections: CollectionDto[]; collectionsLoading: boolean; - selectedCollectionId: number | null; + selectedCollectionId: string | null; flags: FlagDto[]; flagsLoading: boolean; @@ -89,7 +89,7 @@ export class FlagStoreState implements NgxsOnInit { const collectionId = params['collectionId'] as string | undefined; if (collectionId) { - ctx.dispatch(new SelectCollection(parseInt(collectionId, 10))); + ctx.dispatch(new SelectCollection(collectionId)); } } @@ -117,7 +117,7 @@ export class FlagStoreState implements NgxsOnInit { } @Selector() - static selectedCollectionId(state: FlagStoreStateModel): number | null { + static selectedCollectionId(state: FlagStoreStateModel): string | null { return state.selectedCollectionId; } diff --git a/ui/src/environments.ts b/ui/src/environments.ts index f557454..ceb449e 100644 --- a/ui/src/environments.ts +++ b/ui/src/environments.ts @@ -1,6 +1,6 @@ /* This file is generated by scripts/generate-environments.js. */ export const GIT_TAG: string | null = null; -export const GIT_COMMIT_HASH: string | null = "d555b2f74580b37fed3dfd9c2309e84b323afd33"; +export const GIT_COMMIT_HASH: string | null = "9d046fd70a2572b2d42db6d2d801da340e242136"; export const DEFAULT_BACKEND_ROOT: string | null = null; export const BACKEND_TYPE: 'rest' | 'wasm' = "rest" as 'rest' | 'wasm'; From f11453f58d17a21799c8672436ffeca079eb6063 Mon Sep 17 00:00:00 2001 From: Daniel Habenicht Date: Mon, 20 Apr 2026 23:57:36 +0000 Subject: [PATCH 30/38] fix build errors --- ui/angular.json | 4 +++- ui/scripts/generate-environments.js | 2 ++ ui/src/app/app.html | 12 ++++++++++++ ui/src/app/app.ts | 18 ++++++++++++++++-- ui/src/app/services/flag-backend.ts | 5 +++++ ui/src/app/services/rest-flag-backend.ts | 18 ++++-------------- ui/src/app/services/wasm-backend.service.ts | 4 ++++ ui/src/app/services/wasm-flag-backend.ts | 4 ++++ ui/src/environments.development.ts | 2 ++ ui/src/environments.ts | 4 +++- 10 files changed, 55 insertions(+), 18 deletions(-) diff --git a/ui/angular.json b/ui/angular.json index acacb50..f69899d 100644 --- a/ui/angular.json +++ b/ui/angular.json @@ -18,6 +18,7 @@ "build": { "builder": "@angular/build:application", "options": { + "externalDependencies": ["fs/promises", "process", "module"], "outputPath": { "base": "../public", "browser": "" @@ -79,7 +80,8 @@ "buildTarget": "ui:build:production" }, "development": { - "buildTarget": "ui:build:development" + "buildTarget": "ui:build:development", + "prebundle": { "exclude": ["bootsharp"] } } }, "defaultConfiguration": "development" diff --git a/ui/scripts/generate-environments.js b/ui/scripts/generate-environments.js index 941366a..49d6318 100644 --- a/ui/scripts/generate-environments.js +++ b/ui/scripts/generate-environments.js @@ -47,6 +47,8 @@ export const GIT_TAG: string | null = ${tagValue}; export const GIT_COMMIT_HASH: string | null = ${commitValue}; export const DEFAULT_BACKEND_ROOT: string | null = ${backendValue}; export const BACKEND_TYPE: 'rest' | 'wasm' = ${backendTypeValue} as 'rest' | 'wasm'; +export type Environment = 'production' | 'development' | 'preview'; +export const ENVIRONMENT: Environment = 'production'; `; fs.mkdirSync(path.dirname(targetFile), { recursive: true }); diff --git a/ui/src/app/app.html b/ui/src/app/app.html index e9ec669..c95aa85 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -76,6 +76,18 @@

    {{ displayedCollectionName() }}

    }
    +
    + @if (canExportDatabase) { + + } +
    + @if (isOverviewComponentActive()) {
    -
    - @if (canExportDatabase) { - - } -
    - @if (isOverviewComponentActive()) {
    + +
    + + }