diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f32351e..20c306c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -21,7 +21,9 @@ "ms-playwright.playwright", "vitest.explorer", "dbaeumer.vscode-eslint", - "tamasfe.even-better-toml" + "ms-dotnettools.csdevkit", + "Angular.ng-template", + "ms-vscode-remote.remote-containers" ], "settings": {} } diff --git a/.dockerignore b/.dockerignore index ba6d2e8..705ee75 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,4 +7,11 @@ ui/dist ui/coverage ui/test-results ui/playwright-report -**/*.log \ No newline at end of file +ui/public +**/*.log + +# Generated / local build artifacts (produced inside the image instead) +**/bin +**/obj +ui/OpenFeatureManager.Api.json +ui/src/app/api-client diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 2c805a1..3a79618 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -1,4 +1,4 @@ -name: Rust Backend Checks +name: Checks on: pull_request: @@ -10,33 +10,6 @@ on: workflow_dispatch: jobs: - rust-backend: - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Checkout - uses: actions/checkout@v5 - - - name: Set up Rust - uses: dtolnay/rust-toolchain@stable - with: - toolchain: 1.93.0 - components: rustfmt - - - name: Cache Rust build artifacts - uses: Swatinem/rust-cache@v2 - - - name: Check formatting - run: cargo fmt --all --check - - - name: Validate backend builds - run: cargo check --all-targets - - - name: Run backend tests - run: cargo test --all-targets - prettier: runs-on: ubuntu-latest permissions: diff --git a/.gitignore b/.gitignore index 79c5c4a..d4180c5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,13 +11,16 @@ Cargo.lock .env .env.local +bin +obj + public/* !public/.gitkeep flags/* !flags/demo.flagd.json -ui/openapi.json +ui/OpenFeatureManager.Api.json # Zensical build output site/ @@ -35,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/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1ab25c5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "chat.tools.terminal.autoApprove": { + "dotnet build": true, + "dotnet publish": true + } +} 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/Dockerfile b/Dockerfile index 3b569ac..9f758f7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,37 +1,52 @@ -FROM rust:1.93-bookworm AS backend-builder +# ─── Backend build: .NET API (also emits the OpenAPI document to ui/) ───────── +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS backend-builder WORKDIR /app -# Copy manifests and build script first (these rarely change) -COPY Cargo.toml Cargo.lock* ./ +# ── Toolchain (kept before COPY src so source changes don't re-run these) ── +# Bootsharp compiles the WASM project into an npm package at +# src/OpenFeatureManager.Wasm/bin/bootsharp (consumed by the UI via a file: +# dependency). browser-wasm publishing needs the wasm-tools workload; Debug +# skips the heavy NativeAOT-LLVM path — the REST image never runs the wasm, it +# only needs the generated bindings so the frontend compiles. +RUN dotnet workload install wasm-tools +# The browser-wasm native compile needs python (emscripten/emcc); Bootsharp's +# post-build step bundles its JS with `npx rollup`, so node + rollup are needed too. +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 nodejs npm \ + && ln -sf /usr/bin/python3 /usr/bin/python \ + && npm install -g rollup \ + && rm -rf /var/lib/apt/lists/* -# Create dummy source and build directory -RUN mkdir -p src ui && echo "fn main() {}" > src/main.rs - -# Build dependencies (this layer is cached and reused) -RUN cargo build --release +# ui/ must exist so the API's OpenApiGenerateDocumentsOnBuild target can emit +# ui/openapi.json (see OpenFeatureManager.Api.csproj). +RUN mkdir -p ui -# Copy actual source code -COPY build.rs ./ -COPY schema ./schema COPY src ./src +COPY schema ./schema + +RUN dotnet publish src/OpenFeatureManager.Wasm/OpenFeatureManager.Wasm.csproj -c Debug -# Build the final release (only user code is recompiled) -RUN cargo build --release +RUN dotnet publish src/OpenFeatureManager.Api/OpenFeatureManager.Api.csproj \ + -c Release \ + -o /app/publish +# ─── OpenAPI document artifact (consumed by CI docker-image.yml) ────────────── +# The API emits its document as OpenFeatureManager.Api.json (see the csproj and +# scripts/setup-api-client.js); expose it as openapi.json for downstream tooling. FROM scratch AS artifacts -COPY --from=backend-builder /app/ui/openapi.json /openapi.json +COPY --from=backend-builder /app/ui/OpenFeatureManager.Api.json /openapi.json -FROM openapitools/openapi-generator-cli:v7.19.0 AS api-client-generator +# ─── Generate the typescript-angular API client from the OpenAPI document ───── +FROM openapitools/openapi-generator-cli:v7.21.0 AS api-client-generator WORKDIR /local -COPY ui/.openapi-generator-ignore /local/.openapi-generator-ignore -COPY --from=backend-builder /app/ui/openapi.json /local/openapi.json +COPY --from=backend-builder /app/ui/OpenFeatureManager.Api.json /local/openapi.json RUN openapi-generator-cli generate \ -i /local/openapi.json \ @@ -39,41 +54,54 @@ RUN openapi-generator-cli generate \ -o /local/api-client +# ─── Frontend build: Angular UI ─────────────────────────────────────────────── FROM node:22-bookworm-slim AS frontend-builder WORKDIR /ui +# schema/ is resolved as ../../schema by the schema-type generator. COPY schema /schema + +# The bootsharp package is a file: dependency at ../src/.../bin/bootsharp +# relative to ui/; it must exist before npm ci links it into node_modules. +COPY --from=backend-builder /app/src/OpenFeatureManager.Wasm/bin/bootsharp /src/OpenFeatureManager.Wasm/bin/bootsharp + COPY ui/package*.json ./ RUN npm ci --ignore-scripts COPY ui/ ./ COPY --from=api-client-generator /local/api-client ./src/app/api-client -RUN cp ./.openapi-generator-ignore ./src/app/api-client/.openapi-generator-ignore \ - && npm run ng -- build --configuration production +# REST backend, served same-origin by the API container. +ENV FLAGD_UI_BACKEND_TYPE=rest +# generate:api-client applies the post-generation patches to the client copied +# from the generator stage (the OpenAPI spec is absent here, so it skips the +# generation step itself). generate:schema-types and generate-environments are +# the same scripts npm runs via postinstall/prebuild. +RUN npm run generate:api-client \ + && node scripts/generate-environments.js \ + && npm run generate:schema-types \ + && npm run ng -- build --configuration production -FROM debian:bookworm-slim AS runtime -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates \ - && rm -rf /var/lib/apt/lists/* \ - && adduser --system --group --no-create-home appuser +# ─── Runtime: ASP.NET serving the API + built UI on one port ────────────────── +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime WORKDIR /app -COPY --from=backend-builder /app/target/release/flagd-ui /usr/local/bin/flagd-ui -COPY --from=frontend-builder /public ./public +COPY --from=backend-builder /app/publish ./ +COPY --from=frontend-builder /public ./wwwroot COPY schema ./schema -ENV SERVER_PORT=3000 \ - STATIC_DIR=./public \ - FLAGS_DIR=./flags \ - FLAGD_SCHEMA_FILE=./schema/flagd-schema.json +# SQLite database lives in a world-writable dir so the container can run as an +# arbitrary (docker-compose provided) UID. +RUN mkdir -p /app/data && chmod 777 /app/data -EXPOSE 3000 +ENV ASPNETCORE_URLS=http://+:3000 \ + ConnectionStrings__Flagd="Data Source=/app/data/flagd.db" \ + FlagdSchemaFile=./schema/flagd-schema.json -USER appuser +EXPOSE 3000 -ENTRYPOINT ["/usr/local/bin/flagd-ui"] \ No newline at end of file +ENTRYPOINT ["dotnet", "OpenFeatureManager.Api.dll"] diff --git a/README.md b/README.md index c1c6b49..30ab2fd 100644 --- a/README.md +++ b/README.md @@ -19,3 +19,24 @@ 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 + +```bash +cd src +dotnet publish +dotnet run --project OpenFeatureManager.Api +# http://localhost:5000/scalar/ + +cd ui +npm install +npm start +# http://localhost:4200 +``` + +To build and run the full stack (API + UI) in a single container: + +```bash +docker compose up --build +# http://localhost:3000 +``` 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/docker-compose.yaml b/docker-compose.yaml index 35ce45e..a661e21 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -7,8 +7,6 @@ services: user: "${UID:-1000}:${GID:-1000}" ports: - '3000:3000' - volumes: - - ./flags:/app/flags flagd: image: ghcr.io/open-feature/flagd:latest @@ -19,36 +17,3 @@ services: - '8013:8013' - '8016:8016' - '8014:8014' - - azurite: - image: local-azurite:latest - build: - context: services/azurite - ports: - - 10000:10000 - - 10001:10001 - - 10002:10002 - volumes: - - ./services/azurite/azurite-store:/data - - ./services/azurite/azurite-logs:/logs - - azurite-init: - image: mcr.microsoft.com/azure-cli:2.81.0 - depends_on: - - azurite - volumes: - - ./services/azurite/init.sh:/workdir/init.sh:ro - - ./flags/demo.flagd.json:/workdir/demo.flagd.json - working_dir: /workdir - command: ['sh', '/workdir/init.sh'] - # auth: - # image: local-keycloak:latest - # build: ./services/authentication - # environment: - # - KEYCLOAK_ADMIN=admin - # - KEYCLOAK_ADMIN_PASSWORD=admin - # ports: - # - 8080:8080 - # command: - # - start-dev - # - --import-realm 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 97% rename from e2e-tests/basic.spec.ts rename to e2e-tests/tests/basic.spec.ts index 7320c1d..ca47698 100644 --- a/e2e-tests/basic.spec.ts +++ b/e2e-tests/tests/basic.spec.ts @@ -79,7 +79,7 @@ test("creates a flags-file from the sidebar form", async ({ page }) => { await createFlagsFile(page, "new-project.flagd.json"); - await expect(page).toHaveURL(/\/local\/browser\/new-project.flagd.json$/); + await expect(page).toHaveURL(/\/[^/]+\/[0-9a-f-]{36}$/i); await expect( page.getByRole("heading", { name: "new-project.flagd.json" }), ).toBeVisible(); diff --git a/e2e-tests/boolean-flag-playground.spec.ts b/e2e-tests/tests/boolean-flag-playground.spec.ts similarity index 86% rename from e2e-tests/boolean-flag-playground.spec.ts rename to e2e-tests/tests/boolean-flag-playground.spec.ts index dd2b782..a746f4d 100644 --- a/e2e-tests/boolean-flag-playground.spec.ts +++ b/e2e-tests/tests/boolean-flag-playground.spec.ts @@ -41,7 +41,7 @@ test('creates file and boolean flag, evaluates in playground, then switches valu await expect(createFileButton).toBeEnabled(); await createFileButton.click(); - await expect(page).toHaveURL(new RegExp(`/local/browser/${fileName}$`)); + await expect(page).toHaveURL(/\/[^/]+\/[0-9a-f-]{36}$/i); await expect(page.getByRole('heading', { name: fileName })).toBeVisible(); await page.getByRole('button', { name: 'Create your first flag' }).click(); @@ -72,12 +72,18 @@ test('creates file and boolean flag, evaluates in playground, then switches valu await globalSwitch.click(); await expect(globalSwitch).toHaveAttribute('aria-checked', wasChecked ? 'false' : 'true'); await expect(page.getByRole('cell', { name: flagKey })).toBeVisible(); - await page.waitForTimeout(1600); - await playgroundDrawer.getByRole('button', { name: 'Evaluate' }).click(); - const secondResult = await getEvaluationResult(playgroundDrawer); - - expect(secondResult.value).not.toBe(firstResult.value); + // Re-evaluate until the toggled value propagates (autosave debounce + flag + // update + schema refresh are async), rather than relying on a fixed wait. + await expect + .poll( + async () => { + await playgroundDrawer.getByRole('button', { name: 'Evaluate' }).click(); + return (await getEvaluationResult(playgroundDrawer)).value; + }, + { timeout: 15_000 }, + ) + .not.toBe(firstResult.value); } finally { // await request // .delete(`/api/flags/${encodeURIComponent(fileName)}`) diff --git a/e2e-tests/docs-screenshot.spec.ts b/e2e-tests/tests/docs-screenshot.spec.ts similarity index 98% rename from e2e-tests/docs-screenshot.spec.ts rename to e2e-tests/tests/docs-screenshot.spec.ts index a443652..b120434 100644 --- a/e2e-tests/docs-screenshot.spec.ts +++ b/e2e-tests/tests/docs-screenshot.spec.ts @@ -71,7 +71,7 @@ test("captures docs screenshots for key workflows", async ({ page }) => { await mapLocalDialog.getByRole("tab", { name: "From Disk" }).click(); await mapLocalDialog.getByRole("button", { name: "Open" }).click(); await expect( - page.locator(".project-list").getByRole("link", { + page.locator(".sidebar").getByRole("link", { name: /customer-journey-overrides(\.flagd\.json)?/i, }), ).toBeVisible(); 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..540b6f1 100644 --- a/flagd-ui.code-workspace +++ b/flagd-ui.code-workspace @@ -1,24 +1,40 @@ { "folders": [ { - "path": "." - } + "name": "Backend", + "path": "src", + }, + { + "name": "Frontend", + "path": "ui", + }, + { + "name": "Docs", + "path": "docs", + }, + { + "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 +46,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/src/OpenFeatureManager.Api/OpenFeatureManager.Api.csproj b/src/OpenFeatureManager.Api/OpenFeatureManager.Api.csproj new file mode 100644 index 0000000..33158f0 --- /dev/null +++ b/src/OpenFeatureManager.Api/OpenFeatureManager.Api.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + $(MSBuildProjectDirectory)/../../ui/ + true + + + + + + + + + + + + + + + + + + diff --git a/src/OpenFeatureManager.Api/Program.cs b/src/OpenFeatureManager.Api/Program.cs new file mode 100644 index 0000000..91c57d7 --- /dev/null +++ b/src/OpenFeatureManager.Api/Program.cs @@ -0,0 +1,193 @@ +using Microsoft.EntityFrameworkCore; +using OpenFeatureManager.Data; +using OpenFeatureManager.Models; +using OpenFeatureManager.Services; +using Scalar.AspNetCore; + + +var builder = WebApplication.CreateBuilder(args); + +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(); + +// FlagdService creates and disposes a fresh DbContext per operation +// (`using var db = _contextFactory()`), so the factory must return a new +// context each call rather than a shared scoped instance. The context still +// uses SQLite — FlagdDbContext.OnConfiguring calls UseSqlite(connectionString). +builder.Services.AddScoped(_ => + new FlagdService(() => new FlagdDbContext(connectionString))); + +builder.Services.AddScoped(sp => + new FlagdSchemaService(sp.GetRequiredService(), validator)); + +builder.Services.AddOpenApi(); + +// CORS is off by default. To let this instance be used as a remote backend by a +// flagd-ui served from another origin, set Cors:AllowedOrigins (use "*" for any). +var corsOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? []; +if (corsOrigins.Length > 0) +{ + builder.Services.AddCors(options => + options.AddDefaultPolicy(policy => + { + if (corsOrigins.Contains("*")) + policy.AllowAnyOrigin(); + else + policy.WithOrigins(corsOrigins); + policy.AllowAnyHeader().AllowAnyMethod(); + })); +} + +var app = builder.Build(); + +// Ensure database is created on startup +using (var db = new FlagdDbContext(connectionString)) +{ + db.Database.EnsureCreated(); +} + +if (corsOrigins.Length > 0) + app.UseCors(); + +// Serve the built Angular UI from wwwroot (production/container mode). +app.UseDefaultFiles(); +app.UseStaticFiles(); + +app.MapOpenApi(); +app.MapScalarApiReference(); + +// ─── Collection endpoints ───────────────────────────────────────────── + +app.MapGet("/api/collections", (FlagdService svc) => TypedResults.Ok(svc.GetCollections())) + .WithName("listCollections").WithTags("collections"); + +app.MapPost("/api/collections", (CreateCollectionRequest req, FlagdService svc) => +{ + var collection = svc.CreateCollection(req.Name); + return TypedResults.Created($"/api/collections/{collection.Id}", collection); +}).WithName("createCollection").WithTags("collections"); + +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}", (Guid id, FlagdService svc) => +{ + svc.DeleteCollection(id); + return TypedResults.NoContent(); +}).WithName("deleteCollection").WithTags("collections"); + +// ─── Flag endpoints ─────────────────────────────────────────────────── + +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", (Guid id, FlagEntryDto dto, FlagdService svc) => + TypedResults.Ok(svc.UpsertFlag(id, dto))) + .WithName("createFlag").WithTags("flags"); + +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}", (Guid id, string key, FlagdService svc) => +{ + svc.DeleteFlag(id, Uri.UnescapeDataString(key)); + return TypedResults.NoContent(); +}).WithName("deleteFlag").WithTags("flags"); + +// ─── Environment endpoints ──────────────────────────────────────────── + +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", (Guid id, EnvironmentEntryDto dto, FlagdService svc) => + TypedResults.Ok(svc.UpsertEnvironment(id, dto))) + .WithName("createEnvironment").WithTags("environments"); + +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}", (Guid id, string name, FlagdService svc) => +{ + svc.DeleteEnvironment(id, Uri.UnescapeDataString(name)); + return TypedResults.NoContent(); +}).WithName("deleteEnvironment").WithTags("environments"); + +// ─── Time window endpoints ──────────────────────────────────────────── + +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", (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}", (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}", (Guid id, Guid twId, FlagdService svc) => +{ + svc.DeleteTimeWindow(id, twId); + return TypedResults.NoContent(); +}).WithName("deleteTimeWindow").WithTags("timewindows"); + +// ─── Schema endpoints ───────────────────────────────────────────────── + +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 (Guid id, HttpRequest request, FlagdSchemaService svc) => +{ + using var reader = new StreamReader(request.Body); + var body = await reader.ReadToEndAsync(); + svc.ImportSchema(id, body); + return TypedResults.Ok("Schema imported successfully."); +}).WithName("importSchema").WithTags("schema") + .Accepts("application/json"); + +// Dummy +app.MapPost("/dummy", async (Guid id, HttpRequest request) => +{ + return TypedResults.Ok(new PerEnvironmentDefinitionDto(BooleanValue: true)); +}).WithName("dummy").WithTags("schema"); + +// SPA fallback: any non-API, non-file route serves the Angular entrypoint. +app.MapFallbackToFile("index.html"); + +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 CreateCollectionRequest(string Name); +record RenameCollectionRequest(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.Tests/FlagdSchemaServiceTests.cs b/src/OpenFeatureManager.Tests/FlagdSchemaServiceTests.cs new file mode 100644 index 0000000..40ea458 --- /dev/null +++ b/src/OpenFeatureManager.Tests/FlagdSchemaServiceTests.cs @@ -0,0 +1,805 @@ +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 collection = _flagdService.CreateCollection("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(collection.Id, json); + + var flags = _flagdService.GetFlags(collection.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.BooleanValue); + } + + [Fact] + public void ImportSchema_StringFlag_StoresCorrectly() + { + var collection = _flagdService.CreateCollection("test"); + var json = """ + { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "color": { + "state": "DISABLED", + "variants": { "red": "#FF0000", "blue": "#0000FF" }, + "defaultVariant": "red" + } + } + } + """; + + _schemaService.ImportSchema(collection.Id, json); + + var flags = _flagdService.GetFlags(collection.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.StringValue); + } + + [Fact] + public void ImportSchema_NumberFlag_StoresCorrectly() + { + var collection = _flagdService.CreateCollection("test"); + var json = """ + { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "rate-limit": { + "state": "ENABLED", + "variants": { "default": 100 }, + "defaultVariant": "default" + } + } + } + """; + + _schemaService.ImportSchema(collection.Id, json); + + var flags = _flagdService.GetFlags(collection.Id); + Assert.Single(flags); + Assert.Equal("number", flags[0].Type); + Assert.Equal(100.0, flags[0].NumberValue); + } + + [Fact] + public void ImportSchema_ObjectFlag_StoresCorrectly() + { + var collection = _flagdService.CreateCollection("test"); + var json = """ + { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "config": { + "state": "ENABLED", + "variants": { "default": { "key": "value" } }, + "defaultVariant": "default" + } + } + } + """; + + _schemaService.ImportSchema(collection.Id, json); + + var flags = _flagdService.GetFlags(collection.Id); + Assert.Single(flags); + Assert.Equal("object", flags[0].Type); + 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_WithFlagMetadata_StoresMetadata() + { + var collection = _flagdService.CreateCollection("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(collection.Id, json); + + var flags = _flagdService.GetFlags(collection.Id); + 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] + public void ImportSchema_WithEvaluators_StoresEnvironments() + { + var collection = _flagdService.CreateCollection("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(collection.Id, json); + + var envs = _flagdService.GetEnvironments(collection.Id); + Assert.Equal(2, envs.Count); + + var prod = envs.First(e => e.Name == "Production"); + Assert.Equal(["prod", "production"], prod.Aliases.Order().ToArray()); + + var staging = envs.First(e => e.Name == "Staging"); + Assert.Equal(["staging"], staging.Aliases); + } + + [Fact] + public void ImportSchema_WithCollectionMetadata_StoresOnCollection() + { + var collection = _flagdService.CreateCollection("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(collection.Id, json); + + 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 collection = _flagdService.CreateCollection("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(collection.Id, json); + + 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); + Assert.Equal("number", flags.First(f => f.Key == "gamma").Type); + } + + [Fact] + public void ImportSchema_ReplacesExistingData() + { + var collection = _flagdService.CreateCollection("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(collection.Id, json1); + Assert.Single(_flagdService.GetFlags(collection.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(collection.Id, json2); + + var flags = _flagdService.GetFlags(collection.Id); + Assert.Single(flags); + Assert.Equal("new-flag", flags[0].Key); + } + + [Fact] + public void ImportSchema_NonexistentCollection_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(Guid.NewGuid(), json)); + } + + // ─── Export tests ───────────────────────────────────────────────────── + + [Fact] + public void ExportSchema_BooleanFlag_ProducesValidJson() + { + var collection = _flagdService.CreateCollection("test"); + _flagdService.UpsertFlag(collection.Id, new FlagEntryDto("my-flag", "boolean", "ENABLED", BooleanValue: true)); + + var json = _schemaService.ExportSchema(collection.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 collection = _flagdService.CreateCollection("test"); + _flagdService.UpsertFlag(collection.Id, new FlagEntryDto("color", "string", "ENABLED", StringValue: "red")); + + var json = _schemaService.ExportSchema(collection.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_WithFlagMetadata_IncludesMetadataBlock() + { + var collection = _flagdService.CreateCollection("test"); + var metadata = new List { new("owner", StringValue: "team-a") }; + _flagdService.UpsertFlag(collection.Id, new FlagEntryDto("feature", "boolean", "ENABLED", BooleanValue: true, Metadata: metadata)); + + var json = _schemaService.ExportSchema(collection.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 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(collection.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()); + var aliasValues = inArray[1].EnumerateArray().Select(a => a.GetString()).Order().ToArray(); + Assert.Equal("prod", aliasValues[0]); + Assert.Equal("production", aliasValues[1]); + } + + [Fact] + public void ExportSchema_WithCollectionMetadata_IncludesTopLevelMetadata() + { + 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(collection.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_EmptyCollection_ProducesMinimalValidJson() + { + var collection = _flagdService.CreateCollection("test"); + + var json = _schemaService.ExportSchema(collection.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_NonexistentCollection_ThrowsKeyNotFoundException() + { + Assert.Throws(() => _schemaService.ExportSchema(Guid.NewGuid())); + } + + // ─── Round-trip tests ───────────────────────────────────────────────── + + [Fact] + public void ImportThenExport_PreservesFlags() + { + var collection = _flagdService.CreateCollection("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(collection.Id, input); + var output = _schemaService.ExportSchema(collection.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 collection = _flagdService.CreateCollection("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(collection.Id, input); + var output = _schemaService.ExportSchema(collection.Id); + + using var doc = JsonDocument.Parse(output); + var evaluators = doc.RootElement.GetProperty("$evaluators"); + var staging = evaluators.GetProperty("isStaging"); + var aliases = staging.GetProperty("in")[1]; + var aliasValues = aliases.EnumerateArray().Select(a => a.GetString()).Order().ToArray(); + Assert.Equal("staging", aliasValues[0]); + Assert.Equal("stg", aliasValues[1]); + } + + // ─── Typed flag CRUD tests ──────────────────────────────────────────── + + [Fact] + public void UpsertFlag_BooleanFlag_RoundTrips() + { + var collection = _flagdService.CreateCollection("test"); + _flagdService.UpsertFlag(collection.Id, new FlagEntryDto("toggle", "boolean", "ENABLED", BooleanValue: true)); + + var flags = _flagdService.GetFlags(collection.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 collection = _flagdService.CreateCollection("test"); + _flagdService.UpsertFlag(collection.Id, new FlagEntryDto("color", "string", "ENABLED", StringValue: "blue")); + + var flags = _flagdService.GetFlags(collection.Id); + Assert.Single(flags); + Assert.Equal("string", flags[0].Type); + Assert.Equal("blue", flags[0].StringValue); + } + + [Fact] + public void UpsertFlag_NumberFlag_RoundTrips() + { + var collection = _flagdService.CreateCollection("test"); + _flagdService.UpsertFlag(collection.Id, new FlagEntryDto("rate", "number", "ENABLED", NumberValue: 42.5)); + + var flags = _flagdService.GetFlags(collection.Id); + Assert.Single(flags); + Assert.Equal("number", flags[0].Type); + Assert.Equal(42.5, flags[0].NumberValue); + } + + [Fact] + public void UpsertFlag_ObjectFlag_RoundTrips() + { + var collection = _flagdService.CreateCollection("test"); + _flagdService.UpsertFlag(collection.Id, new FlagEntryDto("config", "object", "ENABLED", ObjectValue: """{"a":1}""")); + + var flags = _flagdService.GetFlags(collection.Id); + Assert.Single(flags); + Assert.Equal("object", flags[0].Type); + Assert.Equal("""{"a":1}""", flags[0].ObjectValue); + } + + [Fact] + public void UpsertFlag_TypeChange_ReplacesEntity() + { + 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(collection.Id); + Assert.Single(flags); + Assert.Equal("string", flags[0].Type); + Assert.Equal("hello", flags[0].StringValue); + } + + [Fact] + public void UpsertFlag_WithMetadata_StoresAndRetrieves() + { + 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(collection.Id, new FlagEntryDto("flag", "boolean", "ENABLED", BooleanValue: true, Metadata: metadata)); + + 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); + 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 collection = _flagdService.CreateCollection("test"); + // Environments must exist before referencing them in per-env definitions + _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(collection.Id, new FlagEntryDto("flag", "boolean", "ENABLED", BooleanValue: true, PerEnvironmentDefinitions: perEnv)); + + 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); + Assert.Equal(true, flags[0].PerEnvironmentDefinitions!["Staging"].BooleanValue); + } + + [Fact] + 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(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)); + + 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); + } + + // ─── Invalid schema tests ───────────────────────────────────────────── + + [Fact] + public void ImportSchema_InvalidJson_Throws() + { + var collection = _flagdService.CreateCollection("test"); + Assert.ThrowsAny(() => _schemaService.ImportSchema(collection.Id, "not json at all")); + } + + [Fact] + public void ImportSchema_MissingFlags_ThrowsSchemaValidationException() + { + var collection = _flagdService.CreateCollection("test"); + var json = """{ "notFlags": {} }"""; + Assert.Throws(() => _schemaService.ImportSchema(collection.Id, json)); + } + + [Fact] + public void ImportSchema_InvalidFlagDefinition_ThrowsSchemaValidationException() + { + var collection = _flagdService.CreateCollection("test"); + var json = """ + { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "bad-flag": { + "state": "ENABLED" + } + } + } + """; + Assert.Throws(() => _schemaService.ImportSchema(collection.Id, json)); + } + + [Fact] + public void ImportSchema_InvalidState_ThrowsSchemaValidationException() + { + var collection = _flagdService.CreateCollection("test"); + var json = """ + { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "bad": { + "state": "BROKEN", + "variants": { "on": true }, + "defaultVariant": "on" + } + } + } + """; + Assert.Throws(() => _schemaService.ImportSchema(collection.Id, json)); + } + + [Fact] + public void ImportSchema_FlagWithInvalidVariantType_ThrowsSchemaValidationException() + { + var collection = _flagdService.CreateCollection("test"); + var json = """ + { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "bad": { + "state": "ENABLED", + "variants": { "on": true }, + "defaultVariant": 42 + } + } + } + """; + Assert.Throws(() => _schemaService.ImportSchema(collection.Id, json)); + } + + [Fact] + public void SchemaValidationException_ContainsErrorDetails() + { + var collection = _flagdService.CreateCollection("test"); + var json = """{ "notFlags": {} }"""; + + var ex = Assert.Throws(() => _schemaService.ImportSchema(collection.Id, json)); + Assert.NotEmpty(ex.Errors); + Assert.Contains("error", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + // ─── Time window CRUD tests ─────────────────────────────────────────── + + [Fact] + 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(Guid.Empty, "Christmas Time", start, end)); + + Assert.NotEqual(Guid.Empty, tw.Id); + Assert.Equal("Christmas Time", tw.Name); + Assert.Equal(start, tw.StartTime); + Assert.Equal(end, tw.EndTime); + } + + [Fact] + public void GetTimeWindows_ReturnsAll() + { + var collection = _flagdService.CreateCollection("test"); + _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); + } + + [Fact] + public void UpdateTimeWindow_ChangesFields() + { + var collection = _flagdService.CreateCollection("test"); + 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)); + + Assert.Equal("New Name", updated.Name); + Assert.Equal(newStart, updated.StartTime); + } + + [Fact] + public void DeleteTimeWindow_RemovesEntry() + { + var collection = _flagdService.CreateCollection("test"); + var tw = _flagdService.CreateTimeWindow(collection.Id, new TimeWindowDto(Guid.Empty, "Temporary")); + + _flagdService.DeleteTimeWindow(collection.Id, tw.Id); + + Assert.Empty(_flagdService.GetTimeWindows(collection.Id)); + } + + [Fact] + 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(Guid.Empty, "Holiday Window")); + var perEnv = new Dictionary + { + ["Production"] = new(BooleanValue: false, TimeWindowId: tw.Id) + }; + _flagdService.UpsertFlag(collection.Id, new FlagEntryDto("flag", "boolean", "ENABLED", BooleanValue: true, PerEnvironmentDefinitions: perEnv)); + + var flags = _flagdService.GetFlags(collection.Id); + Assert.NotNull(flags[0].PerEnvironmentDefinitions); + Assert.Equal(tw.Id, flags[0].PerEnvironmentDefinitions!["Production"].TimeWindowId); + } + + // ─── 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.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..3817049 --- /dev/null +++ b/src/OpenFeatureManager.Wasm/FlagdWasmService.cs @@ -0,0 +1,82 @@ +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(string id) => + _runtime.RequireService().GetCollection(Guid.Parse(id)); + + public FlagsCollectionDto CreateCollection(string name) => + _runtime.RequireService().CreateCollection(name); + + public FlagsCollectionDto RenameCollection(string id, string name) => + _runtime.RequireService().RenameCollection(Guid.Parse(id), name); + + public void DeleteCollection(string id) => + _runtime.RequireService().DeleteCollection(Guid.Parse(id)); + + public void ClearCollectionData(string collectionId) => + _runtime.RequireService().ClearCollectionData(Guid.Parse(collectionId)); + + public void UpdateCollectionMetadata(string collectionId, MetadataEntryDto[] metadata) => + _runtime.RequireService().UpdateCollectionMetadata(Guid.Parse(collectionId), metadata.ToList()); + + // ─── Flags ──────────────────────────────────────────────────────── + + public FlagEntryDto[] GetFlags(string collectionId) => + _runtime.RequireService().GetFlags(Guid.Parse(collectionId)).ToArray(); + + public FlagEntryDto UpsertFlag(string collectionId, FlagEntryDto dto) => + _runtime.RequireService().UpsertFlag(Guid.Parse(collectionId), dto); + + public void DeleteFlag(string collectionId, string flagKey) => + _runtime.RequireService().DeleteFlag(Guid.Parse(collectionId), flagKey); + + // ─── Environments ───────────────────────────────────────────────── + + public EnvironmentEntryDto[] GetEnvironments(string collectionId) => + _runtime.RequireService().GetEnvironments(Guid.Parse(collectionId)).ToArray(); + + public EnvironmentEntryDto UpsertEnvironment(string collectionId, EnvironmentEntryDto dto) => + _runtime.RequireService().UpsertEnvironment(Guid.Parse(collectionId), dto); + + public void DeleteEnvironment(string collectionId, string name) => + _runtime.RequireService().DeleteEnvironment(Guid.Parse(collectionId), name); + + // ─── Time Windows ───────────────────────────────────────────────── + + public TimeWindowDto[] GetTimeWindows(string collectionId) => + _runtime.RequireService().GetTimeWindows(Guid.Parse(collectionId)).ToArray(); + + public TimeWindowDto CreateTimeWindow(string collectionId, TimeWindowDto dto) => + _runtime.RequireService().CreateTimeWindow(Guid.Parse(collectionId), dto); + + public TimeWindowDto UpdateTimeWindow(string collectionId, string timeWindowId, TimeWindowDto dto) => + _runtime.RequireService().UpdateTimeWindow(Guid.Parse(collectionId), Guid.Parse(timeWindowId), dto); + + public void DeleteTimeWindow(string collectionId, string timeWindowId) => + _runtime.RequireService().DeleteTimeWindow(Guid.Parse(collectionId), Guid.Parse(timeWindowId)); + + // ─── Schema ─────────────────────────────────────────────────────── + + public string ExportSchema(string collectionId) => + _runtime.RequireSchemaService().ExportSchema(Guid.Parse(collectionId)); + + 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/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..781fb84 --- /dev/null +++ b/src/OpenFeatureManager.Wasm/IFlagdWasmService.cs @@ -0,0 +1,46 @@ +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(string id); + FlagsCollectionDto CreateCollection(string name); + FlagsCollectionDto RenameCollection(string id, string name); + void DeleteCollection(string id); + void ClearCollectionData(string collectionId); + void UpdateCollectionMetadata(string collectionId, MetadataEntryDto[] metadata); + + // ─── Flags ──────────────────────────────────────────────────────── + + FlagEntryDto[] GetFlags(string collectionId); + FlagEntryDto UpsertFlag(string collectionId, FlagEntryDto dto); + void DeleteFlag(string collectionId, string flagKey); + + // ─── Environments ───────────────────────────────────────────────── + + EnvironmentEntryDto[] GetEnvironments(string collectionId); + EnvironmentEntryDto UpsertEnvironment(string collectionId, EnvironmentEntryDto dto); + void DeleteEnvironment(string collectionId, string name); + + // ─── Time Windows ───────────────────────────────────────────────── + + 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(string collectionId); + void ImportSchema(string collectionId, string schemaJson); + + PerEnvironmentDefinitionDto Dummy(); +} diff --git a/wasm-tryout/Backend/LinkerConfig.xml b/src/OpenFeatureManager.Wasm/LinkerConfig.xml similarity index 76% rename from wasm-tryout/Backend/LinkerConfig.xml rename to src/OpenFeatureManager.Wasm/LinkerConfig.xml index efc6ed5..3776c05 100644 --- a/wasm-tryout/Backend/LinkerConfig.xml +++ b/src/OpenFeatureManager.Wasm/LinkerConfig.xml @@ -4,5 +4,6 @@ - + + diff --git a/wasm-tryout/Backend/Backend.csproj b/src/OpenFeatureManager.Wasm/OpenFeatureManager.Wasm.csproj similarity index 63% rename from wasm-tryout/Backend/Backend.csproj rename to src/OpenFeatureManager.Wasm/OpenFeatureManager.Wasm.csproj index 9e963f6..c7a4c36 100644 --- a/wasm-tryout/Backend/Backend.csproj +++ b/src/OpenFeatureManager.Wasm/OpenFeatureManager.Wasm.csproj @@ -7,6 +7,7 @@ enable Exe true + $(NoWarn);WASM0001 @@ -15,8 +16,12 @@ - - + + + + + + diff --git a/src/OpenFeatureManager.Wasm/Program.cs b/src/OpenFeatureManager.Wasm/Program.cs new file mode 100644 index 0000000..c32951f --- /dev/null +++ b/src/OpenFeatureManager.Wasm/Program.cs @@ -0,0 +1,31 @@ +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."); + } + + [JSEvent] + public static partial void OnReady(string message); +} + diff --git a/wasm-tryout/Backend/TrimmerRoots.cs b/src/OpenFeatureManager.Wasm/TrimmerRoots.cs similarity index 96% rename from wasm-tryout/Backend/TrimmerRoots.cs rename to src/OpenFeatureManager.Wasm/TrimmerRoots.cs index 7cb0066..ae526f1 100644 --- a/wasm-tryout/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..206efef --- /dev/null +++ b/src/OpenFeatureManager.Wasm/WasmBindings.cs @@ -0,0 +1,5 @@ +// 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/src/OpenFeatureManager.slnx b/src/OpenFeatureManager.slnx new file mode 100644 index 0000000..40e427b --- /dev/null +++ b/src/OpenFeatureManager.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/OpenFeatureManager/Data/DatabaseManager.cs b/src/OpenFeatureManager/Data/DatabaseManager.cs new file mode 100644 index 0000000..d4da2f5 --- /dev/null +++ b/src/OpenFeatureManager/Data/DatabaseManager.cs @@ -0,0 +1,73 @@ +using Microsoft.Data.Sqlite; + +namespace OpenFeatureManager.Data; + +/// +/// 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/OpenFeatureManager/Data/FlagdDbContext.cs b/src/OpenFeatureManager/Data/FlagdDbContext.cs new file mode 100644 index 0000000..d163768 --- /dev/null +++ b/src/OpenFeatureManager/Data/FlagdDbContext.cs @@ -0,0 +1,127 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using OpenFeatureManager.Entities; + +namespace OpenFeatureManager.Data; + +public class FlagdDbContext : DbContext +{ + private readonly string? _connectionString; + + public DbSet FlagsCollections => 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 CollectionMetadataEntries => Set(); + public DbSet PerEnvironmentDefinitions => Set(); + public DbSet TimeWindows => 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) + { + if (!options.IsConfigured && _connectionString is not null) + options.UseSqlite(_connectionString); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("flags_collections"); + entity.HasKey(e => e.Id); + 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); + 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 => + { + entity.ToTable("flag_entries"); + entity.HasKey(e => e.Id); + 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()); + 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 => + { + entity.ToTable("environment_entries"); + entity.HasKey(e => e.Id); + 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); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("environment_aliases"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedNever(); + entity.Property(e => e.Alias).IsRequired(); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("collection_metadata_entries"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedNever(); + entity.Property(e => e.Key).IsRequired(); + entity.HasIndex(e => new { e.CollectionId, e.Key }).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("flag_metadata_entries"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedNever(); + 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).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); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("time_windows"); + entity.HasKey(e => e.Id); + 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/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/CollectionMetadataEntry.cs b/src/OpenFeatureManager/Entities/CollectionMetadataEntry.cs new file mode 100644 index 0000000..38188a4 --- /dev/null +++ b/src/OpenFeatureManager/Entities/CollectionMetadataEntry.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OpenFeatureManager.Entities; + +[Table("collection_metadata_entries")] +public class CollectionMetadataEntry +{ + [Key] + [Column("id")] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Column("collection_id")] + public Guid CollectionId { 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/EnvironmentAlias.cs b/src/OpenFeatureManager/Entities/EnvironmentAlias.cs new file mode 100644 index 0000000..cf3c97a --- /dev/null +++ b/src/OpenFeatureManager/Entities/EnvironmentAlias.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OpenFeatureManager.Entities; + +[Table("environment_aliases")] +public class EnvironmentAlias +{ + [Key] + [Column("id")] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Column("environment_entry_id")] + public Guid 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 new file mode 100644 index 0000000..e30353b --- /dev/null +++ b/src/OpenFeatureManager/Entities/EnvironmentEntry.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OpenFeatureManager.Entities; + +[Table("environment_entries")] +public class EnvironmentEntry +{ + [Key] + [Column("id")] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Column("collection_id")] + public Guid CollectionId { get; set; } + + /// Human-readable environment name, e.g. "Production", "Staging" + [Required] + [Column("name")] + public string Name { get; set; } = string.Empty; + + public List Aliases { get; set; } = []; +} diff --git a/src/OpenFeatureManager/Entities/FlagEntry.cs b/src/OpenFeatureManager/Entities/FlagEntry.cs new file mode 100644 index 0000000..12f75cd --- /dev/null +++ b/src/OpenFeatureManager/Entities/FlagEntry.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OpenFeatureManager.Entities; + +[Table("flag_entries")] +public abstract class FlagEntry +{ + [Key] + [Column("id")] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Column("collection_id")] + public Guid CollectionId { get; set; } + + [Required] + [Column("flag_key")] + public string FlagKey { get; set; } = string.Empty; + + [Column("state")] + public FlagState State { get; set; } = FlagState.ENABLED; + + [Column("global_time_window_id")] + public Guid? GlobalTimeWindowId { get; set; } + + public TimeWindow? GlobalTimeWindow { get; set; } + + public List Metadata { get; set; } = []; + public List PerEnvironmentDefinitions { get; set; } = []; +} diff --git a/src/OpenFeatureManager/Entities/FlagMetadataEntry.cs b/src/OpenFeatureManager/Entities/FlagMetadataEntry.cs new file mode 100644 index 0000000..47eb0ef --- /dev/null +++ b/src/OpenFeatureManager/Entities/FlagMetadataEntry.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OpenFeatureManager.Entities; + +[Table("flag_metadata_entries")] +public class FlagMetadataEntry +{ + [Key] + [Column("id")] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Column("flag_entry_id")] + public Guid 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/FlagsCollection.cs b/src/OpenFeatureManager/Entities/FlagsCollection.cs new file mode 100644 index 0000000..14eb2da --- /dev/null +++ b/src/OpenFeatureManager/Entities/FlagsCollection.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OpenFeatureManager.Entities; + +[Table("flags_collections")] +public class FlagsCollection +{ + [Key] + [Column("id")] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + [Column("name")] + public string Name { get; set; } = string.Empty; + + [Column("created_at")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + 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/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..acb6649 --- /dev/null +++ b/src/OpenFeatureManager/Entities/PerEnvironmentDefinition.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OpenFeatureManager.Entities; + +[Table("per_environment_definitions")] +public class PerEnvironmentDefinition +{ + [Key] + [Column("id")] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Column("flag_entry_id")] + public Guid FlagEntryId { get; set; } + + [Column("environment_entry_id")] + public Guid 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 Guid? 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..1ce91c6 --- /dev/null +++ b/src/OpenFeatureManager/Entities/TimeWindow.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OpenFeatureManager.Entities; + +[Table("time_windows")] +public class TimeWindow +{ + [Key] + [Column("id")] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Column("collection_id")] + public Guid CollectionId { 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 new file mode 100644 index 0000000..20f24ec --- /dev/null +++ b/src/OpenFeatureManager/Models/FlagdJsonContext.cs @@ -0,0 +1,75 @@ +using System.Text.Json.Serialization; + +namespace OpenFeatureManager.Models; + +/// DTO for a flags collection (schema document). +public record FlagsCollectionDto( + Guid 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 with typed values. +/// Only the value field matching Type is populated. +/// +public record FlagEntryDto( + string Key, + string Type, + string State, + 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, + Guid? TimeWindowId = null); + +/// DTO for a global time-windowed value override referencing a TimeWindow entity. +public record GlobalTimeWindowDto( + Guid TimeWindowId, + bool? BooleanValue = null, + string? StringValue = null, + double? NumberValue = null, + string? ObjectValue = null); + +/// DTO for an environment definition. +public record EnvironmentEntryDto(string Name, string[] Aliases); + +/// DTO for a reusable time window. +public record TimeWindowDto(Guid Id, string Name, DateTime? StartTime = null, DateTime? EndTime = null); + +[JsonSerializable(typeof(string[]))] +[JsonSerializable(typeof(FlagsCollectionDto))] +[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)] +public partial class FlagdJsonContext : JsonSerializerContext; diff --git a/src/OpenFeatureManager/OpenFeatureManager.csproj b/src/OpenFeatureManager/OpenFeatureManager.csproj new file mode 100644 index 0000000..7a1b9b4 --- /dev/null +++ b/src/OpenFeatureManager/OpenFeatureManager.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + + + + + + + + + diff --git a/src/OpenFeatureManager/Services/FlagdSchemaService.cs b/src/OpenFeatureManager/Services/FlagdSchemaService.cs new file mode 100644 index 0000000..5ce1f27 --- /dev/null +++ b/src/OpenFeatureManager/Services/FlagdSchemaService.cs @@ -0,0 +1,321 @@ +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 typed DTO model +/// exposed by . Values are stored with their native +/// types rather than as raw JSON strings. +/// +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(Guid collectionId, string schemaJson) + { + _validator?.ValidateOrThrow(schemaJson); + + using var doc = JsonDocument.Parse(schemaJson); + var root = doc.RootElement; + + // Clear existing data for this collection + _flagdService.ClearCollectionData(collectionId); + + // 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.UpdateCollectionMetadata(collectionId, metadata); + } + + // 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..]; // "Production" from "isProduction" + var aliases = aliasesPart.EnumerateArray() + .Select(a => a.GetString() ?? string.Empty) + .Where(a => !string.IsNullOrEmpty(a)) + .ToArray(); + + _flagdService.UpsertEnvironment(collectionId, new EnvironmentEntryDto(envName, 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"; + 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) + && variantsElem.ValueKind == JsonValueKind.Object) + { + var defaultVariantName = defaultVariantElem.GetString(); + if (!string.IsNullOrEmpty(defaultVariantName) + && variantsElem.TryGetProperty(defaultVariantName, out var defaultValue)) + { + switch (defaultValue.ValueKind) + { + 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; + } + } + } + + List? flagMetadata = null; + if (flagDef.TryGetProperty("metadata", out var flagMetaElem) + && flagMetaElem.ValueKind == JsonValueKind.Object) + { + flagMetadata = ParseMetadata(flagMetaElem); + } + + _flagdService.UpsertFlag(collectionId, new FlagEntryDto( + flagKey, flagType, state, + boolVal, strVal, numVal, objVal, + flagMetadata)); + } + } + } + + /// + /// 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(Guid collectionId) + { + 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 }); + + 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); + WriteFlagValue(writer, flag); + writer.WriteEndObject(); + + writer.WriteString("defaultVariant", variantKey); + + if (flag.Metadata is { Count: > 0 }) + { + writer.WritePropertyName("metadata"); + WriteMetadata(writer, flag.Metadata); + } + + 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 + env.Name; // "is" + "Production" = "isProduction" + + 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 + } + + // collection-level metadata + if (collection.Metadata is { Count: > 0 }) + { + writer.WritePropertyName("metadata"); + WriteMetadata(writer, collection.Metadata); + } + + writer.WriteEndObject(); // root + writer.Flush(); + + var json = Encoding.UTF8.GetString(stream.ToArray()); + + _validator?.ValidateOrThrow(json); + + return json; + } + + // ─── Private helpers ────────────────────────────────────────────────── + + private static List ParseMetadata(JsonElement metadataObj) + { + 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 WriteFlagValue(Utf8JsonWriter writer, FlagEntryDto flag) + { + 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 new file mode 100644 index 0000000..201886c --- /dev/null +++ b/src/OpenFeatureManager/Services/FlagdService.cs @@ -0,0 +1,435 @@ +using Microsoft.EntityFrameworkCore; +using OpenFeatureManager.Data; +using OpenFeatureManager.Entities; +using OpenFeatureManager.Models; + +namespace OpenFeatureManager.Services; + +/// +/// SQLite-backed flagd CRUD service with typed flag entities. +/// +/// Provides multi-file flag/environment management using TPH inheritance +/// for type-safe flag value storage. +/// +public class FlagdService +{ + private readonly Func _contextFactory; + + public FlagdService(Func contextFactory) + { + _contextFactory = contextFactory; + } + + // ─── Collection management ────────────────────────────────────────── + + public FlagsCollectionDto GetCollection(Guid id) + { + using var db = _contextFactory(); + var collection = db.FlagsCollections.Include(f => f.Metadata).FirstOrDefault(f => f.Id == id) + ?? throw new KeyNotFoundException($"Collection {id} not found"); + return ToDto(collection); + } + + public FlagsCollectionDto CreateCollection(string name) + { + using var db = _contextFactory(); + var collection = new FlagsCollection { Name = name }; + db.FlagsCollections.Add(collection); + db.SaveChanges(); + return ToDto(collection); + } + + public List GetCollections() + { + using var db = _contextFactory(); + return db.FlagsCollections.Include(f => f.Metadata).OrderBy(f => f.Id).ToList().Select(ToDto).ToList(); + } + + public FlagsCollectionDto RenameCollection(Guid id, string name) + { + using var db = _contextFactory(); + 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(collection); + } + + public void DeleteCollection(Guid id) + { + using var db = _contextFactory(); + 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 ClearCollectionData(Guid collectionId) + { + using var db = _contextFactory(); + 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 UpdateCollectionMetadata(Guid collectionId, List metadata) + { + using var db = _contextFactory(); + var collection = db.FlagsCollections.Find(collectionId) ?? throw new KeyNotFoundException($"Collection {collectionId} not found"); + + db.CollectionMetadataEntries.RemoveRange(db.CollectionMetadataEntries.Where(m => m.CollectionId == collectionId)); + foreach (var entry in metadata) + { + db.CollectionMetadataEntries.Add(new CollectionMetadataEntry + { + CollectionId = collectionId, + Key = entry.Key, + StringValue = entry.StringValue, + NumberValue = entry.NumberValue, + BooleanValue = entry.BooleanValue + }); + } + db.SaveChanges(); + } + + // ─── Flag management ────────────────────────────────────────────────── + + public List GetFlags(Guid collectionId) + { + using var db = _contextFactory(); + var envLookup = db.EnvironmentEntries + .Where(e => e.CollectionId == collectionId) + .ToDictionary(e => e.Id, e => e.Name); + + return db.FlagEntries + .Where(f => f.CollectionId == collectionId) + .Include(f => f.Metadata) + .Include(f => f.PerEnvironmentDefinitions) + .OrderBy(f => f.FlagKey) + .ToList() + .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(Guid 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.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.CollectionId == collectionId && 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 = CreateFlagEntry(dto.Type, collectionId, dto.Key); + db.FlagEntries.Add(existing); + } + + 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.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 collection {collectionId}"); + + existing.PerEnvironmentDefinitions.Add(new PerEnvironmentDefinition + { + EnvironmentEntryId = envId, + BooleanValue = envDef.BooleanValue, + StringValue = envDef.StringValue, + NumberValue = envDef.NumberValue, + ObjectValue = envDef.ObjectValue, + TimeWindowId = envDef.TimeWindowId + }); + } + } + + db.SaveChanges(); + + var envLookup = db.EnvironmentEntries + .Where(e => e.CollectionId == collectionId) + .ToDictionary(e => e.Id, e => e.Name); + return ToDto(existing, envLookup); + } + + public void DeleteFlag(Guid collectionId, string flagKey) + { + using var db = _contextFactory(); + 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(Guid collectionId) + { + using var db = _contextFactory(); + return db.EnvironmentEntries + .Where(e => e.CollectionId == collectionId) + .Include(e => e.Aliases) + .OrderBy(e => e.Name) + .ToList() + .Select(ToDto) + .ToList(); + } + + public EnvironmentEntryDto UpsertEnvironment(Guid collectionId, EnvironmentEntryDto dto) + { + if (string.IsNullOrWhiteSpace(dto.Name)) + throw new ArgumentException("Environment name is required"); + + using var db = _contextFactory(); + + var existing = db.EnvironmentEntries + .Include(e => e.Aliases) + .FirstOrDefault(e => e.CollectionId == collectionId && e.Name == dto.Name); + + if (existing is null) + { + existing = new EnvironmentEntry + { + CollectionId = collectionId, + Name = dto.Name + }; + db.EnvironmentEntries.Add(existing); + } + + // 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(Guid collectionId, string name) + { + using var db = _contextFactory(); + 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(Guid collectionId) + { + using var db = _contextFactory(); + return db.TimeWindows + .Where(t => t.CollectionId == collectionId) + .OrderBy(t => t.Name) + .ToList() + .Select(ToDto) + .ToList(); + } + + public TimeWindowDto CreateTimeWindow(Guid collectionId, TimeWindowDto dto) + { + if (string.IsNullOrWhiteSpace(dto.Name)) + throw new ArgumentException("Time window name is required"); + + using var db = _contextFactory(); + if (!db.FlagsCollections.Any(f => f.Id == collectionId)) + throw new KeyNotFoundException($"Collection {collectionId} not found"); + + var tw = new TimeWindow + { + CollectionId = collectionId, + Name = dto.Name, + StartTime = dto.StartTime, + EndTime = dto.EndTime + }; + db.TimeWindows.Add(tw); + db.SaveChanges(); + return ToDto(tw); + } + + 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) + ?? throw new KeyNotFoundException($"Time window {timeWindowId} not found in collection {collectionId}"); + + tw.Name = dto.Name; + tw.StartTime = dto.StartTime; + tw.EndTime = dto.EndTime; + db.SaveChanges(); + return ToDto(tw); + } + + public void DeleteTimeWindow(Guid collectionId, Guid timeWindowId) + { + using var db = _contextFactory(); + 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(); + } + + // ─── Private helpers ────────────────────────────────────────────────── + + 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, Guid collectionId, string flagKey) => type switch + { + "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}") + }; + + 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 void SetGlobalTimeWindowValue(FlagEntry entry, GlobalTimeWindowDto? tw) + { + 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 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() + : 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/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; + } +} 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/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/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/package-lock.json b/ui/package-lock.json index 34d69a7..fea0ac8 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -18,6 +18,7 @@ "@angular/material": "^21.1.4", "@angular/platform-browser": "^21.1.0", "@angular/router": "^21.1.0", + "@ngxs/devtools-plugin": "^21.0.0", "@ngxs/router-plugin": "^21.0.0", "@ngxs/storage-plugin": "^21.0.0", "@ngxs/store": "^21.0.0", @@ -25,6 +26,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 +50,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", @@ -3135,6 +3138,24 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, + "node_modules/@ngxs/devtools-plugin": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/@ngxs/devtools-plugin/-/devtools-plugin-21.0.0.tgz", + "integrity": "sha512-d/EfNBM0t+1QTKkdGErisimJuumZJ2RlhwMVD6hOS2ffAdUPQmj5rDmLt9IPt1ciCxcIp+6jWPdlxwBjeefVAw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ngxs" + }, + "peerDependencies": { + "@angular/core": ">=21.0.0 <22.0.0", + "@ngxs/store": "^21.0.0 || ^21.0.0-dev", + "rxjs": ">=7.0.0" + } + }, "node_modules/@ngxs/router-plugin": { "version": "21.0.0", "resolved": "https://registry.npmjs.org/@ngxs/router-plugin/-/router-plugin-21.0.0.tgz", @@ -5952,6 +5973,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..d3636d8 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", @@ -41,6 +41,7 @@ "@angular/material": "^21.1.4", "@angular/platform-browser": "^21.1.0", "@angular/router": "^21.1.0", + "@ngxs/devtools-plugin": "^21.0.0", "@ngxs/router-plugin": "^21.0.0", "@ngxs/storage-plugin": "^21.0.0", "@ngxs/store": "^21.0.0", @@ -48,6 +49,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 +72,4 @@ "typescript-eslint": "8.50.1", "vitest": "^4.0.8" } -} \ No newline at end of file +} 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/scripts/generate-environments.js b/ui/scripts/generate-environments.js index 4fa863a..49d6318 100644 --- a/ui/scripts/generate-environments.js +++ b/ui/scripts/generate-environments.js @@ -30,15 +30,25 @@ 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'; +export type Environment = 'production' | 'development' | 'preview'; +export const ENVIRONMENT: Environment = 'production'; `; fs.mkdirSync(path.dirname(targetFile), { recursive: true }); diff --git a/ui/scripts/setup-api-client.js b/ui/scripts/setup-api-client.js index a0fad5a..0524aa8 100644 --- a/ui/scripts/setup-api-client.js +++ b/ui/scripts/setup-api-client.js @@ -24,24 +24,54 @@ if (fs.existsSync(sourceFile)) { console.warn(`⚠ Warning: ${sourceFile} not found.`); } -// 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.`); - process.exit(0); -} +// Generate the client from the OpenAPI spec if it is present. When it is not +// (e.g. the Docker build generates the client in a dedicated stage), generation +// is skipped and only the post-generation patches below are applied to the +// already-generated client. +const openapiFile = 'OpenFeatureManager.Api.json'; +const openapiPath = path.join(uiRoot, openapiFile); +if (fs.existsSync(openapiPath)) { + console.log(`✓ Working directory: ${uiRoot}`); + + // Run Docker command with absolute path + // 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 "${uiRoot}/${openapiFile}:/local/openapi.json" -v "${uiRoot}/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', + }); -// Get absolute path for Docker volume mount -const cwd = uiRoot; -console.log(`✓ Working directory: ${cwd}`); + if (result.status !== 0) { + process.exit(result.status); + } +} else { + console.warn(`⚠ ${openapiFile} not found; skipping client generation and only applying patches.`); + console.warn( + `⚠ To regenerate, run 'dotnet build' in the src directory, then re-run this script.`, + ); +} -// 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`; +// 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', + }, +]; -const result = spawnSync(dockerCmd, { - shell: true, - stdio: 'inherit' -}); +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(result.status); +process.exit(0); diff --git a/ui/src/app/app.config.ts b/ui/src/app/app.config.ts index b8e8115..9e0bc28 100644 --- a/ui/src/app/app.config.ts +++ b/ui/src/app/app.config.ts @@ -6,14 +6,14 @@ 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 { DEFAULT_BACKEND_ROOT } from '../environments'; import { routes } from './app.routes'; import { globalLoadingInterceptor } from './interceptors/global-loading.interceptor'; -import { FlagFileStore } from './state/flag-file-store.state'; -import { CurrentFlagStoreState } from './state/current-flag-store.state'; +import { FlagStoreState } from './state/flag-store.state'; import { UiPreferencesState } from './state/ui-preferences.state'; import { PlaygroundPreferencesState } from './state/playground-preferences.state'; +import { provideApi } from './api-client'; +import { withNgxsReduxDevtoolsPlugin } from '@ngxs/devtools-plugin'; export const appConfig: ApplicationConfig = { providers: [ @@ -23,12 +23,13 @@ export const appConfig: ApplicationConfig = { provideNativeDateAdapter(), provideApi({ basePath: DEFAULT_BACKEND_ROOT ?? '' }), provideStore( - [FlagFileStore, CurrentFlagStoreState, UiPreferencesState, PlaygroundPreferencesState], + [FlagStoreState, UiPreferencesState, PlaygroundPreferencesState], withNgxsStoragePlugin({ - keys: [UiPreferencesState, PlaygroundPreferencesState, FlagFileStore], + keys: [UiPreferencesState, PlaygroundPreferencesState, 'flagStore.servers'], }), withNgxsRouterPlugin(), ), + withNgxsReduxDevtoolsPlugin(), provideAnimationsAsync(), ], }; diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 6c18065..5539573 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -52,9 +52,16 @@

