diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79257d2..7f499c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,10 +38,14 @@ jobs: dotnet-quality: preview - name: Restore - run: dotnet restore src/ConfigWay.Tests/ConfigWay.Tests.csproj + run: | + dotnet restore src/ConfigWay.Tests/ConfigWay.Tests.csproj + dotnet restore src/ConfigWay.FeatureManagement.Tests/ConfigWay.FeatureManagement.Tests.csproj - name: Build - run: dotnet build src/ConfigWay.Tests/ConfigWay.Tests.csproj --no-restore --configuration Release + run: | + dotnet build src/ConfigWay.Tests/ConfigWay.Tests.csproj --no-restore --configuration Release + dotnet build src/ConfigWay.FeatureManagement.Tests/ConfigWay.FeatureManagement.Tests.csproj --no-restore --configuration Release - name: Test run: > @@ -49,6 +53,12 @@ jobs: --no-build --configuration Release --logger "trx;LogFilePrefix=unit-results" + - name: Test feature flags + run: > + dotnet test src/ConfigWay.FeatureManagement.Tests/ConfigWay.FeatureManagement.Tests.csproj + --no-build --configuration Release + --logger "trx;LogFilePrefix=feature-flags-results" + - name: Upload test results uses: actions/upload-artifact@v4 if: always() diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b6e3de8..da151d6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -39,10 +39,14 @@ jobs: dotnet-quality: preview - name: Restore - run: dotnet restore src/ConfigWay.Tests/ConfigWay.Tests.csproj + run: | + dotnet restore src/ConfigWay.Tests/ConfigWay.Tests.csproj + dotnet restore src/ConfigWay.FeatureManagement.Tests/ConfigWay.FeatureManagement.Tests.csproj - name: Build - run: dotnet build src/ConfigWay.Tests/ConfigWay.Tests.csproj --no-restore --configuration Release + run: | + dotnet build src/ConfigWay.Tests/ConfigWay.Tests.csproj --no-restore --configuration Release + dotnet build src/ConfigWay.FeatureManagement.Tests/ConfigWay.FeatureManagement.Tests.csproj --no-restore --configuration Release - name: Test run: > @@ -50,6 +54,12 @@ jobs: --no-build --configuration Release --logger "trx;LogFileName=unit-results.trx" + - name: Test feature flags + run: > + dotnet test src/ConfigWay.FeatureManagement.Tests/ConfigWay.FeatureManagement.Tests.csproj + --no-build --configuration Release + --logger "trx;LogFileName=feature-flags-results.trx" + test-postgres: name: PostgreSQL Tests runs-on: ubuntu-latest diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index f85531c..df18996 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -53,6 +53,9 @@ jobs: dotnet build src/ConfigWay.SqlServer/ConfigWay.SqlServer.csproj \ --configuration Release \ -p:Version=${{ steps.version.outputs.VERSION }} + dotnet build src/ConfigWay.FeatureManagement/ConfigWay.FeatureManagement.csproj \ + --configuration Release \ + -p:Version=${{ steps.version.outputs.VERSION }} - name: Pack run: | @@ -76,6 +79,10 @@ jobs: --configuration Release --no-build \ -p:Version=${{ steps.version.outputs.VERSION }} \ --output ./nupkgs + dotnet pack src/ConfigWay.FeatureManagement/ConfigWay.FeatureManagement.csproj \ + --configuration Release --no-build \ + -p:Version=${{ steps.version.outputs.VERSION }} \ + --output ./nupkgs - name: Push to NuGet run: | diff --git a/README.md b/README.md index c9d3e91..bfb79b5 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![CI](https://github.com/kododo-dev/ConfigWay/actions/workflows/ci.yml/badge.svg)](https://github.com/kododo-dev/ConfigWay/actions/workflows/ci.yml) [![Demo](https://img.shields.io/badge/demo-live-brightgreen)](https://kododo.dev/configway/demo) -Runtime configuration editor for ASP.NET Core. View and modify `IOptions` values at runtime through a built-in web UI without restarting the application. +Runtime configuration editor for ASP.NET Core. View and modify `IOptions` values at runtime through a built-in web UI without restarting the application, and manage [feature flags](#feature-flags) the same way. A live demo is available at [kododo.dev/configway/demo](https://kododo.dev/configway/demo). @@ -15,6 +15,10 @@ A live demo is available at [kododo.dev/configway/demo](https://kododo.dev/confi ![Array editor](docs/screenshots/03-array-editor.png) +![Feature flags](docs/screenshots/04-feature-flags.png) + +![Flag editor](docs/screenshots/05-flag-editor.png) + ## Packages | Package | NuGet | Description | @@ -24,6 +28,7 @@ A live demo is available at [kododo.dev/configway/demo](https://kododo.dev/confi | `Kododo.ConfigWay.UI` | [![NuGet](https://img.shields.io/nuget/v/Kododo.ConfigWay.UI)](https://www.nuget.org/packages/Kododo.ConfigWay.UI) | Embedded web UI | | `Kododo.ConfigWay.PostgreSQL` | [![NuGet](https://img.shields.io/nuget/v/Kododo.ConfigWay.PostgreSQL)](https://www.nuget.org/packages/Kododo.ConfigWay.PostgreSQL) | PostgreSQL persistence store | | `Kododo.ConfigWay.SqlServer` | [![NuGet](https://img.shields.io/nuget/v/Kododo.ConfigWay.SqlServer)](https://www.nuget.org/packages/Kododo.ConfigWay.SqlServer) | SQL Server persistence store | +| `Kododo.ConfigWay.FeatureManagement` | [![NuGet](https://img.shields.io/nuget/v/Kododo.ConfigWay.FeatureManagement)](https://www.nuget.org/packages/Kododo.ConfigWay.FeatureManagement) | Feature flags of Microsoft.FeatureManagement in the editor | ## Quick start @@ -194,6 +199,44 @@ builder.Services.AddSingleton, EmailOptionsValida Errors are shown in the UI and saving is blocked until they are resolved. +## Feature flags + +`Kododo.ConfigWay.FeatureManagement` adds a Feature flags view to the editor. You can turn [Microsoft.FeatureManagement](https://learn.microsoft.com/azure/azure-app-configuration/feature-management-dotnet-reference) flags on and off, roll them out to users and groups, or limit them to a time window without redeploying. Your code keeps using `IFeatureManager`, `[FeatureGate]` and the other feature management APIs. + +```bash +dotnet add package Kododo.ConfigWay.FeatureManagement +``` + +```csharp +builder.AddConfigWay(x => +{ + x.UsePostgreSql(connectionString); + x.AddUiEditor(); + x.AddFeatureFlags(); +}); + +builder.Services.AddFeatureManagement(); +``` + +The view lists the flags from your configuration (the `feature_management` section of appsettings, environment variables and so on), including flags in the older `FeatureManagement` schema. You can also add flags that exist only in ConfigWay. + +A flag saved in the editor is stored by ConfigWay and replaces the flag with the same id from your configuration. Resetting it brings the configured flag back. A flag that exists only in ConfigWay is deleted on reset. + +| Filter | Editor | +|---|---| +| `Microsoft.Percentage` | Slider | +| `Microsoft.TimeWindow` | Start and end in your time zone, stored in UTC | +| `Microsoft.Targeting` | Users, groups with a percentage, default percentage, exclusions | +| Other filters | JSON parameters | + +A filter that your application has not registered can't be saved, because checking the flag would throw. + +Variants, allocation, telemetry and recurring time windows are kept when you save a flag, but you can't edit them yet. + +To see how a flag evaluates for a user, enter the user and groups under "Check for a user". The check uses your application's feature manager. + +The flag endpoints are part of the editor's API (`{path}/api`) and use the same authorization. If the app runs as several replicas, set [`ReloadInterval`](#several-replicas) so that a changed flag reaches all of them. + ## Security Outside the Development environment the editor and its API answer `403` until you decide who may use them: @@ -226,9 +269,23 @@ app.UseWayShell("/admin").RequireAuthorization(); // every Way the user may op > Use `IOptionsSnapshot` or `IOptionsMonitor` in your code to always get the latest values after a reload. +## Several replicas + +A save reloads the configuration in the instance that handled it. When the app runs as several replicas sharing one store, let each of them check the store for changes: + +```csharp +builder.AddConfigWay(x => +{ + x.UsePostgreSql(connectionString); + x.ReloadInterval = TimeSpan.FromSeconds(30); +}); +``` + +Every 30 seconds each replica reads the store and reloads only when a setting was added, changed or removed, so the other replicas get a change within one interval. Without `ReloadInterval` they keep their values until a restart. + ## Limitations -- Changes are reloaded only in the instance that saved them. When the app runs as several replicas, the other replicas pick up the new values after a restart. +- Without `ReloadInterval`, changes are reloaded only in the instance that saved them (see [Several replicas](#several-replicas)). - Saves do not check for concurrent edits. If two people edit the same section at the same time, the last save wins. ## Custom store diff --git a/docs/feature-flags.md b/docs/feature-flags.md new file mode 100644 index 0000000..81d101c --- /dev/null +++ b/docs/feature-flags.md @@ -0,0 +1,246 @@ +# Flagi funkcji w ConfigWay: plan + +Stan: F0-F2 i F3 (bez wydania) zrobione (2026-10-21); zostało wydanie 2.1.0. Źródło pomysłu: `IDEAS.md`, pkt 2. + +## 1. Cel i zakres + +Nowa, opcjonalna paczka **`Kododo.ConfigWay.FeatureManagement`**. Pozwala zarządzać flagami +`Microsoft.FeatureManagement` z panelu ConfigWay bez redeployu. Flagi dalej czyta standardowy +`IFeatureManager` / `IVariantFeatureManager` z `IConfiguration`, więc kod aplikacji +(`[FeatureGate]`, `IsEnabledAsync`, tag helpery) się nie zmienia. + +Zależność od `Microsoft.FeatureManagement` (4.8.0: net8.0 + net10.0; net9.0 korzysta z buildu net8.0) +ma tylko nowa paczka. Rdzeń ConfigWay, UI i magazyny jej nie ciągną. + +**v1:** +- lista flag ze wszystkich źródeł (appsettings, env, ConfigWay) z oznaczeniem źródła i nadpisania; +- szybkie włącz/wyłącz z listy; +- edytor flagi: `enabled`, `requirement_type` (Any/All), filtry `Microsoft.Percentage`, + `Microsoft.TimeWindow` (bez cykliczności), `Microsoft.Targeting` (użytkownicy, grupy z %, domyślny %, + wykluczenia) oraz dowolny filtr własny jako nazwa + parametry JSON; +- dodawanie nowych flag i reset nadpisania, czyli powrót do definicji z appsettings albo usunięcie flagi, + która istnieje tylko w ConfigWay; +- tester "czy flaga jest włączona dla użytkownika X z grupami Y" (ewaluacja po stronie serwera); +- walidacja po stronie serwera; i18n en/pl; demo; testy. + +**Później (v1.x):** warianty i alokacja (`variants`, `allocation`, `status_override`), cykliczność +TimeWindow, flaga `telemetry`, narzędzia MCP. Odświeżanie między replikami jest w v1 (sekcja 7). + +**Poza zakresem:** historia zmian i audyt (ConfigWay ich nie ma; to osobny temat dla całego ConfigWay), +edycja flag w starym schemacie .NET (`FeatureManagement`). Te flagi są tylko wyświetlane, a nadpisanie +zapisuje się w schemacie Microsoft. + +## 2. Stan wyjściowy ConfigWay (istotne fakty) + +- Magazyn to płaskie `key -> value` (`IStore`: Get/Set/Delete, tabele `configway.settings` + w PostgreSQL i SQL Server, `InMemoryStore` domyślnie). +- `ConfigurationProvider.IsAllowedKey` przepuszcza **tylko klucze zarejestrowanych typów `IOptions`**, + więc wpisy `feature_management:*` byłyby odrzucone. +- `ConfigurationBuilder` ma publiczne tylko `Services`, `Store` i `AddOptions`. Brak punktów + rozszerzeń dla źródeł konfiguracji i dodatkowych endpointów. +- Przeładowanie: `IConfigurationEditor.ReloadAllAsync` po zapisie, tylko w bieżącej instancji. +- UI: jeden `WayModule("config")`, menu w `SideMenu.tsx`, API Reiho `POST /config/api/{Request}`. +- Way.Shell nie pozwala innej paczce dołożyć widoku do cudzego modułu (jeden `App` na Way). +- ConfigWay nie ma MCP ani testów SPA. + +## 3. Model zapisu + +**Jedna flaga = jeden wiersz** w istniejącym `IStore`: + +``` +key: feature_management:flags/ (prefiks niekolidujący z kluczami IOptions) +value: JSON flagi w schemacie Microsoft {"id":"Beta","enabled":true,"conditions":{...}} +``` + +Dlaczego tak, a nie spłaszczone `feature_management:feature_flags:0:...`: +- indeksy tablicy rozjeżdżają się przy dodawaniu i usuwaniu, a scalanie po indeksie z appsettings + nadpisywałoby niewłaściwe flagi; +- zapis jednej flagi jest atomowy (jeden upsert) i nie wymaga zmian schematu baz; +- reset flagi to usunięcie jednego klucza. + +Nie trzeba zmieniać tabel ani `IStore`. + +**Źródło konfiguracji paczki:** `FeatureFlagsProvider : StoreConfigurationProvider` wybiera z magazynu klucze +z prefiksem `feature_management:flags/`, parsuje JSON i wystawia go spłaszczony jako +`feature_management:feature_flags:{100000+i}:...`. Główny provider ConfigWay dalej ignoruje te klucze, bo nie +należą do żadnego typu opcji. + +**Scalanie z appsettings po `id`:** paczka włącza +`ConfigurationFeatureDefinitionProviderOptions.CustomConfigurationMergingEnabled = true` +(od 4.3.0). Wtedy definicja z ConfigWay (źródło dodane później) wygrywa w całości z flagą o tym samym +`id` z appsettings, niezależnie od indeksów. + +**Sprawdzone w F1 (źródła `ConfigurationFeatureDefinitionProvider` 4.8.0 + testy):** +- Przy scalaniu każdy provider (`ConfigurationProvider` albo `ChainedConfigurationProvider`) jest osobnym + źródłem, a wygrywa ten o najwyższym priorytecie, czyli dodany później. W obrębie źródła wpis w schemacie + Microsoft wygrywa ze starym schematem, więc nadpisanie flagi ze starego schematu działa. +- Jeśli aplikacja ma providera, który nie dziedziczy z `ConfigurationProvider`, biblioteka wraca do + zwykłego scalania po indeksach. Dlatego flagi z ConfigWay zaczynają się od indeksu **100000**: są wtedy + po flagach aplikacji, a biblioteka bierze ostatnią definicję o danym `id`. Mutacja w testach: każde + z dwóch zabezpieczeń osobno wystarcza, bez obu polegają 4 testy nadpisań. +- Id porównywane bez wielkości liter; brak `enabled` = wyłączona. + +## 4. Zmiany w rdzeniu ConfigWay (F0), zrobione 2026-10-20 + +Publiczne punkty rozszerzeń, bez zmian zachowania dla obecnych użytkowników: + +1. **`StoreConfigurationProvider`** (Kododo.ConfigWay): abstrakcyjna klasa bazowa providera, który + wystawia wartości z magazynu pod własnymi kluczami (`Select(IReadOnlyList)`). Rejestracja + przez `ConfigurationBuilder.AddProvider(provider)`. Główny provider ConfigWay też z niej dziedziczy + i czyta magazyn **raz** dla wszystkich: przy `Load`, po zapisie (`IConfigurationEditor.ReloadAllAsync`) + i przy odpytywaniu. Providery rozszerzeń są dodawane do `IConfiguration` zaraz po głównym. + `GetConfigurationHandler` wyłącza wszystkie `StoreConfigurationProvider` z wartości domyślnych. +2. **`AddUiExtension(id, assembly)`** (Kododo.ConfigWay.UI): rejestruje handlery Reiho z assembly + i rozszerzenie w DI. `UseConfigWay` mapuje requesty rozszerzeń na tę samą grupę `/api`, więc + dziedziczą autoryzację grupy. Użytkownik nie wywołuje nic dodatkowego. +3. **`GetCapabilities`** -> `{ extensions: string[] }`. SPA na tej podstawie pokaże "Flagi" (F2). +4. **`ReloadInterval`** (sekcja 7). +5. Way.Shell podbity do 2.1.1 (NuGet). Paczka npm zostaje na 2.0.0 do F2. + +Testy: 192 w ConfigWay.Tests (net8/9/10), po 24 w PostgreSQL i SQL Server (w tym test dwóch replik). + +## 5. API publiczne paczki + +```csharp +builder.AddConfigWay(x => +{ + x.UsePostgreSql(cs); + x.AddOptions(); + x.AddUiEditor(); + x.AddFeatureFlags(); // źródło + handlery + rozszerzenie UI +}); + +builder.Services.AddFeatureManagement().WithTargeting(); + +app.UseConfigWay().RequireAuthorization("admin"); // bez zmian; flagi pod /config +``` + +`AddFeatureFlags` **nie** wywołuje `AddFeatureManagement`. Aplikacja sama decyduje o targetingu +i własnych filtrach. Paczka tylko konfiguruje opcje scalania i rejestruje swoje elementy. Opcjonalnie +`AddFeatureFlags(o => o.KnownFilters...)` podpowiada nazwy własnych filtrów w UI. + +## 6. Backend (Reiho, w paczce FeatureManagement), zrobione 2026-10-21 + +Requesty (pod `/config/api`, z autoryzacją grupy ConfigWay): + +| Request | Działanie | +|---|---| +| `GetFeatureFlags` -> `{ isFeatureManagementRegistered, filters[], flags[] }` | Pozycja: `id`, `flag` (w użyciu), `configured` (z konfiguracji aplikacji albo `null`), `hasOverride`, `isDotnetSchema`, `hasVariants`, `error` (nieczytelne nadpisanie). `filters` to aliasy zarejestrowanych `IFeatureFilterMetadata` (podpowiedzi w UI). Flagi bazowe czyta `FlagCatalog` z każdego providera osobno (bez `Load`), tak jak scala je biblioteka. | +| `SaveFeatureFlag(flag, isNew)` | Walidacja; `isNew` + istniejące id -> 400 `flag.id`; zmiana nieistniejącej -> 404. Zachowuje id istniejącej flagi (wielkość liter) i jej nieedytowane pola. Upsert jednego klucza + przeładowanie. | +| `ResetFeatureFlag(id)` | Usuwa nadpisanie (404, gdy go nie ma). Flaga z konfiguracji wraca, flaga tylko z ConfigWay znika. | +| `EvaluateFeatureFlag(id, userId?, groups?)` -> `{ enabled, variant }` | `IsEnabledAsync(id, TargetingContext)` + `GetVariantAsync`; brak `AddFeatureManagement` -> 409, nieznana flaga -> 404. Informacji "który filtr zdecydował" biblioteka nie udostępnia, więc jej nie ma. | + +Model edycji (`FeatureFlag`): `id`, `enabled`, `requirementType` (Any/All), `filters[]` (`name`, +`parameters` jako JSON), `description`. + +**Walidacja** (`IValidatableObject`; Reiho zwraca ProblemDetails z kluczami typu +`flag.filters[1].parameters`): +- `id`: niepuste, bez spacji na brzegach, bez `:`, najwyżej 200 znaków; +- Percentage: `Value` w zakresie 0-100; +- TimeWindow: `Start` i/lub `End` jako data, `Start < End`; zapis w RFC 1123 (UTC); `Recurrence` + zachowywane bez sprawdzania (edycja w F4); +- Targeting: `Audience` wymagane, niepuste nazwy, grupy bez duplikatów, procenty 0-100; +- inne filtry: dowolne parametry, ale **nazwa musi należeć do zarejestrowanego filtra**, gdy aplikacja ma + `AddFeatureManagement` (`RegisteredFilters`, dopasowanie jak w bibliotece: pełny alias albo ostatni segment, + `AlwaysOn`/`On` zawsze). Powód: dla nieznanego filtra biblioteka rzuca `FeatureManagementException` przy + każdym sprawdzeniu flagi; sprawdzone na demo (strona 500). Błąd: `flag.filters[i].name`. + +**Zapis:** JSON w schemacie Microsoft, parametry wbudowanych filtrów w jednym kształcie (liczby jako liczby). +Nieedytowane pola (`variants`, `allocation`, `telemetry`, ...) są **zachowywane**, także przy pierwszym +nadpisaniu flagi z appsettings. Warianty flag ze starego schematu nie są przenoszone (`HasDroppedVariants` +w `BaseFlag`; UI może to pokazać). + +**Testy:** `ConfigWay.FeatureManagement.Tests` (82 × net8/9/10), w tym integracja z prawdziwym +`IVariantFeatureManager`: nadpisanie, reset, nowa flaga, stary schemat, Percentage, TimeWindow, Targeting +przez `EvaluateFeatureFlag`, warianty zachowane po zapisie, wielkość liter id, nieczytelne nadpisanie. +PostgreSQL i SQL Server: trwałość po restarcie i przekazanie zmiany do drugiej repliki (`ReloadInterval`). +CI (`ci.yml`) uruchamia nowe testy, a `publish-nuget.yml` pakuje nową paczkę. + +## 7. Wiele instancji, w v1 (decyzja 2026-10-20), zrobione w F0 + +Bez tej opcji zmiana działa tylko w instancji, która ją zapisała. Przy flagach to szczególnie boli, +bo wyłącznik awaryjny musi zadziałać wszędzie. Opcja `x.ReloadInterval = TimeSpan.FromSeconds(30)` +(domyślnie `null`, czyli wyłączona) uruchamia `ReloadService` (`BackgroundService` + `PeriodicTimer`). +Ten co interwał czyta magazyn i przeładowuje wszystkie providery tylko wtedy, gdy zmienił się jakikolwiek +wpis (porównanie z ostatnio wczytanym stanem). Błąd magazynu jest logowany jako ostrzeżenie, a kolejny +tick próbuje ponownie. Bez zmian `IStore`. Tabele są małe, więc koszt jest pomijalny; ewentualnie +później tani znacznik wersji w magazynach. + +## 8. UI (w ConfigWay.UI SPA), zrobione 2026-10-21 + +Widok flag mieszka w **istniejącym SPA ConfigWay** i pojawia się, gdy `GetCapabilities` zwraca `flags` +(`FlagsProvider` pobiera flagi tylko wtedy). Uzasadnienie: Way.Shell nie ma slotów na widoki z innych +paczek, a osobny Way byłby w menu tylko w panelu `/admin`. ConfigWay.UI nie ma zależności NuGet od paczki +flag; `dist/way.js` urósł do ~96 kB (24 kB gzip). Way.Shell npm podbity do ^2.1.1. + +- **Trasy:** `flags` (lista), `flags/new`, `flag/:flagId`. Dopóki `GetCapabilities` nie odpowie, nieznana + trasa nie przekierowuje do korzenia (głęboki link do flagi działa). +- **Lista** (`FlagsPage`): przełącznik zapisuje od razu; snackbar z "Cofnij" (gdy flaga nie miała + nadpisania, cofnięcie robi reset, a nie zapis kopii). Opis warunków ("1 user · 1 group", "50% of checks", + "from ... until ..."), znaczniki: nadpisana / tylko ConfigWay / schemat .NET / warianty / nieczytelna. + Wyszukiwarka po id i opisie, ostrzeżenie, gdy aplikacja nie wywołała `AddFeatureManagement`. +- **Edytor** (`FlagPage`): id (tylko dla nowej), opis, `enabled`, źródło flagi; "Przywróć z konfiguracji" + albo "Usuń flagę" z oknem potwierdzenia. Warunki: menu "Dodaj warunek", Any/All przy 2+ warunkach, + karty filtrów z błędami API przypisanymi po `flag.filters[i]`: + - Percentage: suwak + pole; podpowiedź, że to losowanie na sprawdzenie (nie na użytkownika); + - TimeWindow: `datetime-local` w strefie przeglądarki, zapis w UTC, stan "aktywne / jeszcze nie / zakończone"; + `Recurrence` zachowane z informacją; + - Targeting: chipy użytkowników i wykluczeń, wiersze grup z %, % pozostałych; + - własny filtr: nazwa z podpowiedziami zarejestrowanych filtrów, ostrzeżenie dla niezarejestrowanej + (sprawdzenie flagi rzuciłoby wyjątek), parametry JSON z blokadą zapisu przy błędnym JSON. +- **Tester** (`FlagCheck`): użytkownik + grupy -> włączona/wyłączona i wariant; sprawdza zapisany stan. +- **i18n:** en/pl w `translations.ts` (funkcje dla tekstów z liczbami). +- `PageHeader` dostał `actions` i `searchPlaceholder`. + +**Demo:** klasa opcji `FeatureFlags` zastąpiona prawdziwymi flagami w `appsettings.json` (BetaBanner, +NewCheckout z targetingiem, HolidayTheme z oknem czasowym, MaintenanceMode, SearchEngine z wariantami, +ReadOnlyMode w schemacie .NET). Strona główna pokazuje stan flag dla gościa wybranego linkiem +(`?user=alice|bob|mallory`). + +**e2e szczegółowe** (`ConfigWay.Demo.Web/e2e/flags.mjs`, 53 scenariusze): linki bezpośrednie, wyszukiwanie, +okno czasowe tam i z powrotem przez strefę (wartości w bazie w UTC), edycja targetowania (chipy, grupy, +wykluczenia) z testerem i stroną demo, Any/All na dwóch warunkach, własny filtr (zły JSON, niezarejestrowana +nazwa), id `Team.Billing/V2`, duplikat id, `:` w id, Odrzuć, stary schemat .NET, warianty zachowane po zapisie, +zapis ustawień nie gubi nadpisań flag, telefon (bez poziomego przewijania), język polski. 53/53 w Chrome, +Edge, Firefox i WebKit. Bezpieczeństwo w testach integracyjnych: 403 bez decyzji, 401 bez zalogowania +(wszystkie 4 requesty, flaga nietknięta). Tester czyści wynik po zmianie użytkownika lub grup. + +**e2e** (`ConfigWay.Demo.Web/e2e/check.mjs`): +18 scenariuszy flag (menu, lista, przełącznik i cofnięcie, +targeting i tester, nadpisanie i reset, błąd walidacji w karcie warunku, nowa flaga i usunięcie). Skrypt +najpierw resetuje nadpisania, których dotyka. Wynik: 32/32 w Chrome i WebKit. `SHOTS=` zapisuje zrzuty. + +## 9. Testy + +- `ConfigWay.FeatureManagement.Tests` (net8/9/10): + - parser i spłaszczanie JSON ↔ klucze; + - walidacja; + - handlery; + - **integracja z prawdziwym `IFeatureManager`**: flaga z appsettings, nadpisanie z ConfigWay, reset, + Percentage, TimeWindow, Targeting przez `EvaluateFeatureFlag`; zachowanie nieznanych pól. +- PostgreSQL/SQL Server: po jednym teście API na Testcontainers (zapis, odczyt, przeładowanie). +- Testy rdzenia po F0: brak regresji (istniejące 170+ testów) i nowe testy punktów rozszerzeń. +- e2e: nowe scenariusze w `ConfigWay.Demo.Web/e2e/check.mjs` (lista, przełącznik, edytor, reset, tester). + +## 10. Demo i dokumentacja + +- Demo: klasę `FeatureFlags` (4 boole jako zastępstwo flag) zastępujemy prawdziwymi flagami + w `appsettings.json` (np. `NewCheckout` z Percentage, `BetaBanner` z Targeting, + `MaintenanceMode`, `HolidayTheme` z TimeWindow). `HomeView` pokazuje ich stan dla bieżącego + (wybieralnego) użytkownika. +- README: tabela paczek, sekcja "Feature flags", `.nuget/README.md` nowej paczki, + aktualizacja Limitations. +- Zrzut ekranu widoku flag w `docs/screenshots`. + +## 11. Fazy + +| Faza | Zakres | Efekt | +|---|---|---| +| F0 ✅ | Punkty rozszerzeń w rdzeniu i UI (sekcja 4), `ReloadInterval` + testy | Bez zmian zachowania | +| F1 ✅ | Backend paczki: źródło, scalanie, handlery, walidacja, testy integracyjne z `IFeatureManager` | Flagi edytowalne przez API | +| F2 ✅ | UI: lista, przełącznik, edytor (Percentage/TimeWindow/Targeting/własny), tester, i18n | Pełny v1 w panelu | +| F3 ✅ (bez wydania) | Demo, README, e2e, zrzuty, `publish-nuget.yml` (nowa paczka) | Gotowe do wydania **ConfigWay 2.1.0** | +| F4 (v1.x) | Warianty i alokacja, cykliczność TimeWindow | 2.2.0 | +| później | MCP (lista, przełącz, sprawdź flagę), historia zmian | - | + +Wersja: wszystkie paczki ConfigWay wydawane razem z tagu, więc nowa paczka startuje od 2.1.0. +Way.Shell bez zmian (kontrakt 2). diff --git a/docs/screenshots/01-overview.png b/docs/screenshots/01-overview.png index c8cf4e4..e97ab5f 100644 Binary files a/docs/screenshots/01-overview.png and b/docs/screenshots/01-overview.png differ diff --git a/docs/screenshots/02-fields.png b/docs/screenshots/02-fields.png index 2d230b1..a598f0e 100644 Binary files a/docs/screenshots/02-fields.png and b/docs/screenshots/02-fields.png differ diff --git a/docs/screenshots/03-array-editor.png b/docs/screenshots/03-array-editor.png index 6e8ea28..6adacde 100644 Binary files a/docs/screenshots/03-array-editor.png and b/docs/screenshots/03-array-editor.png differ diff --git a/docs/screenshots/04-feature-flags.png b/docs/screenshots/04-feature-flags.png new file mode 100644 index 0000000..a70041f Binary files /dev/null and b/docs/screenshots/04-feature-flags.png differ diff --git a/docs/screenshots/05-flag-editor.png b/docs/screenshots/05-flag-editor.png new file mode 100644 index 0000000..6ddba2f Binary files /dev/null and b/docs/screenshots/05-flag-editor.png differ diff --git a/src/ConfigWay.Demo.Web/ConfigWay.Demo.Web.csproj b/src/ConfigWay.Demo.Web/ConfigWay.Demo.Web.csproj index db3132b..935f5b9 100644 --- a/src/ConfigWay.Demo.Web/ConfigWay.Demo.Web.csproj +++ b/src/ConfigWay.Demo.Web/ConfigWay.Demo.Web.csproj @@ -3,5 +3,6 @@ + diff --git a/src/ConfigWay.Demo.Web/HomeView.cs b/src/ConfigWay.Demo.Web/HomeView.cs index a4964c6..81d69bd 100644 --- a/src/ConfigWay.Demo.Web/HomeView.cs +++ b/src/ConfigWay.Demo.Web/HomeView.cs @@ -4,6 +4,19 @@ namespace Kododo.ConfigWay.Demo.Web; static class HomeView { + /// A visitor of the demo, for the targeting of the flags. + public sealed record Visitor(string? Id, string Label, string[] Groups); + + public sealed record FlagState(string Name, bool Enabled, string? Variant); + + public static readonly Visitor[] Visitors = + [ + new(null, "anonymous", []), + new("alice", "alice", []), + new("bob", "bob (beta-testers)", ["beta-testers"]), + new("mallory", "mallory (beta-testers, excluded)", ["beta-testers"]), + ]; + public static string Render( string basePath, BrandingOptions b, @@ -11,7 +24,8 @@ public static string Render( IdentityOptions id, StorageOptions st, WebhooksOptions wh, - FeatureFlags ff) + Visitor visitor, + FlagState[] flags) { static string Flag(bool on) => on ? "✓ on" @@ -41,6 +55,8 @@ static string Row(string label, string value) => .card{background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:20px 22px} .card h2{font-size:.85rem;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:14px} table{width:100%;border-collapse:collapse;font-size:.9rem} + .visitors{font-size:.82rem;color:#6b7280;margin:-6px 0 12px} + .visitors a{color:#2563eb;text-decoration:none} .footer{text-align:center;padding:24px;color:#9ca3af;font-size:.82rem;border-top:1px solid #e5e7eb;margin-top:8px} @@ -111,18 +127,22 @@ Open Configuration Editor -
+

Feature Flags

+

Seen by + {{string.Join(" · ", Visitors.Select(v => v == visitor + ? $"{WebUtility.HtmlEncode(v.Label)}" + : $"{WebUtility.HtmlEncode(v.Label)}"))}} +

- - - - + {{string.Concat(flags.Select(f => + $"" + + $""))}}
New Checkout{{Flag(ff.EnableNewCheckout)}}
Beta Banner{{Flag(ff.EnableBetaBanner)}}
Maintenance Mode{{Flag(ff.MaintenanceMode)}}
Read-Only Mode{{Flag(ff.ReadOnlyMode)}}
{WebUtility.HtmlEncode(f.Name)}{Flag(f.Enabled)}{(f.Variant is null ? "" : $" · {WebUtility.HtmlEncode(f.Variant)}")}
- + """; diff --git a/src/ConfigWay.Demo.Web/Options.cs b/src/ConfigWay.Demo.Web/Options.cs index 64dbde9..b5ebdff 100644 --- a/src/ConfigWay.Demo.Web/Options.cs +++ b/src/ConfigWay.Demo.Web/Options.cs @@ -214,20 +214,3 @@ public class WebhooksOptions public int MaxRetries { get; set; } = 3; } -[Display( - Name = "FeatureFlags", - Description = "Runtime toggles for shipping work-in-progress behavior.")] -public class FeatureFlags -{ - [Display(Name = "Enable New Checkout", Description = "Redirects users to the redesigned checkout flow.")] - public bool EnableNewCheckout { get; set; } = false; - - [Display(Name = "Enable Beta Banner", Description = "Shows the 'beta' banner on top of the SPA.")] - public bool EnableBetaBanner { get; set; } = false; - - [Display(Name = "Maintenance Mode", Description = "Short-circuits the site into a maintenance page.")] - public bool MaintenanceMode { get; set; } = false; - - [Display(Name = "Read-Only Mode", Description = "Disables writes while keeping reads available.")] - public bool ReadOnlyMode { get; set; } = false; -} diff --git a/src/ConfigWay.Demo.Web/Program.cs b/src/ConfigWay.Demo.Web/Program.cs index b1a7fda..6b9e1b3 100644 --- a/src/ConfigWay.Demo.Web/Program.cs +++ b/src/ConfigWay.Demo.Web/Program.cs @@ -3,6 +3,9 @@ using Kododo.ConfigWay.Demo.Web; using Kododo.ConfigWay.UI; using Kododo.ConfigWay.PostgreSQL; +using Kododo.ConfigWay.FeatureManagement; +using Microsoft.FeatureManagement; +using Microsoft.FeatureManagement.FeatureFilters; var builder = WebApplication.CreateBuilder(args); @@ -13,13 +16,14 @@ x.AddOptions("Identity"); x.AddOptions("Storage"); x.AddOptions("Webhooks"); - x.AddOptions("FeatureFlags"); x.AddUiEditor(); + x.AddFeatureFlags(); x.UsePostgreSql(builder.Configuration.GetConnectionString("DemoDB")!); }); builder.Services.AddSingleton, SmtpOptionsValidator>(); builder.Services.AddSingleton, IdentityOptionsValidator>(); +builder.Services.AddFeatureManagement(); var app = builder.Build(); @@ -27,18 +31,33 @@ if(!pathBase.StartsWith("/")) pathBase = "/" + pathBase; if(!pathBase.EndsWith("/")) pathBase += "/"; -app.MapGet("/", ( +app.MapGet("/", async ( + string? user, IOptionsSnapshot branding, IOptionsSnapshot smtp, IOptionsSnapshot identity, IOptionsSnapshot storage, IOptionsSnapshot webhooks, - IOptionsSnapshot flags) => + IVariantFeatureManager featureManager, + CancellationToken cancellationToken) => { + // The demo's visitors: pick one with ?user= to see the flags as that user sees them. + var visitor = HomeView.Visitors.FirstOrDefault(v => v.Id == user) ?? HomeView.Visitors[0]; + var context = new TargetingContext { UserId = visitor.Id, Groups = visitor.Groups }; + + var flags = new List(); + await foreach (var name in featureManager.GetFeatureNamesAsync(cancellationToken)) + { + var enabled = await featureManager.IsEnabledAsync(name, context, cancellationToken); + var variant = await featureManager.GetVariantAsync(name, context, cancellationToken); + flags.Add(new HomeView.FlagState(name, enabled, variant?.Name)); + } + var html = HomeView.Render( pathBase, branding.Value, smtp.Value, identity.Value, - storage.Value, webhooks.Value, flags.Value); + storage.Value, webhooks.Value, + visitor, flags.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase).ToArray()); return Results.Content(html, "text/html"); }); diff --git a/src/ConfigWay.Demo.Web/appsettings.json b/src/ConfigWay.Demo.Web/appsettings.json index 39c42b0..dbe164d 100644 --- a/src/ConfigWay.Demo.Web/appsettings.json +++ b/src/ConfigWay.Demo.Web/appsettings.json @@ -49,10 +49,95 @@ } ] }, - "FeatureFlags": { - "EnableNewCheckout": false, - "EnableBetaBanner": false, - "MaintenanceMode": false, + "feature_management": { + "feature_flags": [ + { + "id": "BetaBanner", + "description": "Shows the beta banner on top of the site.", + "enabled": true + }, + { + "id": "NewCheckout", + "description": "Redesigned checkout flow, rolled out to beta testers first.", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.Targeting", + "parameters": { + "Audience": { + "Users": [ + "alice" + ], + "Groups": [ + { + "Name": "beta-testers", + "RolloutPercentage": 100 + } + ], + "DefaultRolloutPercentage": 0, + "Exclusion": { + "Users": [ + "mallory" + ], + "Groups": [] + } + } + } + } + ] + } + }, + { + "id": "HolidayTheme", + "description": "Festive colors over the holidays.", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.TimeWindow", + "parameters": { + "Start": "Sun, 20 Dec 2026 00:00:00 GMT", + "End": "Thu, 07 Jan 2027 00:00:00 GMT" + } + } + ] + } + }, + { + "id": "MaintenanceMode", + "description": "Short-circuits the site into a maintenance page.", + "enabled": false + }, + { + "id": "SearchEngine", + "description": "Which search engine answers; a variant flag.", + "enabled": true, + "variants": [ + { + "name": "Classic", + "configuration_value": "bm25" + }, + { + "name": "Semantic", + "configuration_value": "vector" + } + ], + "allocation": { + "default_when_enabled": "Classic", + "user": [ + { + "variant": "Semantic", + "users": [ + "alice" + ] + } + ] + } + } + ] + }, + "FeatureManagement": { "ReadOnlyMode": false } } diff --git a/src/ConfigWay.Demo.Web/e2e/check.mjs b/src/ConfigWay.Demo.Web/e2e/check.mjs index d6c56e5..2878302 100644 --- a/src/ConfigWay.Demo.Web/e2e/check.mjs +++ b/src/ConfigWay.Demo.Web/e2e/check.mjs @@ -95,6 +95,93 @@ async function open(options = {}) { await context.close(); } +// Feature flags (Kododo.ConfigWay.FeatureManagement): the demo's flags come from appsettings. Screenshots go to +// $SHOTS when it is set. +{ + const { context, page, errors } = await open({ viewport: { width: 1280, height: 800 } }); + const shot = (name) => process.env.SHOTS ? page.screenshot({ path: `${process.env.SHOTS}/${which}-${name}.png` }) : null; + const flagsLoaded = () => page.waitForResponse((r) => r.url().endsWith('/config/api/GetFeatureFlags')); + const homeFlag = async (name, user = '') => { + const html = await (await fetch(`${base}/?user=${user}`)).text(); + return new RegExp(`>${name}]*>✓ on`).test(html); + }; + + // Overrides left by an earlier run would change what the scenarios see. + for (const id of ['BetaBanner', 'NewCheckout', 'E2eFlag']) + await fetch(`${base}/config/api/ResetFeatureFlag`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }), + }); + + await page.goto(`${base}/config/`); + const menu = page.getByTestId('way-menu'); + record('flags: in the menu', await visible(menu.getByText('Feature flags'))); + await menu.getByText('Feature flags').click(); + record('flags: list', (await visible(page.getByTestId('flag-row-BetaBanner'))) && page.url().endsWith('#/flags'), page.url()); + record('flags: .NET schema flag listed', await visible(page.getByTestId('flag-row-ReadOnlyMode').getByText('.NET schema'))); + record('flags: variants shown', await visible(page.getByTestId('flag-row-SearchEngine').getByText('variants'))); + await shot('flags-list'); + + // A switch saves at once; Undo puts the flag back. + const beta = page.getByRole('switch', { name: 'BetaBanner' }); + // The list is drawn again after the response that reloads it; wait for the switch rather than read it at once. + const becomes = (checked) => beta.evaluate((e, c) => new Promise((resolve) => { + const check = () => (e.checked === c ? resolve(true) : setTimeout(check, 50)); + check(); + }), checked).then(() => true); + await Promise.all([flagsLoaded(), beta.click()]); + record('flags: switch turns a flag off', (await becomes(false)) && !(await homeFlag('BetaBanner'))); + record('flags: the switch is an override', await visible(page.getByTestId('flag-row-BetaBanner').getByText('overridden'))); + await Promise.all([flagsLoaded(), page.getByRole('button', { name: 'Undo' }).click()]); + record('flags: undo', (await becomes(true)) && (await homeFlag('BetaBanner'))); + record('flags: undo removes the override it made', + await page.getByTestId('flag-row-BetaBanner').getByText('overridden').waitFor({ state: 'detached', timeout: 5000 }).then(() => true, () => false)); + + // The targeting editor, and the check through the app's feature manager. + await page.getByTestId('flag-row-NewCheckout').click(); + record('flags: editor opens', (await visible(page.getByRole('heading', { name: 'NewCheckout' }))) && page.url().endsWith('#/flag/NewCheckout'), page.url()); + record('flags: targeting form with its users', await visible(page.getByRole('button', { name: 'alice' }))); + await page.getByRole('textbox', { name: 'User', exact: true }).fill('bob'); + await page.getByLabel('Groups (comma-separated)').fill('beta-testers'); + await page.getByRole('button', { name: 'Check', exact: true }).click(); + record('flags: check for a user in a targeted group', await visible(page.getByText('On for this user'))); + await page.getByRole('textbox', { name: 'User', exact: true }).fill('carol'); + await page.getByLabel('Groups (comma-separated)').fill(''); + await page.getByRole('button', { name: 'Check', exact: true }).click(); + record('flags: check for another user', await visible(page.getByText('Off for this user'))); + await shot('flag-targeting'); + + // Override, then reset to the configuration. + await page.getByLabel('Enabled').click(); + await Promise.all([flagsLoaded(), page.getByRole('button', { name: 'Save' }).click()]); + record('flags: saved override', (await visible(page.getByText('Overrides the flag defined'))) && !(await homeFlag('NewCheckout', 'alice'))); + await page.getByRole('button', { name: 'Reset to configuration' }).click(); + await Promise.all([flagsLoaded(), page.getByRole('dialog').getByRole('button', { name: 'Reset to configuration' }).click()]); + record('flags: reset to configuration', (await visible(page.getByText('Defined in the application'))) && (await homeFlag('NewCheckout', 'alice'))); + + // A new flag: the API turns down a wrong percentage, then the flag is added and deleted. + await page.goto(`${base}/config/#/flags/new`); + await page.getByTestId('flag-id').fill('E2eFlag'); + await page.getByRole('button', { name: 'Add condition' }).click(); + await page.getByRole('menuitem', { name: 'Percentage' }).click(); + await page.getByLabel('%', { exact: true }).fill('150'); + await page.getByRole('button', { name: 'Save' }).click(); + record('flags: error on the condition', await visible(page.getByText('The percentage must be between 0 and 100.'))); + await shot('flag-new-error'); + await page.getByLabel('%', { exact: true }).fill('100'); + await Promise.all([flagsLoaded(), page.getByRole('button', { name: 'Save' }).click()]); + await page.waitForURL(/#\/flag\/E2eFlag$/, { timeout: 5000 }).catch(() => {}); + record('flags: new flag saved', page.url().endsWith('#/flag/E2eFlag') && (await homeFlag('E2eFlag')), page.url()); + await page.getByRole('button', { name: 'Delete flag' }).click(); + await Promise.all([flagsLoaded(), page.getByRole('dialog').getByRole('button', { name: 'Delete flag' }).click()]); + await page.waitForURL(/#\/flags$/, { timeout: 5000 }).catch(() => {}); + record('flags: deleted', page.url().endsWith('#/flags') && !(await homeFlag('E2eFlag')), page.url()); + + // Errors the API was meant to answer with (the 400 of the wrong percentage) are not page errors. + const unexpected = errors.filter((e) => !/^400 .*\/SaveFeatureFlag$/.test(e)); + record('flags: no errors', unexpected.length === 0, unexpected.join(' | ')); + await context.close(); +} + await browser.close(); const failed = results.filter((r) => !r.ok); for (const r of results) console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.name}${r.detail ? ` — ${r.detail}` : ''}`); diff --git a/src/ConfigWay.Demo.Web/e2e/flags.mjs b/src/ConfigWay.Demo.Web/e2e/flags.mjs new file mode 100644 index 0000000..bea866c --- /dev/null +++ b/src/ConfigWay.Demo.Web/e2e/flags.mjs @@ -0,0 +1,337 @@ +// Thorough browser scenarios of the feature flags view, against the running demo (its flags come from appsettings): +// node flags.mjs http://localhost:5089 [chrome|msedge|firefox|webkit] +// Needs the playwright package (npm i --no-save playwright). Every scenario puts the flags it changes back. +import { chromium, firefox, webkit } from 'playwright'; + +const base = process.argv[2].replace(/\/$/, ''); +const which = process.argv[3] ?? 'chrome'; +const browser = which === 'firefox' ? await firefox.launch() : which === 'webkit' ? await webkit.launch() : await chromium.launch({ channel: which }); +const results = []; +const record = (name, ok, detail = '') => results.push({ name, ok, detail }); +const visible = (l, timeout = 10000) => l.first().waitFor({ timeout }).then(() => true, () => false); +const gone = (l, timeout = 5000) => l.first().waitFor({ state: 'detached', timeout }).then(() => true, () => false); + +// ── API and the demo's home page ───────────────────────────────────────────── + +const api = async (request, body = {}) => { + const response = await fetch(`${base}/config/api/${request}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), + }); + return response.status === 204 ? null : response.json(); +}; +const flagItem = async (id) => (await api('GetFeatureFlags')).flags.find((f) => f.id === id); +const resetAll = async () => { + for (const item of (await api('GetFeatureFlags')).flags) + if (item.hasOverride) await api('ResetFeatureFlag', { id: item.id }); +}; +/** The flag as the demo's home page shows it: { on, variant }, or null when the page does not list it. */ +const home = async (name, user = '') => { + const html = await (await fetch(`${base}/?user=${user}`)).text(); + const match = new RegExp(`>${name.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}(]*>[^<]*)(?: ]*>· ([^<]*))?`).exec(html); + return match ? { on: match[1].includes('✓ on'), variant: match[2] ?? null } : null; +}; + +// ── Browser ────────────────────────────────────────────────────────────────── + +async function open(options = {}) { + const context = await browser.newContext({ locale: 'en-US', colorScheme: 'light', viewport: { width: 1280, height: 900 }, ...options }); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + page.on('console', (m) => { if (m.type() === 'error' && !m.text().startsWith('Failed to load resource')) errors.push(m.text()); }); + page.on('response', (r) => { + // The 400s are the answers the scenarios ask for; anything else is an error. + if (r.status() >= 400 && !r.url().endsWith('/favicon.ico') && !(r.status() === 400 && r.url().endsWith('/SaveFeatureFlag'))) + errors.push(`${r.status()} ${r.url()}`); + }); + return { context, page, errors }; +} + +const flagsLoaded = (page) => page.waitForResponse((r) => r.url().endsWith('/config/api/GetFeatureFlags')); +const save = async (page) => { + await Promise.all([flagsLoaded(page), page.getByRole('button', { name: 'Save' }).click()]); +}; +const openFlag = async (page, id) => { + await page.goto(`${base}/config/#/flag/${encodeURIComponent(id)}`); + await page.getByRole('heading', { name: id, exact: true }).waitFor(); +}; +const check = async (page, user, groups = '') => { + await page.getByRole('textbox', { name: 'User', exact: true }).fill(user); + await page.getByLabel('Groups (comma-separated)').fill(groups); + // Editing the user clears the last result, so the one that shows up is the answer to this check. + const result = page.getByTestId('flag-check-result'); + await gone(result); + await page.getByRole('button', { name: 'Check', exact: true }).click(); + await result.waitFor(); + return (await result.textContent()).includes('On for this user'); +}; +const addChip = async (page, label, value) => { + const input = page.getByRole('combobox', { name: label, exact: true }); + await input.click(); + await input.fill(value); + await input.press('Enter'); +}; +const pad = (n) => String(n).padStart(2, '0'); +const localInput = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; + +await resetAll(); + +// ── Navigation ─────────────────────────────────────────────────────────────── +{ + const { context, page, errors } = await open(); + + await page.goto(`${base}/config/#/flag/NewCheckout`); + record('deep link to a flag on a fresh load', await visible(page.getByRole('heading', { name: 'NewCheckout', exact: true }))); + + await page.goto(`${base}/config/#/flag/Nope`); + await page.waitForURL(/#\/flags$/, { timeout: 5000 }).catch(() => {}); + record('unknown flag goes to the list', page.url().endsWith('#/flags'), page.url()); + + await page.getByPlaceholder('Search flags…').fill('check'); + const rows = page.locator('[data-testid^="flag-row-"]'); + await gone(page.getByTestId('flag-row-BetaBanner')); + record('search by id', (await rows.count()) === 1 && await visible(page.getByTestId('flag-row-NewCheckout')), String(await rows.count())); + await page.getByPlaceholder('Search flags…').fill('festive'); + record('search by description', await visible(page.getByTestId('flag-row-HolidayTheme')) && (await rows.count()) === 1); + await page.getByPlaceholder('Search flags…').fill(''); + + record('navigation: no errors', errors.length === 0, errors.join(' | ')); + await context.close(); +} + +// ── Time window: local times in, UTC stored, the same local times back ───────── +{ + const { context, page, errors } = await open(); + await openFlag(page, 'HolidayTheme'); + record('time window: not started yet', await visible(page.getByText('not started yet'))); + + const start = new Date(); start.setDate(start.getDate() - 1); start.setHours(10, 30, 0, 0); + const end = new Date(); end.setFullYear(end.getFullYear() + 1); end.setHours(18, 45, 0, 0); + await page.getByLabel('Start').fill(localInput(start)); + await page.getByLabel('End').fill(localInput(end)); + record('time window: active now', await visible(page.getByText('active now'))); + await save(page); + + const stored = (await flagItem('HolidayTheme')).flag.filters[0].parameters; + record('time window: stored in UTC', new Date(stored.Start).getTime() === start.getTime() && new Date(stored.End).getTime() === end.getTime(), + `${stored.Start} / ${stored.End}`); + record('time window: the app sees it on', (await home('HolidayTheme'))?.on === true); + + await page.reload(); + await page.getByLabel('Start').waitFor(); + record('time window: the same local times after a reload', + (await page.getByLabel('Start').inputValue()) === localInput(start) && (await page.getByLabel('End').inputValue()) === localInput(end), + `${await page.getByLabel('Start').inputValue()} / ${await page.getByLabel('End').inputValue()}`); + + await page.getByLabel('End').fill(''); + await save(page); + const open_ = (await flagItem('HolidayTheme')).flag.filters[0].parameters; + record('time window: open end', open_.End === undefined && (await home('HolidayTheme'))?.on === true, JSON.stringify(open_)); + + await page.getByLabel('Start').fill(''); + await page.getByRole('button', { name: 'Save' }).click(); + record('time window: needs a start or an end', await visible(page.getByText('The time window needs a start, an end or both.'))); + + await api('ResetFeatureFlag', { id: 'HolidayTheme' }); + record('time window: no errors', errors.length === 0, errors.join(' | ')); + await context.close(); +} + +// ── Targeting: users, groups and exclusions ─────────────────────────────────── +{ + const { context, page, errors } = await open(); + await openFlag(page, 'NewCheckout'); + + await addChip(page, 'Users', 'carol'); + await page.getByRole('button', { name: 'alice' }).locator('.MuiChip-deleteIcon').click(); + await page.getByRole('button', { name: 'Add group' }).click(); + await page.getByLabel('Group', { exact: true }).last().fill('qa'); + await page.getByRole('spinbutton', { name: '% of the group' }).last().fill('100'); + await addChip(page, 'Excluded groups', 'contractors'); + await save(page); + + const audience = (await flagItem('NewCheckout')).flag.filters[0].parameters.Audience; + record('targeting: stored', JSON.stringify(audience.Users) === '["carol"]' + && audience.Groups.map((g) => `${g.Name}:${g.RolloutPercentage}`).join() === 'beta-testers:100,qa:100' + && JSON.stringify(audience.Exclusion.Groups) === '["contractors"]', JSON.stringify(audience)); + + record('targeting: added user is on', await check(page, 'carol')); + record('targeting: removed user is off', !(await check(page, 'alice')) && (await home('NewCheckout', 'alice'))?.on === false); + record('targeting: added group is on', await check(page, 'dave', 'qa')); + record('targeting: excluded group wins', !(await check(page, 'erin', 'qa, contractors'))); + record('targeting: excluded user wins', !(await check(page, 'mallory', 'beta-testers'))); + + await page.getByRole('button', { name: 'Reset to configuration' }).click(); + await Promise.all([flagsLoaded(page), page.getByRole('dialog').getByRole('button', { name: 'Reset to configuration' }).click()]); + record('targeting: reset brings alice back', await visible(page.getByRole('button', { name: 'alice' })) && (await home('NewCheckout', 'alice'))?.on === true); + record('targeting: no errors', errors.length === 0, errors.join(' | ')); + await context.close(); +} + +// ── Any or all of two conditions ────────────────────────────────────────────── +{ + const { context, page, errors } = await open(); + await openFlag(page, 'BetaBanner'); + + await page.getByRole('button', { name: 'Add condition' }).click(); + await page.getByRole('menuitem', { name: 'Targeting' }).click(); + await addChip(page, 'Users', 'bob'); + await page.getByRole('button', { name: 'Add condition' }).click(); + await page.getByRole('menuitem', { name: 'Time window' }).click(); + const future = new Date(); future.setFullYear(future.getFullYear() + 2); future.setHours(12, 0, 0, 0); + await page.getByLabel('Start').fill(localInput(future)); + await save(page); + record('any: one condition is enough', (await home('BetaBanner', 'bob'))?.on === true && (await home('BetaBanner'))?.on === false); + + await page.getByRole('button', { name: 'all', exact: true }).click(); + await save(page); + record('all: every condition is needed', (await home('BetaBanner', 'bob'))?.on === false + && (await flagItem('BetaBanner')).flag.requirementType === 'All'); + record('all: shown in the list', (await (await fetch(`${base}/config/api/GetFeatureFlags`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })).json()) + .flags.find((f) => f.id === 'BetaBanner').flag.filters.length === 2); + + await page.getByRole('button', { name: 'Remove condition' }).first().click(); + await page.getByRole('button', { name: 'Remove condition' }).first().click(); + record('no conditions: on for everyone', await visible(page.getByText('No conditions: when enabled'))); + await save(page); + record('no conditions: saved', (await home('BetaBanner'))?.on === true && (await flagItem('BetaBanner')).flag.filters.length === 0); + + await api('ResetFeatureFlag', { id: 'BetaBanner' }); + record('any/all: no errors', errors.length === 0, errors.join(' | ')); + await context.close(); +} + +// ── A new flag: odd id, custom filter, duplicate id ─────────────────────────── +{ + const { context, page, errors } = await open(); + const id = 'Team.Billing/V2'; + + await page.goto(`${base}/config/#/flags/new`); + await page.getByTestId('flag-id').fill(id); + await page.getByRole('button', { name: 'Add condition' }).click(); + await page.getByRole('menuitem', { name: 'Custom filter' }).click(); + await page.getByRole('combobox', { name: 'Filter name' }).fill('Acme.Browser'); + record('custom filter: warns about an unregistered filter', await visible(page.getByText('has not registered a filter with this name'))); + await page.getByLabel('Parameters (JSON)').fill('{ "Allowed": ['); + record('custom filter: invalid JSON blocks the save', + await visible(page.getByText('Not a valid JSON object.')) && await page.getByRole('button', { name: 'Save' }).isDisabled()); + await page.getByLabel('Parameters (JSON)').fill('{ "Allowed": ["Edge"] }'); + await page.getByRole('button', { name: 'Save' }).click(); + record('custom filter: unregistered filter refused on the condition', await visible(page.getByText("has not registered a filter named 'Acme.Browser'"))); + record('custom filter: nothing saved', (await flagItem(id)) === undefined); + + await page.getByRole('button', { name: 'Remove condition' }).click(); + await page.getByRole('button', { name: 'Add condition' }).click(); + await page.getByRole('menuitem', { name: 'Percentage' }).click(); + await page.getByLabel('%', { exact: true }).fill('0'); + await save(page); + await page.waitForURL(/#\/flag\/Team\.Billing%2FV2$/, { timeout: 5000 }).catch(() => {}); + record('odd id: saved and opened', page.url().endsWith('#/flag/Team.Billing%2FV2') && (await home(id))?.on === false, page.url()); + await page.reload(); + record('odd id: deep link', await visible(page.getByRole('heading', { name: id, exact: true }))); + + await page.goto(`${base}/config/#/flags/new`); + await page.getByTestId('flag-id').fill('betabanner'); + await page.getByRole('button', { name: 'Save' }).click(); + record('duplicate id ignoring case', await visible(page.getByText("A flag named 'betabanner' already exists."))); + await page.getByTestId('flag-id').fill('Bad:Id'); + await page.getByRole('button', { name: 'Save' }).click(); + record('id with a colon', await visible(page.getByText("The id cannot contain ':'."))); + + await api('ResetFeatureFlag', { id }); + record('new flag: no errors', errors.length === 0, errors.join(' | ')); + await context.close(); +} + +// ── Discard, the .NET schema and variants ───────────────────────────────────── +{ + const { context, page, errors } = await open(); + + await openFlag(page, 'MaintenanceMode'); + await page.getByLabel('Enabled').click(); + await page.getByLabel('Description').fill('changed'); + await page.getByRole('button', { name: 'Discard' }).click(); + record('discard', !(await page.getByLabel('Enabled').isChecked()) + && (await page.getByLabel('Description').inputValue()) === 'Short-circuits the site into a maintenance page.' + && await page.getByRole('button', { name: 'Save' }).isDisabled()); + + await page.goto(`${base}/config/#/flags`); + await Promise.all([flagsLoaded(page), page.getByRole('switch', { name: 'ReadOnlyMode' }).click()]); + record('.NET schema: overridden from the list', (await home('ReadOnlyMode'))?.on === true); + await openFlag(page, 'ReadOnlyMode'); + record('.NET schema: explained in the editor', await visible(page.getByText('older .NET schema'))); + await page.getByRole('button', { name: 'Reset to configuration' }).click(); + await Promise.all([flagsLoaded(page), page.getByRole('dialog').getByRole('button', { name: 'Reset to configuration' }).click()]); + record('.NET schema: reset', (await home('ReadOnlyMode'))?.on === false); + + record('variants: before', (await home('SearchEngine', 'alice'))?.variant === 'Semantic' && (await home('SearchEngine', 'bob'))?.variant === 'Classic'); + await openFlag(page, 'SearchEngine'); + record('variants: explained in the editor', await visible(page.getByText('This flag has variants'))); + await page.getByLabel('Description').fill('Which search engine answers.'); + await save(page); + record('variants: kept after a save', (await home('SearchEngine', 'alice'))?.variant === 'Semantic'); + record('variants: tester shows the variant', (await check(page, 'alice')) && await visible(page.getByText('variant Semantic'))); + + await api('ResetFeatureFlag', { id: 'SearchEngine' }); + record('discard/.NET/variants: no errors', errors.length === 0, errors.join(' | ')); + await context.close(); +} + +// ── Saving settings keeps the flags ─────────────────────────────────────────── +{ + const { context, page, errors } = await open(); + await page.goto(`${base}/config/#/flags`); + await Promise.all([flagsLoaded(page), page.getByRole('switch', { name: 'BetaBanner' }).click()]); + + await page.getByTestId('way-menu').getByText('Branding').click(); + await page.locator('main textarea').first().waitFor(); + const index = await page.locator('main textarea').evaluateAll((els) => els.findIndex((e) => e.value === 'Acme Corp')); + const field = page.locator('main textarea').nth(index); + await field.fill('Acme Flags'); + await Promise.all([ + page.waitForResponse((r) => r.url().endsWith('/config/api/UpdateConfiguration')), + page.waitForResponse((r) => r.url().endsWith('/config/api/GetConfiguration')), + page.getByRole('button', { name: 'Save' }).click(), + ]); + record('settings save keeps the flag overrides', (await home('BetaBanner'))?.on === false && (await flagItem('BetaBanner')).hasOverride); + await field.fill('Acme Corp'); + await Promise.all([ + page.waitForResponse((r) => r.url().endsWith('/config/api/GetConfiguration')), + page.getByRole('button', { name: 'Save' }).click(), + ]); + + await api('ResetFeatureFlag', { id: 'BetaBanner' }); + record('settings: no errors', errors.length === 0, errors.join(' | ')); + await context.close(); +} + +// ── Phone and Polish ────────────────────────────────────────────────────────── +{ + const { context, page, errors } = await open({ viewport: { width: 390, height: 800 } }); + const fits = () => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1); + + await page.goto(`${base}/config/#/flags`); + await page.getByTestId('flag-row-NewCheckout').waitFor(); + record('phone: list fits', await fits()); + await page.getByTestId('flag-row-NewCheckout').click(); + await page.getByRole('heading', { name: 'NewCheckout', exact: true }).waitFor(); + record('phone: editor fits', await fits()); + record('phone: no errors', errors.length === 0, errors.join(' | ')); + await context.close(); +} +{ + const { context, page, errors } = await open({ locale: 'pl-PL' }); + await page.goto(`${base}/config/#/flags`); + record('polish: list', await visible(page.getByRole('heading', { name: 'Flagi funkcji' })) && await visible(page.getByRole('button', { name: 'Nowa flaga' }))); + await page.getByTestId('flag-row-NewCheckout').click(); + record('polish: editor', await visible(page.getByText('Targetowanie')) && await visible(page.getByText('Sprawdź dla użytkownika'))); + record('polish: no errors', errors.length === 0, errors.join(' | ')); + await context.close(); +} + +await resetAll(); +await browser.close(); +const failed = results.filter((r) => !r.ok); +for (const r of results) console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.name}${r.detail ? ` — ${r.detail}` : ''}`); +console.log(`\n${which} ${base}: ${results.length - failed.length}/${results.length} ok`); +process.exit(failed.length ? 1 : 0); diff --git a/src/ConfigWay.FeatureManagement.Tests/ConfigWay.FeatureManagement.Tests.csproj b/src/ConfigWay.FeatureManagement.Tests/ConfigWay.FeatureManagement.Tests.csproj new file mode 100644 index 0000000..de13370 --- /dev/null +++ b/src/ConfigWay.FeatureManagement.Tests/ConfigWay.FeatureManagement.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0;net9.0;net10.0 + false + true + true + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/src/ConfigWay.FeatureManagement.Tests/Integration/FeatureFlagsApiTests.cs b/src/ConfigWay.FeatureManagement.Tests/Integration/FeatureFlagsApiTests.cs new file mode 100644 index 0000000..84064df --- /dev/null +++ b/src/ConfigWay.FeatureManagement.Tests/Integration/FeatureFlagsApiTests.cs @@ -0,0 +1,528 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json.Nodes; +using Kododo.ConfigWay.Core.Configuration; +using Kododo.ConfigWay.Core.Model; +using Kododo.ConfigWay.UI; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FeatureManagement; +using Microsoft.FeatureManagement.FeatureFilters; +using Xunit; + +namespace Kododo.ConfigWay.FeatureManagement.Tests.Integration; + +/// The flags API, checked against the application's real feature manager. +public class FeatureFlagsApiTests : IAsyncLifetime +{ + private WebApplication _app = null!; + private HttpClient _client = null!; + + /// The application's own flags, as appsettings would define them. + private static readonly Dictionary Configured = new() + { + ["feature_management:feature_flags:0:id"] = "Beta", + ["feature_management:feature_flags:0:enabled"] = "true", + + ["feature_management:feature_flags:1:id"] = "Rollout", + ["feature_management:feature_flags:1:enabled"] = "true", + ["feature_management:feature_flags:1:conditions:client_filters:0:name"] = "Microsoft.Percentage", + ["feature_management:feature_flags:1:conditions:client_filters:0:parameters:Value"] = "0", + + ["feature_management:feature_flags:2:id"] = "Layout", + ["feature_management:feature_flags:2:enabled"] = "true", + ["feature_management:feature_flags:2:variants:0:name"] = "Big", + ["feature_management:feature_flags:2:variants:0:configuration_value"] = "500px", + ["feature_management:feature_flags:2:variants:1:name"] = "Small", + ["feature_management:feature_flags:2:allocation:default_when_enabled"] = "Big", + ["feature_management:feature_flags:2:allocation:default_when_disabled"] = "Small", + + ["feature_management:feature_flags:3:id"] = "Audience", + ["feature_management:feature_flags:3:enabled"] = "true", + ["feature_management:feature_flags:3:conditions:client_filters:0:name"] = "Microsoft.Targeting", + ["feature_management:feature_flags:3:conditions:client_filters:0:parameters:Audience:Users:0"] = "jeff", + ["feature_management:feature_flags:3:conditions:client_filters:0:parameters:Audience:Groups:0:Name"] = "Ring0", + ["feature_management:feature_flags:3:conditions:client_filters:0:parameters:Audience:Groups:0:RolloutPercentage"] = "100", + ["feature_management:feature_flags:3:conditions:client_filters:0:parameters:Audience:Exclusion:Users:0"] = "ross", + + ["FeatureManagement:Old"] = "true", + }; + + public async Task InitializeAsync() + { + (_app, _client) = await StartAsync(featureManagement: true); + } + + public async Task DisposeAsync() + { + _client.Dispose(); + await _app.DisposeAsync(); + } + + private static async Task<(WebApplication, HttpClient)> StartAsync(bool featureManagement) + { + return await StartAsync(featureManagement ? fm => fm.AddFeatureFilter() : null); + } + + private static async Task<(WebApplication, HttpClient)> StartAsync(Action? featureManagement) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Configuration.AddInMemoryCollection(Configured); + builder.AddConfigWay(x => + { + x.AddUiEditor(); + x.AddFeatureFlags(); + }); + if (featureManagement is not null) + featureManagement(builder.Services.AddFeatureManagement()); + + var app = builder.Build(); + app.UseConfigWay().AllowAnonymous(); + await app.StartAsync(); + return (app, app.GetTestClient()); + } + + private IVariantFeatureManager FeatureManager => _app.Services.GetRequiredService(); + + // ── Listing ────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetCapabilities_ReportsTheFlagsExtension() + { + var capabilities = await PostJson("GetCapabilities", new { }); + + Assert.Contains("flags", capabilities["extensions"]!.AsArray().Select(e => e!.GetValue())); + } + + [Fact] + public async Task GetFeatureFlags_ListsTheConfiguredFlagsOfBothSchemas() + { + var result = await PostJson("GetFeatureFlags", new { }); + + Assert.True(result["isFeatureManagementRegistered"]!.GetValue()); + Assert.Equal(["Audience", "Beta", "Layout", "Old", "Rollout"], Ids(result)); + + var beta = Item(result, "Beta"); + Assert.True(beta["flag"]!["enabled"]!.GetValue()); + Assert.False(beta["hasOverride"]!.GetValue()); + Assert.NotNull(beta["configured"]); + + Assert.True(Item(result, "Old")["isDotnetSchema"]!.GetValue()); + Assert.True(Item(result, "Layout")["hasVariants"]!.GetValue()); + + var rollout = Item(result, "Rollout")["flag"]!["filters"]![0]!; + Assert.Equal("Microsoft.Percentage", rollout["name"]!.GetValue()); + Assert.Equal(0, rollout["parameters"]!["Value"]!.GetValue()); + } + + [Fact] + public async Task GetFeatureFlags_ListsTheRegisteredFilters() + { + var result = await PostJson("GetFeatureFlags", new { }); + + var filters = result["filters"]!.AsArray().Select(f => f!.GetValue()).ToArray(); + Assert.Contains("Microsoft.Percentage", filters); + Assert.Contains("Microsoft.TimeWindow", filters); + Assert.Contains("Microsoft.Targeting", filters); + } + + [Fact] + public async Task GetFeatureFlags_WithoutFeatureManagement_SaysSo() + { + var (app, client) = await StartAsync(featureManagement: false); + await using var _ = app; + + var result = await (await client.PostAsJsonAsync("/config/api/GetFeatureFlags", new { })) + .Content.ReadFromJsonAsync(); + + Assert.False(result!["isFeatureManagementRegistered"]!.GetValue()); + Assert.Empty(result["filters"]!.AsArray()); + Assert.Equal(5, result["flags"]!.AsArray().Count); + } + + // ── Overrides ──────────────────────────────────────────────────────────── + + [Fact] + public async Task SaveFeatureFlag_OverridesTheConfiguredFlag() + { + Assert.True(await FeatureManager.IsEnabledAsync("Beta")); + + await Save(Flag("Beta", enabled: false)); + + Assert.False(await FeatureManager.IsEnabledAsync("Beta")); + var item = Item(await PostJson("GetFeatureFlags", new { }), "Beta"); + Assert.True(item["hasOverride"]!.GetValue()); + Assert.False(item["flag"]!["enabled"]!.GetValue()); + Assert.True(item["configured"]!["enabled"]!.GetValue()); + } + + [Fact] + public async Task SaveFeatureFlag_OverridesAFlagOfTheDotnetSchema() + { + Assert.True(await FeatureManager.IsEnabledAsync("Old")); + + await Save(Flag("Old", enabled: false)); + + Assert.False(await FeatureManager.IsEnabledAsync("Old")); + } + + [Fact] + public async Task SaveFeatureFlag_WithAPercentage_ChangesTheRollout() + { + Assert.False(await FeatureManager.IsEnabledAsync("Rollout")); + + await Save(Flag("Rollout", enabled: true, filters: [Filter("Microsoft.Percentage", """{ "Value": 100 }""")])); + + Assert.True(await FeatureManager.IsEnabledAsync("Rollout")); + } + + [Fact] + public async Task SaveFeatureFlag_WithATimeWindow_FollowsTheWindow() + { + await Save(Flag("Window", enabled: true, filters: [Filter("Microsoft.TimeWindow", + """{ "Start": "2000-01-01T00:00:00Z", "End": "2001-01-01T00:00:00Z" }""")]), isNew: true); + Assert.False(await FeatureManager.IsEnabledAsync("Window")); + + await Save(Flag("Window", enabled: true, filters: [Filter("TimeWindow", + """{ "Start": "2000-01-01T00:00:00Z" }""")])); + Assert.True(await FeatureManager.IsEnabledAsync("Window")); + } + + [Fact] + public async Task SaveFeatureFlag_IgnoresTheCaseOfTheId() + { + await Save(Flag("beta", enabled: false)); + + var result = await PostJson("GetFeatureFlags", new { }); + Assert.Single(Ids(result), id => id.Equals("beta", StringComparison.OrdinalIgnoreCase)); + Assert.True(Item(result, "Beta")["hasOverride"]!.GetValue()); + Assert.False(await FeatureManager.IsEnabledAsync("BETA")); + } + + [Fact] + public async Task SaveFeatureFlag_KeepsTheVariantsOfTheConfiguredFlag() + { + Assert.Equal("Big", (await FeatureManager.GetVariantAsync("Layout"))!.Name); + + await Save(Flag("Layout", enabled: false)); + + Assert.False(await FeatureManager.IsEnabledAsync("Layout")); + Assert.Equal("Small", (await FeatureManager.GetVariantAsync("Layout"))!.Name); + var stored = await StoredJson("Layout"); + Assert.Equal(2, stored["variants"]!.AsArray().Count); + } + + [Fact] + public async Task SaveFeatureFlag_NewFlag_IsAddedAndResetRemovesIt() + { + Assert.False(await FeatureManager.IsEnabledAsync("Fresh")); + + await Save(Flag("Fresh", enabled: true), isNew: true); + Assert.True(await FeatureManager.IsEnabledAsync("Fresh")); + var item = Item(await PostJson("GetFeatureFlags", new { }), "Fresh"); + Assert.Null(item["configured"]); + + await Post("ResetFeatureFlag", new { Id = "Fresh" }, HttpStatusCode.NoContent); + Assert.False(await FeatureManager.IsEnabledAsync("Fresh")); + Assert.DoesNotContain("Fresh", Ids(await PostJson("GetFeatureFlags", new { }))); + } + + [Fact] + public async Task ResetFeatureFlag_BringsBackTheConfiguredFlag() + { + await Save(Flag("Beta", enabled: false)); + + await Post("ResetFeatureFlag", new { Id = "BETA" }, HttpStatusCode.NoContent); + + Assert.True(await FeatureManager.IsEnabledAsync("Beta")); + Assert.False(Item(await PostJson("GetFeatureFlags", new { }), "Beta")["hasOverride"]!.GetValue()); + } + + [Fact] + public async Task ResetFeatureFlag_WithoutOverride_IsNotFound() + { + await Post("ResetFeatureFlag", new { Id = "Beta" }, HttpStatusCode.NotFound); + } + + [Fact] + public async Task UnreadableOverride_IsReportedAndCanBeReset() + { + var store = _app.Services.GetRequiredService().Store; + await store.SetAsync([new Setting("feature_management:flags/Beta", "{ broken")]); + await _app.Services.GetRequiredService().ReloadAllAsync(CancellationToken.None); + + var item = Item(await PostJson("GetFeatureFlags", new { }), "Beta"); + Assert.NotNull(item["error"]); + Assert.True(await FeatureManager.IsEnabledAsync("Beta")); + + await Post("ResetFeatureFlag", new { Id = "Beta" }, HttpStatusCode.NoContent); + Assert.Null(Item(await PostJson("GetFeatureFlags", new { }), "Beta")["error"]); + } + + // ── Validation ─────────────────────────────────────────────────────────── + + [Fact] + public async Task SaveFeatureFlag_NewFlagWithATakenId_IsInvalid() + { + var errors = await SaveInvalid(Flag("beta", enabled: true), isNew: true); + + Assert.Contains("flag.id", errors.Select(e => e.Key)); + } + + [Fact] + public async Task SaveFeatureFlag_UnknownFlag_IsNotFound() + { + await Post("SaveFeatureFlag", new { Flag = Flag("Missing", enabled: true) }, HttpStatusCode.NotFound); + } + + [Theory] + [InlineData("a:b")] + [InlineData(" Beta")] + [InlineData("")] + public async Task SaveFeatureFlag_InvalidId_IsInvalid(string id) + { + var errors = await SaveInvalid(Flag(id, enabled: true), isNew: true); + + Assert.Contains("flag.id", errors.Select(e => e.Key)); + } + + [Fact] + public async Task SaveFeatureFlag_InvalidFilterParameters_AreReportedPerFilter() + { + var errors = await SaveInvalid(Flag("Rollout", enabled: true, filters: + [ + Filter("Microsoft.Percentage", """{ "Value": 50 }"""), + Filter("Microsoft.Percentage", """{ "Value": 150 }"""), + ])); + + var error = Assert.Single(errors); + Assert.Equal("flag.filters[1].parameters", error.Key); + Assert.False(await FeatureManager.IsEnabledAsync("Rollout")); + } + + [Fact] + public async Task SaveFeatureFlag_UnregisteredFilter_IsInvalidAndTheAppKeepsWorking() + { + var errors = await SaveInvalid(Flag("Rollout", enabled: true, filters: + [ + Filter("Microsoft.Percentage", """{ "Value": 100 }"""), + Filter("Microsoft.Percentag", "{}"), + ])); + + var error = Assert.Single(errors); + Assert.Equal("flag.filters[1].name", error.Key); + Assert.False(await FeatureManager.IsEnabledAsync("Rollout")); + } + + [Theory] + [InlineData("Acme.Browser")] + [InlineData("browser")] + [InlineData("AlwaysOn")] + public async Task SaveFeatureFlag_RegisteredFilter_IsSaved(string name) + { + await Save(Flag("Beta", enabled: true, filters: [Filter(name, """{ "Allowed": ["Edge"] }""")])); + + var stored = await StoredJson("Beta"); + Assert.Equal(name, stored["conditions"]!["client_filters"]![0]!["name"]!.GetValue()); + Assert.Equal(name == "AlwaysOn", await FeatureManager.IsEnabledAsync("Beta")); + } + + [Fact] + public async Task SaveFeatureFlag_WithoutFeatureManagement_AcceptsAnyFilter() + { + var (app, client) = await StartAsync(featureManagement: false); + await using var _ = app; + + var response = await client.PostAsJsonAsync("/config/api/SaveFeatureFlag", new + { + Flag = Flag("Beta", enabled: true, filters: [Filter("Acme.NotYetDeployed", "{}")]), + }); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + [Fact] + public async Task GetFeatureFlags_ListsACustomFilterByItsAlias() + { + var result = await PostJson("GetFeatureFlags", new { }); + + Assert.Contains("Acme.Browser", result["filters"]!.AsArray().Select(f => f!.GetValue())); + } + + // ── Access ─────────────────────────────────────────────────────────────── + + public static TheoryData Requests => new() + { + { "GetFeatureFlags", "{}" }, + { "SaveFeatureFlag", """{ "flag": { "id": "Beta", "enabled": false, "requirementType": "Any", "filters": [] } }""" }, + { "ResetFeatureFlag", """{ "id": "Beta" }""" }, + { "EvaluateFeatureFlag", """{ "id": "Beta" }""" }, + }; + + [Theory] + [MemberData(nameof(Requests))] + public async Task FlagsApi_OutsideDevelopmentWithoutADecision_IsForbidden(string request, string body) + { + await using var app = await StartProtectedAsync(group => { }); + var response = await app.GetTestClient().PostAsync( + $"/config/api/{request}", new StringContent(body, System.Text.Encoding.UTF8, "application/json")); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.True(await app.Services.GetRequiredService().IsEnabledAsync("Beta")); + } + + [Theory] + [MemberData(nameof(Requests))] + public async Task FlagsApi_FollowsTheEditorsAuthorization(string request, string body) + { + await using var app = await StartProtectedAsync(group => group.RequireAuthorization()); + var response = await app.GetTestClient().PostAsync( + $"/config/api/{request}", new StringContent(body, System.Text.Encoding.UTF8, "application/json")); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.True(await app.Services.GetRequiredService().IsEnabledAsync("Beta")); + } + + private static async Task StartProtectedAsync(Action decide) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Production" }); + builder.WebHost.UseTestServer(); + builder.Configuration.AddInMemoryCollection(Configured); + builder.AddConfigWay(x => + { + x.AddUiEditor(); + x.AddFeatureFlags(); + }); + builder.Services.AddFeatureManagement(); + builder.Services.AddAuthentication("Test").AddScheme("Test", null); + builder.Services.AddAuthorization(); + + var app = builder.Build(); + app.UseAuthentication(); + app.UseAuthorization(); + decide(app.UseConfigWay()); + await app.StartAsync(); + return app; + } + + // ── Evaluation ─────────────────────────────────────────────────────────── + + [Theory] + [InlineData("jeff", new string[0], true)] + [InlineData("anna", new[] { "Ring0" }, true)] + [InlineData("anna", new string[0], false)] + [InlineData("ross", new[] { "Ring0" }, false)] + [InlineData(null, new string[0], false)] + public async Task EvaluateFeatureFlag_ChecksTheGivenUser(string? user, string[] groups, bool expected) + { + var result = await PostJson("EvaluateFeatureFlag", new { Id = "Audience", UserId = user, Groups = groups }); + + Assert.Equal(expected, result["enabled"]!.GetValue()); + } + + [Fact] + public async Task EvaluateFeatureFlag_ReturnsTheVariant() + { + var result = await PostJson("EvaluateFeatureFlag", new { Id = "Layout" }); + + Assert.True(result["enabled"]!.GetValue()); + Assert.Equal("Big", result["variant"]!.GetValue()); + } + + [Fact] + public async Task EvaluateFeatureFlag_UnknownFlag_IsNotFound() + { + await Post("EvaluateFeatureFlag", new { Id = "Missing" }, HttpStatusCode.NotFound); + } + + [Fact] + public async Task EvaluateFeatureFlag_WithoutFeatureManagement_IsAConflict() + { + var (app, client) = await StartAsync(featureManagement: false); + await using var _ = app; + + var response = await client.PostAsJsonAsync("/config/api/EvaluateFeatureFlag", new { Id = "Beta" }); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + public async Task Evaluation_ReflectsAnOverrideOfTheTargeting() + { + var context = new TargetingContext { UserId = "anna", Groups = [] }; + Assert.False(await FeatureManager.IsEnabledAsync("Audience", context)); + + await Save(Flag("Audience", enabled: true, filters: + [Filter("Microsoft.Targeting", """{ "Audience": { "Users": ["anna"] } }""")])); + + Assert.True(await FeatureManager.IsEnabledAsync("Audience", context)); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private static object Flag(string id, bool enabled, object[]? filters = null, string requirementType = "Any") => + new { Id = id, Enabled = enabled, RequirementType = requirementType, Filters = filters ?? [] }; + + private static object Filter(string name, string parameters) => + new { Name = name, Parameters = JsonNode.Parse(parameters) }; + + private Task Save(object flag, bool isNew = false) => + Post("SaveFeatureFlag", new { Flag = flag, IsNew = isNew }, HttpStatusCode.NoContent); + + private async Task> SaveInvalid(object flag, bool isNew = false) + { + var response = await _client.PostAsJsonAsync("/config/api/SaveFeatureFlag", new { Flag = flag, IsNew = isNew }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var problem = await response.Content.ReadFromJsonAsync(); + return problem!["errors"]!.AsObject() + .ToDictionary(e => e.Key, e => e.Value!.AsArray().Select(m => m!.GetValue()).ToArray()); + } + + private async Task Post(string request, object body, HttpStatusCode expected) + { + var response = await _client.PostAsJsonAsync($"/config/api/{request}", body); + Assert.True(expected == response.StatusCode, + $"{request}: expected {expected}, got {response.StatusCode}: {await response.Content.ReadAsStringAsync()}"); + } + + private async Task PostJson(string request, object body) + { + var response = await _client.PostAsJsonAsync($"/config/api/{request}", body); + Assert.True(response.IsSuccessStatusCode, + $"{request}: {response.StatusCode}: {await response.Content.ReadAsStringAsync()}"); + return (await response.Content.ReadFromJsonAsync())!; + } + + private async Task StoredJson(string id) + { + var settings = await _app.Services.GetRequiredService().Store.GetAllAsync(); + return JsonNode.Parse(settings.Single(s => s.Key == $"feature_management:flags/{id}").Value!)!.AsObject(); + } + + private static string[] Ids(JsonObject result) => + result["flags"]!.AsArray().Select(f => f!["id"]!.GetValue()).ToArray(); + + private static JsonObject Item(JsonObject result, string id) => + result["flags"]!.AsArray().Single(f => f!["id"]!.GetValue() == id)!.AsObject(); +} + +/// A filter of the application's own, registered with an alias. +[FilterAlias("Acme.Browser")] +internal sealed class BrowserFilter : IFeatureFilter +{ + public Task EvaluateAsync(FeatureFilterEvaluationContext context) => Task.FromResult(false); +} + +/// An authentication scheme that never finds a user. +internal sealed class NoUserHandler( + Microsoft.Extensions.Options.IOptionsMonitor options, + Microsoft.Extensions.Logging.ILoggerFactory logger, + System.Text.Encodings.Web.UrlEncoder encoder) + : Microsoft.AspNetCore.Authentication.AuthenticationHandler(options, logger, encoder) +{ + protected override Task HandleAuthenticateAsync() => + Task.FromResult(Microsoft.AspNetCore.Authentication.AuthenticateResult.NoResult()); +} diff --git a/src/ConfigWay.FeatureManagement.Tests/Unit/ConfigurationJsonTests.cs b/src/ConfigWay.FeatureManagement.Tests/Unit/ConfigurationJsonTests.cs new file mode 100644 index 0000000..9b7e661 --- /dev/null +++ b/src/ConfigWay.FeatureManagement.Tests/Unit/ConfigurationJsonTests.cs @@ -0,0 +1,95 @@ +using System.Text.Json.Nodes; +using Microsoft.Extensions.Configuration.Memory; +using Xunit; + +namespace Kododo.ConfigWay.FeatureManagement.Tests.Unit; + +public class ConfigurationJsonTests +{ + [Fact] + public void Read_TurnsIndexedSectionsIntoArraysAndKeepsValuesAsText() + { + var provider = Provider(new() + { + ["feature_management:feature_flags:0:id"] = "Beta", + ["feature_management:feature_flags:0:enabled"] = "true", + ["feature_management:feature_flags:1:id"] = "Dark", + ["feature_management:feature_flags:1:conditions:client_filters:0:name"] = "Microsoft.Percentage", + ["feature_management:feature_flags:1:conditions:client_filters:0:parameters:Value"] = "50", + }); + + var node = ConfigurationJson.Read(provider, "feature_management:feature_flags"); + + var flags = Assert.IsType(node); + Assert.Equal(2, flags.Count); + Assert.Equal("Beta", flags[0]!["id"]!.GetValue()); + Assert.Equal("true", flags[0]!["enabled"]!.GetValue()); + Assert.Equal("50", flags[1]!["conditions"]!["client_filters"]![0]!["parameters"]!["Value"]!.GetValue()); + } + + [Fact] + public void Read_IndexesWithGaps_StayAnObject() + { + var provider = Provider(new() { ["list:0"] = "a", ["list:2"] = "c" }); + + var node = ConfigurationJson.Read(provider, "list"); + + var obj = Assert.IsType(node); + Assert.Equal(["a", "c"], obj.Items().Select(i => i.AsText())); + } + + [Fact] + public void Read_MissingSection_IsNull() + { + Assert.Null(ConfigurationJson.Read(Provider(new()), "feature_management")); + } + + [Fact] + public void Read_ObjectsIgnoreCase() + { + var provider = Provider(new() { ["flag:Enabled"] = "true" }); + + var node = (JsonObject)ConfigurationJson.Read(provider, "flag")!; + + Assert.Equal(true, node.Get("enabled").AsBool()); + Assert.True(node.ContainsKey("ENABLED")); + } + + [Fact] + public void Flatten_WritesEveryValueAsConfigurationText() + { + var json = JsonNode.Parse(""" + { "id": "Beta", "enabled": false, "n": 12.5, "none": null, + "list": ["a", { "b": true }], "empty": {} } + """); + var data = new Dictionary(); + + ConfigurationJson.Flatten(json, "root", data); + + Assert.Equal(new Dictionary + { + ["root:id"] = "Beta", + ["root:enabled"] = "false", + ["root:n"] = "12.5", + ["root:none"] = null, + ["root:list:0"] = "a", + ["root:list:1:b"] = "true", + }, data); + } + + [Theory] + [InlineData("50", 50.0)] + [InlineData("12.5", 12.5)] + [InlineData("x", null)] + public void AsNumber_ReadsNumbersWrittenAsText(string text, double? expected) + { + Assert.Equal(expected, JsonValue.Create(text).AsNumber()); + } + + private static MemoryConfigurationProvider Provider(Dictionary data) + { + var provider = new MemoryConfigurationProvider(new MemoryConfigurationSource { InitialData = data }); + provider.Load(); + return provider; + } +} diff --git a/src/ConfigWay.FeatureManagement.Tests/Unit/FlagJsonTests.cs b/src/ConfigWay.FeatureManagement.Tests/Unit/FlagJsonTests.cs new file mode 100644 index 0000000..7296c3b --- /dev/null +++ b/src/ConfigWay.FeatureManagement.Tests/Unit/FlagJsonTests.cs @@ -0,0 +1,146 @@ +using System.Text.Json.Nodes; +using Kododo.ConfigWay.Core.Model; +using Kododo.ConfigWay.FeatureManagement.Model; +using Xunit; + +namespace Kododo.ConfigWay.FeatureManagement.Tests.Unit; + +public class FlagJsonTests +{ + [Fact] + public void ToFlag_ReadsTheMicrosoftSchema() + { + var flag = FlagJson.ToFlag("Beta", Parse(""" + { "id": "Beta", "description": "New checkout", "enabled": "true", + "conditions": { "requirement_type": "all", "client_filters": [ + { "name": "Microsoft.Percentage", "parameters": { "Value": "10" } }, + { "name": "" } ] } } + """)); + + Assert.Equal("Beta", flag.Id); + Assert.True(flag.Enabled); + Assert.Equal("New checkout", flag.Description); + Assert.Equal(RequirementType.All, flag.RequirementType); + var filter = Assert.Single(flag.Filters); + Assert.Equal("""{"Value":10}""", filter.Parameters!.ToJsonString()); + } + + [Fact] + public void ToFlag_WithoutEnabled_IsOff() + { + Assert.False(FlagJson.ToFlag("Beta", Parse("""{ "id": "Beta" }""")).Enabled); + } + + [Fact] + public void Apply_KeepsWhatTheEditorDoesNotEdit() + { + var existing = Parse(""" + { "id": "Beta", "enabled": true, "description": "old", + "conditions": { "client_filters": [ { "name": "Microsoft.Percentage", "parameters": { "Value": 5 } } ] }, + "variants": [ { "name": "On" } ], "allocation": { "default_when_enabled": "On" }, + "telemetry": { "enabled": true } } + """); + + var json = FlagJson.Apply(new FeatureFlag("Beta", false, RequirementType.Any, []), existing); + + Assert.False(json["enabled"]!.GetValue()); + Assert.False(json.ContainsKey("description")); + Assert.False(json.ContainsKey("conditions")); + Assert.Equal("On", json["variants"]![0]!["name"]!.GetValue()); + Assert.Equal("On", json["allocation"]!["default_when_enabled"]!.GetValue()); + Assert.True(json["telemetry"]!["enabled"]!.GetValue()); + Assert.Equal("""{"id":"Beta","enabled":true}""", FlagJson.Apply( + new FeatureFlag("Beta", true, RequirementType.Any, []), null).ToJsonString()); + } + + [Fact] + public void Apply_WritesTheConditionsInTheMicrosoftSchema() + { + var flag = new FeatureFlag("Beta", true, RequirementType.All, + [new FeatureFilter("Percentage", Parse("""{ "Value": "50" }""")), new FeatureFilter("MyCompany.Browser")], + "note"); + + var json = FlagJson.Apply(flag, null); + + Assert.Equal( + """{"id":"Beta","description":"note","enabled":true,"conditions":{"requirement_type":"All","client_filters":[{"name":"Percentage","parameters":{"Value":50}},{"name":"MyCompany.Browser"}]}}""", + json.ToJsonString()); + } + + [Fact] + public void Apply_ReplacesEditedPropertiesWhateverTheirCase() + { + var json = FlagJson.Apply( + new FeatureFlag("Beta", true, RequirementType.Any, []), + Parse("""{ "ID": "Beta", "Enabled": "false", "Conditions": { } }""")); + + Assert.Equal("""{"id":"Beta","enabled":true}""", json.ToJsonString()); + } + + [Fact] + public void FromDotnetSchema_ReadsABoolean() + { + Assert.Equal("""{"id":"Old","enabled":true}""", FlagJson.FromDotnetSchema("Old", JsonValue.Create("True")).ToJsonString()); + } + + [Fact] + public void FromDotnetSchema_ReadsTheFilters() + { + var json = FlagJson.FromDotnetSchema("Old", Parse(""" + { "RequirementType": "All", "EnabledFor": [ { "Name": "Percentage", "Parameters": { "Value": "30" } } ] } + """)); + + var flag = FlagJson.ToFlag("Old", json); + Assert.True(flag.Enabled); + Assert.Equal(RequirementType.All, flag.RequirementType); + Assert.Equal("""{"Value":30}""", Assert.Single(flag.Filters).Parameters!.ToJsonString()); + } + + [Theory] + [InlineData("""{ "EnabledFor": [ { "Name": "AlwaysOn" } ] }""", true, 0)] + [InlineData("""{ "EnabledFor": [] }""", false, 0)] + [InlineData("""{ }""", false, 0)] + [InlineData("""{ "Status": "Disabled", "EnabledFor": [ { "Name": "AlwaysOn" } ] }""", false, 0)] + public void FromDotnetSchema_ReadsTheState(string definition, bool enabled, int filters) + { + var flag = FlagJson.ToFlag("Old", FlagJson.FromDotnetSchema("Old", Parse(definition))); + + Assert.Equal(enabled, flag.Enabled); + Assert.Equal(filters, flag.Filters.Length); + } + + [Fact] + public void StoredFlags_TakeTheIdFromTheKeyAndReportUnreadableValues() + { + var flags = StoredFlags.Read([ + new Setting("feature_management:flags/zeta", """{ "ID": "other", "enabled": true }"""), + new Setting("feature_management:flags/Alpha", "{ not json"), + new Setting("feature_management:flags/", "{}"), + new Setting("Smtp:Host", "localhost"), + ]); + + Assert.Equal(["Alpha", "zeta"], flags.Select(f => f.Id)); + Assert.Null(flags[0].Json); + Assert.NotNull(flags[0].Error); + Assert.Equal("""{"enabled":true,"id":"zeta"}""", flags[1].Json!.ToJsonString()); + } + + [Fact] + public void Provider_ExposesTheStoredFlagsWhereFeatureManagementReadsThem() + { + var provider = new FeatureFlagsProvider(); + provider.Apply([ + new Setting("feature_management:flags/Beta", """{ "id": "Beta", "enabled": true }"""), + new Setting("feature_management:flags/Broken", "nope"), + new Setting("feature_management:flags/Alpha", """{ "enabled": false }"""), + ]); + + Assert.True(provider.TryGet("feature_management:feature_flags:100000:id", out var first)); + Assert.Equal("Alpha", first); + Assert.True(provider.TryGet("feature_management:feature_flags:100001:enabled", out var enabled)); + Assert.Equal("true", enabled); + Assert.False(provider.TryGet("feature_management:feature_flags:100002:id", out _)); + } + + private static JsonObject Parse(string json) => JsonNode.Parse(json)!.AsObject(); +} diff --git a/src/ConfigWay.FeatureManagement.Tests/Unit/KnownFiltersTests.cs b/src/ConfigWay.FeatureManagement.Tests/Unit/KnownFiltersTests.cs new file mode 100644 index 0000000..be77113 --- /dev/null +++ b/src/ConfigWay.FeatureManagement.Tests/Unit/KnownFiltersTests.cs @@ -0,0 +1,124 @@ +using System.Text.Json.Nodes; +using Xunit; + +namespace Kododo.ConfigWay.FeatureManagement.Tests.Unit; + +public class KnownFiltersTests +{ + [Theory] + [InlineData("Microsoft.Percentage", KnownFilters.Percentage)] + [InlineData("percentage", KnownFilters.Percentage)] + [InlineData("Microsoft.TimeWindow", KnownFilters.TimeWindow)] + [InlineData("TimeWindow", KnownFilters.TimeWindow)] + [InlineData("Targeting", KnownFilters.Targeting)] + [InlineData("MyCompany.Browser", null)] + [InlineData("PercentageFilter", null)] + public void Find_AcceptsTheFullAliasOrItsLastSegment(string name, string? expected) + { + Assert.Equal(expected, KnownFilters.Find(name)); + } + + [Fact] + public void Normalize_Percentage_WritesTheValueAsANumber() + { + var result = KnownFilters.Normalize("Percentage", Parse("""{ "value": "25" }""")); + + Assert.Equal("""{"Value":25}""", result!.ToJsonString()); + } + + [Fact] + public void Normalize_TimeWindow_WritesTimesInRfc1123AndKeepsTheRecurrence() + { + var result = KnownFilters.Normalize(KnownFilters.TimeWindow, Parse(""" + { "Start": "2026-10-20T10:00:00+02:00", "End": "", "Recurrence": { "Pattern": { "Type": "Daily" } } } + """)); + + Assert.Equal("Tue, 20 Oct 2026 08:00:00 GMT", result!["Start"]!.GetValue()); + Assert.False(result.ContainsKey("End")); + Assert.Equal("Daily", result["Recurrence"]!["Pattern"]!["Type"]!.GetValue()); + } + + [Fact] + public void Normalize_Targeting_WritesTheWholeAudience() + { + var result = KnownFilters.Normalize(KnownFilters.Targeting, Parse(""" + { "Audience": { "Users": { "0": "jeff" }, "Groups": [ { "Name": "Ring0", "RolloutPercentage": "100" } ], + "DefaultRolloutPercentage": "20" } } + """)); + + Assert.Equal( + """{"Audience":{"Users":["jeff"],"Groups":[{"Name":"Ring0","RolloutPercentage":100}],"DefaultRolloutPercentage":20,"Exclusion":{"Users":[],"Groups":[]}}}""", + result!.ToJsonString()); + } + + [Fact] + public void Normalize_OtherFilter_CopiesTheParameters() + { + var parameters = Parse("""{ "Allowed": ["Edge"] }"""); + + var result = KnownFilters.Normalize("MyCompany.Browser", parameters); + + Assert.NotSame(parameters, result); + Assert.Equal(parameters.ToJsonString(), result!.ToJsonString()); + } + + [Theory] + [InlineData("""{ "Value": 0 }""", 0)] + [InlineData("""{ "Value": "100" }""", 0)] + [InlineData("""{ }""", 1)] + [InlineData("""{ "Value": 101 }""", 1)] + [InlineData("""{ "Value": -1 }""", 1)] + [InlineData("""{ "Value": "half" }""", 1)] + public void Validate_Percentage(string parameters, int errors) + { + Assert.Equal(errors, KnownFilters.Validate(KnownFilters.Percentage, Parse(parameters)).Count()); + } + + [Theory] + [InlineData("""{ "Start": "Sun, 01 Jun 2025 13:59:59 GMT" }""", 0)] + [InlineData("""{ "End": "2025-08-01T00:00:00Z" }""", 0)] + [InlineData("""{ "Start": "2025-06-01T00:00:00Z", "End": "2025-08-01T00:00:00Z" }""", 0)] + [InlineData("""{ }""", 1)] + [InlineData("""{ "Start": "soon" }""", 1)] + [InlineData("""{ "Start": "2025-08-01T00:00:00Z", "End": "2025-06-01T00:00:00Z" }""", 1)] + public void Validate_TimeWindow(string parameters, int errors) + { + Assert.Equal(errors, KnownFilters.Validate(KnownFilters.TimeWindow, Parse(parameters)).Count()); + } + + [Theory] + [InlineData("""{ "Audience": { "Users": ["jeff"], "DefaultRolloutPercentage": 0 } }""", 0)] + [InlineData("""{ "Audience": { "Groups": [ { "Name": "Ring0", "RolloutPercentage": 50 } ] } }""", 0)] + [InlineData("""{ }""", 1)] + [InlineData("""{ "Audience": { "Users": [""] } }""", 1)] + [InlineData("""{ "Audience": { "Groups": [ { "Name": "Ring0" } ] } }""", 1)] + [InlineData("""{ "Audience": { "Groups": [ { "Name": "a", "RolloutPercentage": 1 }, { "Name": "A", "RolloutPercentage": 1 } ] } }""", 1)] + [InlineData("""{ "Audience": { "DefaultRolloutPercentage": 120 } }""", 1)] + [InlineData("""{ "Audience": { "Exclusion": { "Groups": [" "] } } }""", 1)] + public void Validate_Targeting(string parameters, int errors) + { + Assert.Equal(errors, KnownFilters.Validate(KnownFilters.Targeting, Parse(parameters)).Count()); + } + + [Fact] + public void Validate_OtherFilter_AcceptsAnyParameters() + { + Assert.Empty(KnownFilters.Validate("MyCompany.Browser", null)); + } + + [Theory] + [InlineData("Microsoft.Percentage", true)] + [InlineData("percentage", true)] + [InlineData("Acme.Browser", true)] + [InlineData("Browser", true)] + [InlineData("Other.Browser", false)] + [InlineData("Microsoft.Percentag", false)] + [InlineData("AlwaysOn", true)] + [InlineData("on", true)] + public void RegisteredFilters_MatchAsFeatureManagementDoes(string name, bool expected) + { + Assert.Equal(expected, RegisteredFilters.Matches(name, ["Microsoft.Percentage", "Acme.Browser"])); + } + + private static JsonObject Parse(string json) => JsonNode.Parse(json)!.AsObject(); +} diff --git a/src/ConfigWay.FeatureManagement/.nuget/README.md b/src/ConfigWay.FeatureManagement/.nuget/README.md new file mode 100644 index 0000000..df5253d --- /dev/null +++ b/src/ConfigWay.FeatureManagement/.nuget/README.md @@ -0,0 +1,46 @@ +# Kododo.ConfigWay.FeatureManagement + +Feature flags for ConfigWay. Manage [Microsoft.FeatureManagement](https://learn.microsoft.com/azure/azure-app-configuration/feature-management-dotnet-reference) flags from the ConfigWay web UI without redeploying: turn flags on and off, roll them out to a percentage of users, limit them to a time window or target users and groups. + +Your code does not change: it keeps using `IFeatureManager`, `[FeatureGate]` and the other feature management APIs. + +## Install + +```bash +dotnet add package Kododo.ConfigWay.FeatureManagement +``` + +## Setup + +```csharp +builder.AddConfigWay(x => +{ + x.UsePostgreSql(connectionString); + x.AddUiEditor(); + x.AddFeatureFlags(); +}); + +builder.Services.AddFeatureManagement(); + +app.UseConfigWay().RequireAuthorization("Admin"); +``` + +Flags defined in your configuration (the `feature_management` section of appsettings, environment variables, ...) are listed in the editor. A flag saved in the editor is kept in the ConfigWay store and takes the place of the flag with the same id; resetting it brings the configured flag back. Flags can also be added in the editor only. + +## Filters + +The editor has forms for the built-in filters: `Microsoft.Percentage`, `Microsoft.TimeWindow` and `Microsoft.Targeting`. Other filters are edited as JSON parameters; a filter your application has not registered is refused, because checking the flag would throw. Variants and other properties the editor does not edit yet are kept when a flag is saved. + +To check a flag for a given user and groups, use the check in the editor: it asks your application's feature manager. + +## Several replicas + +A save reaches the other replicas when they check the store for changes: + +```csharp +x.ReloadInterval = TimeSpan.FromSeconds(30); +``` + +## Links + +- Source: https://github.com/kododo-dev/ConfigWay diff --git a/src/ConfigWay.FeatureManagement/.nuget/logo.png b/src/ConfigWay.FeatureManagement/.nuget/logo.png new file mode 100644 index 0000000..e7167a1 Binary files /dev/null and b/src/ConfigWay.FeatureManagement/.nuget/logo.png differ diff --git a/src/ConfigWay.FeatureManagement/API/EvaluateFeatureFlag/EvaluateFeatureFlag.cs b/src/ConfigWay.FeatureManagement/API/EvaluateFeatureFlag/EvaluateFeatureFlag.cs new file mode 100644 index 0000000..9181f9c --- /dev/null +++ b/src/ConfigWay.FeatureManagement/API/EvaluateFeatureFlag/EvaluateFeatureFlag.cs @@ -0,0 +1,13 @@ +using Kododo.Reiho.AspNetCore.API; + +namespace Kododo.ConfigWay.FeatureManagement.API.EvaluateFeatureFlag; + +/// Checks a flag for a user, as the application's feature manager does. +/// The flag id. +/// The user, for targeting; none for an anonymous user. +/// The groups of the user, for targeting. +internal record EvaluateFeatureFlag(string Id, string? UserId = null, string[]? Groups = null) : IRequest; + +/// Whether the flag is on for the user. +/// The variant the user gets; null for a flag without variants. +internal record FlagEvaluation(bool Enabled, string? Variant); diff --git a/src/ConfigWay.FeatureManagement/API/EvaluateFeatureFlag/EvaluateFeatureFlagHandler.cs b/src/ConfigWay.FeatureManagement/API/EvaluateFeatureFlag/EvaluateFeatureFlagHandler.cs new file mode 100644 index 0000000..966ba29 --- /dev/null +++ b/src/ConfigWay.FeatureManagement/API/EvaluateFeatureFlag/EvaluateFeatureFlagHandler.cs @@ -0,0 +1,32 @@ +using Kododo.Reiho.AspNetCore.API; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FeatureManagement; +using Microsoft.FeatureManagement.FeatureFilters; + +namespace Kododo.ConfigWay.FeatureManagement.API.EvaluateFeatureFlag; + +internal sealed class EvaluateFeatureFlagHandler(FlagCatalog catalog, IServiceProvider services) + : IRequestHandler +{ + public async Task HandleAsync(EvaluateFeatureFlag request, CancellationToken cancellationToken) + { + var featureManager = services.GetService() + ?? throw RequestException.Conflict( + "Feature management is not registered: call services.AddFeatureManagement() in the application."); + + if (!catalog.ReadBase().ContainsKey(request.Id) && + !(await catalog.ReadStoredAsync(cancellationToken)).ContainsKey(request.Id)) + throw RequestException.NotFound($"The flag '{request.Id}' does not exist."); + + var context = new TargetingContext + { + UserId = string.IsNullOrWhiteSpace(request.UserId) ? null : request.UserId.Trim(), + Groups = (request.Groups ?? []).Where(g => !string.IsNullOrWhiteSpace(g)).Select(g => g.Trim()).ToArray(), + }; + + // The context is passed in, so the contextual targeting filter checks this user instead of the current one. + var enabled = await featureManager.IsEnabledAsync(request.Id, context, cancellationToken); + var variant = await featureManager.GetVariantAsync(request.Id, context, cancellationToken); + return new FlagEvaluation(enabled, variant?.Name); + } +} diff --git a/src/ConfigWay.FeatureManagement/API/GetFeatureFlags/GetFeatureFlags.cs b/src/ConfigWay.FeatureManagement/API/GetFeatureFlags/GetFeatureFlags.cs new file mode 100644 index 0000000..a77f10d --- /dev/null +++ b/src/ConfigWay.FeatureManagement/API/GetFeatureFlags/GetFeatureFlags.cs @@ -0,0 +1,30 @@ +using Kododo.ConfigWay.FeatureManagement.Model; +using Kododo.Reiho.AspNetCore.API; + +namespace Kododo.ConfigWay.FeatureManagement.API.GetFeatureFlags; + +internal record GetFeatureFlags : IRequest; + +/// +/// Whether the application registered feature management (services.AddFeatureManagement()); without it the +/// flags are stored but nothing evaluates them. +/// +/// The aliases of the feature filters the application registered. +/// The flags, by id. +internal record FeatureFlags(bool IsFeatureManagementRegistered, string[] Filters, FeatureFlagItem[] Flags); + +/// The flag id. +/// The flag in effect: the override when there is one, otherwise the configured flag. +/// The flag as the application's configuration defines it; null when only ConfigWay has it. +/// Whether ConfigWay keeps an override of the flag. +/// Whether the configuration defines the flag in the older .NET schema. +/// Whether the flag has variants, which are kept but not edited. +/// Why the stored override cannot be read; resetting the flag removes it. +internal record FeatureFlagItem( + string Id, + FeatureFlag Flag, + FeatureFlag? Configured, + bool HasOverride, + bool IsDotnetSchema, + bool HasVariants, + string? Error); diff --git a/src/ConfigWay.FeatureManagement/API/GetFeatureFlags/GetFeatureFlagsHandler.cs b/src/ConfigWay.FeatureManagement/API/GetFeatureFlags/GetFeatureFlagsHandler.cs new file mode 100644 index 0000000..71c829f --- /dev/null +++ b/src/ConfigWay.FeatureManagement/API/GetFeatureFlags/GetFeatureFlagsHandler.cs @@ -0,0 +1,41 @@ +using Kododo.ConfigWay.FeatureManagement.Model; +using Kododo.Reiho.AspNetCore.API; + +namespace Kododo.ConfigWay.FeatureManagement.API.GetFeatureFlags; + +internal sealed class GetFeatureFlagsHandler(FlagCatalog catalog, RegisteredFilters filters) + : IRequestHandler +{ + public async Task HandleAsync(GetFeatureFlags request, CancellationToken cancellationToken) + { + var configured = catalog.ReadBase(); + var stored = await catalog.ReadStoredAsync(cancellationToken); + + var items = configured.Keys + .Concat(stored.Keys) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(id => ToItem(configured.GetValueOrDefault(id), stored.GetValueOrDefault(id))) + .OrderBy(i => i.Id, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return new FeatureFlags(filters.IsFeatureManagementRegistered, filters.Aliases() ?? [], items); + } + + private static FeatureFlagItem ToItem(BaseFlag? configured, StoredFlag? stored) + { + var configuredFlag = configured is null ? null : FlagJson.ToFlag(configured.Id, configured.Json); + var json = stored?.Json ?? configured?.Json; + var flag = stored?.Json is { } storedJson + ? FlagJson.ToFlag(stored.Id, storedJson) + : configuredFlag ?? new FeatureFlag(stored!.Id, false, Model.RequirementType.Any, []); + + return new FeatureFlagItem( + flag.Id, + flag, + configuredFlag, + stored is not null, + configured?.IsDotnetSchema ?? false, + json is not null && FlagJson.HasVariants(json), + stored?.Error); + } +} diff --git a/src/ConfigWay.FeatureManagement/API/ResetFeatureFlag/ResetFeatureFlag.cs b/src/ConfigWay.FeatureManagement/API/ResetFeatureFlag/ResetFeatureFlag.cs new file mode 100644 index 0000000..ea0e51f --- /dev/null +++ b/src/ConfigWay.FeatureManagement/API/ResetFeatureFlag/ResetFeatureFlag.cs @@ -0,0 +1,10 @@ +using Kododo.Reiho.AspNetCore.API; + +namespace Kododo.ConfigWay.FeatureManagement.API.ResetFeatureFlag; + +/// +/// Removes the override of a flag: a flag of the application's configuration is back to how it is configured, and a +/// flag only ConfigWay has is deleted. +/// +/// The flag id. +internal record ResetFeatureFlag(string Id) : IRequest; diff --git a/src/ConfigWay.FeatureManagement/API/ResetFeatureFlag/ResetFeatureFlagHandler.cs b/src/ConfigWay.FeatureManagement/API/ResetFeatureFlag/ResetFeatureFlagHandler.cs new file mode 100644 index 0000000..2975d5d --- /dev/null +++ b/src/ConfigWay.FeatureManagement/API/ResetFeatureFlag/ResetFeatureFlagHandler.cs @@ -0,0 +1,19 @@ +using Kododo.ConfigWay.Core.Configuration; +using Kododo.Reiho.AspNetCore.API; + +namespace Kododo.ConfigWay.FeatureManagement.API.ResetFeatureFlag; + +internal sealed class ResetFeatureFlagHandler( + FlagCatalog catalog, + Configuration configuration, + IConfigurationEditor configurationEditor) : IRequestHandler +{ + public async Task HandleAsync(ResetFeatureFlag request, CancellationToken cancellationToken) + { + var stored = (await catalog.ReadStoredAsync(cancellationToken)).GetValueOrDefault(request.Id) + ?? throw RequestException.NotFound($"The flag '{request.Id}' has no override."); + + await configuration.Store.DeleteAsync([stored.Key], cancellationToken); + await configurationEditor.ReloadAllAsync(cancellationToken); + } +} diff --git a/src/ConfigWay.FeatureManagement/API/SaveFeatureFlag/SaveFeatureFlag.cs b/src/ConfigWay.FeatureManagement/API/SaveFeatureFlag/SaveFeatureFlag.cs new file mode 100644 index 0000000..1d60ab7 --- /dev/null +++ b/src/ConfigWay.FeatureManagement/API/SaveFeatureFlag/SaveFeatureFlag.cs @@ -0,0 +1,8 @@ +using Kododo.ConfigWay.FeatureManagement.Model; +using Kododo.Reiho.AspNetCore.API; + +namespace Kododo.ConfigWay.FeatureManagement.API.SaveFeatureFlag; + +/// The flag to save as an override. +/// Whether this adds a flag, which must not exist yet; otherwise it changes an existing flag. +internal record SaveFeatureFlag(FeatureFlag Flag, bool IsNew = false) : IRequest; diff --git a/src/ConfigWay.FeatureManagement/API/SaveFeatureFlag/SaveFeatureFlagHandler.cs b/src/ConfigWay.FeatureManagement/API/SaveFeatureFlag/SaveFeatureFlagHandler.cs new file mode 100644 index 0000000..edeed3b --- /dev/null +++ b/src/ConfigWay.FeatureManagement/API/SaveFeatureFlag/SaveFeatureFlagHandler.cs @@ -0,0 +1,56 @@ +using Kododo.ConfigWay.Core.Configuration; +using Kododo.ConfigWay.Core.Model; +using Kododo.Reiho.AspNetCore.API; + +namespace Kododo.ConfigWay.FeatureManagement.API.SaveFeatureFlag; + +internal sealed class SaveFeatureFlagHandler( + FlagCatalog catalog, + RegisteredFilters filters, + Configuration configuration, + IConfigurationEditor configurationEditor) : IRequestHandler +{ + public async Task HandleAsync(SaveFeatureFlag request, CancellationToken cancellationToken) + { + CheckFilters(request.Flag); + + var configured = catalog.ReadBase().GetValueOrDefault(request.Flag.Id); + var stored = (await catalog.ReadStoredAsync(cancellationToken)).GetValueOrDefault(request.Flag.Id); + + if (request.IsNew && (configured is not null || stored is not null)) + throw RequestException.Invalid("flag.id", $"A flag named '{request.Flag.Id}' already exists."); + if (!request.IsNew && configured is null && stored is null) + throw RequestException.NotFound($"The flag '{request.Flag.Id}' does not exist."); + + // Ids are compared ignoring case: an existing flag keeps the id it has. + var id = stored?.Id ?? configured?.Id ?? request.Flag.Id; + var json = FlagJson.Apply(request.Flag with { Id = id }, stored?.Json ?? configured?.Json); + + await configuration.Store.SetAsync( + [new Setting(stored?.Key ?? StoredFlags.KeyFor(id), json.ToJsonString())], + cancellationToken); + await configurationEditor.ReloadAllAsync(cancellationToken); + } + + /// + /// Turns down filters the application has not registered: feature management throws whenever it checks a flag + /// with such a filter, so a typo would break every page that checks the flag. + /// + private void CheckFilters(Model.FeatureFlag flag) + { + if (!filters.IsFeatureManagementRegistered || filters.Aliases() is not { } aliases) + return; + + var errors = new Dictionary(); + for (var i = 0; i < flag.Filters.Length; i++) + { + var name = flag.Filters[i].Name; + if (!RegisteredFilters.Matches(name, aliases)) + errors[$"flag.filters[{i}].name"] = + [$"The application has not registered a filter named '{name}'; checking the flag would fail."]; + } + + if (errors.Count > 0) + throw new RequestException("The flag uses filters the application has not registered.") { Errors = errors }; + } +} diff --git a/src/ConfigWay.FeatureManagement/ConfigWay.FeatureManagement.csproj b/src/ConfigWay.FeatureManagement/ConfigWay.FeatureManagement.csproj new file mode 100644 index 0000000..58d537e --- /dev/null +++ b/src/ConfigWay.FeatureManagement/ConfigWay.FeatureManagement.csproj @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + <_Parameter1>Kododo.ConfigWay.FeatureManagement.Tests + + + + + Kododo.ConfigWay.FeatureManagement + Feature flags for ConfigWay. Manage Microsoft.FeatureManagement flags from the ConfigWay web UI without redeploying: switches, percentage rollouts, time windows and targeting. + MIT + https://github.com/kododo-dev/ConfigWay + true + logo.png + README.md + 1.0.0-local + + + diff --git a/src/ConfigWay.FeatureManagement/ConfigurationBuilderExtensions.cs b/src/ConfigWay.FeatureManagement/ConfigurationBuilderExtensions.cs new file mode 100644 index 0000000..1f6e8a5 --- /dev/null +++ b/src/ConfigWay.FeatureManagement/ConfigurationBuilderExtensions.cs @@ -0,0 +1,48 @@ +using Kododo.ConfigWay.UI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FeatureManagement; + +namespace Kododo.ConfigWay.FeatureManagement; + +/// +/// Extension methods for managing Microsoft.FeatureManagement feature flags with ConfigWay. +/// +public static class ConfigurationBuilderExtensions +{ + /// + /// Lets ConfigWay manage feature flags: flags saved in the editor are kept in the ConfigWay store and take the + /// place of the flags with the same id in the application's configuration (feature_management section of + /// appsettings, environment variables, ...). The editor gets a Flags view. + /// + /// + /// Feature management itself is registered by the application, which also picks the filters and the targeting + /// context: builder.Services.AddFeatureManagement(). This method sets it up to merge flags from several + /// configuration sources by id (). + /// + /// The ConfigWay configuration builder. + /// The same builder instance for method chaining. + /// + /// + /// builder.AddConfigWay(x => + /// { + /// x.UsePostgreSql(connectionString); + /// x.AddUiEditor(); + /// x.AddFeatureFlags(); + /// }); + /// builder.Services.AddFeatureManagement(); + /// + /// + public static ConfigurationBuilder AddFeatureFlags(this ConfigurationBuilder builder) + { + if (builder.Services.Any(s => s.ServiceType == typeof(FlagCatalog))) + return builder; + + builder.AddProvider(new FeatureFlagsProvider()); + builder.AddUiExtension("flags", typeof(ConfigurationBuilderExtensions).Assembly); + builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.Configure( + o => o.CustomConfigurationMergingEnabled = true); + return builder; + } +} diff --git a/src/ConfigWay.FeatureManagement/ConfigurationJson.cs b/src/ConfigWay.FeatureManagement/ConfigurationJson.cs new file mode 100644 index 0000000..eb8d2af --- /dev/null +++ b/src/ConfigWay.FeatureManagement/ConfigurationJson.cs @@ -0,0 +1,110 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Configuration; + +namespace Kododo.ConfigWay.FeatureManagement; + +/// Converts between configuration keys and JSON, the shape feature flags are edited and stored in. +internal static class ConfigurationJson +{ + private static readonly JsonNodeOptions CaseInsensitive = new() { PropertyNameCaseInsensitive = true }; + + /// + /// Reads the section at of one provider as JSON. Sections whose keys are 0..n-1 + /// become arrays; values stay text, as configuration has no types. + /// + /// The section, or null when the provider has nothing at the path. + public static JsonNode? Read(IConfigurationProvider provider, string path) + { + var children = provider.GetChildKeys([], path) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (children.Length == 0) + return provider.TryGet(path, out var value) && value is not null ? JsonValue.Create(value) : null; + + var indexes = children + .Select(c => int.TryParse(c, NumberStyles.None, CultureInfo.InvariantCulture, out var i) ? i : -1) + .ToArray(); + + if (indexes.All(i => i >= 0) && indexes.Order().SequenceEqual(Enumerable.Range(0, indexes.Length))) + { + var array = new JsonArray(); + foreach (var index in indexes.Order()) + array.Add(Read(provider, ConfigurationPath.Combine(path, index.ToString(CultureInfo.InvariantCulture)))); + return array; + } + + var obj = new JsonObject(CaseInsensitive); + foreach (var child in children.Order(StringComparer.OrdinalIgnoreCase)) + obj[child] = Read(provider, ConfigurationPath.Combine(path, child)); + return obj; + } + + /// Writes as configuration keys under . + public static void Flatten(JsonNode? node, string path, IDictionary data) + { + switch (node) + { + case null: + data[path] = null; + break; + case JsonObject obj: + foreach (var (key, value) in obj) + Flatten(value, ConfigurationPath.Combine(path, key), data); + break; + case JsonArray array: + for (var i = 0; i < array.Count; i++) + Flatten(array[i], ConfigurationPath.Combine(path, i.ToString(CultureInfo.InvariantCulture)), data); + break; + case JsonValue value: + data[path] = value.GetValueKind() switch + { + JsonValueKind.String => value.GetValue(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => null, + _ => value.ToJsonString(), + }; + break; + } + } + + /// The property of named , ignoring case. + public static JsonNode? Get(this JsonObject? obj, string name) => + obj?.FirstOrDefault(p => string.Equals(p.Key, name, StringComparison.OrdinalIgnoreCase)).Value; + + /// The text of a value, or null when it is missing or not a value. + public static string? AsText(this JsonNode? node) => + node is JsonValue value + ? value.GetValueKind() switch + { + JsonValueKind.String => value.GetValue(), + JsonValueKind.Number => value.ToJsonString(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => null, + } + : null; + + /// A number written as a number or as text, as configuration has it. + public static double? AsNumber(this JsonNode? node) => + node is JsonValue value && value.GetValueKind() == JsonValueKind.Number + ? value.GetValue() + : double.TryParse(node.AsText(), NumberStyles.Float, CultureInfo.InvariantCulture, out var number) + ? number + : null; + + /// A boolean written as a boolean or as text, as configuration has it. + public static bool? AsBool(this JsonNode? node) => + bool.TryParse(node.AsText(), out var flag) ? flag : null; + + /// The items of an array, or of an object with index keys, e.g. when a source sets only some indexes. + public static IEnumerable Items(this JsonNode? node) => node switch + { + JsonArray array => array, + JsonObject obj => obj.Select(p => p.Value), + _ => [], + }; +} diff --git a/src/ConfigWay.FeatureManagement/FeatureFlagsProvider.cs b/src/ConfigWay.FeatureManagement/FeatureFlagsProvider.cs new file mode 100644 index 0000000..1134b99 --- /dev/null +++ b/src/ConfigWay.FeatureManagement/FeatureFlagsProvider.cs @@ -0,0 +1,36 @@ +using System.Globalization; +using Kododo.ConfigWay.Core.Model; + +namespace Kododo.ConfigWay.FeatureManagement; + +/// +/// Exposes the flags kept in the ConfigWay store as feature_management:feature_flags, where +/// Microsoft.FeatureManagement reads them. +/// +internal sealed class FeatureFlagsProvider : StoreConfigurationProvider +{ + /// + /// The index of the first flag. Feature management is set up to merge flags by id, where the indexes do not + /// matter; should it merge by index instead (it does when the app has a provider it cannot inspect), indexes + /// past those of the app's own flags still let the flags of the store win. + /// + public const int FirstIndex = 100_000; + + protected override IEnumerable> Select(IReadOnlyList settings) + { + var data = new Dictionary(StringComparer.OrdinalIgnoreCase); + var index = FirstIndex; + foreach (var flag in StoredFlags.Read(settings)) + { + if (flag.Json is null) + continue; + ConfigurationJson.Flatten( + flag.Json, + $"feature_management:feature_flags:{index.ToString(CultureInfo.InvariantCulture)}", + data); + index++; + } + + return data; + } +} diff --git a/src/ConfigWay.FeatureManagement/FlagCatalog.cs b/src/ConfigWay.FeatureManagement/FlagCatalog.cs new file mode 100644 index 0000000..d173982 --- /dev/null +++ b/src/ConfigWay.FeatureManagement/FlagCatalog.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Nodes; +using Kododo.ConfigWay.Core.Configuration; +using Microsoft.Extensions.Configuration; + +namespace Kododo.ConfigWay.FeatureManagement; + +/// +/// Reads the flags the application has: those defined by its configuration sources (appsettings, environment +/// variables, ...) and those kept in the ConfigWay store, which take their place. +/// +internal sealed class FlagCatalog(IConfiguration appConfiguration, Configuration configuration) +{ + /// + /// The flags of the configuration sources other than ConfigWay, merged by id as feature management merges them: + /// a later source wins, and within one source the Microsoft schema wins over the .NET schema. + /// + public IReadOnlyDictionary ReadBase() + { + var root = appConfiguration as IConfigurationRoot + ?? throw new InvalidOperationException("Feature flags require IConfiguration to implement IConfigurationRoot."); + + var flags = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var provider in root.Providers.Where(p => p is not StoreConfigurationProvider)) + { + if (ConfigurationJson.Read(provider, "FeatureManagement") is JsonObject dotnetSchema) + { + foreach (var (id, node) in dotnetSchema) + { + flags[id] = new BaseFlag( + id, + FlagJson.FromDotnetSchema(id, node), + IsDotnetSchema: true, + HasDroppedVariants: FlagJson.HasDotnetVariants(node)); + } + } + + foreach (var json in ConfigurationJson.Read(provider, "feature_management:feature_flags").Items().OfType()) + { + if (json.Get("id").AsText() is { Length: > 0 } id) + flags[id] = new BaseFlag(id, json, IsDotnetSchema: false, HasDroppedVariants: false); + } + } + + return flags; + } + + /// The flags kept in the store, by id. + public async Task> ReadStoredAsync(CancellationToken cancellationToken) + { + var settings = await configuration.Store.GetAllAsync(cancellationToken); + var flags = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var flag in StoredFlags.Read(settings)) + flags[flag.Id] = flag; + return flags; + } +} + +/// A flag defined by a configuration source of the application. +/// The id as the source writes it. +/// The flag in the Microsoft schema; values are text, as configuration has them. +/// Whether the source defines it in the older .NET schema (FeatureManagement). +/// Whether it has .NET schema variants, which an override does not keep. +internal sealed record BaseFlag(string Id, JsonObject Json, bool IsDotnetSchema, bool HasDroppedVariants); diff --git a/src/ConfigWay.FeatureManagement/FlagJson.cs b/src/ConfigWay.FeatureManagement/FlagJson.cs new file mode 100644 index 0000000..b4750e4 --- /dev/null +++ b/src/ConfigWay.FeatureManagement/FlagJson.cs @@ -0,0 +1,122 @@ +using System.Text.Json.Nodes; +using Kododo.ConfigWay.FeatureManagement.Model; + +namespace Kododo.ConfigWay.FeatureManagement; + +/// +/// Converts between the editor's and a flag in the Microsoft feature management schema +/// (feature_management:feature_flags), and from the older .NET schema (FeatureManagement). +/// +internal static class FlagJson +{ + /// The flag as the editor shows it. + public static FeatureFlag ToFlag(string id, JsonObject json) + { + var conditions = json.Get("conditions") as JsonObject; + var filters = conditions.Get("client_filters").Items() + .OfType() + .Select(f => (Name: f.Get("name").AsText(), Parameters: f.Get("parameters") as JsonObject)) + .Where(f => !string.IsNullOrWhiteSpace(f.Name)) + .Select(f => new FeatureFilter(f.Name!, KnownFilters.Normalize(f.Name!, f.Parameters))) + .ToArray(); + + return new FeatureFlag( + id, + json.Get("enabled").AsBool() ?? false, + string.Equals(conditions.Get("requirement_type").AsText(), "All", StringComparison.OrdinalIgnoreCase) + ? RequirementType.All + : RequirementType.Any, + filters, + json.Get("description").AsText()); + } + + /// Whether the flag has variants, which the editor keeps but does not edit yet. + public static bool HasVariants(JsonObject json) => + json.Get("variants") is not null || json.Get("allocation") is not null; + + /// + /// The flag with the editor's values applied. Everything the editor does not know about, e.g. variants or + /// telemetry, is kept from . + /// + public static JsonObject Apply(FeatureFlag flag, JsonObject? existing) + { + var json = new JsonObject { ["id"] = flag.Id }; + if (!string.IsNullOrWhiteSpace(flag.Description)) + json["description"] = flag.Description; + json["enabled"] = flag.Enabled; + + if (flag.Filters.Length > 0) + { + json["conditions"] = new JsonObject + { + ["requirement_type"] = flag.RequirementType.ToString(), + ["client_filters"] = new JsonArray(flag.Filters + .Select(f => + { + var filter = new JsonObject { ["name"] = f.Name }; + if (KnownFilters.Normalize(f.Name, f.Parameters) is { } parameters) + filter["parameters"] = parameters; + return (JsonNode)filter; + }) + .ToArray()), + }; + } + + if (existing is not null) + { + foreach (var (key, value) in existing) + { + if (!IsEdited(key)) + json[key] = value?.DeepClone(); + } + } + + return json; + } + + /// + /// A flag of the .NET schema in the Microsoft schema: "Beta": true or + /// "Beta": { "EnabledFor": [...], "RequirementType": "All" }. + /// + public static JsonObject FromDotnetSchema(string id, JsonNode? node) + { + if (node is not JsonObject obj) + return new JsonObject { ["id"] = id, ["enabled"] = node.AsBool() ?? false }; + + var enabledFor = obj.Get("EnabledFor").Items() + .OfType() + .Select(f => (Name: f.Get("Name").AsText(), Parameters: f.Get("Parameters") as JsonObject)) + .Where(f => !string.IsNullOrWhiteSpace(f.Name)) + .ToArray(); + var alwaysOn = enabledFor.Any(f => f.Name is "AlwaysOn" or "On"); + var disabled = string.Equals(obj.Get("Status").AsText(), "Disabled", StringComparison.OrdinalIgnoreCase); + + var json = new JsonObject { ["id"] = id, ["enabled"] = !disabled && enabledFor.Length > 0 }; + if (!alwaysOn && enabledFor.Length > 0) + { + json["conditions"] = new JsonObject + { + ["requirement_type"] = obj.Get("RequirementType").AsText() ?? "Any", + ["client_filters"] = new JsonArray(enabledFor + .Select(f => (JsonNode)new JsonObject + { + ["name"] = f.Name, + ["parameters"] = f.Parameters?.DeepClone(), + }) + .ToArray()), + }; + } + + return json; + } + + /// Whether a .NET schema flag has variants, which are not carried over to an override. + public static bool HasDotnetVariants(JsonNode? node) => + node is JsonObject obj && (obj.Get("Variants") is not null || obj.Get("Allocation") is not null); + + private static bool IsEdited(string key) => + key.Equals("id", StringComparison.OrdinalIgnoreCase) || + key.Equals("enabled", StringComparison.OrdinalIgnoreCase) || + key.Equals("description", StringComparison.OrdinalIgnoreCase) || + key.Equals("conditions", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/ConfigWay.FeatureManagement/KnownFilters.cs b/src/ConfigWay.FeatureManagement/KnownFilters.cs new file mode 100644 index 0000000..d3c0cbd --- /dev/null +++ b/src/ConfigWay.FeatureManagement/KnownFilters.cs @@ -0,0 +1,177 @@ +using System.Globalization; +using System.Text.Json.Nodes; + +namespace Kododo.ConfigWay.FeatureManagement; + +/// +/// The built-in filters of Microsoft.FeatureManagement that the editor has forms for: their parameters are written in +/// one shape and checked before a save. Other filters are kept as they are. +/// +internal static class KnownFilters +{ + public const string Percentage = "Microsoft.Percentage"; + public const string TimeWindow = "Microsoft.TimeWindow"; + public const string Targeting = "Microsoft.Targeting"; + + private static readonly string[] All = [Percentage, TimeWindow, Targeting]; + + /// + /// The built-in filter refers to, by its full alias or its last segment as the library + /// accepts them (Microsoft.Percentage or Percentage); null for any other filter. + /// + public static string? Find(string? name) => + name is null + ? null + : All.FirstOrDefault(f => + string.Equals(f, name, StringComparison.OrdinalIgnoreCase) || + string.Equals(f[(f.LastIndexOf('.') + 1)..], name, StringComparison.OrdinalIgnoreCase)); + + /// + /// The parameters of a built-in filter in one shape: numbers as numbers, times in RFC 1123, empty parts left out. + /// Values that cannot be read are kept as they are, so that nothing is lost; other filters are copied. + /// + public static JsonObject? Normalize(string name, JsonObject? parameters) => Find(name) switch + { + Percentage => NormalizePercentage(parameters), + TimeWindow => NormalizeTimeWindow(parameters), + Targeting => NormalizeTargeting(parameters), + _ => parameters?.DeepClone().AsObject(), + }; + + /// What is wrong with the parameters of a built-in filter; nothing for other filters. + public static IEnumerable Validate(string name, JsonObject? parameters) => Find(name) switch + { + Percentage => ValidatePercentage(parameters), + TimeWindow => ValidateTimeWindow(parameters), + Targeting => ValidateTargeting(parameters), + _ => [], + }; + + private static JsonObject NormalizePercentage(JsonObject? parameters) + { + var result = new JsonObject(); + SetNumber(result, "Value", parameters.Get("Value")); + return result; + } + + private static JsonObject NormalizeTimeWindow(JsonObject? parameters) + { + var result = new JsonObject(); + foreach (var name in new[] { "Start", "End" }) + { + var text = parameters.Get(name).AsText(); + if (string.IsNullOrWhiteSpace(text)) + continue; + result[name] = TryParseTime(text, out var time) ? time.ToString("r", CultureInfo.InvariantCulture) : text; + } + + if (parameters.Get("Recurrence") is { } recurrence) + result["Recurrence"] = recurrence.DeepClone(); + return result; + } + + private static JsonObject NormalizeTargeting(JsonObject? parameters) + { + var audience = parameters.Get("Audience") as JsonObject; + var result = new JsonObject + { + ["Users"] = Texts(audience.Get("Users")), + ["Groups"] = new JsonArray(audience.Get("Groups").Items().OfType().Select(group => + { + var item = new JsonObject { ["Name"] = group.Get("Name").AsText() ?? string.Empty }; + SetNumber(item, "RolloutPercentage", group.Get("RolloutPercentage")); + return (JsonNode)item; + }).ToArray()), + }; + SetNumber(result, "DefaultRolloutPercentage", audience.Get("DefaultRolloutPercentage")); + + var exclusion = audience.Get("Exclusion") as JsonObject; + result["Exclusion"] = new JsonObject + { + ["Users"] = Texts(exclusion.Get("Users")), + ["Groups"] = Texts(exclusion.Get("Groups")), + }; + return new JsonObject { ["Audience"] = result }; + } + + private static IEnumerable ValidatePercentage(JsonObject? parameters) + { + if (parameters.Get("Value").AsNumber() is not { } value) + yield return "The percentage is required."; + else if (value is < 0 or > 100) + yield return "The percentage must be between 0 and 100."; + } + + private static IEnumerable ValidateTimeWindow(JsonObject? parameters) + { + DateTimeOffset? start = null, end = null; + foreach (var name in new[] { "Start", "End" }) + { + var text = parameters.Get(name).AsText(); + if (string.IsNullOrWhiteSpace(text)) + continue; + if (!TryParseTime(text, out var time)) + yield return $"The {name.ToLowerInvariant()} time is not a valid date and time."; + else if (name == "Start") + start = time; + else + end = time; + } + + if (string.IsNullOrWhiteSpace(parameters.Get("Start").AsText()) && + string.IsNullOrWhiteSpace(parameters.Get("End").AsText())) + yield return "The time window needs a start, an end or both."; + else if (start >= end) + yield return "The start must be before the end."; + } + + private static IEnumerable ValidateTargeting(JsonObject? parameters) + { + if (parameters.Get("Audience") is not JsonObject audience) + { + yield return "The audience is required."; + yield break; + } + + if (audience.Get("Users").Items().Any(u => string.IsNullOrWhiteSpace(u.AsText()))) + yield return "User names cannot be empty."; + + var groups = audience.Get("Groups").Items().ToArray(); + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var group in groups) + { + var name = (group as JsonObject).Get("Name").AsText(); + if (string.IsNullOrWhiteSpace(name)) + yield return "Group names cannot be empty."; + else if (!names.Add(name)) + yield return $"The group '{name}' is listed more than once."; + + if (!IsPercentage((group as JsonObject).Get("RolloutPercentage"), required: true)) + yield return $"The rollout percentage of the group '{name}' must be between 0 and 100."; + } + + if (!IsPercentage(audience.Get("DefaultRolloutPercentage"), required: false)) + yield return "The default rollout percentage must be between 0 and 100."; + + var exclusion = audience.Get("Exclusion") as JsonObject; + if (exclusion.Get("Users").Items().Concat(exclusion.Get("Groups").Items()) + .Any(e => string.IsNullOrWhiteSpace(e.AsText()))) + yield return "Excluded users and groups cannot be empty."; + } + + private static bool IsPercentage(JsonNode? node, bool required) => + node is null ? !required : node.AsNumber() is >= 0 and <= 100; + + private static void SetNumber(JsonObject target, string name, JsonNode? value) + { + if (value is null) + return; + target[name] = value.AsNumber() is { } number ? JsonValue.Create(number) : value.DeepClone(); + } + + private static JsonArray Texts(JsonNode? node) => + new(node.Items().Select(i => (JsonNode?)JsonValue.Create(i.AsText() ?? string.Empty)).ToArray()); + + private static bool TryParseTime(string text, out DateTimeOffset time) => + DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out time); +} diff --git a/src/ConfigWay.FeatureManagement/Model/FeatureFlag.cs b/src/ConfigWay.FeatureManagement/Model/FeatureFlag.cs new file mode 100644 index 0000000..378a5be --- /dev/null +++ b/src/ConfigWay.FeatureManagement/Model/FeatureFlag.cs @@ -0,0 +1,58 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Nodes; + +namespace Kododo.ConfigWay.FeatureManagement.Model; + +/// A feature flag as the editor shows and edits it. +/// The flag name used by the application, e.g. in IsEnabledAsync("Beta"). +/// Off when false; when true, on if the filters allow it (or there are none). +/// Whether any or all of the filters must allow the flag. +/// The conditions; an empty list means the flag is simply on or off. +/// An optional note on what the flag is for. +internal sealed record FeatureFlag( + string Id, + bool Enabled, + RequirementType RequirementType, + FeatureFilter[] Filters, + string? Description = null) : IValidatableObject +{ + /// Longest id: the store key, with its prefix, must fit the SQL Server key column. + public const int MaxIdLength = 200; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (string.IsNullOrWhiteSpace(Id)) + yield return new ValidationResult("The id is required.", [nameof(Id)]); + else if (Id.Trim() != Id) + yield return new ValidationResult("The id cannot start or end with a space.", [nameof(Id)]); + else if (Id.Contains(':')) + yield return new ValidationResult("The id cannot contain ':'.", [nameof(Id)]); + else if (Id.Length > MaxIdLength) + yield return new ValidationResult($"The id can have at most {MaxIdLength} characters.", [nameof(Id)]); + } +} + +/// Whether any or all of a flag's filters must allow it. +internal enum RequirementType +{ + Any, + All, +} + +/// A condition of a flag: a feature filter and its parameters. +/// The filter alias, e.g. Microsoft.Percentage. +/// The filter's settings; their shape depends on the filter. +internal sealed record FeatureFilter(string Name, JsonObject? Parameters = null) : IValidatableObject +{ + public IEnumerable Validate(ValidationContext validationContext) + { + if (string.IsNullOrWhiteSpace(Name)) + { + yield return new ValidationResult("The filter name is required.", [nameof(Name)]); + yield break; + } + + foreach (var error in KnownFilters.Validate(Name, Parameters)) + yield return new ValidationResult(error, [nameof(Parameters)]); + } +} diff --git a/src/ConfigWay.FeatureManagement/RegisteredFilters.cs b/src/ConfigWay.FeatureManagement/RegisteredFilters.cs new file mode 100644 index 0000000..ef1ad98 --- /dev/null +++ b/src/ConfigWay.FeatureManagement/RegisteredFilters.cs @@ -0,0 +1,50 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FeatureManagement; + +namespace Kododo.ConfigWay.FeatureManagement; + +/// The feature filters the application registered, named as feature management names them. +internal sealed class RegisteredFilters(IServiceProvider services) +{ + /// Handled by feature management itself, without a registered filter. + private static readonly string[] BuiltIn = ["AlwaysOn", "On"]; + + /// Whether the application registered feature management at all. + public bool IsFeatureManagementRegistered => services.GetService() is not null; + + /// + /// The aliases of the registered filters: the , or the type name without a + /// Filter suffix; null when they cannot be listed, e.g. a filter misses a dependency. + /// + public string[]? Aliases() + { + try + { + return services.GetServices() + .Select(f => f.GetType()) + .Select(t => t.GetCustomAttribute()?.Alias ?? TrimSuffix(t.Name, "Filter")) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + catch (InvalidOperationException) + { + return null; + } + } + + /// + /// Whether feature management finds a filter for : the full alias, or for a name without + /// a namespace, the last segment of an alias (Percentage for Microsoft.Percentage). + /// + public static bool Matches(string name, IEnumerable aliases) => + BuiltIn.Contains(name, StringComparer.OrdinalIgnoreCase) || + aliases.Any(alias => + string.Equals(alias, name, StringComparison.OrdinalIgnoreCase) || + (!name.Contains('.') && + string.Equals(alias[(alias.LastIndexOf('.') + 1)..], name, StringComparison.OrdinalIgnoreCase))); + + private static string TrimSuffix(string name, string suffix) => + name.EndsWith(suffix, StringComparison.Ordinal) && name.Length > suffix.Length ? name[..^suffix.Length] : name; +} diff --git a/src/ConfigWay.FeatureManagement/StoredFlags.cs b/src/ConfigWay.FeatureManagement/StoredFlags.cs new file mode 100644 index 0000000..9e5b232 --- /dev/null +++ b/src/ConfigWay.FeatureManagement/StoredFlags.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Kododo.ConfigWay.Core.Model; + +namespace Kododo.ConfigWay.FeatureManagement; + +/// +/// The flags kept in the ConfigWay store: one setting per flag, keyed feature_management:flags/{id}, with the +/// flag in the Microsoft schema as JSON. +/// +internal static class StoredFlags +{ + public const string Prefix = "feature_management:flags/"; + + public static string KeyFor(string id) => Prefix + id; + + /// The flags among , in the order of their ids. + public static IReadOnlyList Read(IEnumerable settings) => + settings + .Where(s => s.Key.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase) && s.Key.Length > Prefix.Length) + .Select(Parse) + .OrderBy(f => f.Id, StringComparer.OrdinalIgnoreCase) + .ThenBy(f => f.Key, StringComparer.Ordinal) + .ToArray(); + + private static StoredFlag Parse(Setting setting) + { + var id = setting.Key[Prefix.Length..]; + try + { + if (JsonNode.Parse(setting.Value ?? string.Empty) is not JsonObject json) + return new StoredFlag(setting.Key, id, null, "The stored flag is not a JSON object."); + + // The key decides the id, whatever the JSON says. + foreach (var key in json.Select(p => p.Key).Where(k => k.Equals("id", StringComparison.OrdinalIgnoreCase)).ToArray()) + json.Remove(key); + json["id"] = id; + return new StoredFlag(setting.Key, id, json, null); + } + catch (JsonException ex) + { + return new StoredFlag(setting.Key, id, null, $"The stored flag is not valid JSON: {ex.Message}"); + } + } +} + +/// A flag kept in the store. +/// The store key. +/// The flag id, from the key. +/// The flag; null when the stored value cannot be read. +/// Why the stored value cannot be read. +internal sealed record StoredFlag(string Key, string Id, JsonObject? Json, string? Error); diff --git a/src/ConfigWay.PostgreSQL.Tests/ConfigWay.PostgreSQL.Tests.csproj b/src/ConfigWay.PostgreSQL.Tests/ConfigWay.PostgreSQL.Tests.csproj index ca595c4..7fc59f1 100644 --- a/src/ConfigWay.PostgreSQL.Tests/ConfigWay.PostgreSQL.Tests.csproj +++ b/src/ConfigWay.PostgreSQL.Tests/ConfigWay.PostgreSQL.Tests.csproj @@ -29,6 +29,7 @@ + diff --git a/src/ConfigWay.PostgreSQL.Tests/Integration/FeatureFlagsStoreTests.cs b/src/ConfigWay.PostgreSQL.Tests/Integration/FeatureFlagsStoreTests.cs new file mode 100644 index 0000000..69bd58b --- /dev/null +++ b/src/ConfigWay.PostgreSQL.Tests/Integration/FeatureFlagsStoreTests.cs @@ -0,0 +1,85 @@ +using System.Net; +using System.Net.Http.Json; +using Kododo.ConfigWay.FeatureManagement; +using Kododo.ConfigWay.PostgreSQL.Tests.Fixtures; +using Kododo.ConfigWay.UI; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FeatureManagement; +using Xunit; + +namespace Kododo.ConfigWay.PostgreSQL.Tests.Integration; + +/// Feature flags kept in PostgreSQL: they survive a restart and reach other replicas. +[Collection("PostgreSQL")] +public class FeatureFlagsStoreTests(PostgreSqlFixture fixture) : IAsyncLifetime +{ + public Task InitializeAsync() => fixture.ResetAsync(); + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task SavedFlag_SurvivesARestart() + { + await using (var app = await CreateAppAsync()) + { + var response = await app.GetTestClient().PostAsJsonAsync("/config/api/SaveFeatureFlag", new + { + Flag = new { Id = "Beta", Enabled = false, RequirementType = "Any", Filters = Array.Empty() }, + }); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + await using var restarted = await CreateAppAsync(); + var featureManager = restarted.Services.GetRequiredService(); + + Assert.False(await featureManager.IsEnabledAsync("Beta")); + } + + [Fact] + public async Task SavedFlag_ReachesAnotherReplica() + { + await using var app = await CreateAppAsync(); + await using var replica = await CreateAppAsync(reloadInterval: TimeSpan.FromMilliseconds(100)); + var featureManager = replica.Services.GetRequiredService(); + Assert.True(await featureManager.IsEnabledAsync("Beta")); + + var response = await app.GetTestClient().PostAsJsonAsync("/config/api/SaveFeatureFlag", new + { + Flag = new { Id = "Beta", Enabled = false, RequirementType = "Any", Filters = Array.Empty() }, + }); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + + var deadline = DateTime.UtcNow.AddSeconds(10); + while (await featureManager.IsEnabledAsync("Beta") && DateTime.UtcNow < deadline) + await Task.Delay(50); + + Assert.False(await featureManager.IsEnabledAsync("Beta")); + } + + private async Task CreateAppAsync(TimeSpan? reloadInterval = null) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["feature_management:feature_flags:0:id"] = "Beta", + ["feature_management:feature_flags:0:enabled"] = "true", + }); + builder.AddConfigWay(x => + { + x.UsePostgreSql(fixture.ConnectionString); + x.AddUiEditor(); + x.AddFeatureFlags(); + x.ReloadInterval = reloadInterval; + }); + builder.Services.AddFeatureManagement(); + + var app = builder.Build(); + app.UseConfigWay().AllowAnonymous(); + await app.StartAsync(); + return app; + } +} diff --git a/src/ConfigWay.PostgreSQL.Tests/Integration/PostgresApiTests.cs b/src/ConfigWay.PostgreSQL.Tests/Integration/PostgresApiTests.cs index 008f6a4..0dc3644 100644 --- a/src/ConfigWay.PostgreSQL.Tests/Integration/PostgresApiTests.cs +++ b/src/ConfigWay.PostgreSQL.Tests/Integration/PostgresApiTests.cs @@ -5,6 +5,8 @@ using Kododo.ConfigWay.UI; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Xunit; namespace Kododo.ConfigWay.PostgreSQL.Tests.Integration; @@ -158,6 +160,21 @@ public async Task UpdateConfiguration_NullValue_ClearsField() Assert.Null(field.Value); } + [Fact] + public async Task UpdateConfiguration_WithReloadInterval_ReachesAnotherReplica() + { + await using var replica = await CreateAppAsync(reloadInterval: TimeSpan.FromMilliseconds(100)); + var monitor = replica.Services.GetRequiredService>(); + + await PostUpdate(new[] { new { key = "Simple:Name", value = "FromAnotherReplica" } }); + + var deadline = DateTime.UtcNow.AddSeconds(10); + while (monitor.CurrentValue.Name != "FromAnotherReplica" && DateTime.UtcNow < deadline) + await Task.Delay(50); + + Assert.Equal("FromAnotherReplica", monitor.CurrentValue.Name); + } + private Task PostGetConfiguration() => _client.PostAsJsonAsync("/config/api/GetConfiguration", new { }); @@ -174,7 +191,7 @@ private async Task FetchSections() return (await response.Content.ReadFromJsonAsync())!; } - private async Task CreateAppAsync() + private async Task CreateAppAsync(TimeSpan? reloadInterval = null) { var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); @@ -185,6 +202,7 @@ private async Task CreateAppAsync() x.AddOptions("Nested"); x.UsePostgreSql(_fixture.ConnectionString); x.AddUiEditor(); + x.ReloadInterval = reloadInterval; }); var app = builder.Build(); diff --git a/src/ConfigWay.SqlServer.Tests/ConfigWay.SqlServer.Tests.csproj b/src/ConfigWay.SqlServer.Tests/ConfigWay.SqlServer.Tests.csproj index b742927..cc8fa1a 100644 --- a/src/ConfigWay.SqlServer.Tests/ConfigWay.SqlServer.Tests.csproj +++ b/src/ConfigWay.SqlServer.Tests/ConfigWay.SqlServer.Tests.csproj @@ -29,6 +29,7 @@ + diff --git a/src/ConfigWay.SqlServer.Tests/Integration/FeatureFlagsStoreTests.cs b/src/ConfigWay.SqlServer.Tests/Integration/FeatureFlagsStoreTests.cs new file mode 100644 index 0000000..a9a25ad --- /dev/null +++ b/src/ConfigWay.SqlServer.Tests/Integration/FeatureFlagsStoreTests.cs @@ -0,0 +1,85 @@ +using System.Net; +using System.Net.Http.Json; +using Kododo.ConfigWay.FeatureManagement; +using Kododo.ConfigWay.SqlServer.Tests.Fixtures; +using Kododo.ConfigWay.UI; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FeatureManagement; +using Xunit; + +namespace Kododo.ConfigWay.SqlServer.Tests.Integration; + +/// Feature flags kept in SQL Server: they survive a restart and reach other replicas. +[Collection("SqlServer")] +public class FeatureFlagsStoreTests(SqlServerFixture fixture) : IAsyncLifetime +{ + public Task InitializeAsync() => fixture.ResetAsync(); + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task SavedFlag_SurvivesARestart() + { + await using (var app = await CreateAppAsync()) + { + var response = await app.GetTestClient().PostAsJsonAsync("/config/api/SaveFeatureFlag", new + { + Flag = new { Id = "Beta", Enabled = false, RequirementType = "Any", Filters = Array.Empty() }, + }); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + await using var restarted = await CreateAppAsync(); + var featureManager = restarted.Services.GetRequiredService(); + + Assert.False(await featureManager.IsEnabledAsync("Beta")); + } + + [Fact] + public async Task SavedFlag_ReachesAnotherReplica() + { + await using var app = await CreateAppAsync(); + await using var replica = await CreateAppAsync(reloadInterval: TimeSpan.FromMilliseconds(100)); + var featureManager = replica.Services.GetRequiredService(); + Assert.True(await featureManager.IsEnabledAsync("Beta")); + + var response = await app.GetTestClient().PostAsJsonAsync("/config/api/SaveFeatureFlag", new + { + Flag = new { Id = "Beta", Enabled = false, RequirementType = "Any", Filters = Array.Empty() }, + }); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + + var deadline = DateTime.UtcNow.AddSeconds(10); + while (await featureManager.IsEnabledAsync("Beta") && DateTime.UtcNow < deadline) + await Task.Delay(50); + + Assert.False(await featureManager.IsEnabledAsync("Beta")); + } + + private async Task CreateAppAsync(TimeSpan? reloadInterval = null) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["feature_management:feature_flags:0:id"] = "Beta", + ["feature_management:feature_flags:0:enabled"] = "true", + }); + builder.AddConfigWay(x => + { + x.UseSqlServer(fixture.ConnectionString); + x.AddUiEditor(); + x.AddFeatureFlags(); + x.ReloadInterval = reloadInterval; + }); + builder.Services.AddFeatureManagement(); + + var app = builder.Build(); + app.UseConfigWay().AllowAnonymous(); + await app.StartAsync(); + return app; + } +} diff --git a/src/ConfigWay.SqlServer.Tests/Integration/SqlServerApiTests.cs b/src/ConfigWay.SqlServer.Tests/Integration/SqlServerApiTests.cs index b55ca43..c7aec8f 100644 --- a/src/ConfigWay.SqlServer.Tests/Integration/SqlServerApiTests.cs +++ b/src/ConfigWay.SqlServer.Tests/Integration/SqlServerApiTests.cs @@ -5,6 +5,8 @@ using Kododo.ConfigWay.UI; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Xunit; namespace Kododo.ConfigWay.SqlServer.Tests.Integration; @@ -158,6 +160,21 @@ public async Task UpdateConfiguration_NullValue_ClearsField() Assert.Null(field.Value); } + [Fact] + public async Task UpdateConfiguration_WithReloadInterval_ReachesAnotherReplica() + { + await using var replica = await CreateAppAsync(reloadInterval: TimeSpan.FromMilliseconds(100)); + var monitor = replica.Services.GetRequiredService>(); + + await PostUpdate(new[] { new { key = "Simple:Name", value = "FromAnotherReplica" } }); + + var deadline = DateTime.UtcNow.AddSeconds(10); + while (monitor.CurrentValue.Name != "FromAnotherReplica" && DateTime.UtcNow < deadline) + await Task.Delay(50); + + Assert.Equal("FromAnotherReplica", monitor.CurrentValue.Name); + } + private Task PostGetConfiguration() => _client.PostAsJsonAsync("/config/api/GetConfiguration", new { }); @@ -174,7 +191,7 @@ private async Task FetchSections() return (await response.Content.ReadFromJsonAsync())!; } - private async Task CreateAppAsync() + private async Task CreateAppAsync(TimeSpan? reloadInterval = null) { var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); @@ -185,6 +202,7 @@ private async Task CreateAppAsync() x.AddOptions("Nested"); x.UseSqlServer(_fixture.ConnectionString); x.AddUiEditor(); + x.ReloadInterval = reloadInterval; }); var app = builder.Build(); diff --git a/src/ConfigWay.Tests/Integration/ExtensionTests.cs b/src/ConfigWay.Tests/Integration/ExtensionTests.cs new file mode 100644 index 0000000..f98f947 --- /dev/null +++ b/src/ConfigWay.Tests/Integration/ExtensionTests.cs @@ -0,0 +1,172 @@ +using System.Net; +using System.Net.Http.Json; +using Kododo.ConfigWay.Core.Model; +using Kododo.ConfigWay.UI; +using Kododo.Reiho.AspNetCore.API; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Kododo.ConfigWay.Tests.Integration; + +/// The extension points used by extension packages: providers, editor extensions and the reload interval. +public class ExtensionTests +{ + private static async Task<(WebApplication App, HttpClient Client)> StartAsync(Action configure) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.AddConfigWay(x => + { + x.AddOptions("Simple"); + x.AddUiEditor(); + configure(x); + }); + var app = builder.Build(); + app.UseConfigWay().AllowAnonymous(); + await app.StartAsync(); + return (app, app.GetTestClient()); + } + + [Fact] + public async Task GetCapabilities_WithoutExtensions_ReturnsNoExtensions() + { + var (app, client) = await StartAsync(_ => { }); + await using var _ = app; + + var capabilities = await PostAsync(client, "GetCapabilities"); + + Assert.Empty(capabilities.Extensions); + } + + [Fact] + public async Task GetCapabilities_ReturnsTheExtensionIds() + { + var (app, client) = await StartAsync(x => x.AddUiExtension("test", typeof(ExtensionTests).Assembly)); + await using var _ = app; + + var capabilities = await PostAsync(client, "GetCapabilities"); + + Assert.Equal(["test"], capabilities.Extensions); + } + + [Fact] + public async Task ExtensionRequests_AreHandledUnderTheEditorsApi() + { + var (app, client) = await StartAsync(x => x.AddUiExtension("test", typeof(ExtensionTests).Assembly)); + await using var _ = app; + + var response = await client.PostAsJsonAsync("/config/api/PingExtension", new { Name = "flags" }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("pong flags", await response.Content.ReadFromJsonAsync()); + } + + [Fact] + public async Task ExtensionRequests_WithoutTheExtension_AreNotFound() + { + var (app, client) = await StartAsync(_ => { }); + await using var _ = app; + + var response = await client.PostAsJsonAsync("/config/api/PingExtension", new { Name = "flags" }); + + // No API endpoint: the POST falls through to the shell's GET-only routes. + Assert.False(response.IsSuccessStatusCode); + } + + [Fact] + public async Task Provider_ExposesItsKeysAndIsReloadedWithConfigWay() + { + var (app, _) = await StartAsync(x => x.AddProvider(new PrefixProvider("ext/", "Ext:"))); + await using var __ = app; + var configuration = app.Services.GetRequiredService(); + Assert.Null(configuration["Ext:Beta"]); + + var store = app.Services.GetRequiredService().Store; + await store.SetAsync([new Setting("ext/Beta", "on")]); + await app.Services.GetRequiredService().ReloadAllAsync(CancellationToken.None); + + Assert.Equal("on", configuration["Ext:Beta"]); + Assert.Null(configuration["ext/Beta"]); + } + + [Fact] + public async Task Provider_IsExcludedFromTheDefaultsShownInTheEditor() + { + var (app, client) = await StartAsync(x => x.AddProvider(new PrefixProvider("ext/", "Simple:"))); + await using var __ = app; + + var store = app.Services.GetRequiredService().Store; + await store.SetAsync([new Setting("ext/Name", "from-extension")]); + await app.Services.GetRequiredService().ReloadAllAsync(CancellationToken.None); + + var json = await (await client.PostAsJsonAsync("/config/api/GetConfiguration", new { })) + .Content.ReadAsStringAsync(); + + Assert.Contains("\"value\":\"from-extension\"", json); + Assert.DoesNotContain("\"defaultValue\":\"from-extension\"", json); + } + + [Fact] + public async Task ReloadInterval_PicksUpChangesMadeByAnotherInstance() + { + var (app, _) = await StartAsync(x => x.ReloadInterval = TimeSpan.FromMilliseconds(50)); + await using var __ = app; + var monitor = app.Services.GetRequiredService>(); + Assert.Equal(string.Empty, monitor.CurrentValue.Name); + + // Another instance writes to the shared store without reloading this one. + var store = app.Services.GetRequiredService().Store; + await store.SetAsync([new Setting("Simple:Name", "Bob")]); + + var deadline = DateTime.UtcNow.AddSeconds(5); + while (monitor.CurrentValue.Name != "Bob" && DateTime.UtcNow < deadline) + await Task.Delay(20); + + Assert.Equal("Bob", monitor.CurrentValue.Name); + } + + [Fact] + public async Task WithoutReloadInterval_ChangesMadeByAnotherInstanceAreNotPickedUp() + { + var (app, _) = await StartAsync(_ => { }); + await using var __ = app; + var monitor = app.Services.GetRequiredService>(); + + var store = app.Services.GetRequiredService().Store; + await store.SetAsync([new Setting("Simple:Name", "Bob")]); + await Task.Delay(200); + + Assert.Equal(string.Empty, monitor.CurrentValue.Name); + } + + private static async Task PostAsync(HttpClient client, string request) + { + var response = await client.PostAsJsonAsync($"/config/api/{request}", new { }); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync())!; + } + + private sealed record CapabilitiesBody(string[] Extensions); +} + +/// A request of the test extension. +internal record PingExtension(string Name) : IRequest; + +internal sealed class PingExtensionHandler : IRequestHandler +{ + public Task HandleAsync(PingExtension request, CancellationToken cancellationToken) => + Task.FromResult($"pong {request.Name}"); +} + +/// Exposes the settings whose key starts with a store prefix under a configuration prefix. +file sealed class PrefixProvider(string storePrefix, string configurationPrefix) : StoreConfigurationProvider +{ + protected override IEnumerable> Select(IReadOnlyList settings) => + settings + .Where(s => s.Key.StartsWith(storePrefix, StringComparison.OrdinalIgnoreCase)) + .Select(s => new KeyValuePair(configurationPrefix + s.Key[storePrefix.Length..], s.Value)); +} diff --git a/src/ConfigWay.Tests/Unit/ConfigurationBuilderTests.cs b/src/ConfigWay.Tests/Unit/ConfigurationBuilderTests.cs index eeffb5d..88280f7 100644 --- a/src/ConfigWay.Tests/Unit/ConfigurationBuilderTests.cs +++ b/src/ConfigWay.Tests/Unit/ConfigurationBuilderTests.cs @@ -64,9 +64,44 @@ public void Build_WithDefaultStore_UsesInMemoryStore() Assert.NotNull(config.Store); } + [Fact] + public void AddProvider_KeepsTheProvidersInOrder() + { + var builder = CreateBuilder(); + var first = new EmptyProvider(); + var second = new EmptyProvider(); + + builder.AddProvider(first).AddProvider(second); + + Assert.Equal([first, second], builder.Providers); + } + + [Fact] + public void ReloadInterval_DefaultsToNull() + { + Assert.Null(CreateBuilder().ReloadInterval); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void AddConfigWay_WithANonPositiveReloadInterval_Throws(int seconds) + { + var appBuilder = WebApplication.CreateBuilder(); + + Assert.Throws(() => + appBuilder.AddConfigWay(x => x.ReloadInterval = TimeSpan.FromSeconds(seconds))); + } + private static ConfigurationBuilder CreateBuilder() { var appBuilder = WebApplication.CreateBuilder(); return new ConfigurationBuilder(appBuilder); } } + +file sealed class EmptyProvider : StoreConfigurationProvider +{ + protected override IEnumerable> Select( + IReadOnlyList settings) => []; +} diff --git a/src/ConfigWay.Tests/Unit/ConfigurationProviderTests.cs b/src/ConfigWay.Tests/Unit/ConfigurationProviderTests.cs index 7736d42..dab81e3 100644 --- a/src/ConfigWay.Tests/Unit/ConfigurationProviderTests.cs +++ b/src/ConfigWay.Tests/Unit/ConfigurationProviderTests.cs @@ -121,13 +121,144 @@ public async Task ReloadAsync_FiresChangeToken() Assert.True(changed); } - private static ConfigurationProvider CreateProvider(IStore store, params CoreOptions[] options) + [Fact] + public void Load_PassesEverySettingToTheExtensionProviders() + { + var store = Substitute.For(); + store.GetAllAsync().Returns([ + new Setting("Simple:Name", "Alice"), + new Setting("ext/Beta", "on"), + ]); + + var extension = new PrefixProvider("ext/"); + var provider = CreateProvider(store, [extension], new CoreOptions("Simple", typeof(SimpleOptions))); + provider.Load(); + + Assert.True(extension.TryGet("Beta", out var beta)); + Assert.Equal("on", beta); + Assert.False(extension.TryGet("Simple:Name", out _)); + Assert.False(provider.TryGet("ext/Beta", out _)); + } + + [Fact] + public async Task ReloadAsync_UpdatesTheExtensionProvidersAndFiresTheirChangeTokens() + { + var store = Substitute.For(); + store.GetAllAsync().Returns([new Setting("ext/Beta", "off")]); + + var extension = new PrefixProvider("ext/"); + var provider = CreateProvider(store, [extension]); + provider.Load(); + + var changed = false; + extension.GetReloadToken().RegisterChangeCallback(_ => changed = true, null); + + store.GetAllAsync().Returns([new Setting("ext/Beta", "on")]); + await ((IConfigurationEditor)provider).ReloadAllAsync(CancellationToken.None); + + extension.TryGet("Beta", out var value); + Assert.Equal("on", value); + Assert.True(changed); + } + + [Fact] + public void ExtensionProvider_Load_KeepsThePushedData() + { + var store = Substitute.For(); + store.GetAllAsync().Returns([new Setting("ext/Beta", "on")]); + + var extension = new PrefixProvider("ext/"); + CreateProvider(store, [extension]).Load(); + extension.Load(); + + Assert.True(extension.TryGet("Beta", out _)); + } + + [Fact] + public async Task ReloadIfChangedAsync_WithoutChanges_DoesNotReload() + { + var store = Substitute.For(); + store.GetAllAsync().Returns([new Setting("Simple:Name", "Alice")]); + + var provider = CreateProvider(store, new CoreOptions("Simple", typeof(SimpleOptions))); + provider.Load(); + + var changed = false; + provider.GetReloadToken().RegisterChangeCallback(_ => changed = true, null); + + store.GetAllAsync().Returns([new Setting("Simple:Name", "Alice")]); + var reloaded = await provider.ReloadIfChangedAsync(CancellationToken.None); + + Assert.False(reloaded); + Assert.False(changed); + } + + public static TheoryData ChangedStores => new() + { + { [new Setting("Simple:Name", "Bob")] }, + { [new Setting("Simple:Name", null)] }, + { [] }, + { [new Setting("Simple:Name", "Alice"), new Setting("Simple:Value", "1")] }, + { [new Setting("Simple:Name", "Alice"), new Setting("Unknown:Key", "x")] }, + }; + + [Theory] + [MemberData(nameof(ChangedStores))] + public async Task ReloadIfChangedAsync_WhenTheStoreChanged_Reloads(Setting[] current) + { + var store = Substitute.For(); + store.GetAllAsync().Returns([new Setting("Simple:Name", "Alice")]); + + var provider = CreateProvider(store, new CoreOptions("Simple", typeof(SimpleOptions))); + provider.Load(); + + var changed = false; + provider.GetReloadToken().RegisterChangeCallback(_ => changed = true, null); + + store.GetAllAsync().Returns(current); + var reloaded = await provider.ReloadIfChangedAsync(CancellationToken.None); + + Assert.True(reloaded); + Assert.True(changed); + } + + [Fact] + public async Task ReloadIfChangedAsync_AfterReload_ComparesWithTheReloadedValues() + { + var store = Substitute.For(); + store.GetAllAsync().Returns([new Setting("Simple:Name", "Alice")]); + + var provider = CreateProvider(store, new CoreOptions("Simple", typeof(SimpleOptions))); + provider.Load(); + + store.GetAllAsync().Returns([new Setting("Simple:Name", "Bob")]); + await ((IConfigurationEditor)provider).ReloadAllAsync(CancellationToken.None); + + Assert.False(await provider.ReloadIfChangedAsync(CancellationToken.None)); + } + + private static ConfigurationProvider CreateProvider(IStore store, params CoreOptions[] options) => + CreateProvider(store, [], options); + + private static ConfigurationProvider CreateProvider( + IStore store, + StoreConfigurationProvider[] extensions, + params CoreOptions[] options) { var config = new Configuration(store, options); - return new ConfigurationProvider(config); + return new ConfigurationProvider(config, extensions); } } +/// Exposes the settings whose key starts with a prefix, without the prefix. +file sealed class PrefixProvider(string prefix) : StoreConfigurationProvider +{ + protected override IEnumerable> Select(IReadOnlyList settings) => + settings + .Where(s => s.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .Select(s => new KeyValuePair(s.Key[prefix.Length..], s.Value)); +} + file class ReadOnlyPropOptions { public string ReadOnly { get; } = string.Empty; diff --git a/src/ConfigWay.UI/.nuget/README.md b/src/ConfigWay.UI/.nuget/README.md index df76d5c..140a371 100644 --- a/src/ConfigWay.UI/.nuget/README.md +++ b/src/ConfigWay.UI/.nuget/README.md @@ -53,6 +53,7 @@ The editor runs in [Kododo.Way.Shell](https://www.nuget.org/packages/Kododo.Way. - Field type-aware controls: text input for `string`, toggle switch for `bool`, numeric input for numeric types, dropdown for `enum` - Dark / light theme, persisted to `localStorage` - Language follows browser preference (English and Polish) +- Feature flags view with [Kododo.ConfigWay.FeatureManagement](https://www.nuget.org/packages/Kododo.ConfigWay.FeatureManagement): switches, percentage, time window and targeting forms, a check for a user ## Links diff --git a/src/ConfigWay.UI/API/GetCapabilities/GetCapabilities.cs b/src/ConfigWay.UI/API/GetCapabilities/GetCapabilities.cs new file mode 100644 index 0000000..56d1325 --- /dev/null +++ b/src/ConfigWay.UI/API/GetCapabilities/GetCapabilities.cs @@ -0,0 +1,8 @@ +using Kododo.Reiho.AspNetCore.API; + +namespace Kododo.ConfigWay.UI.API.GetCapabilities; + +internal record GetCapabilities : IRequest; + +/// The ids of the editor extensions added by extension packages, e.g. flags. +internal record Capabilities(string[] Extensions); diff --git a/src/ConfigWay.UI/API/GetCapabilities/GetCapabilitiesHandler.cs b/src/ConfigWay.UI/API/GetCapabilities/GetCapabilitiesHandler.cs new file mode 100644 index 0000000..8c71265 --- /dev/null +++ b/src/ConfigWay.UI/API/GetCapabilities/GetCapabilitiesHandler.cs @@ -0,0 +1,10 @@ +using Kododo.Reiho.AspNetCore.API; + +namespace Kododo.ConfigWay.UI.API.GetCapabilities; + +internal sealed class GetCapabilitiesHandler(IEnumerable extensions) + : IRequestHandler +{ + public Task HandleAsync(GetCapabilities request, CancellationToken cancellationToken) => + Task.FromResult(new Capabilities(extensions.Select(e => e.Id).Distinct().ToArray())); +} diff --git a/src/ConfigWay.UI/API/GetConfiguration/GetConfigurationHandler.cs b/src/ConfigWay.UI/API/GetConfiguration/GetConfigurationHandler.cs index a222bac..72e982e 100644 --- a/src/ConfigWay.UI/API/GetConfiguration/GetConfigurationHandler.cs +++ b/src/ConfigWay.UI/API/GetConfiguration/GetConfigurationHandler.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.Reflection; using Kododo.ConfigWay.Core.Configuration; using Kododo.ConfigWay.UI.DTO; @@ -19,7 +19,7 @@ private static IConfigurationRoot BuildBaseConfig(IConfiguration cfg) ?? throw new InvalidOperationException( $"{nameof(GetConfigurationHandler)} requires IConfiguration to implement IConfigurationRoot."); return new ConfigurationRoot( - root.Providers.Where(p => p is not ConfigurationProvider).ToList()); + root.Providers.Where(p => p is not StoreConfigurationProvider).ToList()); } public Task HandleAsync(GetConfiguration request, CancellationToken cancellationToken) diff --git a/src/ConfigWay.UI/ConfigurationBuilderExtensions.cs b/src/ConfigWay.UI/ConfigurationBuilderExtensions.cs index c3a1e2d..ebe5e3e 100644 --- a/src/ConfigWay.UI/ConfigurationBuilderExtensions.cs +++ b/src/ConfigWay.UI/ConfigurationBuilderExtensions.cs @@ -1,5 +1,7 @@ -using Kododo.Reiho.AspNetCore.API; +using System.Reflection; +using Kododo.Reiho.AspNetCore.API; using Kododo.Way.Shell; +using Microsoft.Extensions.DependencyInjection; namespace Kododo.ConfigWay.UI; @@ -22,4 +24,25 @@ public static ConfigurationBuilder AddUiEditor(this ConfigurationBuilder builder builder.Services.AddWayShell(); return builder; } + + /// + /// Adds an extension of the editor, used by extension packages. The API requests of the assembly are handled + /// under {path}/api next to the editor's own, behind the same authorization, and the extension id is + /// reported to the UI, which shows the views of the extensions it knows. + /// + /// The ConfigWay configuration builder. + /// The extension id known to the UI, e.g. flags. + /// The assembly with the extension's API requests and handlers. + /// The same builder instance for method chaining. + public static ConfigurationBuilder AddUiExtension(this ConfigurationBuilder builder, string id, Assembly requests) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + ArgumentNullException.ThrowIfNull(requests); + + builder.Services.AddRequestHandlers(requests); + builder.Services.AddSingleton(new UiExtension(id, requests)); + return builder; + } } + +internal sealed record UiExtension(string Id, Assembly Requests); diff --git a/src/ConfigWay.UI/EndpointRouteBuilderExtensions.cs b/src/ConfigWay.UI/EndpointRouteBuilderExtensions.cs index 038b038..0ed54d5 100644 --- a/src/ConfigWay.UI/EndpointRouteBuilderExtensions.cs +++ b/src/ConfigWay.UI/EndpointRouteBuilderExtensions.cs @@ -2,6 +2,7 @@ using Kododo.Way.Shell; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; namespace Kododo.ConfigWay.UI; @@ -39,7 +40,10 @@ public static class EndpointRouteBuilderExtensions public RouteGroupBuilder UseConfigWay(string path = "/config") { var configwayEndpoints = endpoints.MapWay(Module, path); - configwayEndpoints.MapGroup("/api").MapRequests(typeof(EndpointRouteBuilderExtensions).Assembly); + var api = configwayEndpoints.MapGroup("/api"); + api.MapRequests(typeof(EndpointRouteBuilderExtensions).Assembly); + foreach (var extension in endpoints.ServiceProvider.GetServices()) + api.MapRequests(extension.Requests); return configwayEndpoints; } } diff --git a/src/ConfigWay.UI/SPA/package-lock.json b/src/ConfigWay.UI/SPA/package-lock.json index a309d15..7f99f2b 100644 --- a/src/ConfigWay.UI/SPA/package-lock.json +++ b/src/ConfigWay.UI/SPA/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@kododo-dev/way-shell": "^2.0.0", + "@kododo-dev/way-shell": "^2.1.1", "@mui/icons-material": "^9.0.0", "@mui/material": "^9.0.0", "react": "^19.2.4", @@ -694,9 +694,9 @@ } }, "node_modules/@kododo-dev/way-shell": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@kododo-dev/way-shell/-/way-shell-2.0.0.tgz", - "integrity": "sha512-/D1ta3xMmHU04kVa0d3oBZXRUl+hgp+3BV5LIyYM1PfSbAJxcuPK774UPqor/FLiJx0zgZGq6kylThfq9w4ovg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@kododo-dev/way-shell/-/way-shell-2.1.1.tgz", + "integrity": "sha512-RR9L7t6Y3W8QYyOKe7QunxsEE16U1paGjPJJUhnTuV9NbGBq1qziYqgzZWMt162kvT+hvfV2NIM0vSA2LmbiLA==", "license": "MIT", "peerDependencies": { "@emotion/react": "^11.14.0", diff --git a/src/ConfigWay.UI/SPA/package.json b/src/ConfigWay.UI/SPA/package.json index cbd51d2..1fe27c0 100644 --- a/src/ConfigWay.UI/SPA/package.json +++ b/src/ConfigWay.UI/SPA/package.json @@ -12,7 +12,7 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@kododo-dev/way-shell": "^2.0.0", + "@kododo-dev/way-shell": "^2.1.1", "@mui/icons-material": "^9.0.0", "@mui/material": "^9.0.0", "react": "^19.2.4", diff --git a/src/ConfigWay.UI/SPA/src/App.tsx b/src/ConfigWay.UI/SPA/src/App.tsx index 87fc301..2163b3b 100644 --- a/src/ConfigWay.UI/SPA/src/App.tsx +++ b/src/ConfigWay.UI/SPA/src/App.tsx @@ -1,23 +1,39 @@ import { Route, Routes } from 'react-router-dom'; import { SectionsProvider } from './context/SectionsContext'; +import { FlagsProvider, useFlags } from './context/FlagsContext'; import SideMenu from './components/layout/SideMenu'; import ToWayRoot from './components/common/ToWayRoot'; import AllSettingsPage from './pages/AllSettingsPage'; import SectionPage from './pages/SectionPage'; +import FlagsPage from './pages/FlagsPage'; +import FlagPage from './pages/FlagPage'; // The editor inside Way.Shell: the shell provides the router, theme, language and layout; the sections go into the -// shell's side menu. +// shell's side menu. The flags views are there when the app added Kododo.ConfigWay.FeatureManagement. function App() { return ( - - - } /> - } /> - } /> - + + + + ); } +function AppRoutes() { + const { available, loading } = useFlags(); + return ( + + } /> + } /> + {available && } />} + {available && } />} + {available && } />} + {/* Until the server says whether there are flags, a link to them waits instead of going to the root. */} + } /> + + ); +} + export default App; diff --git a/src/ConfigWay.UI/SPA/src/api/flags.ts b/src/ConfigWay.UI/SPA/src/api/flags.ts new file mode 100644 index 0000000..29b6446 --- /dev/null +++ b/src/ConfigWay.UI/SPA/src/api/flags.ts @@ -0,0 +1,70 @@ +import type { WayApi } from '@kododo-dev/way-shell'; + +// The feature flags API of Kododo.ConfigWay.FeatureManagement, under the editor's API when the package is added. + +export type RequirementType = 'Any' | 'All'; + +export type FilterParameters = Record; + +export interface FeatureFilter { + name: string; + parameters: FilterParameters | null; +} + +export interface FeatureFlag { + id: string; + enabled: boolean; + requirementType: RequirementType; + filters: FeatureFilter[]; + description: string | null; +} + +export interface FeatureFlagItem { + id: string; + /** The flag in effect: the override when there is one, otherwise the configured flag. */ + flag: FeatureFlag; + /** The flag as the application's configuration defines it; null when only ConfigWay has it. */ + configured: FeatureFlag | null; + hasOverride: boolean; + isDotnetSchema: boolean; + hasVariants: boolean; + /** Why the stored override cannot be read. */ + error: string | null; +} + +export interface FeatureFlags { + isFeatureManagementRegistered: boolean; + /** Aliases of the filters the application registered. */ + filters: string[]; + flags: FeatureFlagItem[]; +} + +export interface FlagEvaluation { + enabled: boolean; + variant: string | null; +} + +export interface Capabilities { + extensions: string[]; +} + +export const fetchCapabilities = (api: WayApi): Promise => + api.post('GetCapabilities'); + +export const fetchFlags = (api: WayApi): Promise => + api.post('GetFeatureFlags'); + +/** Throws ApiError with field errors such as `flag.id` or `flag.filters[1].parameters`. */ +export const saveFlag = (api: WayApi, flag: FeatureFlag, isNew = false): Promise => + api.post('SaveFeatureFlag', { flag, isNew }); + +export const resetFlag = (api: WayApi, id: string): Promise => + api.post('ResetFeatureFlag', { id }); + +export const evaluateFlag = ( + api: WayApi, + id: string, + userId: string | null, + groups: string[], +): Promise => + api.post('EvaluateFeatureFlag', { id, userId, groups }); diff --git a/src/ConfigWay.UI/SPA/src/components/flags/ChipsInput.tsx b/src/ConfigWay.UI/SPA/src/components/flags/ChipsInput.tsx new file mode 100644 index 0000000..032866a --- /dev/null +++ b/src/ConfigWay.UI/SPA/src/components/flags/ChipsInput.tsx @@ -0,0 +1,35 @@ +import Autocomplete from '@mui/material/Autocomplete'; +import Chip from '@mui/material/Chip'; +import TextField from '@mui/material/TextField'; +import { useI18n } from '../../i18n/I18nContext'; + +interface ChipsInputProps { + label: string; + values: string[]; + onChange: (values: string[]) => void; +} + +/** A list of names typed one by one, e.g. the users of a targeting filter. */ +const ChipsInput = ({ label, values, onChange }: ChipsInputProps) => { + const { t } = useI18n(); + return ( + onChange( + [...new Set(next.map(v => (typeof v === 'string' ? v : String(v)).trim()).filter(v => v !== ''))])} + renderValue={(items, getItemProps) => + items.map((item, index) => { + const { key, ...props } = getItemProps({ index }); + return ; + })} + renderInput={params => ( + + )} + /> + ); +}; + +export default ChipsInput; diff --git a/src/ConfigWay.UI/SPA/src/components/flags/FilterEditors.tsx b/src/ConfigWay.UI/SPA/src/components/flags/FilterEditors.tsx new file mode 100644 index 0000000..a485c44 --- /dev/null +++ b/src/ConfigWay.UI/SPA/src/components/flags/FilterEditors.tsx @@ -0,0 +1,268 @@ +import { useEffect, useState } from 'react'; +import Alert from '@mui/material/Alert'; +import Autocomplete from '@mui/material/Autocomplete'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import IconButton from '@mui/material/IconButton'; +import Slider from '@mui/material/Slider'; +import TextField from '@mui/material/TextField'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; +import AddIcon from '@mui/icons-material/Add'; +import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; +import type { FeatureFilter, FilterParameters } from '../../api/flags'; +import { useI18n } from '../../i18n/I18nContext'; +import { + asNumber, asText, filterKind, fromLocalInput, isRegisteredFilter, readAudience, toLocalInput, windowState, +} from '../../utils/flags'; +import type { Audience } from '../../utils/flags'; +import ChipsInput from './ChipsInput'; +import { MONO } from './Panel'; + +const HINT = { ...MONO, fontSize: '0.72rem', color: 'text.secondary' } as const; + +interface EditorProps { + filter: FeatureFilter; + onChange: (filter: FeatureFilter) => void; +} + +/** The form for the filter's parameters: one per built-in filter, JSON for the others. */ +export const FilterEditor = ({ filter, onChange, registered, onValidity }: EditorProps & { + registered: string[]; + onValidity: JsonValidity; +}) => { + switch (filterKind(filter.name)) { + case 'Percentage': return ; + case 'TimeWindow': return ; + case 'Targeting': return ; + case 'Custom': + return ; + } +}; + +// ── Percentage ───────────────────────────────────────────────────────────── + +const PercentageEditor = ({ filter, onChange }: EditorProps) => { + const { t } = useI18n(); + const value = asNumber(filter.parameters?.Value); + const set = (next: number | null) => onChange({ ...filter, parameters: { ...filter.parameters, Value: next } }); + + return ( + <> + + set(v as number)} + min={0} max={100} step={1} + sx={{ flex: 1 }} + aria-label={t.filterPercentage} + /> + + + {t.percentageHint} + + ); +}; + +/** A number from 0 to 100; empty while typed over. */ +const PercentInput = ({ value, onChange, label, width = 120 }: { + value: number | null; + onChange: (v: number | null) => void; + label: string; + width?: number; +}) => ( + onChange(e.target.value === '' ? null : asNumber(e.target.value))} + label={label} + size="small" + type="number" + slotProps={{ htmlInput: { min: 0, max: 100, step: 1 } }} + sx={{ width }} + /> +); + +// ── Time window ──────────────────────────────────────────────────────────── + +const TimeWindowEditor = ({ filter, onChange }: EditorProps) => { + const { t } = useI18n(); + const parameters = filter.parameters ?? {}; + const set = (name: 'Start' | 'End', value: string) => { + const next: FilterParameters = { ...parameters }; + const time = fromLocalInput(value); + if (time) next[name] = time; + else delete next[name]; + onChange({ ...filter, parameters: next }); + }; + + const state = windowState(parameters); + const zone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const stateLabel = { active: t.windowActive, upcoming: t.windowUpcoming, ended: t.windowEnded, unknown: null }[state]; + + return ( + <> + + {(['Start', 'End'] as const).map(name => ( + set(name, e.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + sx={{ minWidth: 220 }} + /> + ))} + {stateLabel && ( + + )} + + {t.windowTimeZone(zone)} + {parameters.Recurrence != null && {t.recurrenceKept}} + + ); +}; + +// ── Targeting ────────────────────────────────────────────────────────────── + +const TargetingEditor = ({ filter, onChange }: EditorProps) => { + const { t } = useI18n(); + const audience = readAudience(filter.parameters); + const set = (next: Partial) => + onChange({ ...filter, parameters: { ...filter.parameters, Audience: { ...audience, ...next } } }); + + const setGroup = (index: number, patch: Partial) => + set({ Groups: audience.Groups.map((g, i) => (i === index ? { ...g, ...patch } : g)) }); + + return ( + <> + {t.targetingHint} + set({ Users })} /> + + + {t.targetGroups} + {audience.Groups.map((group, index) => ( + + setGroup(index, { Name: e.target.value })} + sx={{ flex: 1 }} + /> + setGroup(index, { RolloutPercentage })} + /> + + set({ Groups: audience.Groups.filter((_, i) => i !== index) })}> + + + + + ))} + + + + + + set({ DefaultRolloutPercentage })} + /> + + + set({ Exclusion: { ...audience.Exclusion, Users } })} + /> + set({ Exclusion: { ...audience.Exclusion, Groups } })} + /> + + + ); +}; + +// ── Custom filter ────────────────────────────────────────────────────────── + +/** Reports whether the JSON typed so far is valid, so that the page can block the save. */ +export type JsonValidity = (valid: boolean) => void; + +const CustomFilterEditor = ({ filter, onChange, registered, onValidity }: EditorProps & { registered: string[]; onValidity?: JsonValidity }) => { + const { t } = useI18n(); + const [text, setText] = useState(() => JSON.stringify(filter.parameters ?? {}, null, 2)); + const [invalid, setInvalid] = useState(false); + + // A filter replaced from outside (discard, reload) brings its own parameters. + useEffect(() => { + setText(current => { + try { + if (JSON.stringify(JSON.parse(current)) === JSON.stringify(filter.parameters ?? {})) return current; + } catch { /* the text was not valid: replace it */ } + return JSON.stringify(filter.parameters ?? {}, null, 2); + }); + }, [filter.parameters]); + + const changeText = (next: string) => { + setText(next); + try { + const parsed = JSON.parse(next === '' ? '{}' : next); + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(); + setInvalid(false); + onValidity?.(true); + onChange({ ...filter, parameters: parsed }); + } catch { + setInvalid(true); + onValidity?.(false); + } + }; + + const custom = registered.filter(r => filterKind(r) === 'Custom'); + const unregistered = registered.length > 0 && filter.name.trim() !== '' && !isRegisteredFilter(filter.name, registered); + + return ( + <> + onChange({ ...filter, name })} + renderInput={params => } + /> + {unregistered && {t.unregisteredFilter}} + changeText(e.target.value)} + error={invalid} + helperText={invalid ? t.invalidJson : undefined} + multiline + minRows={3} + size="small" + slotProps={{ htmlInput: { spellCheck: false, style: { ...MONO, fontSize: '0.78rem' } } }} + /> + + ); +}; diff --git a/src/ConfigWay.UI/SPA/src/components/flags/FlagCheck.tsx b/src/ConfigWay.UI/SPA/src/components/flags/FlagCheck.tsx new file mode 100644 index 0000000..7e3e997 --- /dev/null +++ b/src/ConfigWay.UI/SPA/src/components/flags/FlagCheck.tsx @@ -0,0 +1,101 @@ +import { useState } from 'react'; +import Alert from '@mui/material/Alert'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import CircularProgress from '@mui/material/CircularProgress'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import { useShell } from '@kododo-dev/way-shell'; +import { ApiError } from '../../api/api'; +import { evaluateFlag } from '../../api/flags'; +import type { FlagEvaluation } from '../../api/flags'; +import { useI18n } from '../../i18n/I18nContext'; +import Panel, { MONO } from './Panel'; + +interface FlagCheckProps { + flagId: string; + /** The editor has unsaved changes, which the check does not see. */ + hasChanges: boolean; +} + +/** Asks the application's feature manager whether the saved flag is on for a user and groups. */ +const FlagCheck = ({ flagId, hasChanges }: FlagCheckProps) => { + const { api } = useShell(); + const { t } = useI18n(); + const [user, setUser] = useState(''); + const [groups, setGroups] = useState(''); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [running, setRunning] = useState(false); + + // A result is about the user and groups it was asked for; editing them clears it. + const edit = (set: (value: string) => void) => (value: string) => { + set(value); + setResult(null); + setError(null); + }; + + const run = async () => { + setRunning(true); + setResult(null); + setError(null); + try { + setResult(await evaluateFlag( + api, + flagId, + user.trim() || null, + groups.split(',').map(g => g.trim()).filter(g => g !== ''), + )); + } catch (e) { + setResult(null); + setError(e instanceof ApiError ? e.messages.join(' ') : e instanceof Error ? e.message : t.unexpectedError); + } finally { + setRunning(false); + } + }; + + return ( + + {t.checkSavedOnly} + { e.preventDefault(); run(); }} + sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }} + > + edit(setUser)(e.target.value)} /> + edit(setGroups)(e.target.value)} + sx={{ flex: 1, minWidth: 200 }} + /> + + + {result && ( + + + {result.variant && } + + )} + {error && {error}} + + ); +}; + +export default FlagCheck; diff --git a/src/ConfigWay.UI/SPA/src/components/flags/Panel.tsx b/src/ConfigWay.UI/SPA/src/components/flags/Panel.tsx new file mode 100644 index 0000000..0005cdb --- /dev/null +++ b/src/ConfigWay.UI/SPA/src/components/flags/Panel.tsx @@ -0,0 +1,49 @@ +import type { ReactNode } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { useTheme } from '@mui/material/styles'; + +export const MONO = { fontFamily: "'IBM Plex Mono', monospace" }; + +interface PanelProps { + title: ReactNode; + /** Actions on the right of the title bar. */ + actions?: ReactNode; + children: ReactNode; + /** A red border, e.g. for a condition the API turned down. */ + invalid?: boolean; +} + +/** A card of the flag editor, in the look of the settings' section cards. */ +const Panel = ({ title, actions, children, invalid }: PanelProps) => { + const theme = useTheme(); + const isDark = theme.palette.mode === 'dark'; + const border = invalid ? theme.palette.error.main : isDark ? '#2e2e2e' : '#e0e0e0'; + + return ( + + + + {title} + + {actions} + + + {children} + + + ); +}; + +export default Panel; diff --git a/src/ConfigWay.UI/SPA/src/components/flags/flagSummary.ts b/src/ConfigWay.UI/SPA/src/components/flags/flagSummary.ts new file mode 100644 index 0000000..a9b1ad0 --- /dev/null +++ b/src/ConfigWay.UI/SPA/src/components/flags/flagSummary.ts @@ -0,0 +1,41 @@ +import type { FeatureFilter, FeatureFlag } from '../../api/flags'; +import type { TranslationKeys } from '../../i18n/translations'; +import { asNumber, asText, filterKind, formatTime, readAudience } from '../../utils/flags'; + +/** A short description of when the flag is on, for the list of flags. */ +export function flagSummary(flag: FeatureFlag, t: TranslationKeys, lang: string): string { + if (!flag.enabled) return t.flagOff; + if (flag.filters.length === 0) return t.summaryEveryone; + return flag.filters + .map(f => filterSummary(f, t, lang)) + .join(flag.requirementType === 'All' ? t.summaryAnd : t.summaryOr); +} + +function filterSummary(filter: FeatureFilter, t: TranslationKeys, lang: string): string { + const parameters = filter.parameters; + switch (filterKind(filter.name)) { + case 'Percentage': + return t.summaryPercentage(asNumber(parameters?.Value) ?? '?'); + case 'TimeWindow': { + const start = asText(parameters?.Start); + const end = asText(parameters?.End); + const parts = [ + start ? t.summaryFrom(formatTime(start, lang)) : null, + end ? t.summaryUntil(formatTime(end, lang)) : null, + parameters?.Recurrence ? `(${t.summaryRepeats})` : null, + ].filter(Boolean); + return parts.join(' '); + } + case 'Targeting': { + const audience = readAudience(parameters); + const parts = [ + audience.Users.length > 0 ? t.summaryUsers(audience.Users.length) : null, + audience.Groups.length > 0 ? t.summaryGroups(audience.Groups.length) : null, + audience.DefaultRolloutPercentage ? t.summaryDefaultRollout(audience.DefaultRolloutPercentage) : null, + ].filter(Boolean); + return parts.length > 0 ? parts.join(' · ') : t.summaryNobody; + } + case 'Custom': + return filter.name; + } +} diff --git a/src/ConfigWay.UI/SPA/src/components/layout/PageHeader.tsx b/src/ConfigWay.UI/SPA/src/components/layout/PageHeader.tsx index f9cdc9e..b194d0a 100644 --- a/src/ConfigWay.UI/SPA/src/components/layout/PageHeader.tsx +++ b/src/ConfigWay.UI/SPA/src/components/layout/PageHeader.tsx @@ -9,6 +9,7 @@ import RefreshIcon from '@mui/icons-material/Refresh'; import SaveIcon from '@mui/icons-material/Save'; import SearchIcon from '@mui/icons-material/Search'; import CloseIcon from '@mui/icons-material/Close'; +import type { ReactNode } from 'react'; import type { SxProps } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import { PageHeader as ShellPageHeader } from '@kododo-dev/way-shell'; @@ -24,6 +25,9 @@ interface PageHeaderProps { errors?: string[]; searchQuery?: string; onSearchChange?: (q: string) => void; + searchPlaceholder?: string; + /** More actions, before the refresh button. */ + actions?: ReactNode; sx?: SxProps; } @@ -33,7 +37,8 @@ const PageHeader = ({ title, onRefresh, hasChanges, saving, onSave, onDiscard, errors, - searchQuery, onSearchChange, + searchQuery, onSearchChange, searchPlaceholder, + actions, sx, }: PageHeaderProps) => { const theme = useTheme(); @@ -73,7 +78,7 @@ const PageHeader = ({ onSearchChange!(e.target.value)} - placeholder={t.searchPlaceholder} + placeholder={searchPlaceholder ?? t.searchPlaceholder} fullWidth sx={{ ...MONO, @@ -146,6 +151,8 @@ const PageHeader = ({ )} + {actions} + {onRefresh && ( { const { navigate, wayId } = useShell(); const { sections } = useSections(); + const { available: flagsAvailable } = useFlags(); const { t } = useI18n(); const theme = useTheme(); const isDark = theme.palette.mode === 'dark'; const path = useParams()['*'] ?? ''; const isAll = path === '' || path === 'all'; + const isFlags = path === 'flags' || path.startsWith('flags/') || path.startsWith('flag/'); const activeSectionKey = path.startsWith('section/') ? decodeURIComponent(path.slice('section/'.length)) : null; @@ -48,6 +52,14 @@ const SideMenu = () => { + {flagsAvailable && ( + + navigate(wayId, 'flags')}> + + + + + )} navigate(wayId, '')}> diff --git a/src/ConfigWay.UI/SPA/src/context/FlagsContext.tsx b/src/ConfigWay.UI/SPA/src/context/FlagsContext.tsx new file mode 100644 index 0000000..6608135 --- /dev/null +++ b/src/ConfigWay.UI/SPA/src/context/FlagsContext.tsx @@ -0,0 +1,64 @@ +import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; +import { useShell } from '@kododo-dev/way-shell'; +import { fetchCapabilities, fetchFlags } from '../api/flags'; +import type { FeatureFlags } from '../api/flags'; + +interface FlagsContextValue { + /** Whether the application added the feature flags package (Kododo.ConfigWay.FeatureManagement). */ + available: boolean; + data: FeatureFlags | null; + loading: boolean; + error: string | null; + reload: () => Promise; +} + +const FlagsContext = createContext({ + available: false, + data: null, + loading: false, + error: null, + reload: async () => {}, +}); + +// The flags are loaded only when the server reports the extension, so the editor works the same without it. +export const FlagsProvider = ({ children }: { children: React.ReactNode }) => { + const { api } = useShell(); + const [available, setAvailable] = useState(false); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + try { + setLoading(true); + setError(null); + setData(await fetchFlags(api)); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load'); + } finally { + setLoading(false); + } + }, [api]); + + useEffect(() => { + let cancelled = false; + fetchCapabilities(api) + .then(c => { + if (cancelled) return; + const has = c.extensions.includes('flags'); + setAvailable(has); + if (has) load(); + else setLoading(false); + }) + .catch(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [api, load]); + + return ( + + {children} + + ); +}; + +export const useFlags = () => useContext(FlagsContext); diff --git a/src/ConfigWay.UI/SPA/src/i18n/translations.ts b/src/ConfigWay.UI/SPA/src/i18n/translations.ts index 8141377..08a95a1 100644 --- a/src/ConfigWay.UI/SPA/src/i18n/translations.ts +++ b/src/ConfigWay.UI/SPA/src/i18n/translations.ts @@ -25,6 +25,99 @@ export const translations = { resetSection: 'Reset section to defaults', resetArray: 'Reset array to defaults', resetItem: 'Reset item to defaults', + + // Feature flags (Kododo.ConfigWay.FeatureManagement) + featureFlags: 'Feature flags', + newFlag: 'New flag', + flagsSearchPlaceholder: 'Search flags…', + noFlags: 'No feature flags yet. Add one here, or define them in the feature_management section of appsettings.', + featureManagementMissing: 'Feature management is not registered in the application: the flags are stored, but nothing checks them. Call builder.Services.AddFeatureManagement().', + flagOn: 'on', + flagOff: 'off', + summaryEveryone: 'on for everyone', + summaryPercentage: (value: number | string) => `${value}% of checks`, + summaryFrom: (time: string) => `from ${time}`, + summaryUntil: (time: string) => `until ${time}`, + summaryUsers: (n: number) => n === 1 ? '1 user' : `${n} users`, + summaryGroups: (n: number) => n === 1 ? '1 group' : `${n} groups`, + summaryDefaultRollout: (value: number) => `${value}% of others`, + summaryNobody: 'nobody', + summaryRepeats: 'repeats', + summaryAnd: ' and ', + summaryOr: ' or ', + badgeOverride: 'overridden', + badgeConfigWayOnly: 'ConfigWay only', + badgeDotnetSchema: '.NET schema', + badgeVariants: 'variants', + badgeError: 'unreadable', + flagTurnedOn: (id: string) => `${id} is on`, + flagTurnedOff: (id: string) => `${id} is off`, + undo: 'Undo', + + flagSection: 'Flag', + flagId: 'Id', + flagIdHint: 'The name the application checks, e.g. IsEnabledAsync("Beta").', + flagDescription: 'Description', + flagEnabled: 'Enabled', + sourceConfigured: 'Defined in the application\'s configuration.', + sourceOverride: 'Overrides the flag defined in the application\'s configuration.', + sourceConfigWayOnly: 'Defined only in ConfigWay.', + resetToConfigured: 'Reset to configuration', + deleteFlag: 'Delete flag', + confirmResetTitle: 'Reset the flag?', + confirmResetText: (id: string) => `${id} goes back to how the application's configuration defines it.`, + confirmDeleteTitle: 'Delete the flag?', + confirmDeleteText: (id: string) => `${id} is removed. The application treats a flag it does not know as off.`, + cancel: 'Cancel', + variantsKept: 'This flag has variants. They are kept when you save, but cannot be edited here yet.', + dotnetSchemaNote: 'The configuration defines this flag in the older .NET schema (FeatureManagement section). Saving stores an override in the Microsoft schema; variants of the .NET schema are not carried over.', + overrideError: (error: string) => `The stored override cannot be read (${error}). Reset it to use the configured flag again.`, + + conditions: 'Conditions', + requirementAny: 'any', + requirementAll: 'all', + requirementLabel: 'On when the flag passes', + requirementSuffix: 'of the conditions', + noConditions: 'No conditions: when enabled, the flag is on for everyone.', + disabledHint: 'The flag is disabled, so it is off whatever the conditions say.', + addCondition: 'Add condition', + removeCondition: 'Remove condition', + filterPercentage: 'Percentage', + filterTimeWindow: 'Time window', + filterTargeting: 'Targeting', + filterCustom: 'Custom filter', + percentageHint: 'Each check is on with this probability, independently of the user. For a stable rollout per user, use targeting.', + windowStart: 'Start', + windowEnd: 'End', + windowActive: 'active now', + windowUpcoming: 'not started yet', + windowEnded: 'ended', + windowTimeZone: (zone: string) => `Times in your time zone (${zone}), stored in UTC. Leave one empty for an open window.`, + recurrenceKept: 'This window repeats. The repetition is kept, but cannot be edited here yet.', + targetUsers: 'Users', + targetGroups: 'Groups', + groupName: 'Group', + groupRollout: '% of the group', + addGroup: 'Add group', + removeGroup: 'Remove group', + defaultRollout: '% of everyone else', + excludedUsers: 'Excluded users', + excludedGroups: 'Excluded groups', + chipsHint: 'Type and press Enter', + targetingHint: 'On for the listed users, for the given share of each group and of everyone else; never for the excluded ones.', + filterName: 'Filter name', + filterParameters: 'Parameters (JSON)', + invalidJson: 'Not a valid JSON object.', + unregisteredFilter: 'The application has not registered a filter with this name. Saving is refused, because checking the flag would fail.', + + checkTitle: 'Check for a user', + checkUser: 'User', + checkGroups: 'Groups (comma-separated)', + checkRun: 'Check', + checkOn: 'On for this user', + checkOff: 'Off for this user', + checkVariant: (variant: string) => `variant ${variant}`, + checkSavedOnly: 'Checks the saved flag, with the application\'s feature manager. Save your changes first.', }, pl: { appTitle: 'Konfiguracja', @@ -50,6 +143,99 @@ export const translations = { resetSection: 'Przywróć sekcję do wartości domyślnych', resetArray: 'Przywróć tablicę do wartości domyślnych', resetItem: 'Przywróć element do wartości domyślnych', + + // Feature flags (Kododo.ConfigWay.FeatureManagement) + featureFlags: 'Flagi funkcji', + newFlag: 'Nowa flaga', + flagsSearchPlaceholder: 'Szukaj flag…', + noFlags: 'Nie ma jeszcze flag. Dodaj flagę tutaj albo zdefiniuj je w sekcji feature_management w appsettings.', + featureManagementMissing: 'Aplikacja nie rejestruje feature management: flagi są zapisywane, ale nic ich nie sprawdza. Wywołaj builder.Services.AddFeatureManagement().', + flagOn: 'włączona', + flagOff: 'wyłączona', + summaryEveryone: 'włączona dla wszystkich', + summaryPercentage: (value: number | string) => `${value}% sprawdzeń`, + summaryFrom: (time: string) => `od ${time}`, + summaryUntil: (time: string) => `do ${time}`, + summaryUsers: (n: number) => n === 1 ? '1 użytkownik' : `${n} użytkowników`, + summaryGroups: (n: number) => n === 1 ? '1 grupa' : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14) ? `${n} grupy` : `${n} grup`, + summaryDefaultRollout: (value: number) => `${value}% pozostałych`, + summaryNobody: 'nikt', + summaryRepeats: 'powtarza się', + summaryAnd: ' i ', + summaryOr: ' lub ', + badgeOverride: 'nadpisana', + badgeConfigWayOnly: 'tylko ConfigWay', + badgeDotnetSchema: 'schemat .NET', + badgeVariants: 'warianty', + badgeError: 'nieczytelna', + flagTurnedOn: (id: string) => `${id} włączona`, + flagTurnedOff: (id: string) => `${id} wyłączona`, + undo: 'Cofnij', + + flagSection: 'Flaga', + flagId: 'Id', + flagIdHint: 'Nazwa, którą sprawdza aplikacja, np. IsEnabledAsync("Beta").', + flagDescription: 'Opis', + flagEnabled: 'Włączona', + sourceConfigured: 'Zdefiniowana w konfiguracji aplikacji.', + sourceOverride: 'Nadpisuje flagę zdefiniowaną w konfiguracji aplikacji.', + sourceConfigWayOnly: 'Zdefiniowana tylko w ConfigWay.', + resetToConfigured: 'Przywróć z konfiguracji', + deleteFlag: 'Usuń flagę', + confirmResetTitle: 'Przywrócić flagę?', + confirmResetText: (id: string) => `${id} wraca do definicji z konfiguracji aplikacji.`, + confirmDeleteTitle: 'Usunąć flagę?', + confirmDeleteText: (id: string) => `${id} zostanie usunięta. Aplikacja traktuje nieznaną flagę jako wyłączoną.`, + cancel: 'Anuluj', + variantsKept: 'Ta flaga ma warianty. Zapis je zachowuje, ale nie można ich jeszcze tu edytować.', + dotnetSchemaNote: 'Konfiguracja definiuje tę flagę w starszym schemacie .NET (sekcja FeatureManagement). Zapis tworzy nadpisanie w schemacie Microsoft; warianty ze schematu .NET nie są przenoszone.', + overrideError: (error: string) => `Nie można odczytać zapisanego nadpisania (${error}). Przywróć flagę, żeby znów używać definicji z konfiguracji.`, + + conditions: 'Warunki', + requirementAny: 'dowolny', + requirementAll: 'wszystkie', + requirementLabel: 'Włączona, gdy spełnia', + requirementSuffix: 'z warunków', + noConditions: 'Brak warunków: gdy flaga jest włączona, działa dla wszystkich.', + disabledHint: 'Flaga jest wyłączona, więc warunki nie mają znaczenia.', + addCondition: 'Dodaj warunek', + removeCondition: 'Usuń warunek', + filterPercentage: 'Procent', + filterTimeWindow: 'Okno czasowe', + filterTargeting: 'Targetowanie', + filterCustom: 'Własny filtr', + percentageHint: 'Każde sprawdzenie jest włączone z tym prawdopodobieństwem, niezależnie od użytkownika. Do stabilnego wdrażania per użytkownik użyj targetowania.', + windowStart: 'Początek', + windowEnd: 'Koniec', + windowActive: 'aktywne teraz', + windowUpcoming: 'jeszcze się nie zaczęło', + windowEnded: 'zakończone', + windowTimeZone: (zone: string) => `Czas w Twojej strefie (${zone}), zapisywany w UTC. Zostaw jedno pole puste, żeby okno było otwarte.`, + recurrenceKept: 'To okno się powtarza. Powtarzanie zostaje zachowane, ale nie można go jeszcze tu edytować.', + targetUsers: 'Użytkownicy', + targetGroups: 'Grupy', + groupName: 'Grupa', + groupRollout: '% grupy', + addGroup: 'Dodaj grupę', + removeGroup: 'Usuń grupę', + defaultRollout: '% pozostałych', + excludedUsers: 'Wykluczeni użytkownicy', + excludedGroups: 'Wykluczone grupy', + chipsHint: 'Wpisz i naciśnij Enter', + targetingHint: 'Włączona dla wymienionych użytkowników, dla podanej części każdej grupy i pozostałych; nigdy dla wykluczonych.', + filterName: 'Nazwa filtru', + filterParameters: 'Parametry (JSON)', + invalidJson: 'To nie jest poprawny obiekt JSON.', + unregisteredFilter: 'Aplikacja nie rejestruje filtru o tej nazwie. Zapis zostanie odrzucony, bo sprawdzanie flagi kończyłoby się błędem.', + + checkTitle: 'Sprawdź dla użytkownika', + checkUser: 'Użytkownik', + checkGroups: 'Grupy (po przecinku)', + checkRun: 'Sprawdź', + checkOn: 'Włączona dla tego użytkownika', + checkOff: 'Wyłączona dla tego użytkownika', + checkVariant: (variant: string) => `wariant ${variant}`, + checkSavedOnly: 'Sprawdza zapisaną flagę feature managerem aplikacji. Najpierw zapisz zmiany.', }, } as const; diff --git a/src/ConfigWay.UI/SPA/src/pages/FlagPage.tsx b/src/ConfigWay.UI/SPA/src/pages/FlagPage.tsx new file mode 100644 index 0000000..34c7f40 --- /dev/null +++ b/src/ConfigWay.UI/SPA/src/pages/FlagPage.tsx @@ -0,0 +1,341 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import Alert from '@mui/material/Alert'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogTitle from '@mui/material/DialogTitle'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import IconButton from '@mui/material/IconButton'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Switch from '@mui/material/Switch'; +import TextField from '@mui/material/TextField'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; +import AddIcon from '@mui/icons-material/Add'; +import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; +import RestoreIcon from '@mui/icons-material/Restore'; +import { useShell } from '@kododo-dev/way-shell'; +import { ApiError } from '../api/api'; +import { resetFlag, saveFlag } from '../api/flags'; +import type { FeatureFilter, FeatureFlag } from '../api/flags'; +import PageHeader from '../components/layout/PageHeader'; +import Panel, { MONO } from '../components/flags/Panel'; +import { FilterEditor } from '../components/flags/FilterEditors'; +import FlagCheck from '../components/flags/FlagCheck'; +import { useFlags } from '../context/FlagsContext'; +import { useI18n } from '../i18n/I18nContext'; +import { filterErrors, filterKind, newFilter, newFlag, otherErrors, stableJson } from '../utils/flags'; +import type { FilterKind } from '../utils/flags'; + +const HINT = { ...MONO, fontSize: '0.72rem', color: 'text.secondary' } as const; + +/** What a save compares and sends: an empty description is none. */ +const normalize = (flag: FeatureFlag): FeatureFlag => ({ + ...flag, + description: flag.description?.trim() ? flag.description.trim() : null, +}); + +/** The editor of one flag (`flag/:flagId`) or of a new one (`flags/new`). */ +const FlagPage = ({ isNew = false }: { isNew?: boolean }) => { + const { flagId } = useParams<{ flagId: string }>(); + const { data, loading, reload } = useFlags(); + const { api, navigate, wayId } = useShell(); + const { t } = useI18n(); + + const id = decodeURIComponent(flagId ?? ''); + const item = isNew ? null : data?.flags.find(f => f.id.toLowerCase() === id.toLowerCase()) ?? null; + const original = useMemo(() => (item ? item.flag : newFlag()), [item]); + + const [draft, setDraft] = useState(original); + const [invalidJson, setInvalidJson] = useState([]); + const [errors, setErrors] = useState>({}); + const [messages, setMessages] = useState([]); + const [saving, setSaving] = useState(false); + const [confirm, setConfirm] = useState(false); + const [addMenu, setAddMenu] = useState(null); + + useEffect(() => { + setDraft(original); + setInvalidJson([]); + setErrors({}); + setMessages([]); + }, [original]); + + // A flag that no longer exists, e.g. deleted elsewhere: back to the list. + useEffect(() => { + if (!isNew && !loading && data && !item) navigate(wayId, 'flags', { replace: true }); + }, [isNew, loading, data, item, navigate, wayId]); + + const hasChanges = stableJson(normalize(draft)) !== stableJson(normalize(original)); + const canSave = (isNew || hasChanges) && !invalidJson.some(Boolean); + + // The API's errors are about the values sent; an edit clears them until the next save. + const change = useCallback((patch: Partial) => { + setDraft(prev => ({ ...prev, ...patch })); + setErrors({}); + setMessages([]); + }, []); + + const setFilter = (index: number, filter: FeatureFilter) => + change({ filters: draft.filters.map((f, i) => (i === index ? filter : f)) }); + + const addFilter = (kind: FilterKind) => { + setAddMenu(null); + change({ filters: [...draft.filters, newFilter(kind)] }); + }; + + const removeFilter = (index: number) => { + change({ filters: draft.filters.filter((_, i) => i !== index) }); + setInvalidJson(prev => prev.filter((_, i) => i !== index)); + }; + + const fail = (e: unknown) => { + if (e instanceof ApiError && Object.keys(e.errors).length > 0) { + setErrors(e.errors); + setMessages(otherErrors(e.errors).filter(m => !(e.errors['flag.id'] ?? []).includes(m))); + } else { + setErrors({}); + setMessages([e instanceof ApiError ? e.messages.join(' ') : e instanceof Error ? e.message : t.unexpectedError]); + } + }; + + const save = async () => { + if (!canSave) return; + setSaving(true); + setErrors({}); + setMessages([]); + try { + const flag = normalize(draft); + await saveFlag(api, flag, isNew); + await reload(); + if (isNew) navigate(wayId, `flag/${encodeURIComponent(flag.id)}`, { replace: true }); + } catch (e) { + fail(e); + } finally { + setSaving(false); + } + }; + + const discard = () => { + setDraft(original); + setInvalidJson([]); + setErrors({}); + setMessages([]); + }; + + const reset = async () => { + if (!item) return; + setConfirm(false); + setSaving(true); + try { + await resetFlag(api, item.id); + await reload(); + if (!item.configured) navigate(wayId, 'flags', { replace: true }); + } catch (e) { + fail(e); + } finally { + setSaving(false); + } + }; + + if (!isNew && (loading || !item)) return ( + + + + ); + + const kindLabel = (kind: FilterKind) => ({ + Percentage: t.filterPercentage, + TimeWindow: t.filterTimeWindow, + Targeting: t.filterTargeting, + Custom: t.filterCustom, + })[kind]; + + const source = !item ? null + : !item.configured ? t.sourceConfigWayOnly + : item.hasOverride ? t.sourceOverride + : t.sourceConfigured; + + return ( + + + + + + {item?.error && {t.overrideError(item.error)}} + {item?.hasVariants && {t.variantsKept}} + {item?.isDotnetSchema && {t.dotnetSchemaNote}} + + : } + color={item.configured ? 'primary' : 'error'} + onClick={() => setConfirm(true)} + disabled={saving} + sx={{ ...MONO, fontSize: '0.72rem', textTransform: 'none' }} + > + {item.configured ? t.resetToConfigured : t.deleteFlag} + + )} + > + change({ id: e.target.value })} + disabled={!isNew} + error={!!errors['flag.id']} + helperText={errors['flag.id']?.join(' ') ?? t.flagIdHint} + autoFocus={isNew} + slotProps={{ htmlInput: { spellCheck: false, 'data-testid': 'flag-id' } }} + /> + change({ description: e.target.value })} + multiline + /> + change({ enabled: e.target.checked })} />} + label={{t.flagEnabled}} + /> + {source && {source}} + + + + + setAddMenu(null)}> + {(['Percentage', 'TimeWindow', 'Targeting', 'Custom'] as const).map(kind => ( + addFilter(kind)} sx={{ ...MONO, fontSize: '0.8rem' }}> + {kindLabel(kind)} + + ))} + + + )} + > + {!draft.enabled && {t.disabledHint}} + {draft.filters.length === 0 && {t.noConditions}} + + {draft.filters.length > 1 && ( + + {t.requirementLabel} + value && change({ requirementType: value })} + > + + {t.requirementAny} + + + {t.requirementAll} + + + {t.requirementSuffix} + + )} + + {draft.filters.map((filter, index) => { + const kind = filterKind(filter.name); + const messagesOfFilter = filterErrors(errors, index); + return ( + 0} + title={( + <> + {kindLabel(kind)} + {kind !== 'Custom' && ( + {filter.name} + )} + + )} + actions={( + + removeFilter(index)}> + + + + )} + > + {messagesOfFilter.length > 0 && ( + {messagesOfFilter.join(' ')} + )} + setFilter(index, next)} + registered={data?.filters ?? []} + onValidity={valid => setInvalidJson(prev => { + const next = [...prev]; + next[index] = !valid; + return next; + })} + /> + + ); + })} + + + {!isNew && item && } + + + + setConfirm(false)}> + + {item?.configured ? t.confirmResetTitle : t.confirmDeleteTitle} + + + + {item && (item.configured ? t.confirmResetText(item.id) : t.confirmDeleteText(item.id))} + + + + + + + + + ); +}; + +export default FlagPage; diff --git a/src/ConfigWay.UI/SPA/src/pages/FlagsPage.tsx b/src/ConfigWay.UI/SPA/src/pages/FlagsPage.tsx new file mode 100644 index 0000000..87792d8 --- /dev/null +++ b/src/ConfigWay.UI/SPA/src/pages/FlagsPage.tsx @@ -0,0 +1,215 @@ +import { useState } from 'react'; +import Alert from '@mui/material/Alert'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import CircularProgress from '@mui/material/CircularProgress'; +import Snackbar from '@mui/material/Snackbar'; +import Switch from '@mui/material/Switch'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; +import AddIcon from '@mui/icons-material/Add'; +import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined'; +import { useTheme } from '@mui/material/styles'; +import { useShell } from '@kododo-dev/way-shell'; +import { ApiError } from '../api/api'; +import { resetFlag, saveFlag } from '../api/flags'; +import type { FeatureFlag, FeatureFlagItem } from '../api/flags'; +import PageHeader from '../components/layout/PageHeader'; +import Highlight from '../components/common/Highlight'; +import { MONO } from '../components/flags/Panel'; +import { flagSummary } from '../components/flags/flagSummary'; +import { useFlags } from '../context/FlagsContext'; +import { useI18n } from '../i18n/I18nContext'; + +interface Toggled { + previous: FeatureFlag; + /** Without an override before the switch, undo removes the override instead of saving one. */ + hadOverride: boolean; + message: string; +} + +/** The list of feature flags, with a switch to turn each on or off at once. */ +const FlagsPage = () => { + const { data, loading, error, reload } = useFlags(); + const { api, navigate, wayId } = useShell(); + const { t, lang } = useI18n(); + const theme = useTheme(); + const isDark = theme.palette.mode === 'dark'; + + const [search, setSearch] = useState(''); + const [pending, setPending] = useState(null); + const [toggled, setToggled] = useState(null); + const [toggleError, setToggleError] = useState(null); + + const run = async (id: string, action: () => Promise) => { + setPending(id); + setToggleError(null); + try { + await action(); + await reload(); + return true; + } catch (e) { + setToggleError(e instanceof ApiError ? e.messages.join(' ') : e instanceof Error ? e.message : t.unexpectedError); + return false; + } finally { + setPending(null); + } + }; + + const toggle = async (item: FeatureFlagItem) => { + const next = { ...item.flag, enabled: !item.flag.enabled }; + if (await run(item.id, () => saveFlag(api, next))) + setToggled({ + previous: item.flag, + hadOverride: item.hasOverride, + message: next.enabled ? t.flagTurnedOn(item.id) : t.flagTurnedOff(item.id), + }); + }; + + const undo = async () => { + if (!toggled) return; + const { previous, hadOverride } = toggled; + setToggled(null); + await run(previous.id, () => (hadOverride ? saveFlag(api, previous) : resetFlag(api, previous.id))); + }; + + const q = search.trim().toLowerCase(); + const items = (data?.flags ?? []).filter(i => + !q || i.id.toLowerCase().includes(q) || (i.flag.description ?? '').toLowerCase().includes(q)); + + const open = (id: string) => navigate(wayId, `flag/${encodeURIComponent(id)}`); + const rowBorder = isDark ? '#2e2e2e' : '#e0e0e0'; + + return ( + + } + onClick={() => navigate(wayId, 'flags/new')} + sx={{ ...MONO, fontSize: '0.72rem', textTransform: 'none', flexShrink: 0 }} + > + {t.newFlag} + + )} + errors={[error, toggleError].filter((e): e is string => !!e)} + /> + + + {data && !data.isFeatureManagementRegistered && ( + {t.featureManagementMissing} + )} + + {loading && !data && ( + + + + )} + + {data && data.flags.length === 0 && ( + {t.noFlags} + )} + + {data && data.flags.length > 0 && items.length === 0 && ( + + {t.noResults} "{search}" + + )} + + {items.length > 0 && ( + + {items.map((item, index) => ( + open(item.id)} + sx={{ + display: 'flex', alignItems: 'center', gap: 1.5, + px: 2, py: 1.25, + borderTop: index === 0 ? 'none' : `1px solid ${rowBorder}`, + cursor: 'pointer', + '&:hover': { background: isDark ? '#2a2a2a' : '#fafafa' }, + }} + > + e.stopPropagation()} sx={{ flexShrink: 0 }}> + + + toggle(item)} + slotProps={{ input: { 'aria-label': item.id } }} + /> + + + + + + + + + + {item.hasOverride && item.configured && } + {!item.configured && } + {item.isDotnetSchema && } + {item.hasVariants && } + {item.error && ( + + + + )} + + + {flagSummary(item.flag, t, lang)} + {item.flag.description && ` · ${item.flag.description}`} + + + + ))} + + )} + + + { if (reason !== 'clickaway') setToggled(null); }} + message={toggled?.message} + action={( + + )} + /> + + ); +}; + +const Badge = ({ label, color }: { label: string; color?: 'primary' | 'secondary' }) => ( + +); + +export default FlagsPage; diff --git a/src/ConfigWay.UI/SPA/src/utils/flags.ts b/src/ConfigWay.UI/SPA/src/utils/flags.ts new file mode 100644 index 0000000..3cb37dd --- /dev/null +++ b/src/ConfigWay.UI/SPA/src/utils/flags.ts @@ -0,0 +1,151 @@ +import type { FeatureFilter, FeatureFlag, FilterParameters } from '../api/flags'; + +/** The built-in filters the editor has forms for; others are edited as JSON. */ +export type FilterKind = 'Percentage' | 'TimeWindow' | 'Targeting' | 'Custom'; + +export const FILTER_ALIASES: Record, string> = { + Percentage: 'Microsoft.Percentage', + TimeWindow: 'Microsoft.TimeWindow', + Targeting: 'Microsoft.Targeting', +}; + +/** The built-in filter a name refers to, by its full alias or its last segment, as feature management accepts them. */ +export function filterKind(name: string): FilterKind { + const lower = name.trim().toLowerCase(); + for (const [kind, alias] of Object.entries(FILTER_ALIASES)) { + const a = alias.toLowerCase(); + if (lower === a || lower === a.slice(a.lastIndexOf('.') + 1)) return kind as FilterKind; + } + return 'Custom'; +} + +/** Whether the application registered a filter with this name (full alias or last segment). */ +export function isRegisteredFilter(name: string, registered: string[]): boolean { + const lower = name.trim().toLowerCase(); + return registered.some(r => { + const a = r.toLowerCase(); + return lower === a || lower === a.slice(a.lastIndexOf('.') + 1); + }); +} + +export function newFilter(kind: FilterKind): FeatureFilter { + switch (kind) { + case 'Percentage': return { name: FILTER_ALIASES.Percentage, parameters: { Value: 50 } }; + case 'TimeWindow': return { name: FILTER_ALIASES.TimeWindow, parameters: { Start: new Date().toISOString() } }; + case 'Targeting': return { + name: FILTER_ALIASES.Targeting, + parameters: { Audience: { Users: [], Groups: [], DefaultRolloutPercentage: 0, Exclusion: { Users: [], Groups: [] } } }, + }; + case 'Custom': return { name: '', parameters: {} }; + } +} + +export function newFlag(): FeatureFlag { + return { id: '', enabled: true, requirementType: 'Any', filters: [], description: null }; +} + +// ── Parameters ───────────────────────────────────────────────────────────── + +export function asNumber(value: unknown): number | null { + if (typeof value === 'number') return value; + if (typeof value === 'string' && value.trim() !== '' && !isNaN(Number(value))) return Number(value); + return null; +} + +export function asText(value: unknown): string | null { + return typeof value === 'string' ? value : typeof value === 'number' ? String(value) : null; +} + +export function asTexts(value: unknown): string[] { + return Array.isArray(value) ? value.map(v => asText(v) ?? '').filter(v => v !== '') : []; +} + +export interface GroupRollout { Name: string; RolloutPercentage: number | null } + +export interface Audience { + Users: string[]; + Groups: GroupRollout[]; + DefaultRolloutPercentage: number | null; + Exclusion: { Users: string[]; Groups: string[] }; +} + +export function readAudience(parameters: FilterParameters | null): Audience { + const audience = (parameters?.Audience ?? {}) as Record; + const exclusion = (audience.Exclusion ?? {}) as Record; + return { + Users: asTexts(audience.Users), + Groups: Array.isArray(audience.Groups) + ? audience.Groups.map(g => { + const group = (g ?? {}) as Record; + return { Name: asText(group.Name) ?? '', RolloutPercentage: asNumber(group.RolloutPercentage) }; + }) + : [], + DefaultRolloutPercentage: asNumber(audience.DefaultRolloutPercentage), + Exclusion: { Users: asTexts(exclusion.Users), Groups: asTexts(exclusion.Groups) }, + }; +} + +// ── Time ─────────────────────────────────────────────────────────────────── + +/** A stored time (RFC 1123 or ISO 8601) as the value of a datetime-local input, in the browser's time zone. */ +export function toLocalInput(text: string | null): string { + if (!text) return ''; + const date = new Date(text); + if (isNaN(date.getTime())) return ''; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +/** The value of a datetime-local input (local time) as an ISO 8601 time in UTC; null when empty. */ +export function fromLocalInput(value: string): string | null { + if (!value) return null; + const date = new Date(value); + return isNaN(date.getTime()) ? null : date.toISOString(); +} + +export type WindowState = 'active' | 'upcoming' | 'ended' | 'unknown'; + +export function windowState(parameters: FilterParameters | null, now = new Date()): WindowState { + const start = asText(parameters?.Start); + const end = asText(parameters?.End); + const startDate = start ? new Date(start) : null; + const endDate = end ? new Date(end) : null; + if ((startDate && isNaN(startDate.getTime())) || (endDate && isNaN(endDate.getTime()))) return 'unknown'; + if (!startDate && !endDate) return 'unknown'; + if (startDate && now < startDate) return 'upcoming'; + if (endDate && now >= endDate) return 'ended'; + return 'active'; +} + +export function formatTime(text: string | null, lang: string): string { + if (!text) return ''; + const date = new Date(text); + return isNaN(date.getTime()) + ? text + : date.toLocaleString(lang, { dateStyle: 'medium', timeStyle: 'short' }); +} + +// ── Comparison and errors ────────────────────────────────────────────────── + +/** JSON with sorted keys, so that two flags with the same values compare equal. */ +export function stableJson(value: unknown): string { + return JSON.stringify(value, (_, v) => + v && typeof v === 'object' && !Array.isArray(v) + ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b))) + : v); +} + +/** The field errors of the API for one filter, e.g. `flag.filters[1].parameters`. */ +export function filterErrors(errors: Record, index: number): string[] { + const prefix = `flag.filters[${index}]`; + return Object.entries(errors) + .filter(([key]) => key === prefix || key.startsWith(prefix + '.')) + .flatMap(([, messages]) => messages); +} + +/** The field errors of the API that are not about a filter. */ +export function otherErrors(errors: Record): string[] { + return Object.entries(errors) + .filter(([key]) => !key.startsWith('flag.filters[')) + .flatMap(([, messages]) => messages); +} diff --git a/src/ConfigWay.slnx b/src/ConfigWay.slnx index ee02dcb..18f235e 100644 --- a/src/ConfigWay.slnx +++ b/src/ConfigWay.slnx @@ -7,10 +7,12 @@ + + diff --git a/src/ConfigWay/.nuget/README.md b/src/ConfigWay/.nuget/README.md index 996acd1..a6f29c5 100644 --- a/src/ConfigWay/.nuget/README.md +++ b/src/ConfigWay/.nuget/README.md @@ -76,6 +76,14 @@ builder.AddConfigWay(x => }); ``` +## Several replicas + +A save reloads the configuration in the instance that handled it. With several replicas sharing one store, let each of them check the store for changes: + +```csharp +x.ReloadInterval = TimeSpan.FromSeconds(30); +``` + ## Security The `/config` route is not protected by default: diff --git a/src/ConfigWay/ConfigWay.csproj b/src/ConfigWay/ConfigWay.csproj index bdc1aaa..e64924e 100644 --- a/src/ConfigWay/ConfigWay.csproj +++ b/src/ConfigWay/ConfigWay.csproj @@ -24,6 +24,9 @@ <_Parameter1>Kododo.ConfigWay.PostgreSQL.Tests + + <_Parameter1>Kododo.ConfigWay.FeatureManagement.Tests + diff --git a/src/ConfigWay/ConfigurationBuilder.cs b/src/ConfigWay/ConfigurationBuilder.cs index 808252c..bdaf5a1 100644 --- a/src/ConfigWay/ConfigurationBuilder.cs +++ b/src/ConfigWay/ConfigurationBuilder.cs @@ -27,8 +27,18 @@ public sealed class ConfigurationBuilder(IHostApplicationBuilder builder) /// public IStore Store { get; set; } = new InMemoryStore(); + /// + /// Gets or sets how often ConfigWay checks the store for changes made by other instances of the application. + /// When a setting was added, changed or removed, the configuration is reloaded as after a save in the UI. + /// Defaults to null: no checks, so the changes reach only the instance that saved them. + /// Set it when the application runs as several replicas, e.g. to TimeSpan.FromSeconds(30). + /// + public TimeSpan? ReloadInterval { get; set; } + internal Dictionary OptionTypes { get; } = []; + internal List Providers { get; } = []; + /// /// Registers an options class so that ConfigWay can read, display, and persist /// its values through the UI editor. @@ -51,6 +61,20 @@ public sealed class ConfigurationBuilder(IHostApplicationBuilder builder) return this; } + /// + /// Adds a configuration provider that exposes values kept in the store under keys of its own. + /// Used by extension packages; the provider is added to the application configuration right after ConfigWay's + /// own provider and is reloaded together with it. + /// + /// The provider to add. + /// The same builder instance for method chaining. + public ConfigurationBuilder AddProvider(StoreConfigurationProvider provider) + { + ArgumentNullException.ThrowIfNull(provider); + Providers.Add(provider); + return this; + } + internal Configuration Build() { return new Configuration( diff --git a/src/ConfigWay/ConfigurationProvider.cs b/src/ConfigWay/ConfigurationProvider.cs index aeec943..4f04330 100644 --- a/src/ConfigWay/ConfigurationProvider.cs +++ b/src/ConfigWay/ConfigurationProvider.cs @@ -1,4 +1,5 @@ using Kododo.ConfigWay.Core.Configuration; +using Kododo.ConfigWay.Core.Model; namespace Kododo.ConfigWay; @@ -16,35 +17,100 @@ namespace Kododo.ConfigWay; public interface IConfigurationEditor { /// - /// Reloads all ConfigWay overrides from the store and notifies - /// subscribers. + /// Reloads all ConfigWay overrides from the store, including the providers added by extension packages, + /// and notifies subscribers. /// Task ReloadAllAsync(CancellationToken stoppingToken); } -internal sealed class ConfigurationProvider(Configuration configuration) - : Microsoft.Extensions.Configuration.ConfigurationProvider, IConfigurationEditor +internal sealed class ConfigurationProvider( + Configuration configuration, + IReadOnlyList? extensions = null) + : StoreConfigurationProvider, IConfigurationEditor { private readonly HashSet _exactKeys = BuildExactKeys(configuration); private readonly HashSet _arrayPrefixes = BuildArrayPrefixes(configuration); + private readonly IReadOnlyList _extensions = extensions ?? []; + private readonly SemaphoreSlim _reloadLock = new(1, 1); + private Dictionary _loaded = new(StringComparer.Ordinal); public override void Load() { - LoadAsync().GetAwaiter().GetResult(); + var settings = configuration.Store.GetAllAsync().GetAwaiter().GetResult(); + Apply(settings, notify: false); } async Task IConfigurationEditor.ReloadAllAsync(CancellationToken stoppingToken) { - await LoadAsync(stoppingToken); - OnReload(); + await _reloadLock.WaitAsync(stoppingToken); + try + { + var settings = await configuration.Store.GetAllAsync(stoppingToken); + Apply(settings, notify: true); + } + finally + { + _reloadLock.Release(); + } + } + + /// + /// Reads the store and reloads the providers only when a setting was added, changed or removed since the last + /// load, e.g. by another instance of the application. + /// + /// true when the values changed and the providers were reloaded. + internal async Task ReloadIfChangedAsync(CancellationToken stoppingToken) + { + await _reloadLock.WaitAsync(stoppingToken); + try + { + var settings = await configuration.Store.GetAllAsync(stoppingToken); + if (!HasChanged(settings)) + return false; + + Apply(settings, notify: true); + return true; + } + finally + { + _reloadLock.Release(); + } + } + + protected override IEnumerable> Select(IReadOnlyList settings) => + settings + .Where(s => IsAllowedKey(s.Key)) + .Select(s => new KeyValuePair(s.Key, s.Value)); + + private void Apply(IReadOnlyList settings, bool notify) + { + _loaded = Snapshot(settings); + + Apply(settings); + foreach (var extension in _extensions) + extension.Apply(settings); + + if (!notify) + return; + + NotifyReload(); + foreach (var extension in _extensions) + extension.NotifyReload(); + } + + private bool HasChanged(IReadOnlyList settings) + { + var current = Snapshot(settings); + return current.Count != _loaded.Count || + current.Any(s => !_loaded.TryGetValue(s.Key, out var value) || value != s.Value); } - private async Task LoadAsync(CancellationToken stoppingToken = default) + private static Dictionary Snapshot(IReadOnlyList settings) { - var entries = await configuration.Store.GetAllAsync(stoppingToken); - Data = entries - .Where(e => IsAllowedKey(e.Key)) - .ToDictionary(e => e.Key, e => e.Value, StringComparer.OrdinalIgnoreCase); + var snapshot = new Dictionary(StringComparer.Ordinal); + foreach (var setting in settings) + snapshot[setting.Key] = setting.Value; + return snapshot; } private bool IsAllowedKey(string key) => diff --git a/src/ConfigWay/ConfigurationSource.cs b/src/ConfigWay/ConfigurationSource.cs index 773b9fe..bb234e7 100644 --- a/src/ConfigWay/ConfigurationSource.cs +++ b/src/ConfigWay/ConfigurationSource.cs @@ -2,7 +2,7 @@ namespace Kododo.ConfigWay; -internal sealed class ConfigurationSource(ConfigurationProvider configurationProvider) : IConfigurationSource +internal sealed class ConfigurationSource(StoreConfigurationProvider configurationProvider) : IConfigurationSource { public IConfigurationProvider Build(IConfigurationBuilder builder) { diff --git a/src/ConfigWay/HostApplicationBuilderExtensions.cs b/src/ConfigWay/HostApplicationBuilderExtensions.cs index db4719b..ff198a0 100644 --- a/src/ConfigWay/HostApplicationBuilderExtensions.cs +++ b/src/ConfigWay/HostApplicationBuilderExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace Kododo.ConfigWay; @@ -35,10 +36,24 @@ public static void AddConfigWay(this IHostApplicationBuilder builder, Action(x => configurationProvider); + + // The main provider loads the store for the extension providers too, so it is added first. builder.Configuration.Add(new ConfigurationSource(configurationProvider)); + foreach (var provider in configurationBuilder.Providers) + builder.Configuration.Add(new ConfigurationSource(provider)); + + if (configurationBuilder.ReloadInterval is { } interval) + { + if (interval <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException( + nameof(configure), interval, $"{nameof(ConfigurationBuilder.ReloadInterval)} must be positive."); + + builder.Services.AddHostedService(x => new ReloadService( + configurationProvider, interval, x.GetRequiredService>())); + } } } diff --git a/src/ConfigWay/ReloadService.cs b/src/ConfigWay/ReloadService.cs new file mode 100644 index 0000000..ecfb07e --- /dev/null +++ b/src/ConfigWay/ReloadService.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Kododo.ConfigWay; + +/// +/// Checks the store every and reloads the configuration when +/// another instance of the application changed it. +/// +internal sealed class ReloadService( + ConfigurationProvider configurationProvider, + TimeSpan interval, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(interval); + try + { + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + try + { + if (await configurationProvider.ReloadIfChangedAsync(stoppingToken)) + logger.LogInformation("ConfigWay settings changed in the store; the configuration was reloaded."); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // The store may be unavailable for a while; the current values stay and the next tick tries again. + logger.LogWarning(ex, "ConfigWay could not check the store for changes."); + } + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + } + } +} diff --git a/src/ConfigWay/StoreConfigurationProvider.cs b/src/ConfigWay/StoreConfigurationProvider.cs new file mode 100644 index 0000000..fb1acbf --- /dev/null +++ b/src/ConfigWay/StoreConfigurationProvider.cs @@ -0,0 +1,38 @@ +using Kododo.ConfigWay.Core.Model; + +namespace Kododo.ConfigWay; + +/// +/// Base class for configuration providers that expose values kept in the ConfigWay store. +/// Extension packages derive from it and register the provider with +/// ; ConfigWay reads the store once and hands every setting to each +/// provider, so a save or a reload updates all of them together. +/// +/// +/// does nothing: the data is pushed by ConfigWay when it loads or reloads the store. +/// +public abstract class StoreConfigurationProvider : Microsoft.Extensions.Configuration.ConfigurationProvider +{ + /// + /// Maps the settings read from the store to the configuration keys this provider exposes. + /// Settings that do not belong to the provider are skipped. + /// + /// Every setting in the store. + /// The configuration keys and values; keys are compared case-insensitively. + protected abstract IEnumerable> Select(IReadOnlyList settings); + + /// + public override void Load() + { + } + + internal void Apply(IReadOnlyList settings) + { + var data = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in Select(settings)) + data[key] = value; + Data = data; + } + + internal void NotifyReload() => OnReload(); +} diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 0c99a44..1f5fe56 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -47,7 +47,10 @@ - + + + +