flagd-ui

@if (sourceBreadcrumb()) { - + {{ sourceBreadcrumb() }} - + } @if (flagsFileDetailRoute()) { @@ -64,11 +71,11 @@

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

} @else { -

{{ displayedFlagsFileName() }}

+

{{ displayedCollectionName() }}

} @if (detailBreadcrumb()) { diff --git a/ui/src/app/app.routes.ts b/ui/src/app/app.routes.ts index 477a55f..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: ':backendType/:uri/:fileName', component: FlagsFileDetailComponent }, - { path: ':backendType/:uri/:fileName/settings', component: FlagsFileSettingsPageComponent }, - { path: ':backendType/:uri/:fileName/edit/new', component: FlagsFileEditPageComponent }, - { path: ':backendType/:uri/:fileName/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/app.scss b/ui/src/app/app.scss index 35ba264..8ee76c3 100644 --- a/ui/src/app/app.scss +++ b/ui/src/app/app.scss @@ -127,6 +127,7 @@ h1 { .breadcrumb-link { color: inherit; text-decoration: none; + cursor: pointer; } .breadcrumb-link:hover { diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index 85daf1d..fd50046 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -23,9 +23,8 @@ 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'; +import { SaveDatabase } from './state/flag-store.actions'; type AppTheme = 'light' | 'dark'; @@ -54,10 +53,10 @@ 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 selectedServerUri = this.ngxsStore.selectSignal(FlagStoreState.selectedServerUri); + readonly selectedCollectionId = this.ngxsStore.selectSignal(FlagStoreState.selectedCollectionId); + // readonly backendsMap = this.ngxsStore.selectSignal(FlagFileStore.backendsMap); readonly prefersDark = signal(this.systemPrefersDark()); readonly theme = computed(() => { const mode = this.themeMode(); @@ -75,7 +74,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( @@ -116,31 +115,14 @@ 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; - }); - readonly sourceBreadcrumbRoute = computed(() => ['/']); - readonly flagsFileDetailRoute = computed(() => { - const backendType = this.currentFlagsBackendType(); - const backendUri = this.currentFlagsBackendUri(); - const fileName = this.currentFlagsFileName(); - - if (backendType && backendUri && fileName) { - return ['/', backendType, backendUri, fileName]; - } - - return null; + readonly sourceBreadcrumb = computed(() => 'Overview'); + // Link the collection-name crumb back to its detail view, but only when we are + // on a sub-page (edit/settings); on the detail view itself it is the current page. + readonly flagsFileDetailRoute = computed(() => { + if (this.isOverviewComponentActive()) return null; + const uri = this.selectedServerUri(); + const collectionId = this.selectedCollectionId(); + return uri && collectionId ? ['/', uri, collectionId] : null; }); navOpen = signal(!this.isCompactLayout()); @@ -210,11 +192,35 @@ export class App implements OnDestroy { this.navOpen.set(!compact); } + @HostListener('window:beforeunload', ['$event']) + beforeUnloadHandler(event: any) { + this.ngxsStore.dispatch(new SaveDatabase()); + } + + // @HostListener('mouseout') + // async onMouseLeave() { + // await this.backend.saveState?.(); + // // debugger; + // } + toggleNavigation(): void { if (!this.isCompactLayout()) return; this.navOpen.set(!this.navOpen()); } + /** + * The "Overview" breadcrumb is the flags-file list. On mobile that lives in the + * nav drawer, so clicking it toggles the drawer (like the burger); on wider + * layouts the drawer is always visible, so navigate to the root instead. + */ + onOverviewClick(): void { + if (this.isCompactLayout()) { + this.toggleNavigation(); + return; + } + void this.router.navigate(['/']); + } + onNavigationStateChange(opened: boolean): void { if (!this.isCompactLayout()) return; this.navOpen.set(opened); @@ -244,9 +250,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..45990ac 100644 --- a/ui/src/app/components/flag-editor/flag-editor.ts +++ b/ui/src/app/components/flag-editor/flag-editor.ts @@ -35,14 +35,19 @@ 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, + TimeWindowDto, +} from '../../services/flag-backend'; +import { FlagStoreState } from '../../state/flag-store.state'; export type EditorMode = 'interactive' | 'json'; @@ -84,20 +89,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 +113,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 +121,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 +138,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 +195,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 +210,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 +254,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 +269,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 +325,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 +444,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 +526,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 +537,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 { @@ -598,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; @@ -609,22 +614,17 @@ 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: '', // Placeholder, backend should assign real ID + name: `${key}-global-time-window`, + startTime: startDate as any, + endTime: endDate as any, }; } } // 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 +648,29 @@ 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, + booleanValue: flagType === 'boolean' ? (currentValue as boolean) : null, + stringValue: flagType === 'string' ? (currentValue as string) : null, + numberValue: flagType === 'number' ? (currentValue as number) : null, + objectValue: flagType === 'object' ? JSON.stringify(currentValue) : null, ...(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 +717,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 +813,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 +936,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 +947,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..09eae0d 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,22 @@ 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 UpdateFlag(this.currentCollectionId() as any, updatedFlag, flag.key), + ); 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 +213,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 +232,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 +254,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 +296,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 +305,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 +315,7 @@ export class FlagsFileDetailComponent implements OnInit { } private getSortValue( - flag: DisplayFlag, + flag: FlagDto, column: 'key' | 'type' | 'state' | 'default' | 'targeting', ): string { switch (column) { @@ -341,22 +341,13 @@ export class FlagsFileDetailComponent implements OnInit { private getFlagsFileRouteSegments(): string[] | null { const params = this.route.snapshot.paramMap; - const backendType = params.get('backendType'); - const backendUri = params.get('uri'); - const fileName = params.get('fileName'); - - if (backendType && backendUri && fileName) { - return ['/', backendType, backendUri, fileName]; - } - - const name = params.get('name'); - const backendId = params.get('backendId'); - if (!name) return null; + const uri = params.get('uri'); + const collectionId = params.get('collectionId'); - if (backendId) { - return ['/flags-files', 'remote', backendId, name]; + if (uri && collectionId) { + return ['/', uri, collectionId]; } - return ['/flags-files', 'local', name]; + return null; } } 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..80afc4c 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,15 @@ 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); + /** The flag being edited, resolved from the route key and the loaded flags. */ + readonly editingFlag = computed(() => { + const key = this.routeFlagKey(); + if (!key || key === 'new') return null; + return this.flagEntries().find((flag) => flag.key === key) ?? 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 +37,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 +44,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); @@ -92,22 +84,8 @@ export class FlagsFileEditPageComponent implements OnInit { private getFlagsFileRouteSegments(): string[] | null { const params = this.route.snapshot.paramMap; - const backendType = params.get('backendType'); - const backendUri = params.get('uri'); - const fileName = params.get('fileName'); - - if (backendType && backendUri && fileName) { - return ['/', backendType, backendUri, fileName]; - } - - const name = params.get('name'); - const backendId = params.get('backendId'); - if (!name) return null; - - if (backendId) { - return ['/flags-files', 'remote', backendId, name]; - } - - return ['/flags-files', 'local', name]; + const uri = params.get('uri'); + const collectionId = params.get('collectionId'); + return uri && collectionId ? ['/', uri, collectionId] : null; } } 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..b9d283c 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,52 +1,34 @@
- @if (hasFiles()) { - @for (group of fileGroups(); track group.backendType + group.backendUri) { -
- {{ group.icon }} - {{ group.label }} - @if (group.canSync) { - - } - @if (group.canRemove) { - - } -
+ @for (server of serverEntries(); track server.uri) { +
+ dns + {{ server.name }} +
+ @if (selectedServerUri() === server.uri) { - @for (entry of group.entries; track entry.name + entry.backendUri + entry.backendType) { + @for (entry of server.collections; track entry.id) { - @if (entry.isDirty) { - sync_problem - } {{ entry.name }} } + @if (server.collections.length === 0) { +
No collections yet
+ }
} - } @else { -
- No flag files yet -
+ } + @if (serverEntries().length === 0) { +
No servers configured
}